현대 웹 애플리케이션 개발에서 유연하고 강력한 데이터 조회 API는 필수적입니다. 사용자는 다양한 조건으로 데이터를 필터링, 정렬, 페이징하기를 원합니다. 이 글에서는 Spring Data JPA의 JpaSpecificationExecutor를 사용하여 동적 조건 조회 및 선택적 필터링 파라미터를 우아하게 처리하는 백엔드 구현 방식을 심층적으로 다룹니다.
ConsignmentSettlement (위탁 정산) 모듈을 예시로 들어, Controller가 어떻게 파라미터를 수신하고, Service가 동적 쿼리를 어떻게 구성하며, Repository가 Specification 패턴을 활용하여 쿼리를 실행하는지 살펴보겠습니다. 특히, 선택적 비즈니스 파라미터인 consignmentType (위탁 유형)을 URL 쿼리 파라미터로 받고, 요청 본문으로 전달되는 핵심 페이징 검색 객체 PageWithSearch와 분리하여 처리하는 방법에 중점을 둘 것입니다.
백엔드 핵심 로직 요약
- Controller 계층:
@RequestBody PageWithSearch pageWithSearch: 핵심 페이징 및 일반 검색 조건을 수신합니다.@RequestParam(required = false) Integer consignmentType: 선택적인 특정 비즈니스 필터링 파라미터를 수신합니다.required = false설정으로consignmentType이 선택적으로 작동합니다.
- Service 계층:
PageWithSearch객체와 개별consignmentType파라미터를 수신합니다.org.springframework.data.jpa.domain.Specification을 동적으로 빌드합니다.- Specification 내에서
consignmentType이null인지 여부에 따라 조건부로 쿼리 Predicate를 추가합니다. - 페이징 및 정렬 로직을 처리하여
Pageable객체를 생성합니다.
- Repository 계층:
JpaRepository와JpaSpecificationExecutor<ConsignmentSettlement>를 상속합니다.- 동적 쿼리를 위한 별도의
findBy...메서드를 작성할 필요 없이findAll(Specification<T> spec, Pageable pageable)메서드를 활용합니다.
PageWithSearchDTO:- 일반적인 페이징(
page,size), 정렬(properties,direction), 기본 검색(field,value) 필드를 포함합니다.consignmentType과 같은 특정 비즈니스 필드는 포함하지 않습니다.
- 일반적인 페이징(
- 프론트엔드 연동:
consignmentType(선택 시)을 URL 쿼리 파라미터로 전송합니다.PageWithSearch관련 필드는 POST 요청 본문으로 전송합니다.
코드 구현 상세 분석
1. Controller 계층: 파라미터 분배 센터
// ConsignmentSettlementController.java
@RestController
@RequestMapping("/api/settlements")
public class ConsignmentSettlementController {
@Autowired
private ConsignmentSettlementService consignmentSettlementService;
// ... 다른 Controller 메서드들 ...
@PostMapping("/paginatedList")
public ResponseEntity<?> getPaginatedSettlements(
// 요청 본문으로 일반 페이징 및 검색 조건 수신
@Valid @RequestBody PageWithSearch pageWithSearch,
// URL 쿼리 파라미터로 선택적 비즈니스 필터 수신 (예: /paginatedList?consignmentType=1)
@RequestParam(required = false) Integer consignmentType
) {
try {
// 분리된 파라미터를 Service 계층으로 전달
Page<ConsignmentSettlement> resultPage = consignmentSettlementService.findSettlements(
pageWithSearch, consignmentType);
return ResponseEntity.ok(resultPage);
} catch (IllegalArgumentException e) {
// 유효성 검사 실패 등 비즈니스 로직 예외 처리
return ResponseEntity.badRequest().body(e.getMessage());
} catch (Exception e) {
// 기타 서버 내부 오류 처리
// 로깅 추가
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An internal error occurred.");
}
}
}
주요 역할:
@RequestBody를 사용하여 JSON 요청 본문을PageWithSearch객체로 바인딩합니다. 이 객체는 일반적인 페이징, 정렬, 기본 검색 정보를 담습니다.@RequestParam(required = false)를 사용하여consignmentType을 별도로 수신합니다. URL에 이 파라미터가 제공되면 바인딩되고, 제공되지 않으면consignmentType은null이 됩니다. 이는 선택적 동작을 위한 핵심입니다.- 모든 파라미터를 명확하게 Service 계층으로 전달합니다.
2. Service 계층: 동적 쿼리의 지휘 본부
// ConsignmentSettlementService.java
@Service
@Transactional(readOnly = true)
public class ConsignmentSettlementService {
@Autowired
private ConsignmentSettlementRepository settlementRepository;
public Page<ConsignmentSettlement> findSettlements(
PageWithSearch pageWithSearch, Integer consignmentType) {
// 1. 정렬 조건 빌드
Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); // 기본 정렬
if (pageWithSearch.getSortDirection() != null && !pageWithSearch.getSortField().isEmpty()) {
Sort.Direction direction = Sort.Direction.fromString(pageWithSearch.getSortDirection().toUpperCase());
sort = Sort.by(direction, pageWithSearch.getSortField());
}
// 2. Pageable 객체 생성 (페이징 + 정렬 정보)
int pageNumber = pageWithSearch.getPageNumber() != null ? pageWithSearch.getPageNumber() : 0;
int pageSize = pageWithSearch.getPageSize() != null ? pageWithSearch.getPageSize() : 10;
Pageable pageable = PageRequest.of(pageNumber, pageSize, sort);
String searchField = pageWithSearch.getSearchField();
String searchValue = pageWithSearch.getSearchValue();
// 3. Specification 동적 빌드
Specification<ConsignmentSettlement> spec = (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
// 기본 검색 조건 (예: 로그인한 사용자의 데이터만 조회)
// predicates.add(criteriaBuilder.equal(root.get("userId"), getCurrentUserId()));
// 선택적 consignmentType 조건 추가
if (consignmentType != null) {
predicates.add(criteriaBuilder.equal(root.get("consignmentType"), consignmentType));
}
// 일반 검색 필터 조건
if (!searchField.isEmpty() && !searchValue.isEmpty()) {
if ("settlementId".equals(searchField)) {
try {
// ID 필드: 정수형으로 파싱하여 정확히 일치하는 조건 추가
predicates.add(criteriaBuilder.equal(root.get(searchField), Integer.parseInt(searchValue)));
} catch (NumberFormatException e) {
// 잘못된 숫자 형식 처리
throw new IllegalArgumentException("Invalid ID format: " + searchValue);
}
} else if ("customerName".equals(searchField)) {
// 문자열 필드: 대소문자 구분 없이 LIKE 검색 조건 추가
predicates.add(criteriaBuilder.like(
criteriaBuilder.lower(root.get(searchField)),
"%" + searchValue.toLowerCase() + "%"));
}
// ... 다른 필드들에 대한 조건 추가 ...
}
// 모든 Predicate를 AND 연산자로 결합
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
};
// 4. Repository를 통한 쿼리 실행
Page<ConsignmentSettlement> settlementPage = settlementRepository.findAll(spec, pageable);
// 5. (선택 사항) 조회 결과 후처리
// 예: 각 settlement 객체에 대한 추가 정보 계산 또는 설정
// settlementPage.getContent().forEach(settlement -> {
// settlement.setAdditionalInfo("Processed");
// });
return settlementPage;
}
// ... 기타 Service 메서드 ...
}
주요 역할:
PageWithSearch객체에서 페이징 및 정렬 정보를 추출하여Pageable객체를 생성합니다.pageNumber와pageSize가null일 경우를 대비한 기본값 설정을 포함합니다.Specification을 동적으로 빌드합니다. Lambda 표현식을 사용하여 전달된 파라미터(선택적consignmentType및 동적searchField/searchValue포함)에 따라 조건 목록(List<Predicate>)을 동적으로 구성합니다.consignmentType이null이 아니면, 해당 조건이 추가됩니다.searchField와searchValue가 제공되면, 필드에 따라 다른 매칭 전략(예: ID 정확히 일치, 이름 부분 일치)을 적용합니다.
- 생성된
spec과pageable을 사용하여settlementRepository.findAll(...)을 호출합니다. - 쿼리 결과 반환 후, 필요에 따라 추가적인 비즈니스 로직(예: 임시 필드 채우기)을 수행할 수 있습니다.
3. Repository 계층: JpaSpecificationExecutor의 힘
// ConsignmentSettlementRepository.java
@Repository
public interface ConsignmentSettlementRepository extends JpaRepository<ConsignmentSettlement, Long>,
JpaSpecificationExecutor<ConsignmentSettlement> {
// 기존에 정의된 특정 쿼리 메서드는 그대로 유지 가능
List<ConsignmentSettlement> findByUserId(Long userId);
// Service 계층에서 Specification을 통해 처리되므로,
// 동적 쿼리를 위한 별도의 findBy... 메서드들은 더 이상 필요하지 않음.
// 이는 Repository 인터페이스를 깔끔하게 유지시켜 줍니다.
}
주요 역할:
JpaSpecificationExecutor<ConsignmentSettlement>를 상속받아 Specification 기반 쿼리 실행 기능을 활용합니다.findAll(Specification<T> spec, Pageable pageable)메서드를 통해 복잡한 동적 쿼리를 실행할 수 있습니다.- 각기 다른 조건 조합에 대한 Repository 메서드를 작성할 필요가 없어 코드 복잡성이 크게 줄어듭니다.
결론: 백엔드 API의 "동적"과 "우아함"
위에서 제시된 Controller, Service, Repository의 계층적 설계와 기술 선택을 통해 유연하면서도 유지보수하기 쉬운 백엔드 쿼리 인터페이스를 성공적으로 구축했습니다.
JpaSpecificationExecutor는 다양한 쿼리 조건 조합에 대한 Repository 메서드 작성을 없애주어 데이터 접근 계층을 매우 간결하게 유지합니다.- Service 계층의
Specification빌딩 로직은 동적 쿼리의 중심 역할을 하며, 모든 조건 판단 및 조합이 이곳에 집중되어 있어 명확하게 관리됩니다. - Controller 계층의 파라미터 분리 전략(핵심 DTO용
@RequestBody, 개별 선택적 파라미터용@RequestParam)은 API 설계를 합리적으로 만들고 범용 DTO 유지보수를 용이하게 합니다. - 프론트엔드의 협력(선택적 파라미터를 URL 쿼리 파라미터로 전송, 값이 있을 때만 전송)은 전체 프로세스의 원활함과 효율성을 보장합니다.
이러한 디자인 패턴은 코드의 가독성과 유지보수성을 향상시킬 뿐만 아니라, 향후 새로운 쿼리 요구사항 발생 시에도 훌륭한 확장 기반을 제공합니다. 복잡한 쿼리 기능을 구현하는 데 있어 이 글이 통찰력과 도움을 제공하기를 바랍니다! 지속적인 학습과 최적화를 통해 백엔드 서비스를 더욱 강력하게 만들어 갑시다!