QueryDSL — 동적 쿼리를 타입 안전하게 작성하는 방법
검색 조건이 10가지인데, 사용자가 그중 몇 개만 입력한다면 쿼리를 어떻게 만들어야 할까요?
동적 쿼리는 JPA를 사용할 때 가장 까다로운 부분 중 하나입니다. 문자열 기반 JPQL은 오타에 취약하고, Criteria API는 코드가 너무 복잡합니다. QueryDSL 은 자바 코드로 쿼리를 작성하면서도 컴파일 타임에 오류를 잡아주는 도구입니다.
개념 정의
QueryDSL 은 타입 안전한 쿼리를 자바 코드로 작성할 수 있게 해주는 프레임워크입니다. 엔티티 클래스를 기반으로 Q클래스(메타모델) 를 자동 생성하고, 이를 통해 IDE의 자동 완성과 컴파일 타임 검증을 활용할 수 있습니다.
// 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 어노테이션이 붙은 클래스를 분석하여 메타모델 클래스를 자동으로 만들어냅니다.
// 원본 엔티티
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
// 자동 생성되는 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 설정
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 설정
@Configuration
public class QueryDslConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
기본 조회
@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 방식
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로 결합됩니다.
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을 반환하면 해당 조건이 자동으로 제외됩니다.
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
엔티티 조회
List<Member> members = queryFactory
.selectFrom(member)
.fetch();
DTO Projection — 생성자 방식
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class,
member.name,
member.age
))
.from(member)
.fetch();
DTO Projection — @QueryProjection
// 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에 의존하게 되는 트레이드오프가 있습니다.
서브쿼리
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 쿼리를 분리하면 각각 최적화할 수 있습니다.
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 쿼리를 생략합니다.
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 미지원, 네이티브 쿼리나 분리 필요 |