Spring Data JPA Specification

동적 쿼리의 난제

관리자 화면의 검색 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)은 내부적으로 두 개의 쿼리를 실행한다:

  1. 데이터 조회 쿼리 (결과 타입 = 엔티티)
  2. 전체 개수 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 심화

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux