Specification 패턴 — 검색 조건을 조합 가능한 객체로 만드는 방법
검색 필터가 5개, 10개로 늘어날 때마다 if-else로 쿼리를 분기하고 계신가요? 조건 하나하나를 레고 블록처럼 조합할 수 있다면 어떨까요?
Specification이란
Specification은 JPA Criteria API를 추상화해서 검색 조건 하나를 독립적인 객체 로 표현하는 패턴입니다. Eric Evans의 DDD(Domain-Driven Design)에서 나온 개념으로, Spring Data JPA가 이를 인터페이스로 제공합니다.
핵심 아이디어는 간단합니다. "이름에 '김'이 포함된다"는 조건과 "나이가 20 이상이다"는 조건을 각각 별도의 객체로 만들고, 필요에 따라 .and(), .or()로 조합하는 것입니다.
// 각 조건이 독립적인 Specification 객체
Specification<Product> byName = ProductSpec.nameContains("키보드");
Specification<Product> byPrice = ProductSpec.priceBetween(50000, 200000);
Specification<Product> byCategory = ProductSpec.categoryEq("전자기기");
// 레고 블록처럼 조합
Specification<Product> combined = Specification.where(byName)
.and(byPrice)
.and(byCategory);
List<Product> result = productRepository.findAll(combined);
기본 설정
외부 의존성 없이 Spring Data JPA만 있으면 됩니다. Repository에 JpaSpecificationExecutor를 상속하면 끝입니다.
public interface ProductRepository extends JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
}
이것만으로 다음 메서드들을 사용할 수 있습니다.
findAll(Specification<T> spec)— 조건에 맞는 엔티티 리스트findAll(Specification<T> spec, Pageable pageable)— 페이징findAll(Specification<T> spec, Sort sort)— 정렬findOne(Specification<T> spec)— 단건 조회count(Specification<T> spec)— 카운트exists(Specification<T> spec)— 존재 여부delete(Specification<T> spec)— 조건 삭제
Specification 작성법
Specification<T>는 함수형 인터페이스로, toPredicate() 메서드 하나만 구현하면 됩니다.
@FunctionalInterface
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
Root<T>: 쿼리의 루트 엔티티 — 필드에 접근할 때 사용CriteriaQuery<?>: 쿼리 자체 —distinct,orderBy등 설정CriteriaBuilder: 조건 생성기 —equal,like,greaterThan등
단일 조건 예시
public class ProductSpec {
// 이름 검색
public static Specification<Product> nameContains(String keyword) {
return (root, query, cb) ->
cb.like(root.get("name"), "%" + keyword + "%");
}
// 가격 범위
public static Specification<Product> priceBetween(Integer min, Integer max) {
return (root, query, cb) ->
cb.between(root.get("price"), min, max);
}
// 카테고리 일치
public static Specification<Product> categoryEq(String category) {
return (root, query, cb) ->
cb.equal(root.get("category").get("name"), category);
}
// 재고 여부
public static Specification<Product> inStock() {
return (root, query, cb) ->
cb.greaterThan(root.get("stockQuantity"), 0);
}
}
각 Specification이 단일 책임 을 가지는 것이 포인트입니다. 하나의 조건만 표현하고, 복잡한 검색은 조합으로 해결합니다.
Null 안전 패턴
동적 쿼리에서 가장 중요한 부분입니다. 사용자가 특정 필터를 입력하지 않았을 때(null) 해당 조건을 무시해야 합니다.
public class ProductSpec {
public static Specification<Product> nameContains(String keyword) {
return (root, query, cb) -> {
if (keyword == null || keyword.isBlank()) {
return cb.conjunction(); // 항상 true — 조건 무시
}
return cb.like(root.get("name"), "%" + keyword + "%");
};
}
public static Specification<Product> priceLoe(Integer maxPrice) {
return (root, query, cb) -> {
if (maxPrice == null) {
return cb.conjunction();
}
return cb.lessThanOrEqualTo(root.get("price"), maxPrice);
};
}
}
cb.conjunction()은 1=1과 같은 항상 true인 조건을 만듭니다. 반대로 항상 false가 필요하면 cb.disjunction()을 사용합니다.
공부하다 보니 이 null 처리를 빠뜨려서 NullPointerException이 터지는 경우가 많더라고요. Specification을 만들 때 null 체크를 습관적으로 넣는 게 좋습니다.
실무 예시 — 상품 검색 필터
REST API에서 쿼리 파라미터를 받아 동적 쿼리로 변환하는 전체 흐름을 보겠습니다.
검색 조건 DTO
@Getter
@Setter
public class ProductSearchCondition {
private String name; // 상품명 (부분 일치)
private Integer minPrice; // 최소 가격
private Integer maxPrice; // 최대 가격
private String category; // 카테고리
private Boolean inStock; // 재고 있는 상품만
}
Specification 클래스
public class ProductSpec {
public static Specification<Product> nameContains(String name) {
return (root, query, cb) ->
name == null ? cb.conjunction()
: cb.like(root.get("name"), "%" + name + "%");
}
public static Specification<Product> priceGoe(Integer minPrice) {
return (root, query, cb) ->
minPrice == null ? cb.conjunction()
: cb.greaterThanOrEqualTo(root.get("price"), minPrice);
}
public static Specification<Product> priceLoe(Integer maxPrice) {
return (root, query, cb) ->
maxPrice == null ? cb.conjunction()
: cb.lessThanOrEqualTo(root.get("price"), maxPrice);
}
public static Specification<Product> categoryEq(String category) {
return (root, query, cb) ->
category == null ? cb.conjunction()
: cb.equal(root.get("category").get("name"), category);
}
public static Specification<Product> inStock(Boolean flag) {
return (root, query, cb) -> {
if (flag == null || !flag) {
return cb.conjunction();
}
return cb.greaterThan(root.get("stockQuantity"), 0);
};
}
}
Service 계층
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public Page<Product> search(ProductSearchCondition condition, Pageable pageable) {
Specification<Product> spec = Specification
.where(ProductSpec.nameContains(condition.getName()))
.and(ProductSpec.priceGoe(condition.getMinPrice()))
.and(ProductSpec.priceLoe(condition.getMaxPrice()))
.and(ProductSpec.categoryEq(condition.getCategory()))
.and(ProductSpec.inStock(condition.getInStock()));
return productRepository.findAll(spec, pageable);
}
}
Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
@GetMapping
public Page<Product> search(
@ModelAttribute ProductSearchCondition condition,
@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC)
Pageable pageable) {
return productService.search(condition, pageable);
}
}
GET /api/products?name=키보드&minPrice=50000&inStock=true로 요청하면, 해당 조건만 적용된 동적 쿼리가 실행됩니다.
조인이 필요한 경우
연관 엔티티의 필드로 검색해야 할 때는 Root.join()을 사용합니다.
// 주문 엔티티에서 특정 회원이 주문한 것을 검색
public static Specification<Order> memberNameEq(String memberName) {
return (root, query, cb) -> {
if (memberName == null) {
return cb.conjunction();
}
Join<Order, Member> memberJoin = root.join("member", JoinType.INNER);
return cb.equal(memberJoin.get("name"), memberName);
};
}
한 가지 주의할 점이 있습니다. 같은 Specification 조합에서 같은 엔티티에 여러 번 조인하면 중복 조인이 발생 합니다. 이 경우 query.getRestriction()으로 기존 조인을 재사용하거나, Specification을 합쳐서 하나의 조인 안에서 처리해야 합니다.
// 같은 조인을 공유하는 복합 Specification
public static Specification<Order> memberConditions(String name, String email) {
return (root, query, cb) -> {
Join<Order, Member> memberJoin = root.join("member", JoinType.INNER);
List<Predicate> predicates = new ArrayList<>();
if (name != null) {
predicates.add(cb.equal(memberJoin.get("name"), name));
}
if (email != null) {
predicates.add(cb.like(memberJoin.get("email"), "%" + email + "%"));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
복잡한 조인이 여러 번 얽히기 시작하면 Specification의 장점이 퇴색됩니다. 이런 경우에는 QueryDSL이나 네이티브 쿼리가 더 나을 수 있습니다.
QueryDSL과 비교
| 기준 | Specification | QueryDSL |
|---|---|---|
| 외부 의존성 | 없음 (JPA 표준) | 있음 (라이브러리 + APT) |
| ** 코드 생성** | 불필요 | Q클래스 생성 필요 |
| ** 타입 안전성** | 낮음 (문자열 속성 참조) | 높음 (Q클래스) |
| ** 가독성** | 보통 (Criteria API 기반) | 좋음 (SQL과 유사) |
| ** 조합 용이성** | 좋음 (and/or 체이닝) | 좋음 (BooleanExpression) |
| ** 조인 처리** | 복잡 (중복 조인 주의) | 간편 |
| ** 학습 곡선** | 중간 | 중간 |
Specification을 선택하면 좋은 경우를 정리하면 이렇습니다.
- 외부 의존성을 최소화하고 싶을 때
- 검색 조건이 단순하고 조인이 많지 않을 때
- JPA 표준만으로 해결하고 싶을 때
- QueryDSL의 유지보수 불안정성이 걱정될 때
반대로 QueryDSL이 더 나은 경우는 이렇습니다.
- 복잡한 조인과 서브쿼리가 빈번할 때
- 문자열 기반 속성 참조의 오타가 걱정될 때
- DTO 프로젝션이 복잡할 때
Spring Data JPA 4.0 — PredicateSpecification
Spring Data JPA 4.0에서는 PredicateSpecification이라는 새로운 인터페이스가 도입되었습니다. 기존 Specification의 불편한 점을 개선한 것입니다.
// 기존 Specification — CriteriaQuery 파라미터가 있지만 대부분 사용하지 않음
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
// 새로운 PredicateSpecification — 더 간결
Predicate toPredicate(Root<T> root, CriteriaBuilder cb);
CriteriaQuery<?> 파라미터가 사라졌습니다. 실제로 대부분의 Specification에서 이 파라미터를 사용하지 않았기 때문에, 불필요한 파라미터를 제거하고 더 직관적으로 만든 것입니다.
// PredicateSpecification 사용 예시
PredicateSpecification<Product> nameContains = (root, cb) ->
cb.like(root.get("name"), "%키보드%");
PredicateSpecification<Product> inStock = (root, cb) ->
cb.greaterThan(root.get("stockQuantity"), 0);
// 조합
PredicateSpecification<Product> combined = nameContains.and(inStock);
추가로, CriteriaQuery 의존이 없어지면서 count 쿼리와 select 쿼리에 동일한 Specification을 문제 없이 공유할 수 있게 되었습니다. 기존에는 count 쿼리에서 query.select() 관련 이슈가 간혹 있었는데, 이 문제가 구조적으로 해결됩니다.
유틸리티 메서드로 반복 줄이기
매번 null 체크를 하는 것이 번거롭다면, 유틸리티 메서드를 만들어 반복을 줄일 수 있습니다.
public class SpecUtils {
/**
* 값이 null이 아닐 때만 Specification을 적용한다.
*/
public static <T> Specification<T> ifNotNull(Object value, Specification<T> spec) {
if (value == null) {
return (root, query, cb) -> cb.conjunction();
}
if (value instanceof String str && str.isBlank()) {
return (root, query, cb) -> cb.conjunction();
}
return spec;
}
}
// 사용
Specification<Product> spec = Specification
.where(SpecUtils.ifNotNull(name, ProductSpec.nameContains(name)))
.and(SpecUtils.ifNotNull(minPrice, ProductSpec.priceGoe(minPrice)))
.and(SpecUtils.ifNotNull(maxPrice, ProductSpec.priceLoe(maxPrice)));
이렇게 하면 각 Specification 내부에 null 체크를 넣지 않아도 됩니다. 팀에서 통일된 방식을 정해두면 코드 일관성도 좋아집니다.
기억해둘 포인트
- Specification은 외부 의존성 없이 JPA 표준만으로 동적 쿼리를 구현하는 방법입니다.
- 각 Specification은 ** 단일 조건만 표현 **하고,
and(),or()로 조합합니다. - null 안전 처리 가 필수입니다.
cb.conjunction()으로 조건을 무시하는 패턴을 기억하세요. - 복잡한 조인이 필요하면 Specification보다 QueryDSL이나 네이티브 쿼리가 더 적합할 수 있습니다.
- Spring Data JPA 4.0의
PredicateSpecification은CriteriaQuery파라미터를 제거해 더 간결하고 유연합니다.