QueryDSL - OpenFeign Querydsl 6.12의 builder 기반 @QueryProjection

목차

기존 @QueryProjection의 문제

@QueryProjection 은 DTO 생성자에 붙이면 해당 DTO용 Q 타입을 생성해 주는 기능이다. Projections.constructor 와 달리 컴파일 타임에 타입을 체크해 주기 때문에 더 안전하다.

// DTO
public class MemberSummaryDto {

private final Long memberId;
private final String username;
private final Integer age;
private final String teamName;

@QueryProjection
public MemberSummaryDto(Long memberId, String username, Integer age, String teamName) {
this.memberId = memberId;
this.username = username;
this.age = age;
this.teamName = teamName;
}
}
// 쿼리 사용

List<MemberSummaryDto> result = queryFactory
.select(new QMemberSummaryDto(
member.id,
member.username,
member.age,
team.name
))
.from(member)
.leftJoin(member.team, team)
.fetch();

파라미터가 적을 때는 문제가 없지만, 필드가 늘어날수록 다음 문제가 생긴다.

  • 파라미터가 많아지면 어떤 인자가 어느 필드로 가는지 한눈에 파악하기 어렵다.
  • Long, String, Integer 처럼 같은 타입의 파라미터가 여럿 있으면 순서를 바꿔도 컴파일 에러가 나지 않는다.
  • 생성자 오버로드가 여러 개일 때 new QMemberSummaryDto(...) 호출부만 보고는 어느 오버로드를 사용하는지 즉시 파악하기 어렵다.

Builder 기반 @QueryProjection

OpenFeign/querydsl 6.12 에서는 이 문제를 해결하기 위해 @QueryProjectionuseBuilderbuilderName 옵션을 추가했다.

@QueryProjection(useBuilder = true, builderName = "Summary")
public MemberSummaryDto(Long memberId, String username, Integer age, String teamName) { ... }

이 옵션이 붙으면 annotation processor가 QDTO 클래스 내부에 builder 클래스를 함께 생성한다.

핵심을 요약하면 다음과 같다.

  • builder는 DTO 안이 아니라 생성된 QDTO 내부에 만들어진다.
  • 각 setter는 Expression<T> 계열을 받는다.
  • build() 호출 시 기존 생성자 projection과 동일한 QDTO 인스턴스를 반환한다.
  • 하나의 DTO에 생성자 오버로드가 여러 개라면 각각 다른 builderName 을 지정해 독립적인 builder를 만들 수 있다.
  • useBuilder = true 를 사용할 때 builderName 이 비어 있으면 컴파일 에러가 발생한다.

설정

의존성 추가

Spring Boot 3, Jakarta EE 기준이다.

Gradle

dependencies {
implementation "io.github.openfeign.querydsl:querydsl-jpa:6.12"

annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:6.12"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"

compileOnly "org.projectlombok:lombok"
annotationProcessor "org.projectlombok:lombok"
}

Maven

<dependencies>
<dependency>
<groupId>io.github.openfeign.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>6.12</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.github.openfeign.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>6.12</version>
</path>
<path>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version>
</path>
<path>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>3.0.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

querydsl-jpa 만 추가해서는 Q 클래스가 생성되지 않는다. querydsl-apt 가 annotation processor로 연결되어 있어야 한다.

DTO 선언

package com.example.member.dto;

import com.querydsl.core.annotations.QueryProjection;

public class MemberSummaryDto {

private final Long memberId;
private final String username;
private final Integer age;
private final String teamName;

@QueryProjection(useBuilder = true, builderName = "Summary")
public MemberSummaryDto(Long memberId, String username, Integer age, String teamName) {
this.memberId = memberId;
this.username = username;
this.age = age;
this.teamName = teamName;
}

public Long getMemberId() { return memberId; }
public String getUsername() { return username; }
public Integer getAge() { return age; }
public String getTeamName() { return teamName; }
}

별도의 APT 옵션이나 플러그인 설정은 필요 없다. @QueryProjection(useBuilder = true, builderName = "...") 선언 자체가 builder 생성 트리거다.

컴파일

./gradlew clean compileJava

생성된 Q 클래스는 기본적으로 아래 경로에서 확인할 수 있다.

build/generated/sources/annotationProcessor/java/main

생성되는 Q 클래스 형태

@QueryProjection(useBuilder = true, builderName = "Summary") 를 선언하면 대략 다음과 같은 코드가 생성된다.

public class QMemberSummaryDto extends ConstructorExpression<MemberSummaryDto> {

// 기존 생성자 기반 projection도 그대로 유지됨
public QMemberSummaryDto(
Expression<Long> memberId,
Expression<String> username,
Expression<Integer> age,
Expression<String> teamName) {
super(MemberSummaryDto.class,
new Class<?>[]{Long.class, String.class, Integer.class, String.class},
memberId, username, age, teamName);
}

// builderName = "Summary" 로 인해 생성되는 static factory 메서드
public static SummaryBuilder builderSummary() {
return new SummaryBuilder();
}

// QDTO 내부에 생성되는 Builder 클래스
public static class SummaryBuilder {
private Expression<Long> memberId;
private Expression<String> username;
private Expression<Integer> age;
private Expression<String> teamName;

public SummaryBuilder setMemberId(Expression<Long> memberId) {
this.memberId = memberId;
return this;
}

public SummaryBuilder setUsername(Expression<String> username) {
this.username = username;
return this;
}

public SummaryBuilder setAge(Expression<Integer> age) {
this.age = age;
return this;
}

public SummaryBuilder setTeamName(Expression<String> teamName) {
this.teamName = teamName;
return this;
}

public QMemberSummaryDto build() {
return new QMemberSummaryDto(memberId, username, age, teamName);
}
}
}

builder가 DTO가 아니라 QMemberSummaryDto 내부에 생성된다는 점이 중요하다. 따라서 DTO 자체의 구조에는 변화가 없다.

쿼리 작성 예제

기본 사용

import static com.example.member.domain.QMember.member;
import static com.example.member.domain.QTeam.team;

import com.example.member.dto.QMemberSummaryDto;

List<MemberSummaryDto> result = queryFactory
.select(
QMemberSummaryDto.builderSummary()
.setMemberId(member.id)
.setUsername(member.username)
.setAge(member.age)
.setTeamName(team.name)
.build()
)
.from(member)
.leftJoin(member.team, team)
.fetch();

기존 생성자 기반 코드와 비교하면 다음 차이가 있다.

  • 어느 인자가 어느 필드로 가는지 이름으로 명확하게 읽힌다.
  • String 타입 파라미터 두 개(username, teamName) 의 순서를 바꿔도 컴파일이 통과하던 문제가 setter 이름으로 구분되면서 실수할 여지가 줄어든다.

Repository에서의 활용

@Repository
@RequiredArgsConstructor
public class MemberQueryRepository {

private final JPAQueryFactory queryFactory;

public List<MemberSummaryDto> findSummaryByTeamName(String teamName) {
return queryFactory
.select(
QMemberSummaryDto.builderSummary()
.setMemberId(member.id)
.setUsername(member.username)
.setAge(member.age)
.setTeamName(team.name)
.build()
)
.from(member)
.leftJoin(member.team, team)
.where(team.name.eq(teamName))
.orderBy(member.username.asc())
.fetch();
}
}

페이징과 함께

public Page<MemberSummaryDto> findSummaryPage(String teamName, Pageable pageable) {

List<MemberSummaryDto> content = queryFactory
.select(
QMemberSummaryDto.builderSummary()
.setMemberId(member.id)
.setUsername(member.username)
.setAge(member.age)
.setTeamName(team.name)
.build()
)
.from(member)
.leftJoin(member.team, team)
.where(team.name.eq(teamName))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(team.name.eq(teamName));

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

생성자 오버로드가 여러 개일 때

이 기능의 또 다른 장점은 생성자마다 다른 builder를 만들 수 있다는 점이다.

public class MemberSummaryDto {

private final Long memberId;
private final String username;
private final Integer age;
private final String teamName;

// 팀 정보 없이 멤버 정보만 조회할 때
@QueryProjection(useBuilder = true, builderName = "Simple")
public MemberSummaryDto(Long memberId, String username, Integer age) {
this.memberId = memberId;
this.username = username;
this.age = age;
this.teamName = null;
}

// 팀 정보까지 포함해서 조회할 때
@QueryProjection(useBuilder = true, builderName = "WithTeam")
public MemberSummaryDto(Long memberId, String username, Integer age, String teamName) {
this.memberId = memberId;
this.username = username;
this.age = age;
this.teamName = teamName;
}
}
// 팀 정보 없이 조회
List<MemberSummaryDto> simpleResult = queryFactory
.select(
QMemberSummaryDto.builderSimple()
.setMemberId(member.id)
.setUsername(member.username)
.setAge(member.age)
.build()
)
.from(member)
.fetch();

// 팀 정보 포함하여 조회
List<MemberSummaryDto> withTeamResult = queryFactory
.select(
QMemberSummaryDto.builderWithTeam()
.setMemberId(member.id)
.setUsername(member.username)
.setAge(member.age)
.setTeamName(team.name)
.build()
)
.from(member)
.leftJoin(member.team, team)
.fetch();

기존 생성자 방식이었다면 new QMemberSummaryDto(...) 인자 수로 어느 오버로드인지 추측해야 했는데, builder 이름(builderSimple, builderWithTeam) 으로 의도가 직접적으로 드러난다.

Lombok @Builder와의 관계

DTO에 Lombok @Builder를 붙이면 DTO 인스턴스를 생성하는 빌더가 만들어진다. 이것은 Java 객체 생성을 위한 빌더다.

@QueryProjection(useBuilder = true) 로 만들어지는 builder는 Querydsl Expression<T>를 받는 빌더 로, 목적이 다르다. select 절에 넣을 Q 타입 인스턴스를 만들기 위한 것이다.

Lombok을 함께 써도 충돌은 없지만 역할을 혼동하지 않도록 주의한다.

public class MemberSummaryDto {

private final Long memberId;
private final String username;
private final Integer age;
private final String teamName;

// Querydsl projection용 - Expression<T>를 받아 Q 타입 인스턴스를 만든다
@QueryProjection(useBuilder = true, builderName = "Summary")
public MemberSummaryDto(Long memberId, String username, Integer age, String teamName) {
this.memberId = memberId;
this.username = username;
this.age = age;
this.teamName = teamName;
}
}

기존 방식과 비교

항목 생성자 기반 new QDto(...) builder 기반 QDto.builderXxx()...build()
파라미터 순서 의존 있음 없음 (setter 이름으로 지정)
같은 타입 파라미터 순서 실수 컴파일 통과 가능 setter 이름으로 구분
여러 생성자 오버로드 구분 인자 수/타입으로 추론 builder 이름으로 명시
코드 길이 짧음 상대적으로 김
Querydsl 버전 요건 5.x 이상 OpenFeign 6.12 이상

언제 쓰는 게 좋은가

다음 상황이라면 builder 기반 @QueryProjection 을 사용하는 것이 유리하다.

  • projection 대상 필드가 5개 이상으로 많아서 생성자 인자 순서를 자주 확인해야 하는 경우
  • String, Long 처럼 같은 타입의 필드가 여러 개 있어서 순서 실수의 위험이 있는 경우
  • 하나의 DTO에서 생성자 오버로드를 의도적으로 나눠서 사용하는 경우
  • 조회 전용 DTO가 많고 select 절의 가독성이 장기 유지보수에 중요한 경우

반대로 DTO가 단순하고 파라미터가 2~3개라면 기존 new QDto(...) 가 더 짧고 충분하다. 모든 DTO에 일괄 적용하기보다는 필드가 많거나 타입이 겹치는 DTO를 중심으로 선택적으로 적용하는 것이 현실적이다.

정리

OpenFeign/querydsl 6.12 의 builder 기반 @QueryProjection 은 기존 생성자 projection의 타입 안정성을 유지하면서 select 절 가독성을 높여 주는 기능이다.

  • 대상은 io.github.openfeign.querydsl 6.12 이상 이다.
  • 문법은 @QueryProjection(useBuilder = true, builderName = "이름")이다.
  • 생성되는 builder는 DTO 내부가 아니라 QDTO 내부 에 생긴다.
  • 생성자 오버로드마다 다른 builderName 을 지정해 독립적인 builder를 만들 수 있다.
  • 기존 생성자 기반 방식과 하위 호환이 유지되므로 점진적으로 전환할 수 있다.
  • 별도 플러그인 없이 기존 annotation processor 설정만으로 동작한다.
Share