
목차
기존 @QueryProjection의 문제
@QueryProjection 은 DTO 생성자에 붙이면 해당 DTO용 Q 타입을 생성해 주는 기능이다. Projections.constructor 와 달리 컴파일 타임에 타입을 체크해 주기 때문에 더 안전하다.
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 에서는 이 문제를 해결하기 위해 @QueryProjection 에 useBuilder 와 builderName 옵션을 추가했다.
@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> {
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); }
public static SummaryBuilder builderSummary() { return new SummaryBuilder(); }
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;
@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 설정만으로 동작한다.