동적 쿼리의 난제
관리자 화면의 검색 API를 떠올려보자. 이름, 상태, 날짜 범위, 카테고리 — 조건이 있을 수도 없을 수도 있고, 조합은 수십 가지다. if문으로 JPQL을 조립하면 코드가 금세 스파게티가 되고, QueryDSL을 도입하자니 APT 설정과 Q-class 생성이 부담된다.
Spring Data JPA Specification은 이 문제를 JPA Criteria API 위에 함수형 조합 패턴으로 해결한다. 별도 라이브러리 없이, Spring Data가 이미 품고 있는 기능만으로 타입 세이프한 동적 쿼리를 작성할 수 있다.
Specification 인터페이스 구조
// Spring Data JPA 내장 인터페이스
public interface Specification<T> extends Serializable {
@Nullable
Predicate toPredicate(
Root<T> root,
CriteriaQuery<?> query,
CriteriaBuilder cb
);
}
핵심은 단 하나 — toPredicate() 메서드다. Root는 FROM 절의 엔티티, CriteriaBuilder는 조건을 만드는 팩토리, CriteriaQuery는 전체 쿼리에 대한 참조다. 이 세 가지로 WHERE 절의 모든 조건을 표현한다.
기본 Specification 작성
단일 조건 Spec
public class OrderSpecs {
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) ->
cb.equal(root.get("status"), status);
}
public static Specification<Order> createdAfter(LocalDateTime from) {
return (root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("createdAt"), from);
}
public static Specification<Order> createdBefore(LocalDateTime to) {
return (root, query, cb) ->
cb.lessThan(root.get("createdAt"), to);
}
public static Specification<Order> totalAmountBetween(
BigDecimal min, BigDecimal max) {
return (root, query, cb) ->
cb.between(root.get("totalAmount"), min, max);
}
public static Specification<Order> customerNameLike(String keyword) {
return (root, query, cb) ->
cb.like(
cb.lower(root.get("customerName")),
"%" + keyword.toLowerCase() + "%"
);
}
}
각 메서드가 하나의 조건만 담당한다. 이것이 Specification 패턴의 핵심 — 조건을 원자 단위로 분리하고, 조합은 호출부에서 한다.
Repository 설정
public interface OrderRepository
extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> {
// JpaSpecificationExecutor를 상속하면
// findAll(Specification), findOne(Specification),
// count(Specification) 등이 자동으로 사용 가능
}
JpaSpecificationExecutor<T>를 상속하는 것만으로 Specification 기반 조회 메서드가 활성화된다. 별도 구현 클래스가 필요 없다.
조건 조합: and, or, not
Specification의 진짜 힘은 함수형 조합에 있다.
@Service
@RequiredArgsConstructor
public class OrderSearchService {
private final OrderRepository orderRepository;
public Page<Order> search(OrderSearchDto dto, Pageable pageable) {
Specification<Order> spec = Specification.where(null);
// where(null)은 항상 true — 초기값 역할
if (dto.getStatus() != null) {
spec = spec.and(OrderSpecs.hasStatus(dto.getStatus()));
}
if (dto.getFromDate() != null) {
spec = spec.and(OrderSpecs.createdAfter(dto.getFromDate()));
}
if (dto.getToDate() != null) {
spec = spec.and(OrderSpecs.createdBefore(dto.getToDate()));
}
if (dto.getMinAmount() != null && dto.getMaxAmount() != null) {
spec = spec.and(
OrderSpecs.totalAmountBetween(
dto.getMinAmount(), dto.getMaxAmount()
)
);
}
if (StringUtils.hasText(dto.getKeyword())) {
spec = spec.and(
OrderSpecs.customerNameLike(dto.getKeyword())
.or(OrderSpecs.orderNumberLike(dto.getKeyword()))
);
}
return orderRepository.findAll(spec, pageable);
}
}
핵심 포인트:
Specification.where(null)은 “조건 없음”을 의미한다. 모든 조건이 null이면 전체 조회가 된다..and(),.or(),.not()으로 논리 연산을 체이닝한다.- 조건별 null 체크로 동적 쿼리가 자연스럽게 구현된다 — if문이 있지만 JPQL 문자열 조립이 아니라 타입 세이프한 Predicate 조합이다.
JOIN 처리: fetch join과 N+1 방지
Specification에서 가장 실수가 많은 부분이 JOIN이다.
// ❌ 잘못된 방법: 매번 새로운 JOIN 생성 → 카테시안 곱 위험
public static Specification<Order> hasItemName(String itemName) {
return (root, query, cb) -> {
Join<Order, OrderItem> items = root.join("items");
return cb.like(items.get("name"), "%" + itemName + "%");
};
}
// ✅ 올바른 방법: 기존 JOIN 재사용 + count 쿼리 구분
public static Specification<Order> hasItemName(String itemName) {
return (root, query, cb) -> {
// Pageable 사용 시 count 쿼리에서는 fetch join 불가
if (Long.class != query.getResultType()) {
root.fetch("items", JoinType.LEFT);
}
Join<Order, OrderItem> items = root.join("items", JoinType.LEFT);
return cb.like(items.get("name"), "%" + itemName + "%");
};
}
// ✅✅ 최선의 방법: JOIN과 FETCH를 분리
public static Specification<Order> fetchItems() {
return (root, query, cb) -> {
if (Long.class != query.getResultType()) {
root.fetch("items", JoinType.LEFT);
}
return cb.conjunction(); // 항상 true
};
}
public static Specification<Order> hasItemName(String itemName) {
return (root, query, cb) -> {
Join<Order, OrderItem> items = root.join("items", JoinType.LEFT);
return cb.like(items.get("name"), "%" + itemName + "%");
};
}
query.getResultType() 체크가 왜 필요한가?
Spring Data의 findAll(Specification, Pageable)은 내부적으로 두 개의 쿼리를 실행한다:
- 데이터 조회 쿼리 (결과 타입 = 엔티티)
- 전체 개수 count 쿼리 (결과 타입 =
Long)
count 쿼리에서 fetch join을 사용하면 Hibernate가 "query specified join fetching, but the owner of the fetched association was not present in the select list" 에러를 던진다. 따라서 Long.class != query.getResultType()로 count 쿼리일 때는 fetch를 건너뛰어야 한다.
Metamodel로 타입 안전성 확보
문자열 기반 필드 접근(root.get("status"))은 오타에 취약하다. JPA Metamodel을 사용하면 컴파일 타임에 필드명을 검증할 수 있다.
// Metamodel 사용 전
root.get("status") // 오타 → 런타임 에러
// Metamodel 사용 후
root.get(Order_.status) // 오타 → 컴파일 에러
// Metamodel 적용 Specification
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) ->
cb.equal(root.get(Order_.status), status);
}
public static Specification<Order> createdBetween(
LocalDateTime from, LocalDateTime to) {
return (root, query, cb) ->
cb.between(root.get(Order_.createdAt), from, to);
}
Metamodel 클래스(Order_)는 hibernate-jpamodelgen 의존성을 추가하면 컴파일 시 자동 생성된다:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<scope>provided</scope>
</dependency>
서브쿼리와 고급 조건
// EXISTS 서브쿼리: "특정 상품을 주문한 적 있는 주문"
public static Specification<Order> hasProduct(Long productId) {
return (root, query, cb) -> {
Subquery<Long> subquery = query.subquery(Long.class);
Root<OrderItem> itemRoot = subquery.from(OrderItem.class);
subquery.select(cb.literal(1L))
.where(
cb.equal(itemRoot.get("order"), root),
cb.equal(itemRoot.get("productId"), productId)
);
return cb.exists(subquery);
};
}
// IN 절: 여러 상태 중 하나
public static Specification<Order> statusIn(List<OrderStatus> statuses) {
return (root, query, cb) ->
root.get("status").in(statuses);
}
// NULL 체크
public static Specification<Order> hasShippingAddress() {
return (root, query, cb) ->
cb.isNotNull(root.get("shippingAddress"));
}
// 집계 조건 (HAVING 절)
public static Specification<Order> itemCountGreaterThan(int count) {
return (root, query, cb) -> {
Subquery<Long> sub = query.subquery(Long.class);
Root<OrderItem> itemRoot = sub.from(OrderItem.class);
sub.select(cb.count(itemRoot))
.where(cb.equal(itemRoot.get("order"), root));
return cb.greaterThan(sub, (long) count);
};
}
Specification + Projection: 필요한 컬럼만 조회
엔티티 전체를 로딩하면 불필요한 필드까지 가져온다. Specification과 Projection을 결합하면 SELECT 절을 제한할 수 있다.
// Interface-based Projection
public interface OrderSummary {
Long getId();
String getCustomerName();
OrderStatus getStatus();
BigDecimal getTotalAmount();
}
// Repository 메서드
public interface OrderRepository
extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> {
// Spring Data 2.7+: Specification + Projection 조합 가능
<T> List<T> findAll(Specification<Order> spec, Class<T> type);
}
// 사용
List<OrderSummary> summaries =
orderRepository.findAll(spec, OrderSummary.class);
주의: Specification + Projection + Pageable 세 가지를 동시에 사용하는 것은 Spring Data JPA 기본 메서드로는 지원되지 않는다. 이 경우 커스텀 Repository 구현이 필요하다.
테스트 전략
@DataJpaTest
class OrderSpecsTest {
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp() {
orderRepository.saveAll(List.of(
Order.of("김철수", OrderStatus.CREATED,
BigDecimal.valueOf(50000), LocalDateTime.of(2026, 1, 1, 0, 0)),
Order.of("이영희", OrderStatus.CONFIRMED,
BigDecimal.valueOf(120000), LocalDateTime.of(2026, 1, 15, 0, 0)),
Order.of("박지민", OrderStatus.SHIPPED,
BigDecimal.valueOf(30000), LocalDateTime.of(2026, 2, 1, 0, 0))
));
}
@Test
void 상태_필터링() {
var spec = OrderSpecs.hasStatus(OrderStatus.CONFIRMED);
var result = orderRepository.findAll(spec);
assertThat(result).hasSize(1);
assertThat(result.get(0).getCustomerName()).isEqualTo("이영희");
}
@Test
void 복합_조건_AND() {
var spec = Specification.where(OrderSpecs.hasStatus(OrderStatus.CREATED))
.and(OrderSpecs.totalAmountBetween(
BigDecimal.valueOf(10000), BigDecimal.valueOf(60000)
));
var result = orderRepository.findAll(spec);
assertThat(result).hasSize(1);
}
@Test
void 모든_조건_null이면_전체_조회() {
var spec = Specification.<Order>where(null);
var result = orderRepository.findAll(spec);
assertThat(result).hasSize(3);
}
@Test
void OR_조합() {
var spec = OrderSpecs.hasStatus(OrderStatus.CREATED)
.or(OrderSpecs.hasStatus(OrderStatus.SHIPPED));
var result = orderRepository.findAll(spec);
assertThat(result).hasSize(2);
}
}
Specification 단위 테스트는 @DataJpaTest로 실제 쿼리 실행을 검증한다. 각 Spec이 독립적이므로 조합별 테스트도 용이하다.
실무 설계 패턴과 Anti-Pattern
✅ 권장 패턴
| 패턴 | 설명 |
|---|---|
| Spec 메서드를 static 팩토리로 | 재사용성과 가독성 확보. OrderSpecs.hasStatus() |
| null-safe wrapper | 조건값이 null이면 빈 Spec 반환하는 유틸 메서드 |
| fetch join 전용 Spec 분리 | 조건과 fetch를 분리하면 count 쿼리 문제 회피 |
| Metamodel 사용 | 컴파일 타임 필드명 검증 |
| DTO → Spec 변환 레이어 | Controller DTO를 Service에서 Spec으로 변환 |
❌ Anti-Pattern
| Anti-Pattern | 문제 | 해결 |
|---|---|---|
| 하나의 Spec에 여러 조건 | 재사용 불가, 테스트 어려움 | 단일 조건 원칙 준수 |
| Spec 안에서 비즈니스 로직 | 관심사 혼합 | Spec은 순수 쿼리 조건만 |
| fetch join을 조건 Spec에 포함 | count 쿼리 실패 | fetch 전용 Spec 분리 |
| 모든 쿼리에 Specification 사용 | 단순 쿼리에 과도한 복잡도 | 고정 조건은 @Query가 더 명확 |
| root.get() 중첩으로 깊은 경로 | N+1, 예측 불가 JOIN | 명시적 JOIN 후 get() |
null-safe wrapper 유틸
public class SpecUtils {
public static <T> Specification<T> ifNotNull(
Object value, Specification<T> spec) {
return value != null ? spec : Specification.where(null);
}
public static <T> Specification<T> ifHasText(
String value, Specification<T> spec) {
return StringUtils.hasText(value) ? spec : Specification.where(null);
}
}
// 사용: if문 없이 깔끔하게
Specification<Order> spec = Specification.where(null)
.and(ifNotNull(dto.getStatus(),
OrderSpecs.hasStatus(dto.getStatus())))
.and(ifNotNull(dto.getFromDate(),
OrderSpecs.createdAfter(dto.getFromDate())))
.and(ifHasText(dto.getKeyword(),
OrderSpecs.customerNameLike(dto.getKeyword())));
Specification vs QueryDSL vs JPQL
| 기준 | Specification | QueryDSL | @Query JPQL |
|---|---|---|---|
| 동적 쿼리 | ✅ 함수 조합 | ✅ BooleanExpression | ❌ 문자열 조합 |
| 타입 안전성 | △ Metamodel 필요 | ✅ Q-class 자동 | ❌ 런타임 검증 |
| 외부 의존성 | ✅ 없음 | ❌ querydsl-jpa, APT | ✅ 없음 |
| 가독성 | △ Criteria API 장황 | ✅ SQL과 유사 | ✅ 직관적 |
| 학습 곡선 | 중간 | 낮음 | 낮음 |
| 적합한 상황 | 동적 검색 필터 | 복잡한 동적 쿼리 | 고정 조건 쿼리 |
추천 전략: 고정 조건 조회는 @Query, 동적 필터는 Specification, 복잡한 동적 쿼리 + 서브쿼리가 많으면 QueryDSL. 세 가지를 혼용해도 전혀 문제없다 — 같은 Repository에서 모두 사용할 수 있다.
마치며
Spring Data JPA Specification은 별도 라이브러리 없이 동적 쿼리를 타입 세이프하게 구현할 수 있는 실용적인 도구다. 조건을 원자 단위로 분리하고, and·or·not으로 조합하는 함수형 접근은 코드의 재사용성과 테스트 용이성을 크게 높인다.
다만 Criteria API의 장황함은 피할 수 없는 트레이드오프다. Metamodel 적용, fetch join 분리, null-safe wrapper 같은 실무 패턴으로 보완하면서, 프로젝트의 복잡도에 맞게 QueryDSL이나 @Query와 적절히 혼용하는 것이 가장 현실적인 전략이다.
관련 글: NestJS + MikroORM QueryBuilder 심화 | NestJS + TypeORM QueryBuilder 심화