QueryDSL 가이드 (5/8)

이전 편: [QueryDSL] 4. 동적 쿼리

다음 편: [QueryDSL] 6. Spring Data JPA 통합

엔티티 전체를 가져오는 것이 항상 좋은 것은 아니다. API 응답에는 이름과 이메일만 필요한데 엔티티의 모든 컬럼을 SELECT하면 불필요한 데이터 전송이 발생한다. 필요한 컬럼만 골라서 DTO로 바로 받는 것, 이것이 프로젝션이다.

graph LR subgraph Database E[User Entity
id, name, email, age, password, ...] end subgraph QueryDSL Q[QueryProjection] end subgraph "Application (DTO)" D[UserDto
name, email] end E -- "SELECT ALL" --> Q Q -- "Mapping (Filtering)" --> D style D fill:#e1f5fe,stroke:#01579b

단일 컬럼 프로젝션

컬럼 하나만 조회하면 해당 타입의 리스트로 바로 받을 수 있다.

List<String> names = queryFactory
        .select(user.name)
        .from(user)
        .fetch();

반환 타입이 List<String>이다. select에 Path 하나만 넣으면 해당 Path의 Java 타입으로 결과가 반환된다.

Tuple

컬럼 여러 개를 조회하되, DTO를 따로 만들기 애매할 때 Tuple을 쓴다.

List<Tuple> result = queryFactory
        .select(user.name, user.email)
        .from(user)
        .fetch();

for (Tuple tuple : result) {
    String name = tuple.get(user.name);
    String email = tuple.get(user.email);
}

Tuple은 QueryDSL이 제공하는 제네릭 컨테이너다. tuple.get()에 조회할 때 사용한 것과 같은 Path를 넣으면 해당 값을 꺼낼 수 있다.

Tuple은 Repository 계층까지만

Tuple은 QueryDSL의 타입이다. Service나 Controller 계층까지 Tuple을 노출하면 QueryDSL에 대한 의존이 퍼진다. 계층 간 데이터 전달에는 DTO를 사용하는 것이 맞다.

graph TD subgraph "Persistence Layer" R[Repository] T[Tuple] end subgraph "Business Layer" S[Service] DTO[DTO] end subgraph "Presentation Layer" C[Controller] end R -- "Return Tuple" --> T T -- "Convert to DTO" --> S S -- "Transfer DTO" --> C style T fill:#ffebee,stroke:#c62828 style DTO fill:#e8f5e9,stroke:#2e7d32

Projections.constructor

생성자를 기반으로 DTO에 값을 매핑한다. 가장 널리 쓰이는 방식이다.

public record UserDto(String name, String email) {}

이 DTO에 다음과 같이 매핑한다.

List<UserDto> users = queryFactory
        .select(Projections.constructor(
                UserDto.class,
                user.name,
                user.email
        ))
        .from(user)
        .fetch();

Projections.constructorselect에 나열한 컬럼 순서대로 생성자 파라미터에 매핑한다. DTO에 (String, String) 생성자가 있어야 한다. record 클래스는 컴팩트 생성자가 자동 생성되므로 바로 사용할 수 있다.

장점

  • 컴파일 타임에 생성자 존재 여부를 검증한다.
  • 불변 객체(record)와 잘 어울린다.

주의점

  • 컬럼 순서가 생성자 파라미터 순서와 일치해야 한다. 순서가 바뀌면 런타임에 값이 뒤섞이거나 타입 불일치 예외가 발생한다.

Projections.fields

필드에 직접 값을 주입한다. 리플렉션으로 동작하므로 setter나 생성자가 필요 없다.

List<UserDto> users = queryFactory
        .select(Projections.fields(
                UserDto.class,
                user.name,
                user.email
        ))
        .from(user)
        .fetch();

필드 이름과 컬럼 이름이 일치해야 매핑된다. 이름이 다르면 .as() 별칭을 붙여야 한다.

Projections.fields(
        UserDto.class,
        user.name.as("userName"),   // DTO의 필드명이 userName인 경우
        user.email
)

필드명이 바뀌어도 컴파일 에러가 나지 않고 null로 들어가므로, 리팩토링에 취약하다는 단점이 있다.

Projections.bean

setter를 기반으로 값을 주입한다. JavaBean 규약을 따르는 DTO에 사용한다.

@Getter @Setter
public class UserDto {
    private String name;
    private String email;
}

사용법은 fields와 동일하다.

List<UserDto> users = queryFactory
        .select(Projections.bean(
                UserDto.class,
                user.name,
                user.email
        ))
        .from(user)
        .fetch();

fields와 마찬가지로 이름 기반 매핑이다. 다른 점은 setter 메서드를 호출한다는 것이다. setter가 없으면 값이 주입되지 않는다.

@QueryProjection

DTO 생성자에 @QueryProjection을 붙이면, 해당 DTO의 Q클래스가 생성된다. 컴파일 타임에 타입과 순서를 모두 검증할 수 있는 가장 안전한 방식이다.

public class UserDto {
    private String name;
    private String email;

    @QueryProjection
    public UserDto(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

빌드하면 QUserDto가 생성된다. 사용법은 이렇다.

List<UserDto> users = queryFactory
        .select(new QUserDto(user.name, user.email))
        .from(user)
        .fetch();

QUserDto의 생성자는 StringPath를 받으므로, 잘못된 타입이나 순서를 넣으면 컴파일 에러가 난다. Projections.constructor에서 발생할 수 있는 순서 실수를 원천 차단한다.

QueryDSL 의존성 전파

@QueryProjection을 사용하면 DTO가 QueryDSL에 의존하게 된다. DTO가 여러 계층에서 공유되는 경우, QueryDSL이라는 인프라 의존성이 도메인 계층까지 침투할 수 있다. 이 트레이드오프를 인식하고 사용해야 한다.

sequenceDiagram participant D as DTO (@QueryProjection) participant B as Build Tool (Gradle) participant Q as Q-DTO Class participant C as QueryDSL Logic D->>B: 1. compileJava 실행 B->>Q: 2. APT로 Q파일 생성 C->>Q: 3. new QUserDto(...) 호출 Note over C,Q: 타입/순서 불일치 시
컴파일 에러 발생

방식 비교

각 방식의 특성을 정리한다.

mindmap root((DTO 매핑 방식)) Projections.constructor 순서 기반 매핑 불변 객체(record) 지원 런타임 에러 위험 Projections.fields 필드명 기반 매핑 Reflection 사용 리팩토링에 취약 Projections.bean Setter 기반 매핑 JavaBean 규약 준수 Setter 필수 @QueryProjection 컴파일 타임 안정성 Q클래스 생성 필요 QueryDSL 의존성 전파

Projections.constructor

  • 생성자 파라미터 순서로 매핑
  • 불변 객체(record) 호환
  • 순서 실수 시 런타임 에러

Projections.fields

  • 필드 이름으로 매핑
  • setter/생성자 불필요
  • 이름 불일치 시 null (런타임)

Projections.bean

  • setter로 매핑
  • 필드 이름 기반
  • setter 필수

@QueryProjection

  • 컴파일 타임 검증
  • 타입 + 순서 모두 안전
  • DTO에 QueryDSL 의존 발생

실무에서는 Projections.constructor 또는 @QueryProjection을 주로 사용한다. constructor는 DTO의 독립성을 유지하면서도 생성자 기반 매핑이 가능하고, @QueryProjection은 안전성이 필요한 핵심 쿼리에 적합하다. fieldsbean은 이름 기반이라 리팩토링에 취약하므로, 레거시 호환이 아니면 쓸 이유가 적다.

자주 하는 실수

constructor에서 컬럼 순서 불일치

Projections.constructor는 컬럼 순서로 매핑한다. DTO 생성자가 (String name, String email)인데 select에서 user.email, user.name 순서로 넣으면, email이 name에, name이 email에 들어간다. 타입이 같으면 컴파일 에러도 나지 않으므로 특히 위험하다.

graph TD subgraph "Case 1: Constructor Order" C1[select(user.email, user.name)] C2[DTO(String name, String email)] C1 -- "매핑 오류 (순서 뒤바뀜)" --> C2 end subgraph "Case 2: Name Mismatch" F1[select(user.name)] F2[DTO(String userName)] F1 -- "매핑 실패 (null 주입)" --> F2 end
fields/bean에서 이름 불일치로 null 반환

엔티티 필드명은 name인데 DTO 필드명이 userName이면, 매핑에 실패하고 null이 들어간다. 에러가 발생하지 않기 때문에 디버깅이 어렵다. .as("userName") 별칭을 반드시 지정해야 한다.

[!DANGER] @QueryProjection 빌드 누락

DTO에 @QueryProjection을 붙인 뒤 빌드하지 않으면 QUserDto가 생성되지 않아 컴파일 에러가 난다. 엔티티 Q클래스와 마찬가지로, DTO를 수정하면 ./gradlew compileJava로 다시 빌드해야 한다.