배경
선택적 검색 조건, 쿼리 메서드로 다 만들 수 있을까? 검색 API를 만들다 보면 조건이 하나만 오는 경우도 있고, 여러 개가 조합되어 오는 경우도 있고, 아예 아무 조건 없이 전체 목록을 요청하는 경우도 있다. 예를 들어 고객 검색에서 이름(keyword)과 상태(status)가 각각 있을 수도, 없을 수도 있는 구조라면 이 조합을 어떻게 처리할지가 선택해야한다.
문제: 조건 조합이 지수적으로 늘어난다
가장 단순한 방법은 Spring Data JPA가 제공하는 쿼리 메서드다. findByNameAndStatus, findByName, findByStatus, findAll 처럼 조건 조합마다 메서드를 하나씩 만드는 방식이다.
문제는 조건이 늘어날수록 필요한 메서드 수가 2ⁿ으로 늘어난다는 것이다. 조건이 1개면 메서드 2개(있음/없음), 2개면 4개, 3개면 8개다. 조건이 10개가 되면 1,024개의 메서드가 필요하다 — 애초에 선택지가 될 수 없다.
물론 실제로 1,024개까지 쓰는 경우는 없다 — 조건이 3~4개만 넘어가도 이미 감당이 안 된다는 게 드러나기 때문이다. 이 숫자는 이 방식을 극단까지 밀어붙였을 때의 한계를 보여주는 것이다.
JPQL이냐 Specification이냐
두 가지 방식을 비교했다.
JPQL(@Query)로 "값이 null이면 조건을 통과시킨다"는 규칙을 문자열 안에 직접 쓰는 방법이 있다.
// ProductRepository.java
@Query("SELECT p FROM Product p " +
"WHERE (:keyword IS NULL OR p.name LIKE %:keyword%) " +
"AND (:category IS NULL OR p.category = :category) " +
"AND (:status IS NULL OR p.status = :status)")
Page<Product> search(@Param("keyword") String keyword,
@Param("category") String category,
@Param("status") ProductStatus status,
Pageable pageable);
메서드가 1개뿐이라 단순 쿼리 메서드보다는 훨씬 낫다. 쿼리 전체가 한눈에 보이기 때문에 조건이 적고 잘 안 바뀌는 도메인에서는 오히려 이 방식이 더 직관적이다. 다만 조건을 하나 추가하려면 이 문자열 전체를 열어서 고쳐야 하고, 조건을 다른 조회 로직에서 재사용할 수 없다는 단점이 있다.
Specification은 조건을 문자열이 아니라 이름 붙은 부품 단위로 다룬다.
// CustomerSpecification.java
public class CustomerSpecification {
// 이름 또는 이메일에 keyword 포함된 고객 검색
public static Specification<Customer> keyword(String keyword) {
return (root, query, criteriaBuilder) -> {
if (keyword == null || keyword.isBlank()) {
return null; // 조건 무시
}
return criteriaBuilder.or(
criteriaBuilder.like(root.get("name"), "%" + keyword + "%"),
criteriaBuilder.like(root.get("email"), "%" + keyword + "%")
);
};
}
// 고객 상태 필터
public static Specification<Customer> status(CustomerStatus status) {
return (root, query, criteriaBuilder) -> {
if (status == null) {
return null;
}
return criteriaBuilder.equal(root.get("status"), status);
};
}
}
이 조각들을 서비스에서 조립한다.
// CustomerService.java
Specification<Customer> specification =
Specification.where(CustomerSpecification.keyword(keyword))
.and(CustomerSpecification.status(status));
Page<Customer> customerPage = customerRepository.findAll(specification, pageable);
각 조건은 "값이 있으면 조건을 만들고, 없으면 스스로 빠진다"는 계약을 지키는 독립된 조각이 된다. 조건이 추가되면 조각 하나만 새로 만들어 and로 끼우면 되고, 기존 코드는 건드릴 필요가 없다. 검색어만 오든, 상태만 오든, 둘 다 오든, 아무것도 안 오든 서비스 코드는 위 세 줄 그대로다.
다만 코드 양만 보면 JPQL 쪽이 훨씬 짧고 읽기 편하다. Specification은 조건 하나에도 클래스와 메서드가 따로 필요하다 — 그만큼 초기 비용은 Specification이 더 크다.
왜 Specification이 "구조적으로" 더 안전한가
Specification의 toPredicate()는 조건이 없을 때 null을 반환하도록 만들 수 있는데, 이때 그 조건은 조용히 제외되고 나머지 조건들만으로 쿼리가 조립된다. Spring Data JPA 공식 문서에도 이 동작이 명시되어 있다.
"Specifications returning null, such as unrestricted(), are considered to not contribute to the overall predicate, and their result is not considered in the final predicate."
(참고: 이 null 허용 동작은 Spring Data JPA 2.6 버전부터다. 그 이전 버전에서는 toPredicate()가 null을 반환하면 안 되는 제약이 있었으니, 오래된 프로젝트에서 이 패턴을 쓸 땐 버전을 먼저 확인해야 한다.)
그리고 두 방식의 실제 성능 차이는 거의 없다 — 결국 둘 다 Hibernate가 SQL로 변환해서 실행하기 때문에, 같은 조건이면 실행 계획도 비슷하다. 진짜 차이는 런타임 성능이 아니라 유지보수 단계의 안정성에서 나온다.
가장 대표적인 예가 페이징의 count 쿼리다. 위 customerRepository.findAll(specification, pageable) 한 줄은 목록을 가져오는 쿼리와 전체 개수를 세는 count 쿼리를 둘 다 실행하는데, JPQL 방식은 이 두 쿼리를 각각 따로 관리해야 해서 조건을 하나 추가했을 때 count 쿼리 쪽 수정을 깜빡하면 페이징 총 개수가 실제 데이터와 어긋나는 버그가 생길 수 있다.
Specification은 spec 객체 하나가 본 쿼리와 count 쿼리 양쪽에 그대로 재사용되도록 Spring Data JPA 내부(SimpleJpaRepository)에서 처리해주기 때문에, 이 버그 자체가 애초에 발생할 수 없는 구조가 된다.
하지만 Specification의 한계: "메서드 기반"이라는 말의 함정
Specification이 JPQL보다 안전하다고 할 때 근거는 "메서드 기반이라 잘못된 호출은 컴파일 단계에서 확인할 수 있다"는 것이다. 맞는 말이다 — JPQL은 쿼리 전체가 문자열 하나라 컴파일러가 들여다볼 방법이 없지만, Specification은 criteriaBuilder.equal(...) 같은 실제 메서드 호출이라 타입이 안 맞으면 컴파일 자체가 안 된다.
다만 root.get("name")처럼 필드 이름 자체는 여전히 문자열이다. 코드의 구조는 컴파일 타임에 검증되지만, 그 안에 들어가는 필드 이름까지 검증해주지는 않는다 — root.get("nmae")라고 오타를 내도 컴파일은 되고 런타임에야 드러난다.
필드 이름을 상수 클래스로 빼는 방법도 있지만, 이건 문자열을 한곳에 모아둘 뿐 오타 자체를 컴파일러가 잡아주지는 못한다는 한계가 있다(같은 지적이 여러 자료에서 확인된다).
진짜 컴파일 타임 검증을 하려면 JPA 정적 메타모델(Static Metamodel, Product_ 같은 생성 클래스)이 필요한데, 이것도 결국 QueryDSL처럼 별도 코드 생성기를 빌드에 붙여야 하는 방식이다.
즉 "문자열 기반이라 타입 안정성이 부족하다"는 한계를 메우려는 절충안(상수화)조차 근본적인 해결책은 아니며, 정적 메타모델과 QueryDSL이 정확히 이 문제를 풀기 위해 존재한다.
그래서 언제 어떤 방식을 쓰면 좋을까
지금까지 확인한 차이를 정리하면 이렇다.
- 조건이 적고 거의 바뀌지 않는 도메인이라면 JPQL이 낫다 — 초기 비용이 낮고 쿼리 전체가 한눈에 보인다.
- 조건이 많거나 앞으로 늘어날 가능성이 있는 도메인이라면 Specification이 낫다 — 조각을 재사용할 수 있고, count 쿼리 불일치 버그가 구조적으로 발생하지 않는다.
- 필드 이름의 타입 안정성까지 확보하고 싶거나 프로젝트 규모가 커진다면 QueryDSL을 검토할 가치가 있다. 다만 Q클래스 생성 같은 초기 셋업 비용이 있다.
Sources:
'Spring' 카테고리의 다른 글
| [Spring] 기존 Todo API 리팩터링 - Boot 4 마이그레이션부터 QueryDSL 검색까지 (0) | 2026.09.04 |
|---|---|
| [Spring Security] JWT 인증 성공 로그가 있는데 관리자 API가 401이었던 이유 (0) | 2026.09.04 |
| [Spring] GlobalExceptionHandler와 AOP 로깅 - 응답과 실패 기록의 책임 나누기 (0) | 2026.08.25 |
| [Spring] 엔티티와 DTO는 왜 타입이 달라도 되는가 (0) | 2026.07.28 |