본문으로 건너뛰기
김신건의 로그

[JPA] 동적 쿼리: Criteria API, QueryDSL, Specification

· 수정 · 📖 약 2분 · 702자/단어 #jpa #hibernate #jpql #criteria #querydsl #specification
JPA Criteria API, QueryDSL, Specification, 동적 쿼리, type-safe query, dynamic JPQL, JPA dynamic query

정의

동적 쿼리 = 런타임 조건에 따라 WHERE / JOIN 이 달라지는 쿼리. 검색 필터, admin 도구, 리포팅 등에서 흔함.

// 정적: 모든 조건이 query 에 박혀 있음
List<User> findActiveByEmail(String email);

// 동적: 사용자 입력에 따라 조건 추가 / 제거
List<User> search(@Nullable String email, @Nullable String city, @Nullable Integer minAge, ...);

JPQL string concatenation 으로 만들 수도 있지만:

  • SQL injection 위험
  • 가독성 ↓ (분기마다 + ” AND …”)
  • 컴파일 타임 검증 X

해결책 3가지:

  1. Criteria API (JPA 표준)
  2. QueryDSL (가장 인기)
  3. Spring Data Specification (Criteria API 의 래퍼)

각자 트레이드오프.

비교 한눈에

항목Criteria APIQueryDSLSpecification
표준JPA 표준Third-partySpring Data
가독성낮음 (verbose)높음 (SQL-like)중간
타입 안전부분 (metamodel 필요)완전 (Q 클래스 생성)부분
빌드 의존없음 (또는 annotation processor)annotation processor없음
동적 조립가능 (장황)매우 쉬움 (BooleanBuilder)가능 (Specification.where)
Spring 통합직접별도 설정native
학습 곡선가파름완만완만

현실: 새 프로젝트라면 QueryDSL 또는 Specification, legacy 코드면 Criteria API.

Criteria API 기본

@PersistenceContext
EntityManager em;

public List<User> search(String name, Integer minAge) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<User> cq = cb.createQuery(User.class);
    Root<User> u = cq.from(User.class);

    List<Predicate> predicates = new ArrayList<>();
    if (name != null) {
        predicates.add(cb.like(u.get("name"), "%" + name + "%"));
    }
    if (minAge != null) {
        predicates.add(cb.greaterThanOrEqualTo(u.get("age"), minAge));
    }
    cq.where(predicates.toArray(new Predicate[0]));
    cq.orderBy(cb.desc(u.get("createdAt")));

    return em.createQuery(cq).getResultList();
}

문제:

  • u.get("name") : string 기반 (타입 안전 X, 오타 위험)
  • 길고 verbose

해결: JPA Metamodel

hibernate-jpamodelgen 같은 annotation processor 가 User_ 같은 metamodel 생성:

annotationProcessor("org.hibernate.orm:hibernate-jpamodelgen")
// 자동 생성: User_.java
predicates.add(cb.like(u.get(User_.name), "%" + name + "%"));      // 타입 안전
predicates.add(cb.greaterThanOrEqualTo(u.get(User_.age), minAge));

여전히 boilerplate 많지만 타입 안전 ✓.

JOIN with Criteria

Join<User, Post> post = u.join("posts");
cq.select(u).where(cb.gt(cb.count(post), 10L));
cq.groupBy(u.get("id"));

JPQL 의 JOIN u.posts p 대응. fluent 하지만 가독성 ↓.

QueryDSL (가장 인기)

annotation processor 가 Q 클래스 자동 생성:

annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")

./gradlew compileJavaQUser, QPost 자동 생성.

@Repository
public class UserRepository {
    @PersistenceContext EntityManager em;

    public List<User> search(String name, Integer minAge, String city) {
        JPAQueryFactory query = new JPAQueryFactory(em);
        QUser u = QUser.user;

        return query.selectFrom(u)
            .where(
                nameContains(name),
                ageGoe(minAge),
                cityEq(city)
            )
            .orderBy(u.createdAt.desc())
            .fetch();
    }

    private BooleanExpression nameContains(String name) {
        return name == null ? null : QUser.user.name.contains(name);
    }
    private BooleanExpression ageGoe(Integer age) {
        return age == null ? null : QUser.user.age.goe(age);
    }
    private BooleanExpression cityEq(String city) {
        return city == null ? null : QUser.user.city.eq(city);
    }
}

핵심: where()null 전달하면 자동 무시 → 동적 쿼리 자연스러움. 메서드 분리로 재사용성 ↑.

QueryDSL + projection

public record UserSummary(Long id, String name, String email) {}

List<UserSummary> result = query
    .select(Projections.constructor(UserSummary.class, u.id, u.name, u.email))
    .from(u)
    .fetch();

// 또는 Projections.fields, Projections.bean

또는 직접 record constructor:

.select(new QUserSummary(u.id, u.name, u.email))   // Q-class for DTO

QueryDSL + Pageable

public Page<User> search(Pageable pageable) {
    JPAQueryFactory query = new JPAQueryFactory(em);
    QUser u = QUser.user;

    JPAQuery<User> base = query.selectFrom(u).where(predicates);

    List<User> content = base
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    Long total = query.select(u.count()).from(u).where(predicates).fetchOne();

    return new PageImpl<>(content, pageable, total != null ? total : 0L);
}

count query 직접. Spring Data + QuerydslPredicateExecutor 통합도 가능.

Spring Data Specification

Spring Data 가 Criteria API 를 functional 하게 래핑:

public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {}

public class UserSpecs {
    public static Specification<User> nameContains(String name) {
        return (root, query, cb) -> name == null ? null : cb.like(root.get("name"), "%" + name + "%");
    }
    public static Specification<User> ageGoe(Integer minAge) {
        return (root, query, cb) -> minAge == null ? null : cb.greaterThanOrEqualTo(root.get("age"), minAge);
    }
    public static Specification<User> cityEq(String city) {
        return (root, query, cb) -> city == null ? null : cb.equal(root.get("city"), city);
    }
}

// 사용
List<User> users = userRepo.findAll(
    Specification.where(UserSpecs.nameContains(name))
        .and(UserSpecs.ageGoe(minAge))
        .and(UserSpecs.cityEq(city))
);

장점:

  • Spring Data 통합 (findAll, findOne, count, page)
  • 별도 의존성 없음
  • composition (.and, .or, .not)

단점:

  • 여전히 Criteria API 의 verbose 한 syntax

Specification 의 함수형 활용

public static Specification<User> matching(UserSearchCriteria c) {
    return Specification
        .where(nameContains(c.name()))
        .and(ageGoe(c.minAge()))
        .and(cityEq(c.city()))
        .and(activeIsTrue());
}

userRepo.findAll(matching(criteria), pageable);

검색 조건 객체 + Specification factory → 깔끔한 사용처.

QueryDSL 의 동적 조립 패턴

BooleanBuilder 사용:

BooleanBuilder builder = new BooleanBuilder();
if (name != null) builder.and(u.name.contains(name));
if (minAge != null) builder.and(u.age.goe(minAge));
if (city != null) builder.and(u.city.eq(city));

List<User> result = query.selectFrom(u).where(builder).fetch();

또는 where() 의 multi-arg + null 자동 무시 (앞 예시).

QueryDSL Subquery

JPAQuery<?> sub = JPAExpressions
    .select(post.author.id)
    .from(post)
    .where(post.createdAt.after(oneMonthAgo));

List<User> activeAuthors = query
    .selectFrom(u)
    .where(u.id.in(sub))
    .fetch();

QueryDSL CASE

NumberExpression<Integer> rank = new CaseBuilder()
    .when(u.score.gt(100)).then(1)
    .when(u.score.gt(50)).then(2)
    .otherwise(3);

query.select(u.name, rank).from(u).orderBy(rank.asc()).fetch();

CASE 표현식. JPQL 보다 자연스러움.

QueryDSL 의 native 함정 회피

// JPQL: 컬렉션 fetch join + Pageable = 메모리 paging
query.selectFrom(u).leftJoin(u.posts).fetchJoin()
    .offset(0).limit(10).fetch();   // ⚠️ 같은 함정

해결: 두 단계 query

// 1. id 만 페이징
List<Long> ids = query.select(u.id)
    .from(u)
    .where(...)
    .orderBy(u.createdAt.desc())
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .fetch();

// 2. id 로 fetch join
List<User> users = query.selectFrom(u)
    .leftJoin(u.posts).fetchJoin()
    .where(u.id.in(ids))
    .orderBy(u.createdAt.desc())
    .fetch();

어떤 걸 골라야?

새 프로젝트?
├─ Spring Data 사용 + 간단한 동적 쿼리 → Specification
├─ 복잡한 동적 쿼리 + 가독성 우선 → QueryDSL
└─ 표준 우선 + verbosity 감수 → Criteria API

레거시 / 기존 코드?
├─ 이미 QueryDSL → 그대로 유지
├─ Criteria API → metamodel + 부분 리팩토링
└─ string JPQL concatenation → QueryDSL 마이그레이션 강력 권장

실무에서 가장 흔한 조합: Spring Data JPA + Specification (간단한 검색) + QueryDSL (복잡한 동적 / 통계 쿼리).

함정과 베스트 프랙티스

  • string concatenation 절대 금지: SQL injection + 컴파일 검증 X
  • QueryDSL 의 Q 클래스 빌드 캐싱 주의: gradle clean 후 재생성
  • Specification 의 (root, query, cb) -> null 패턴: 조건 없음 표현
  • Specification.where(null) 도 안전 (null 이면 모두 매칭)
  • Pageable + JOIN FETCH: 둘 다에서 메모리 paging 함정 동일
  • QueryDSL Projection.fields vs constructor: bean 기반은 setter 필요, record 면 constructor
  • subquery 는 IN 절 한정: WHERE EXISTS / NOT EXISTS 가 인덱스 활용 더 좋음
  • count query 명시: 큰 결과셋 + complex projection 시 자동 count 비효율
  • 읽기 전용 쿼리는 @Transactional(readOnly = true): dirty checking 비용 절감

관련 위키

이 글의 용어 (5개)
[JPA] FetchType: LAZY vs EAGER, N+1, fetch joinspring
정의 FetchType 은 JPA 가 연관 entity 를 언제 로드할지 결정하는 hint. - : entity 로딩 시 즉시 함께 SELECT - : 실제 사용 시점 (prox…
[JPA] JPQL DTO Projection: constructor expression, interface, recordspring
정의 DTO Projection 은 entity 전체가 아닌 필요한 컬럼만 query 해서 DTO 로 받기. 장점: - N+1 회피 (lazy 컬렉션 자동 무시) - entity…
[JPA] JPQL: Jakarta Persistence Query Languagespring
정의 JPQL 은 Jakarta Persistence 표준의 객체 지향 쿼리 언어. 문법은 SQL 과 유사하지만 entity 와 그 필드를 대상으로 작동 (table / colu…
[JPA] PersistenceContext: 1차 캐시, dirty checking, flushspring
정의 PersistenceContext 는 가 관리하는 entity 의 메모리 저장소 + 변경 추적기. 트랜잭션 안에서만 살아 있다 ( 진입 시 생성, commit/rollbac…
[Spring] Spring Data JPA: Repository, Query Methodsspring
정의 Spring Data JPA는 JPA(Hibernate) 위에 Repository 추상화를 얹어 boilerplate를 제거. 인터페이스만 정의하면 Spring이 구현체 자…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기