검색 조건이 10가지인데, 사용자가 그중 몇 개만 입력한다면 쿼리를 어떻게 만들어야 할까요?

동적 쿼리는 JPA를 사용할 때 가장 까다로운 부분 중 하나입니다. 문자열 기반 JPQL은 오타에 취약하고, Criteria API는 코드가 너무 복잡합니다. QueryDSL 은 자바 코드로 쿼리를 작성하면서도 컴파일 타임에 오류를 잡아주는 도구입니다.

개념 정의

QueryDSL 은 타입 안전한 쿼리를 자바 코드로 작성할 수 있게 해주는 프레임워크입니다. 엔티티 클래스를 기반으로 Q클래스(메타모델) 를 자동 생성하고, 이를 통해 IDE의 자동 완성과 컴파일 타임 검증을 활용할 수 있습니다.

JAVA
// JPQL (문자열)
em.createQuery("SELECT m FROM Member m WHERE m.name = :name AND m.age > :age");

// QueryDSL (자바 코드)
queryFactory
    .selectFrom(member)
    .where(member.name.eq(name), member.age.gt(age))
    .fetch();

Q클래스 생성 원리

APT (Annotation Processing Tool)

Q클래스는 컴파일 타임 에 생성됩니다. QueryDSL의 APT 프로세서가 @Entity 어노테이션이 붙은 클래스를 분석하여 메타모델 클래스를 자동으로 만들어냅니다.

JAVA
// 원본 엔티티
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}
JAVA
// 자동 생성되는 Q클래스 (build/generated 하위에 위치)
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QMember extends EntityPathBase<Member> {
    public static final QMember member = new QMember("member1");

    public final NumberPath<Long> id = createNumber("id", Long.class);
    public final StringPath name = createString("name");
    public final NumberPath<Integer> age = createNumber("age", Integer.class);
    public final QTeam team;
}

Gradle 설정

GROOVY
dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}

기본 사용법

JPAQueryFactory 설정

JAVA
@Configuration
public class QueryDslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}

기본 조회

JAVA
@Repository
@RequiredArgsConstructor
public class MemberQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<Member> findByNameAndAge(String name, int age) {
        return queryFactory
            .selectFrom(member)
            .where(
                member.name.eq(name),
                member.age.gt(age)
            )
            .orderBy(member.age.desc())
            .fetch();
    }
}

where()에 여러 조건을 콤마로 나열하면 AND로 결합됩니다. null 조건은 자동으로 무시됩니다.

동적 쿼리

BooleanBuilder 방식

JAVA
public List<Member> searchByBuilder(String name, Integer age, String teamName) {
    BooleanBuilder builder = new BooleanBuilder();

    if (name != null) {
        builder.and(member.name.contains(name));
    }
    if (age != null) {
        builder.and(member.age.goe(age));
    }
    if (teamName != null) {
        builder.and(member.team.name.eq(teamName));
    }

    return queryFactory
        .selectFrom(member)
        .where(builder)
        .fetch();
}

BooleanBuilder는 직관적이지만, 조건이 많아지면 코드가 길어지고 재사용이 어렵습니다.

BooleanExpression 방식 (권장)

where 절에 여러 BooleanExpression을 전달하면 AND로 결합됩니다.

JAVA
public List<Member> searchByExpression(MemberSearchCondition cond) {
    return queryFactory
        .selectFrom(member)
        .leftJoin(member.team, team)
        .where(
            nameContains(cond.getName()),
            ageGoe(cond.getAgeGoe()),
            ageLoe(cond.getAgeLoe()),
            teamNameEq(cond.getTeamName())
        )
        .fetch();
}

각 조건을 독립된 메서드로 분리합니다. null을 반환하면 해당 조건이 자동으로 제외됩니다.

JAVA
private BooleanExpression nameContains(String name) {
    return name != null ? member.name.contains(name) : null;
}

private BooleanExpression ageGoe(Integer age) {
    return age != null ? member.age.goe(age) : null;
}

private BooleanExpression ageLoe(Integer age) {
    return age != null ? member.age.loe(age) : null;
}

private BooleanExpression teamNameEq(String teamName) {
    return teamName != null ? member.team.name.eq(teamName) : null;
}

BooleanExpression 방식의 장점은 다음과 같습니다.

  • 각 조건을 독립된 메서드로 분리하여 재사용 가능
  • null 반환 시 where()에서 자동으로 무시
  • 조건을 ** 조합 **할 수 있음: nameContains("심").and(ageGoe(20))

Projection

엔티티 조회

JAVA
List<Member> members = queryFactory
    .selectFrom(member)
    .fetch();

DTO Projection — 생성자 방식

JAVA
List<MemberDto> result = queryFactory
    .select(Projections.constructor(MemberDto.class,
        member.name,
        member.age
    ))
    .from(member)
    .fetch();

DTO Projection — @QueryProjection

JAVA
// DTO 생성자에 어노테이션 추가
public class MemberDto {
    private String name;
    private int age;

    @QueryProjection
    public MemberDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

// 컴파일 시 QMemberDto 생성 → 타입 안전한 Projection
List<MemberDto> result = queryFactory
    .select(new QMemberDto(member.name, member.age))
    .from(member)
    .fetch();

@QueryProjection은 컴파일 타임 검증이 가능하지만, DTO가 QueryDSL에 의존하게 되는 트레이드오프가 있습니다.

서브쿼리

JAVA
QMember memberSub = new QMember("memberSub");

// WHERE 서브쿼리: 평균 나이 이상인 회원
List<Member> result = queryFactory
    .selectFrom(member)
    .where(member.age.goe(
        JPAExpressions
            .select(memberSub.age.avg())
            .from(memberSub)
    ))
    .fetch();

// SELECT 서브쿼리
List<Tuple> result = queryFactory
    .select(member.name,
        JPAExpressions
            .select(memberSub.age.avg())
            .from(memberSub)
    )
    .from(member)
    .fetch();

**JPA 서브쿼리의 한계 **: FROM 절 서브쿼리(인라인 뷰)는 JPQL에서 지원하지 않으므로 QueryDSL에서도 사용할 수 없습니다. 네이티브 쿼리나 쿼리 분리로 해결해야 합니다.

동적 정렬과 페이징

데이터 조회 쿼리와 COUNT 쿼리를 분리하면 각각 최적화할 수 있습니다.

JAVA
public Page<MemberDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {

    List<MemberDto> content = queryFactory
        .select(new QMemberDto(member.name, member.age))
        .from(member)
        .leftJoin(member.team, team)
        .where(nameContains(condition.getName()), teamNameEq(condition.getTeamName()))
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .orderBy(member.age.desc())
        .fetch();

COUNT 쿼리를 PageableExecutionUtils로 감싸면, content가 페이지 크기보다 작을 때 COUNT 쿼리를 생략합니다.

JAVA
    JPAQuery<Long> countQuery = queryFactory
        .select(member.count())
        .from(member)
        .leftJoin(member.team, team)
        .where(nameContains(condition.getName()), teamNameEq(condition.getTeamName()));

    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

PageableExecutionUtils.getPage()는 content 크기가 페이지 크기보다 작을 때 COUNT 쿼리를 실행하지 않는 최적화를 해줍니다.

주의할 점

Q클래스가 생성되지 않으면 빌드 설정을 확인해야 한다

QueryDSL은 APT(Annotation Processing Tool) 로 Q클래스를 생성합니다. Gradle 설정에서 annotationProcessor 의존성이 빠져있거나, IDE에서 어노테이션 프로세싱이 비활성화되어 있으면 Q클래스가 생성되지 않아 컴파일 에러가 발생합니다.

FROM 절 서브쿼리는 JPQL에서 지원하지 않는다

JPA의 JPQL은 FROM 절 서브쿼리(인라인 뷰)를 지원하지 않으므로, QueryDSL에서도 사용할 수 없습니다. 네이티브 쿼리로 대체하거나 쿼리를 분리해야 합니다.

@QueryProjection은 DTO와 QueryDSL의 결합을 만든다

@QueryProjection을 사용하면 DTO 생성자에 대한 Q클래스가 생성되어 타입 안전한 Projection이 가능하지만, DTO가 QueryDSL 라이브러리에 의존 하게 됩니다. 의존성을 최소화하려면 Projections.constructor()를 사용하되, 파라미터 순서 실수 위험을 감수해야 합니다.

정리

항목설명
타입 안전성컴파일 타임에 쿼리 오류 검증
Q클래스APT를 통해 컴파일 시 자동 생성
동적 쿼리BooleanExpression을 메서드로 분리하여 재사용
Projection@QueryProjection(타입 안전) vs Projections.constructor(결합 없음)
FROM 서브쿼리JPQL 미지원, 네이티브 쿼리나 분리 필요
댓글 로딩 중...