Spring Data JPA 동적 쿼리 비교: @Query, Specification, Querydsl

최정윤·2026년 7월 20일

Spring

목록 보기
20/38

검색 조건은 왜 동적으로 처리해야 하는가

관리자 페이지의 검색 조건은 항상 모두 입력되는 것이 아니다.

상품 목록을 예로 들면 사용자는 상품명만 검색할 수도 있고, 카테고리와 상태를 함께 선택할 수도 있으며, 아무 조건 없이 전체 목록을 조회할 수도 있다.

조건 없음
→ 전체 상품 조회

keyword만 입력
→ 상품명 검색

category + status 입력
→ 카테고리와 판매 상태를 함께 적용

조건 조합마다 별도의 Repository 메서드를 만들면 조건이 늘어날수록 관리해야 할 코드가 빠르게 증가한다. 이를 해결하기 위해 Spring Data JPA에서는 @Query, Specification, Querydsl과 같은 여러 방식을 사용할 수 있다.

이번 비교에서는 같은 상품 검색 기능을 세 방식으로 표현하고, 검색 조건이 3개일 때와 10개일 때 구조가 어떻게 달라지는지 분석했다.

실제 팀 프로젝트의 고객 검색에는 Specification을 적용했으며, @Query와 Querydsl은 동일한 요구사항을 기준으로 비교한 예시다.


비교 조건 통일하기

페이징과 정렬은 세 방식 모두 동일한 Pageable을 사용하고, 검색 조건만 다르게 구현한다.

조건 3개

조건의미
keyword상품명 검색
category카테고리 필터
status판매 상태 필터

조건 10개

조건의미
keyword상품명 검색
category카테고리 필터
status판매 상태 필터
minPrice최소 가격
maxPrice최대 가격
minStock최소 재고
maxStock최대 재고
createdFrom등록일 시작
createdTo등록일 종료
adminId등록 관리자

최종적으로 생성하려는 SQL의 의미는 세 방식이 같다.

WHERE name LIKE '%노트북%'
  AND category = '전자기기'
  AND status = 'ON_SALE'
  AND price >= 100000
  AND price <= 2000000

차이는 결과가 아니라 조건을 표현하고 변경하는 방식에 있다.


문자열로 쿼리를 직접 작성하는 @Query

@QueryRepository 메서드 위에 JPQL을 문자열로 직접 작성한다.

조건 3개

@Query("""
    SELECT p
    FROM Product p
    WHERE (:keyword IS NULL
           OR p.name LIKE CONCAT('%', :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
);

조건이 세 개일 때는 쿼리 전체가 짧고, 어떤 SQL이 실행될지 한눈에 예상하기 쉽다.

조건 10개

@Query("""
    SELECT p
    FROM Product p
    WHERE (:keyword IS NULL
           OR p.name LIKE CONCAT('%', :keyword, '%'))
      AND (:category IS NULL OR p.category = :category)
      AND (:status IS NULL OR p.status = :status)
      AND (:minPrice IS NULL OR p.price >= :minPrice)
      AND (:maxPrice IS NULL OR p.price <= :maxPrice)
      AND (:minStock IS NULL OR p.stock >= :minStock)
      AND (:maxStock IS NULL OR p.stock <= :maxStock)
      AND (:createdFrom IS NULL OR p.createdAt >= :createdFrom)
      AND (:createdTo IS NULL OR p.createdAt <= :createdTo)
      AND (:adminId IS NULL OR p.admin.id = :adminId)
""")
Page<Product> search(
        String keyword,
        String category,
        ProductStatus status,
        Integer minPrice,
        Integer maxPrice,
        Integer minStock,
        Integer maxStock,
        LocalDateTime createdFrom,
        LocalDateTime createdTo,
        Long adminId,
        Pageable pageable
);

조건이 열 개로 늘어나도 코드 길이는 세 방식 중 비교적 짧다. 그러나 JPQL 문자열과 메서드 파라미터가 함께 길어지면서 다음 부담이 생긴다.

AND·OR와 괄호 확인
파라미터 이름 연결 확인
같은 자료형의 파라미터 순서 확인
조건 추가 시 기존 쿼리 전체 재검토

@Query는

  • 직관성과 경제성이 높지만
  • 문자열 오타를 컴파일러가 확인하지 못하며,
  • 조건이 늘어나면 한 덩어리의 쿼리를 수정해야 한다.

조건을 부품으로 분리하는 Specification

Specification조건 하나를 독립된 메서드로 만든 뒤 필요한 조건을 조합한다.

조건 메서드

public static Specification<Product> minPrice(
        Integer minPrice
) {
    return (root, query, criteriaBuilder) -> {

        if (minPrice == null) {
            return null;
        }

        return criteriaBuilder.greaterThanOrEqualTo(
                root.get("price"),
                minPrice
        );
    };
}

값이 없으면 null을 반환하고, Spring Data JPA는 그 조건을 최종 WHERE 절에서 제외한다.

조건 3개

Specification<Product> specification =
        Specification
                .where(keyword(condition.getKeyword()))
                .and(category(condition.getCategory()))
                .and(status(condition.getStatus()));

조건 10개

Specification<Product> specification =
        Specification
                .where(keyword(condition.getKeyword()))
                .and(category(condition.getCategory()))
                .and(status(condition.getStatus()))
                .and(minPrice(condition.getMinPrice()))
                .and(maxPrice(condition.getMaxPrice()))
                .and(minStock(condition.getMinStock()))
                .and(maxStock(condition.getMaxStock()))
                .and(createdFrom(condition.getCreatedFrom()))
                .and(createdTo(condition.getCreatedTo()))
                .and(adminId(condition.getAdminId()));

실제 조회를 위해 Repository에는 실행 기능이 필요하다.

public interface ProductRepository
        extends JpaRepository<Product, Long>,
                JpaSpecificationExecutor<Product> {
}
Page<Product> products =
        productRepository.findAll(
                specification,
                pageable
        );

조건 세 개일 때는 별도 클래스와 Criteria 문법 때문에 @Query보다 과하게 느껴질 수 있다. 반면 조건이 열 개로 늘어나면 각 조건이 작은 메서드로 분리되어 있다는 장점이 드러난다.

문제가 발생했을 때 긴 JPQL 전체가 아니라 해당 조건 메서드만 확인할 수 있고, 같은 조건을 다른 조회 기능에서도 재사용할 수 있다. 다만 코드량과 초기 학습 비용이 크고, root.get("price")의 필드명은 여전히 문자열이라는 한계가 있다.


Entity 필드를 Java 코드로 참조하는 Querydsl

QuerydslEntity를 기준으로 생성된 Q 타입을 사용한다.

QProduct product = QProduct.product;

조건은 BooleanExpression 메서드로 분리할 수 있다.

private BooleanExpression priceGoe(
        Integer minPrice
) {
    return minPrice == null
            ? null
            : product.price.goe(minPrice);
}

조건 3개

queryFactory
        .selectFrom(product)
        .where(
                keywordContains(condition.getKeyword()),
                categoryEq(condition.getCategory()),
                statusEq(condition.getStatus())
        )
        .fetch();

조건 10개

queryFactory
        .selectFrom(product)
        .where(
                keywordContains(condition.getKeyword()),
                categoryEq(condition.getCategory()),
                statusEq(condition.getStatus()),
                priceGoe(condition.getMinPrice()),
                priceLoe(condition.getMaxPrice()),
                stockGoe(condition.getMinStock()),
                stockLoe(condition.getMaxStock()),
                createdAtGoe(condition.getCreatedFrom()),
                createdAtLoe(condition.getCreatedTo()),
                adminIdEq(condition.getAdminId())
        )
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

Querydsl도 값이 없는 조건을 null로 반환하여 동적 조합을 처리할 수 있다.(Specification과 동일)

Specification과 가장 큰 차이는 다음과 같이 Entity 필드를 Java 필드로 참조한다는 것이다.

product.price
product.stock
product.createdAt

따라서 존재하지 않는 필드를 작성하면 컴파일 단계에서 오류를 확인할 수 있다.

product.price; // 컴파일 오류

반면 Querydsl을 사용하려면

  • 의존성,
  • Annotation Processor,
  • Q 클래스 생성,
  • JPAQueryFactory,
  • Custom Repository 구조

가 필요하다. 조건 세 개 수준에서는 이 설정 비용이 가장 크게 느껴진다.


조건 수에 따른 차이

비교 기준조건 3개조건 10개
@Query짧고 직관적문자열·파라미터 관리 부담 증가
Specification초기 코드량이 크게 느껴짐조건 분리와 재사용의 장점이 드러남
Querydsl설정 비용이 과하게 느껴짐타입 안정성과 복잡한 확장에 강함
가장 짧은 코드@Query대체로 @Query
변경 범위세 방식의 차이가 작음Specification·Querydsl이 유리
필드 오타 방어@Query, Specification 모두 제한적Querydsl이 가장 유리

조건이 3개일 때는 @Query의 초기 경제성이 높지만,
조건이 10개로 증가하면 Specification과 Querydsl의 조건 분리·확장성·타입 안정성 장점이 커지는 흐름을 표현한 개념 그래프

조건이 3개일 때는 @Query의 장점이 대부분 유지되고 Specification의 초기 비용이 상대적으로 크게 느껴진다.

조건이 10개 수준으로 늘어자면 @Query의 한 문자열 구조는 수정 범위를 넓히지만,
Specification은 긴 코드를 작은 조건 조각으로 분리하여 관리할 수 있다.


필드명 오타는 언제 발견되는가?

방식발견 시점
@Query애플리케이션 시작 또는 쿼리 검증 과정
Specification해당 조건이 실제 실행되는 시점
QuerydslJava 컴파일 시점

@Query의 JPQL과 Specification의 root.get()은 필드 정보를 문자열로 표현한다. 따라서 Java 문법상 정상 문자열이면 컴파일을 통과할 수 있다.

반면 Querydsl의 product.price는 Q 타입의 Java 필드이기 때문에 존재하지 않는 필드를 참조할 수 없다.

Java 메서드나 타입이 틀림
→ 컴파일 오류

문자열 속 필드명·쿼리 의미가 틀림
→ 시작 또는 실행 오류

Specification도 JPA 정적 메타모델을 적용하면 다음과 같이 문자열 사용을 줄일 수 있다.

root.get(Product_.price)

따라서 타입 안정성은 단순히 기술 이름만이 아니라 실제 작성 방식에 따라서도 달라진다.


11번째 조건을 추가한다면??

productNameExact 라는 완전 일치 조건을 새로 추가한다고 가정한다.

방식수정 범위
@QueryJPQL 조건 추가, 메서드 파라미터 추가, 호출부 수정
Specification조건 메서드 추가, Service 조합부에 .and() 추가
Querydsl조건 메서드 추가, where()에 전달

SpecificationQuerydsl기존 코드를 전혀 수정하지 않는 것은 아니다. 조합 부분에는 새 조건을 연결해야 한다.

다만 긴 쿼리 전체를 직접 수정하는 것보다 변경 범위가 좁고, 추가된 조건의 위치와 역할을 구분하기 쉽다.


실무에서는 무엇을 선택하는가?

3 방식 중 하나가 항상 정답인 것은 아니다.

상황우선 고려할 방식
조건 1~3개, 변경 가능성 낮음쿼리 메서드 또는 @Query
JPA Entity의 간단한 동적 필터Specification
조건·JOIN·DTO Projection이 복잡함Querydsl
SQL과 DB 기능을 직접 제어해야 함MyBatis, jOOQ, Native SQL
형태소·관련도·자동완성 검색Elasticsearch, OpenSearch

실무에서는 Specification을 사용하지 않는다? 는 말은 정확하지 않다.
Specification을 사용하는 프로젝트도 있으며, Spring Data JPA 안에서 추가 라이브러리 없이 동적 조건을 처리할 수 잇다는 장점이 있다.

다만 조건이 복잡하고 JOIN이나 DTO Projection이 많아질수록 Querydsl을 선호하는 팀도 있다.

결국 선택 기준은 코드 길이가 아니라 다음과 같다.

조건이 얼마나 복잡한가
앞으로 얼마나 자주 변경되는가
조건을 다른 곳에서 재사용하는가
타입 안정성이 얼마나 중요한가
팀이 어떤 기술에 익숙한가

조건이 적고 고정적이면 현재 코드가 짧은 @Query 가 유리하고,
조건이 많거나 계속 증가한다면 나중에 변경하기 쉬운 구조가 더 중요해진다.


프로젝트에 적용한 판단

팀 프로젝트의 고객 검색 조건은 다음 두 가지였다.

keyword
status

이 정도 조건이라면 @Query로도 충분히 구현할 수 있었다.

그러나 관리자 검색이 이미 Specification 구조로 구현되어 있었고, 고객 검색도 선택 조건을 조합하는 형태가 같았다. 따라서 프로젝트 구조의 일관성과 이후 조건 추가 가능성을 고려하여 Specification을 선택했다.

Querydsl은 타입 안정성이 분명한 장점이지만, 현재 프로젝트의 일정과 검색 복잡도를 고려하면 설정 비용이 더 크다고 판단해 최종 코드에는 적용하지 않았다.

최종 프로젝트
→ Specification

비교 분석
→ @Query, Specification, Querydsl

추후 복잡한 조회
→ Querydsl 검토

다음 보완 방향

이번 비교는 단인 Product Entity의 선택 조건 개수만 변화시켰다.

다음 단계에서는 동일한 조건 결과를 실제로 구현하여 다음 항목을 측정할 수 있다.

전체 코드 줄 수
파일 개수
11번째 조건 추가 시 수정 위치
필드명 오타의 발견 시점
생성된 Hibernate SQL
조회 결과와 페이지 정보

또한 고객별 주문 수, 구매 총액처럼 JOIN·집계·DTO Projection 이 필요한 조회에서는 세 방식의 차이가 더 크게 나타나는지 추가로 비교할 수 있다.


마무리

조건이 3개일 때는 @Query가 가장 짧고 직관적이었다.
Specification과 Querydsl은 별도 구조와 문법이 필요해 초기 비용이 크게 느껴졌다.

조건을 10개로 늘리자 기준이 달라졌다.
@Query는 여전히 코드가 짧았지만 문자열과 파라미터를 함께 관리해야 하는 부담이 증가했다.
Specification은 조건을 독립된 메서드로 분리해 수정 범위를 좁힐 수 있었고,
Querydsl은 Q 타입을 통해 필드 오타를 컴파일 단계에서 확인할 수 있었다.

따라서 세 기술은 우열 관계가 아니라 사용 범위가 다르다.
단순하고 고정된 검색에는 @Query가 경제적이고,
JPA 안에서 선택 조건을 분리하고 싶다면 Specification이 적절하며,
복잡한 동적 조회와 타입 안정성이 중요하다면 Querydsl이 유리하다.

동적 쿼리 도구를 선택할 때는 지금 작성할 코드의 길이뿐 아니라, 검색 조건이 앞으로 얼마나 증가하고 변경될지를 함께 고려해야 한다.

profile
콩떡

0개의 댓글