Spring JPA Specification이란?
JPA Specification은 Criteria API를 감싼 타입 안전한 동적 쿼리 빌더다. 검색 조건이 런타임에 결정되는 필터링, 복합 검색, 관리자 페이지 등에서 if문 없이 조건을 조합할 수 있다. Spring Data JPA의 JpaSpecificationExecutor와 함께 사용한다.
1. 기본 Specification 구현
// Repository에 JpaSpecificationExecutor 추가
public interface ProductRepository extends
JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
}
// Specification 정의
public class ProductSpec {
public static Specification<Product> hasName(String name) {
return (root, query, cb) ->
cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
}
public static Specification<Product> hasCategory(String category) {
return (root, query, cb) ->
cb.equal(root.get("category"), category);
}
public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) ->
cb.between(root.get("price"), min, max);
}
public static Specification<Product> isActive() {
return (root, query, cb) ->
cb.isTrue(root.get("active"));
}
}
각 Specification은 (Root, CriteriaQuery, CriteriaBuilder) → Predicate 람다로, 단일 조건 하나를 표현한다.
2. 조건 조합: and·or·not
// Service에서 동적 조합
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
public Page<Product> search(ProductSearchDto dto, Pageable pageable) {
Specification<Product> spec = Specification.where(isActive());
if (dto.getName() != null) {
spec = spec.and(hasName(dto.getName()));
}
if (dto.getCategory() != null) {
spec = spec.and(hasCategory(dto.getCategory()));
}
if (dto.getMinPrice() != null && dto.getMaxPrice() != null) {
spec = spec.and(priceBetween(dto.getMinPrice(), dto.getMaxPrice()));
}
return repository.findAll(spec, pageable);
}
}
| 메서드 | 동작 | SQL |
|---|---|---|
spec.and(other) |
두 조건 AND | WHERE a AND b |
spec.or(other) |
두 조건 OR | WHERE a OR b |
Specification.not(spec) |
조건 부정 | WHERE NOT a |
Specification.where(spec) |
null 안전한 시작점 | null이면 조건 없음 |
3. Join과 연관 엔티티 필터링
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
@OneToMany(mappedBy = "product")
private List<Review> reviews;
}
public class ProductSpec {
// ManyToOne Join
public static Specification<Product> hasCategoryName(String categoryName) {
return (root, query, cb) -> {
Join<Product, Category> categoryJoin = root.join("category", JoinType.INNER);
return cb.equal(categoryJoin.get("name"), categoryName);
};
}
// OneToMany Join + 집계
public static Specification<Product> hasMinReviews(int minCount) {
return (root, query, cb) -> {
// count 쿼리에서 중복 방지
query.distinct(true);
Subquery<Long> subquery = query.subquery(Long.class);
Root<Review> reviewRoot = subquery.from(Review.class);
subquery.select(cb.count(reviewRoot))
.where(cb.equal(reviewRoot.get("product"), root));
return cb.greaterThanOrEqualTo(subquery, (long) minCount);
};
}
// Fetch Join (N+1 방지)
public static Specification<Product> fetchCategory() {
return (root, query, cb) -> {
// count 쿼리에서 fetch join 제외
if (Long.class != query.getResultType()) {
root.fetch("category", JoinType.LEFT);
}
return cb.conjunction();
};
}
}
⚠️ Specification에서 fetch()를 사용할 때 페이징과 함께 쓰면 count 쿼리에서 오류가 발생한다. query.getResultType() 체크로 이를 방지한다. JPA N+1 문제 해결법은 JPA N+1 해결 5가지 전략 글을 참고하자.
4. 제네릭 Specification 유틸
반복되는 패턴을 제네릭으로 추상화한다.
public class GenericSpec {
public static <T> Specification<T> equals(String field, Object value) {
return (root, query, cb) ->
value == null ? null : cb.equal(root.get(field), value);
}
public static <T> Specification<T> like(String field, String value) {
return (root, query, cb) ->
value == null ? null : cb.like(cb.lower(root.get(field)),
"%" + value.toLowerCase() + "%");
}
public static <T, C extends Comparable<C>> Specification<T> between(
String field, C min, C max
) {
return (root, query, cb) -> {
if (min != null && max != null) return cb.between(root.get(field), min, max);
if (min != null) return cb.greaterThanOrEqualTo(root.get(field), min);
if (max != null) return cb.lessThanOrEqualTo(root.get(field), max);
return null;
};
}
public static <T> Specification<T> in(String field, Collection<?> values) {
return (root, query, cb) ->
values == null || values.isEmpty() ? null : root.get(field).in(values);
}
public static <T> Specification<T> afterDate(String field, LocalDateTime date) {
return (root, query, cb) ->
date == null ? null : cb.greaterThanOrEqualTo(root.get(field), date);
}
}
// 사용: 간결한 동적 쿼리
Specification<Product> spec = Specification
.where(GenericSpec.<Product>like("name", dto.getName()))
.and(GenericSpec.equals("status", dto.getStatus()))
.and(GenericSpec.between("price", dto.getMinPrice(), dto.getMaxPrice()))
.and(GenericSpec.in("category", dto.getCategories()))
.and(GenericSpec.afterDate("createdAt", dto.getFromDate()));
null을 반환하는 Specification은 where/and에서 자동으로 무시된다. 이를 활용하면 if문 없이 조건을 조합할 수 있다.
5. Specification + Projection
// DTO Projection과 함께 사용
public interface ProductSummary {
Long getId();
String getName();
BigDecimal getPrice();
String getCategoryName();
}
// Custom Repository로 Specification + Projection 조합
@Repository
@RequiredArgsConstructor
public class ProductQueryRepository {
private final EntityManager em;
public List<ProductSummary> searchWithProjection(
Specification<Product> spec, Pageable pageable
) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<ProductSummary> query = cb.createQuery(ProductSummary.class);
Root<Product> root = query.from(Product.class);
// Specification 적용
Predicate predicate = spec.toPredicate(root, query, cb);
if (predicate != null) {
query.where(predicate);
}
// Projection
query.select(cb.construct(ProductSummaryDto.class,
root.get("id"),
root.get("name"),
root.get("price"),
root.join("category").get("name")
));
return em.createQuery(query)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
}
}
6. 정렬과 페이징 통합
// Controller
@GetMapping("/products")
public Page<Product> search(
@ModelAttribute ProductSearchDto dto,
@PageableDefault(size = 20, sort = "createdAt", direction = DESC) Pageable pageable
) {
return productService.search(dto, pageable);
}
// Specification 내부에서 정렬 추가
public static Specification<Product> orderByPopularity() {
return (root, query, cb) -> {
if (Long.class != query.getResultType()) {
query.orderBy(cb.desc(root.get("salesCount")), cb.asc(root.get("name")));
}
return cb.conjunction();
};
}
Spring Data의 트랜잭션 전파 전략과 함께 사용하면 더 견고한 쿼리 레이어를 구축할 수 있다. Spring Transaction 전파 전략 글도 참고하자.
마무리
JPA Specification은 동적 쿼리의 타입 안전성과 재사용성을 보장한다. 제네릭 유틸로 반복을 줄이고, null 반환으로 조건을 자동 스킵하며, fetch join과 projection을 조합하면 복잡한 검색 요구사항도 깔끔하게 구현할 수 있다.