Step 05 — 프로젝션과 DTO

학습 목표

  • 엔티티 통째 조회와 필요한 컬럼만 뽑는 프로젝션의 생성 SQL 차이를 눈으로 확인한다
  • Tuple 을 읽고, 그것을 서비스 계층 밖으로 내보내면 안 되는 이유를 설명한다
  • Projections.bean / fields / constructor / @QueryProjection 네 방식을 구분해서 쓴다
  • 필드명이 안 맞으면 예외 없이 null 이 되는 Projections.fields 의 함정을 재현하고 as() 로 고친다
  • 같은 타입 필드의 순서가 바뀌면 값이 조용히 뒤바뀌는 Projections.constructor 의 함정을 재현한다
  • 연산 결과와 서브쿼리 결과를 DTO 필드로 받는다

선행 스텝: Step 04 — 조건과 동적 쿼리 예상 소요: 90분

이 스텝은 이 코스의 정체성이 가장 선명하게 드러나는 곳입니다. 여기서 다루는 두 함정은 컴파일 에러도, 런타임 예외도, 경고 로그도 내지 않습니다. 그저 값이 null 이 되거나, 이름과 도시가 서로 바뀌어 들어갈 뿐입니다.

본문의 모든 예제는 아래 static import 를 전제합니다.

import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QOrderItem.orderItem;
import static com.example.shop.entity.QProduct.product;

5-1. 프로젝션이란 — 엔티티를 통째로 읽는 비용

프로젝션(projection)은 "조회 결과에서 어떤 값을 어떤 모양으로 꺼낼 것인가" 를 정하는 일입니다. 지금까지 우리는 Step 03selectFrom(customer) 처럼 엔티티를 통째로 읽었습니다. 편하지만 공짜가 아닙니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .fetch();

결과hibernate.SQL 로그

select c1_0.customer_id, c1_0.birth_date, c1_0.city, c1_0.created_at,
       c1_0.email, c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
조회 30건

컬럼 9개를 전부 읽습니다. 그리고 SQL 이 끝난 뒤에도 일이 남습니다.

단계엔티티 조회프로젝션 조회
SELECT 컬럼매핑된 전 컬럼필요한 컬럼만
영속성 컨텍스트 등록한다 (1차 캐시에 30개)안 한다
스냅샷 보관한다 (변경 감지용 사본 30개)안 한다
flush 시 더티 체킹대상대상 아님
결과 타입Customer (관리 대상)String, Tuple, DTO

핵심은 스냅샷입니다. JPA 는 변경 감지(dirty checking)를 하려고 엔티티를 읽을 때마다 그 시점의 값을 복사해 따로 보관합니다. 30건이면 사실상 객체가 60개 만들어지는 셈입니다. 600건짜리 orders 를 통째로 읽으면 1,200개가 됩니다.

이름과 도시만 필요하다면 이렇게 씁니다.

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

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건

컬럼이 9개에서 2개로 줄었고, 영속성 컨텍스트에는 아무것도 등록되지 않습니다.

💡 실무 팁 — 조회 전용이면 프로젝션이 기본입니다 "이 결과를 수정해서 저장할 것인가?" 가 판단 기준입니다. 화면에 뿌리고 끝나는 조회라면 엔티티를 읽을 이유가 없습니다. 변경할 게 아닌데 엔티티로 읽으면, 쓰지도 않을 스냅샷을 만들고 flush 마다 비교당합니다. 반대로 값을 바꿔야 한다면 반드시 엔티티로 읽어야 합니다 — DTO 는 변경 감지 대상이 아닙니다.


5-2. 단일 컬럼 프로젝션

컬럼 하나만 뽑으면 그 컬럼의 타입이 그대로 제네릭 타입이 됩니다. Tuple 도 DTO 도 필요 없습니다.

List<String> names = queryFactory
        .select(customer.name)
        .from(customer)
        .where(customer.grade.eq(Grade.VIP))
        .fetch();

결과

select c1_0.name
from customers c1_0
where c1_0.grade = ?
바인딩: [1] VIP
조회 4건 — [김서준, 류하나, 정  훈, 배채영]

customer.nameStringPath 이므로 select(...) 의 반환 타입이 JPAQuery<String> 으로 결정됩니다. customer.pointsNumberPath<Integer> 이니 List<Integer> 가 나옵니다.

List<Integer> points = queryFactory
        .select(customer.points)
        .from(customer)
        .orderBy(customer.points.desc())
        .limit(5)
        .fetch();

결과

select c1_0.points
from customers c1_0
order by c1_0.points desc
limit ?
바인딩: [1] 5
조회 5건 — [48200, 45100, 41800, 39600, 37400]

중복을 제거하려면 selectDistinct 를 씁니다.

List<String> cities = queryFactory
        .selectDistinct(customer.city)
        .from(customer)
        .orderBy(customer.city.asc())
        .fetch();

결과

select distinct c1_0.city
from customers c1_0
order by c1_0.city asc
조회 6건 — [광주, 대구, 대전, 부산, 서울, 인천]

📌 MySQL8 코스 Step 04 — SELECT 기초 에서 SELECT DISTINCT city FROM customers ORDER BY city; 로 썼던 것과 같은 SQL 입니다.


5-3. Tuple — 여러 컬럼을 타입 없이 받기

컬럼을 두 개 이상 나열하면 QueryDSL 은 Tuple 로 감싸 줍니다.

List<Tuple> result = queryFactory
        .select(customer.name, customer.city, customer.points)
        .from(customer)
        .where(customer.grade.eq(Grade.VIP))
        .fetch();

for (Tuple t : result) {
    String name  = t.get(customer.name);
    String city  = t.get(customer.city);
    Integer pts  = t.get(customer.points);
    System.out.println(name + " / " + city + " / " + pts);
}

결과

select c1_0.name, c1_0.city, c1_0.points
from customers c1_0
where c1_0.grade = ?
바인딩: [1] VIP
김서준 / 서울 / 48200
류하나 / 부산 / 45100
정  훈 / 서울 / 41800
배채영 / 인천 / 37400

Tuple 에서 값을 꺼낼 때는 select 에 넣었던 표현식 객체를 그대로 키로 씁니다. t.get(0, String.class) 처럼 인덱스로도 꺼낼 수 있지만, 순서에 의존하게 되므로 권장하지 않습니다. select 목록에 컬럼 하나를 끼워 넣는 순간 인덱스가 전부 밀립니다.

select 에 없는 표현식으로 꺼내면 예외가 아니라 null 이 돌아옵니다.

Tuple t = result.get(0);
System.out.println(t.get(customer.email));   // select 에 없었음

결과

null

⚠️ 함정 — Tuple 을 컨트롤러나 API 응답으로 내보내지 마십시오 Tuplecom.querydsl.core.Tuple 입니다. 이걸 리포지토리 밖으로 반환하면 서비스와 컨트롤러가 QueryDSL 에 의존하게 됩니다. 데이터 접근 기술을 바꾸려는 순간(또는 QueryDSL 6 → 7 로 올릴 때) 리포지토리만 고치면 될 일이 애플리케이션 전 계층으로 번집니다. 게다가 Tuple 은 값을 꺼내려면 QCustomer.customer.name 같은 Q타입 상수가 필요합니다 — 컨트롤러가 Q타입을 import 하는 순간 계층 분리는 끝난 겁니다.

Tuple 은 리포지토리 내부에서만 쓰고, 경계를 넘을 때는 반드시 DTO 로 변환하십시오. 애초에 DTO 로 받는 게 더 간단합니다. 그게 이 스텝의 나머지 전부입니다.


5-4. Projections.bean — setter 로 채우기

Projections.bean기본 생성자로 객체를 만든 뒤 setter 를 호출해 값을 넣습니다.

먼저 DTO 를 준비합니다.

public class CustomerDto {
    private String name;
    private String city;

    public CustomerDto() {}                       // 기본 생성자 필수

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getCity() { return city; }
    public void setCity(String city) { this.city = city; }

    @Override public String toString() {
        return "CustomerDto(name=" + name + ", city=" + city + ")";
    }
}
List<CustomerDto> result = queryFactory
        .select(Projections.bean(CustomerDto.class,
                customer.name,
                customer.city))
        .from(customer)
        .fetch();

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건
CustomerDto(name=김서준, city=서울)
CustomerDto(name=이지은, city=부산)
CustomerDto(name=박철수, city=대구)
...

동작 순서는 이렇습니다.

  1. SQL 로 name, city 두 컬럼을 읽는다
  2. 행마다 new CustomerDto()기본 생성자 호출
  3. select 에 넣은 표현식의 이름(name, city)으로 setter 이름을 조립한다 (setName, setCity)
  4. 리플렉션으로 그 setter 를 호출한다

즉 3번에서 문자열로 이름을 맞춥니다. 이 사실이 5-6 절의 함정으로 이어집니다.

기본 생성자가 없으면 이렇게 죽습니다.

com.querydsl.core.types.ExpressionException:
    com.example.shop.step05.CustomerDto.<init>()
Caused by: java.lang.NoSuchMethodException: com.example.shop.step05.CustomerDto.<init>()

이건 좋은 실패입니다. 즉시 터지니까요. 조용한 실패가 문제입니다.


5-5. Projections.fields — 필드에 직접 넣기

Projections.fields 는 setter 를 거치지 않고 필드에 리플렉션으로 직접 대입합니다. private 필드도 setAccessible(true) 로 열어서 넣습니다.

public class CustomerFieldDto {
    private String name;      // getter/setter 없음
    private String city;

    @Override public String toString() {
        return "CustomerFieldDto(name=" + name + ", city=" + city + ")";
    }
}
List<CustomerFieldDto> result = queryFactory
        .select(Projections.fields(CustomerFieldDto.class,
                customer.name,
                customer.city))
        .from(customer)
        .fetch();

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건
CustomerFieldDto(name=김서준, city=서울)
CustomerFieldDto(name=이지은, city=부산)
CustomerFieldDto(name=박철수, city=대구)
...

생성 SQL 은 bean완전히 같습니다. 차이는 자바 쪽 매핑 방식뿐입니다.

beanfields
값 주입 경로setter 호출필드 직접 대입
필요한 것기본 생성자 + setter기본 생성자 (setter 불필요)
이름 매칭 대상setter 이름필드 이름
final 필드불가불가

fields 가 더 간결해 보여서 실무에서 많이 쓰입니다. 그런데 바로 그 간결함이 다음 절의 사고를 만듭니다.


5-6. ⚠️ 이 스텝의 핵심 — 이름이 안 맞으면 조용히 null

DTO 필드 이름을 name 이 아니라 userName 으로 지었다고 합시다. 화면 스펙이 userName 이라서, 혹은 다른 DTO 와 이름을 맞추려고 — 흔한 일입니다.

public class CustomerDto {
    private String userName;    // ← 엔티티 필드는 name 인데 DTO 는 userName
    private String city;

    @Override public String toString() {
        return "CustomerDto(userName=" + userName + ", city=" + city + ")";
    }
}
List<CustomerDto> result = queryFactory
        .select(Projections.fields(CustomerDto.class,
                customer.name,        // ← 이름이 name. DTO 는 userName
                customer.city))
        .from(customer)
        .fetch();

result.forEach(System.out::println);

컴파일: 성공합니다. 실행: 예외 없습니다. 경고 로그도 없습니다.

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건
CustomerDto(userName=null, city=서울)
CustomerDto(userName=null, city=부산)
CustomerDto(userName=null, city=대구)
CustomerDto(userName=null, city=인천)
CustomerDto(userName=null, city=서울)
... (30건 전부 userName=null)

SQL 은 완벽합니다. name 컬럼을 정확히 읽어 왔습니다. DB 에서 값이 넘어오는 것까지 성공했습니다. 그런데 그 값을 넣을 자리를 못 찾았습니다.

QueryDSL 은 customer.name 이라는 표현식의 이름 "name" 을 꺼내 CustomerDto 에서 name 이라는 필드를 찾습니다. 없습니다. 그래서 아무것도 하지 않고 넘어갑니다. userName 은 초기값 null 그대로 남습니다.

⚠️ 함정 — Projections.fields / bean 의 이름 불일치 이건 이 코스에서 말하는 "조용히 틀리는 코드" 의 교과서적 사례입니다.

  • 컴파일러: 통과. Projections.fields(Class<T>, Expression<?>...) 는 이름을 검사할 방법이 없습니다.
  • 런타임: 예외 없음. "필드를 못 찾았다" 는 에러가 아니라 그냥 스킵입니다.
  • 테스트: DTO 필드가 5개인데 그중 1개만 틀렸다면, 그 필드를 검증하지 않는 테스트는 전부 통과합니다.
  • 발견 시점: 화면에 빈칸이 뜨거나, API 응답에 "userName": null 이 나갔을 때.

근본 원인은 fieldsbean 도 "런타임 문자열 이름 매칭" 이라는 것입니다. 타입 안전을 위해 QueryDSL 을 쓰는데, 정작 DTO 매핑 구간에서 타입 안전이 끊깁니다.

처방 1 — as() 로 별칭 붙이기

표현식에 별칭을 달아 이름을 맞춥니다.

List<CustomerDto> result = queryFactory
        .select(Projections.fields(CustomerDto.class,
                customer.name.as("userName"),     // ← 별칭
                customer.city))
        .from(customer)
        .fetch();

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건
CustomerDto(userName=김서준, city=서울)
CustomerDto(userName=이지은, city=부산)
CustomerDto(userName=박철수, city=대구)
...

생성 SQL 은 한 글자도 바뀌지 않았습니다. as() 는 SQL 의 AS 가 아닙니다. QueryDSL 내부에서 "이 표현식의 이름은 userName 이다" 라고 표시하는 것뿐이고, 그 이름은 DTO 필드를 찾을 때만 쓰입니다. JPQL 별칭으로도 나가지 않습니다.

처방 2 — ExpressionUtils.as()

as() 메서드가 없는 표현식(대표적으로 서브쿼리)에는 ExpressionUtils.as() 를 씁니다.

List<CustomerDto> result = queryFactory
        .select(Projections.fields(CustomerDto.class,
                ExpressionUtils.as(customer.name, "userName"),
                customer.city))
        .from(customer)
        .fetch();

두 방식은 동등합니다. customer.name.as("userName") 이 더 짧으니 평소엔 이쪽을 씁니다. ExpressionUtils.as(JPAExpressions.select(...), "orderCount") 형태가 필요한 경우는 5-11 절에서 봅니다.

bean 도 똑같습니다

public class CustomerDto {
    private String userName;
    private String city;
    public void setUserName(String v) { this.userName = v; }   // setUserName
    public void setCity(String v) { this.city = v; }
}
queryFactory.select(Projections.bean(CustomerDto.class,
        customer.name,          // → setName 을 찾는다. 없다. 스킵.
        customer.city))
    .from(customer).fetch();

결과

조회 30건 — userName 전부 null

bean 은 setter 이름(setName)을 찾고, fields 는 필드 이름(name)을 찾습니다. 찾는 대상만 다를 뿐 못 찾았을 때 조용히 넘어가는 것은 동일합니다.

💡 실무 팁 — 이 함정을 구조적으로 막는 법

  1. DTO 필드 이름을 엔티티 필드 이름과 같게 짓는다. 화면 이름은 프레젠테이션 계층에서 바꾼다.
  2. 이름이 다를 수밖에 없으면 반드시 as() 를 붙인다. 예외 없이.
  3. 가장 확실한 방법은 아예 이름 매칭을 쓰지 않는 것 — @QueryProjection (5-8 절)입니다.
  4. DTO 매핑 테스트에서는 모든 필드에 대해 assertThat(...).isNotNull() 을 최소한 한 번은 돌립니다. "null 이 아니다" 만 확인해도 이 함정은 전부 잡힙니다.

5-7. Projections.constructor — 순서와 타입으로 채우기

Projections.constructor 는 이름을 보지 않습니다. select 에 넣은 표현식의 순서와 타입에 맞는 생성자를 찾아 호출합니다.

public class CustomerDto {
    private final String userName;
    private final String city;

    public CustomerDto(String userName, String city) {
        this.userName = userName;
        this.city = city;
    }
    // final 필드 가능. setter 불필요. 기본 생성자 불필요.
}
List<CustomerDto> result = queryFactory
        .select(Projections.constructor(CustomerDto.class,
                customer.name,      // → 1번째 생성자 인자
                customer.city))     // → 2번째 생성자 인자
        .from(customer)
        .fetch();

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건
CustomerDto(userName=김서준, city=서울)
CustomerDto(userName=이지은, city=부산)
...

DTO 필드가 userName 인데도 정상입니다. 이름을 안 보기 때문입니다. as() 도 필요 없습니다. 이름 오타에 강합니다. final 필드를 쓸 수 있어 불변 DTO 도 만들 수 있습니다.

타입이 안 맞으면 런타임에 즉시 터집니다.

queryFactory.select(Projections.constructor(CustomerDto.class,
        customer.name,
        customer.points))       // Integer 인데 생성자 2번째 인자는 String
    .from(customer).fetch();

결과

com.querydsl.core.types.ExpressionException:
    No constructor found for class com.example.shop.step05.CustomerDto
    with parameters: [class java.lang.String, class java.lang.Integer]

이것도 좋은 실패입니다. 문제는 타입이 같을 때입니다.

⚠️ 함정 — 같은 타입 필드의 순서를 바꾸면 값이 조용히 뒤바뀝니다 CustomerDto(String userName, String city) 를 리팩터링하면서 생성자 파라미터 순서를 (String city, String userName) 으로 바꿨다고 합시다. IDE 의 "Change Signature" 는 호출부의 자바 인자 순서는 바꿔 주지만, Projections.constructor(...) 안의 QueryDSL 표현식 순서까지 알아서 바꿔 주지는 않습니다.

// DTO 를 이렇게 고쳤다
public CustomerDto(String city, String userName) {   // ← 순서가 바뀜
    this.city = city;
    this.userName = userName;
}
// 쿼리는 그대로 두었다
List<CustomerDto> result = queryFactory
        .select(Projections.constructor(CustomerDto.class,
                customer.name,      // → 이제 city 자리에 들어간다
                customer.city))     // → 이제 userName 자리에 들어간다
        .from(customer)
        .fetch();

컴파일: 성공. 실행: 예외 없음. 타입이 둘 다 String 이라 생성자를 정확히 찾습니다.

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건
CustomerDto(userName=서울, city=김서준)
CustomerDto(userName=부산, city=이지은)
CustomerDto(userName=대구, city=박철수)
...

이름과 도시가 통째로 바뀌었습니다. 30건 전부. 화면에는 "서울 님, 안녕하세요" 가 뜹니다. 로그에는 아무것도 남지 않습니다.

fields 의 함정이 "값이 사라지는" 것이라면, constructor 의 함정은 "값이 뒤바뀌는" 것입니다. null 은 눈에 띄기라도 하지만, 뒤바뀐 값은 그럴듯해 보입니다. 이쪽이 더 위험합니다.

처방: 이 함정은 as() 로 못 막습니다. 이름을 안 보니까요. 근본 해결은 @QueryProjection 입니다. 다음 절입니다.


5-8. @QueryProjection — 컴파일 시점에 검증하기

DTO 생성자@QueryProjection 을 붙이면, APT 가 그 DTO 의 Q타입을 생성합니다.

package com.example.shop.step05;

import com.querydsl.core.annotations.QueryProjection;

public class CustomerDto {
    private final String userName;
    private final String city;

    @QueryProjection                                   // ← 이것 하나
    public CustomerDto(String userName, String city) {
        this.userName = userName;
        this.city = city;
    }

    public String getUserName() { return userName; }
    public String getCity() { return city; }

    @Override public String toString() {
        return "CustomerDto(userName=" + userName + ", city=" + city + ")";
    }
}

./gradlew compileJava 를 돌리면 QCustomerDto 가 생성됩니다.

build/generated/sources/annotationProcessor/java/main/com/example/shop/step05/QCustomerDto.java

생성된 코드는 이렇게 생겼습니다 (핵심 부분만).

package com.example.shop.step05;

import com.querydsl.core.types.ConstructorExpression;
import com.querydsl.core.types.Expression;
import javax.annotation.processing.Generated;

@Generated("com.querydsl.codegen.DefaultProjectionSerializer")
public class QCustomerDto extends ConstructorExpression<CustomerDto> {

    private static final long serialVersionUID = -1837294055L;

    public QCustomerDto(Expression<String> userName, Expression<String> city) {
        super(CustomerDto.class,
              new Class<?>[]{ String.class, String.class },
              userName, city);
    }
}

생성자 시그니처가 Expression<String>, Expression<String> 으로 박혀 있습니다. 이제 이렇게 씁니다.

List<CustomerDto> result = queryFactory
        .select(new QCustomerDto(customer.name, customer.city))
        .from(customer)
        .fetch();

결과

select c1_0.name, c1_0.city
from customers c1_0
조회 30건
CustomerDto(userName=김서준, city=서울)
CustomerDto(userName=이지은, city=부산)
...

이제 실수를 해 봅시다.

// 인자 개수가 틀림
new QCustomerDto(customer.name)
error: constructor QCustomerDto in class QCustomerDto cannot be applied to given types;
  required: Expression<String>,Expression<String>
  found:    StringPath
  reason: actual and formal argument lists differ in length
// 타입이 틀림
new QCustomerDto(customer.name, customer.points)
error: incompatible types: NumberPath<Integer> cannot be converted to Expression<String>

컴파일이 안 됩니다. IDE 에 빨간 줄이 그어집니다. 5-7 절의 "순서 바꿔치기" 도 DTO 생성자를 고치면 QCustomerDto 가 재생성되므로, 타입이 다른 경우에는 컴파일 에러로 잡힙니다. 같은 타입끼리 순서를 바꾼 경우는 여전히 컴파일이 되지만, 적어도 DTO 와 Q타입이 한 몸으로 움직인다는 보장은 생깁니다.

단점 두 가지

첫째, DTO 가 QueryDSL 에 의존합니다. import com.querydsl.core.annotations.QueryProjection; 이 DTO 파일에 들어갑니다. 이 DTO 를 API 응답이나 다른 모듈로 넘기면, QueryDSL 의존이 그 모듈까지 따라갑니다. 5-3 절에서 Tuple 을 내보내지 말라고 한 것과 같은 종류의 문제입니다. 정도는 훨씬 가볍지만요.

둘째, APT 대상에 DTO 가 포함돼야 합니다. Q타입 생성은 애노테이션 프로세싱 결과입니다. DTO 가 src/main/java 밖(예: 별도 api 모듈)에 있는데 그 모듈에 annotationProcessor 설정이 없으면 QCustomerDto 가 생성되지 않습니다. cannot find symbol: class QCustomerDto 로 나타납니다.

// DTO 가 있는 모듈에도 이 설정이 필요합니다
annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12:jpa'

💡 실무 팁 — 팀에서 어떻게 정할까 의존성 오염을 감수하고 컴파일 검증을 택하는 팀이 많습니다. 절충안은 리포지토리 전용 DTO 는 @QueryProjection, 외부로 나가는 응답 DTO 는 순수 클래스로 두고 경계에서 변환하는 것입니다. 클래스가 늘어나는 대신 의존이 새지 않습니다. 어느 쪽이든 Projections.fields 를 기본값으로 삼는 것만은 피하십시오.


5-9. 네 방식 비교

beanfieldsconstructor@QueryProjection
검증 시점런타임런타임런타임컴파일
매칭 기준setter 이름필드 이름순서 + 타입순서 + 타입 (컴파일 강제)
필요한 것기본 생성자 + setter기본 생성자해당 시그니처 생성자생성자 + @QueryProjection + APT
final 필드
이름 불일치 시조용히 null조용히 null영향 없음영향 없음
같은 타입 순서 바뀌면영향 없음영향 없음조용히 값 뒤바뀜조용히 값 뒤바뀜 (단, DTO 와 동기화됨)
타입 불일치 시런타임 예외런타임 예외런타임 예외컴파일 에러
인자 개수 틀리면영향 없음(무시)영향 없음(무시)런타임 예외컴파일 에러
DTO 의 QueryDSL 의존없음없음없음있음
권장 상황레거시 JavaBean DTO짧은 내부 전용 DTO (as() 필수)불변 DTO, 외부 모듈 DTO기본값으로 권장

세 줄 요약입니다.

  1. @QueryProjection 을 기본으로 쓰십시오. 컴파일러가 잡아 주는 게 가장 쌉니다.
  2. DTO 에 QueryDSL 의존을 넣을 수 없으면 constructor 를 쓰고, 파라미터 순서를 절대 안 건드립니다.
  3. fields / bean 을 쓸 거면 모든 표현식에 as() 를 붙이는 것을 팀 규칙으로 만드십시오.

5-10. 중첩 DTO — 여러 엔티티의 값을 하나로

"주문번호 + 고객명 + 상품명 + 수량" 처럼 여러 엔티티에서 값을 모으는 것도 프로젝션입니다. 표현식만 여러 엔티티에서 가져오면 됩니다.

public class OrderLineDto {
    private final Long orderId;
    private final String customerName;
    private final String productName;
    private final int quantity;

    @QueryProjection
    public OrderLineDto(Long orderId, String customerName, String productName, int quantity) {
        this.orderId = orderId;
        this.customerName = customerName;
        this.productName = productName;
        this.quantity = quantity;
    }
    // getter 생략
}
List<OrderLineDto> result = queryFactory
        .select(new QOrderLineDto(
                order.id,
                customer.name,
                product.name,
                orderItem.quantity))
        .from(orderItem)
        .join(orderItem.order, order)
        .join(order.customer, customer)
        .join(orderItem.product, product)
        .orderBy(order.id.asc(), product.id.asc())
        .limit(8)
        .fetch();

결과

select o1_0.order_id, c1_0.name, p1_0.name, oi1_0.quantity
from order_items oi1_0
join orders o1_0 on o1_0.order_id = oi1_0.order_id
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
join products p1_0 on p1_0.product_id = oi1_0.product_id
order by o1_0.order_id asc, p1_0.product_id asc
limit ?
바인딩: [1] 8
조회 8건
OrderLineDto(orderId=1, customerName=류하나, productName=27인치 4K 모니터, quantity=3)
OrderLineDto(orderId=1, customerName=류하나, productName=원목 4인 식탁, quantity=1)
OrderLineDto(orderId=2, customerName=정  훈, productName=베이직 옥스퍼드 셔츠, quantity=2)
OrderLineDto(orderId=2, customerName=정  훈, productName=게이밍 노트북 RTX4060, quantity=3)
OrderLineDto(orderId=2, customerName=정  훈, productName=콜드브루 원액 1L, quantity=1)
OrderLineDto(orderId=3, customerName=안지수, productName=인체공학 사무용 의자, quantity=2)
OrderLineDto(orderId=4, customerName=한지호, productName=슬림핏 치노 팬츠, quantity=3)
OrderLineDto(orderId=4, customerName=한지호, productName=보급형 노트북 15, quantity=1)

📌 MySQL8 코스 Step 07 — 조인7-2 절과 같은 결과입니다. 거기서는 FROM orders o JOIN customers c ... JOIN order_items oi ... 로 썼습니다.

여기서 두 가지가 눈에 띕니다.

  • p1_0.namec1_0.name 이 둘 다 name 인데 충돌하지 않습니다. Hibernate 6 은 별칭 접두사로 구분하고, 순서로 결과를 읽으므로 문제가 없습니다.
  • order_id = 1 이 두 줄입니다. 주문 1건에 상품이 2개(1:N)이기 때문입니다. 이 "행 뻥튀기" 가 집계와 만나면 사고가 납니다.

조인은 Step 06 에서 본격적으로 다룹니다. fan-out, on vs where, fetchJoin 같은 주제들이 거기 있습니다.

DTO 안에 DTO 를 넣는 것도 가능합니다. Projections 는 중첩할 수 있습니다.

List<OrderWithCustomerDto> result = queryFactory
        .select(Projections.constructor(OrderWithCustomerDto.class,
                order.id,
                order.totalAmount,
                Projections.constructor(CustomerDto.class,      // ← 중첩
                        customer.name,
                        customer.city)))
        .from(order)
        .join(order.customer, customer)
        .limit(3)
        .fetch();

결과

select o1_0.order_id, o1_0.total_amount, c1_0.name, c1_0.city
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
limit ?
바인딩: [1] 3
OrderWithCustomerDto(orderId=1, totalAmount=1836000.00, customer=CustomerDto(userName=류하나, city=부산))
OrderWithCustomerDto(orderId=2, totalAmount=6663900.00, customer=CustomerDto(userName=정  훈, city=서울))
OrderWithCustomerDto(orderId=3, totalAmount=658000.00, customer=CustomerDto(userName=안지수, city=대구))

SQL 은 평평합니다(컬럼 4개). 중첩은 자바 객체를 조립하는 단계에서만 일어납니다.


5-11. 서브쿼리 결과를 DTO 필드로

"고객명 + 그 고객의 주문 건수" 를 한 번에 뽑으려면 스칼라 서브쿼리가 필요합니다. 서브쿼리 표현식에는 .as() 메서드가 없으므로 ExpressionUtils.as() 를 씁니다.

public class CustomerOrderCountDto {
    private String userName;
    private Long orderCount;
    // toString 생략
}
List<CustomerOrderCountDto> result = queryFactory
        .select(Projections.fields(CustomerOrderCountDto.class,
                customer.name.as("userName"),
                ExpressionUtils.as(
                        JPAExpressions.select(order.count())
                                      .from(order)
                                      .where(order.customer.eq(customer)),
                        "orderCount")))
        .from(customer)
        .orderBy(customer.id.asc())
        .limit(5)
        .fetch();

결과

select c1_0.name,
       (select count(o1_0.order_id)
          from orders o1_0
         where o1_0.customer_id = c1_0.customer_id)
from customers c1_0
order by c1_0.customer_id asc
limit ?
바인딩: [1] 5
CustomerOrderCountDto(userName=김민수, orderCount=20)
CustomerOrderCountDto(userName=이지은, orderCount=20)
CustomerOrderCountDto(userName=박철수, orderCount=20)
CustomerOrderCountDto(userName=최영희, orderCount=20)
CustomerOrderCountDto(userName=정  훈, orderCount=20)

시드 데이터가 고객 30명에게 주문 20건씩(30 × 20 = 600) 배정했으므로 전부 20 입니다.

ExpressionUtils.as 를 빼먹으면 어떻게 될까요? 5-6 절과 똑같습니다.

Projections.fields(CustomerOrderCountDto.class,
        customer.name.as("userName"),
        JPAExpressions.select(order.count()).from(order)
                      .where(order.customer.eq(customer)))   // 별칭 없음

결과

CustomerOrderCountDto(userName=김민수, orderCount=null)
CustomerOrderCountDto(userName=이지은, orderCount=null)
...

서브쿼리 표현식에는 애초에 이름이라는 게 없으니, 매칭할 필드를 찾지 못하고 그냥 넘어갑니다. SQL 은 정상적으로 나가서 값도 가져왔는데, 그 값이 버려집니다.

💡 이런 경우 Projections.constructor@QueryProjection 이 훨씬 안전합니다. 이름이 없는 표현식이라도 순서는 언제나 있으니까요. 서브쿼리를 DTO 에 넣을 일이 있으면 이름 매칭 방식은 피하는 게 좋습니다.

서브쿼리는 Step 07 에서 본격적으로 다룹니다. JPA 가 FROM 절 서브쿼리를 지원하지 않는다는 제약도 거기서 이야기합니다.


5-12. 연산 결과를 DTO 로

프로젝션에 넣는 것은 컬럼만이 아닙니다. 표현식이면 무엇이든 됩니다. quantity × unitPrice 로 라인별 금액을 계산해 봅시다.

public class OrderItemAmountDto {
    private final Long orderId;
    private final String productName;
    private final int quantity;
    private final BigDecimal lineAmount;

    @QueryProjection
    public OrderItemAmountDto(Long orderId, String productName,
                              int quantity, BigDecimal lineAmount) {
        this.orderId = orderId;
        this.productName = productName;
        this.quantity = quantity;
        this.lineAmount = lineAmount;
    }
}
List<OrderItemAmountDto> result = queryFactory
        .select(new QOrderItemAmountDto(
                orderItem.order.id,
                orderItem.product.name,
                orderItem.quantity,
                orderItem.quantity.multiply(orderItem.unitPrice)))   // ← 연산
        .from(orderItem)
        .join(orderItem.product, product)
        .orderBy(orderItem.quantity.multiply(orderItem.unitPrice).desc())
        .limit(5)
        .fetch();

결과

select oi1_0.order_id, p1_0.name, oi1_0.quantity,
       oi1_0.quantity * oi1_0.unit_price
from order_items oi1_0
join products p1_0 on p1_0.product_id = oi1_0.product_id
order by oi1_0.quantity * oi1_0.unit_price desc
limit ?
바인딩: [1] 5
OrderItemAmountDto(orderId=2, productName=게이밍 노트북 RTX4060, quantity=3, lineAmount=6570000.00)
OrderItemAmountDto(orderId=58, productName=게이밍 노트북 RTX4060, quantity=3, lineAmount=6570000.00)
OrderItemAmountDto(orderId=114, productName=게이밍 노트북 RTX4060, quantity=3, lineAmount=6570000.00)
OrderItemAmountDto(orderId=170, productName=게이밍 노트북 RTX4060, quantity=3, lineAmount=6570000.00)
OrderItemAmountDto(orderId=226, productName=게이밍 노트북 RTX4060, quantity=3, lineAmount=6570000.00)

주목할 점 두 가지입니다.

첫째, orderItem.order.id 는 조인을 만들지 않습니다. order_idorder_items 테이블에 이미 있는 FK 컬럼이라, Hibernate 가 조인 없이 그대로 읽습니다. 반면 orderItem.product.nameproducts 테이블을 봐야 하므로 조인이 필요합니다. 위에서 join(orderItem.product, product) 를 명시하지 않았다면 Hibernate 6 가 암시적 조인(implicit join)을 만들어 넣습니다 — 어떤 종류의 조인이 만들어지는지 통제할 수 없으니, 연관을 타고 들어갈 때는 명시적으로 조인하십시오.

둘째, multiply 의 결과 타입입니다. orderItem.quantityNumberPath<Integer>, orderItem.unitPriceNumberPath<BigDecimal> 입니다. quantity.multiply(unitPrice)NumberExpression<Integer> 로 추론되는 경우가 있어 DTO 생성자가 BigDecimal 을 받으면 타입이 어긋날 수 있습니다. 순서를 뒤집어 unitPrice.multiply(quantity) 로 쓰면 BigDecimal 로 안전하게 잡힙니다.

orderItem.unitPrice.multiply(orderItem.quantity)   // NumberExpression<BigDecimal>

결과 — SQL 의 곱셈 순서만 바뀝니다

select oi1_0.order_id, p1_0.name, oi1_0.quantity,
       oi1_0.unit_price * oi1_0.quantity
from order_items oi1_0
join products p1_0 on p1_0.product_id = oi1_0.product_id

명시적으로 캐스팅하고 싶으면 Expressions.numberTemplate 이나 .castToNum(BigDecimal.class) 를 씁니다. 자세한 표현식 조작은 Step 13 에서 다룹니다.

💡 실무 팁 — 금액 계산은 곱셈 순서에 신경 쓰십시오 자바에서는 int * BigDecimal 이 안 되지만 QueryDSL 은 표현식을 조립할 뿐이라 컴파일이 됩니다. 그리고 결과 타입 추론이 어긋나면 DTO 매핑에서 No constructor found 로 늦게 터집니다. BigDecimal 쪽을 왼쪽에 두는 것을 습관으로 만드십시오.


5-13. MySQL8 코스와 나란히

지금까지 본 프로젝션들을 SQL 로 쓰면 이렇습니다.

하려는 것SQLQueryDSL
전 컬럼SELECT * FROM customersselectFrom(customer)
컬럼 하나SELECT name FROM customersselect(customer.name).from(customer)
컬럼 여러 개SELECT name, city FROM customersselect(customer.name, customer.city)Tuple
중복 제거SELECT DISTINCT city ...selectDistinct(customer.city)
컬럼 별칭SELECT name AS user_name ...customer.name.as("userName")SQL 에는 안 나감
계산 컬럼SELECT quantity * unit_price ...orderItem.unitPrice.multiply(orderItem.quantity)
스칼라 서브쿼리SELECT (SELECT COUNT(*) ...) AS cntExpressionUtils.as(JPAExpressions.select(...), "cnt")

가장 중요한 차이는 별칭의 의미입니다.

-- SQL: AS 는 결과셋의 컬럼 이름을 바꾼다. 클라이언트가 그 이름으로 읽는다.
SELECT name AS user_name, city FROM customers;
// QueryDSL: as() 는 SQL 에 나가지 않는다. DTO 필드를 찾는 데만 쓰인다.
customer.name.as("userName")

결과 — 생성 SQL 에 as 가 없습니다

select c1_0.name, c1_0.city
from customers c1_0

SQL 에 익숙한 사람이 가장 자주 오해하는 지점입니다. as("userName") 을 써 놓고 "SQL 에 별칭이 왜 안 붙지?" 라고 묻는 것은 방향이 틀렸습니다. QueryDSL 의 as()자바 객체 조립을 위한 표시이지 SQL 문법이 아닙니다.

📌 MySQL8 코스 Step 04 — SELECT 기초 에서 SELECT * 대신 필요한 컬럼만 쓰라고 했던 이유와, 이 스텝에서 프로젝션을 쓰는 이유는 같습니다. 다만 JPA 에는 영속성 컨텍스트 등록과 스냅샷 이라는 비용이 하나 더 붙습니다.


정리

개념핵심
엔티티 조회전 컬럼 SELECT + 영속성 컨텍스트 등록 + 스냅샷 보관. 변경할 것만 이렇게 읽는다
프로젝션필요한 컬럼만. 영속성 컨텍스트에 등록되지 않는다
단일 컬럼select(customer.name)List<String>
Tuple여러 컬럼. t.get(customer.name). 리포지토리 밖으로 내보내지 말 것
Projections.bean기본 생성자 + setter. setter 이름 매칭 (런타임)
Projections.fields기본 생성자. 필드 리플렉션. 필드 이름 매칭 (런타임)
⚠️ 이름 불일치bean/fields 에서 예외 없이 그 필드만 null. as() 로 해결
as()SQL 별칭이 아니다. DTO 필드를 찾기 위한 이름표
ExpressionUtils.as().as() 가 없는 표현식(서브쿼리 등)에 별칭 부여
Projections.constructor순서 + 타입. 이름 오타에 강함. final 필드 가능
⚠️ 순서 뒤바뀜같은 타입 파라미터 순서를 바꾸면 조용히 값이 교차. as() 로 못 막음
@QueryProjectionQXxxDto 생성. 컴파일 시점 타입·개수 검증. DTO 가 QueryDSL 에 의존
중첩 DTOProjections 를 중첩. SQL 은 평평하고 조립만 중첩된다
연산 프로젝션unitPrice.multiply(quantity)BigDecimal 을 왼쪽에

이 스텝의 함정 2가지

  1. fields/bean 의 이름 불일치 → 조용히 null. SQL 은 정상이고 값도 왔는데 넣을 자리를 못 찾는다.
  2. constructor 의 순서 뒤바뀜 → 조용히 값 교차. 타입이 같으면 예외조차 안 난다.

둘 다 @QueryProjection 을 쓰면 상당 부분 사라집니다.


연습문제

Exercise.java 에 6문제가 있습니다. 정답은 Solution.java.

  1. customers 에서 GOLD 등급 고객의 이메일만 뽑아 List<String> 으로 받으세요. 생성 SQL 에 email 컬럼 하나만 나오는지 확인하십시오.
  2. Tuple도시별 고객 이름과 포인트를 조회하고(포인트 상위 5명), Tuple 에서 값을 꺼내 출력하세요. 그다음 같은 결과를 Projections.constructor 로 다시 작성하고 Tuple 을 반환하면 안 되는지 주석으로 적으세요.
  3. 아래 코드는 userName 이 전부 null 로 나옵니다. SQL 을 바꾸지 말고 고치세요.
    queryFactory.select(Projections.fields(CustomerDto.class,   // DTO 필드: userName, homeCity
            customer.name, customer.city))
        .from(customer).fetch();
  4. OrderSummaryDto(Long orderId, String customerName, String shippingCity, BigDecimal totalAmount)@QueryProjection 으로 만들고, 금액 상위 5건의 주문을 조회하세요. customerNameshippingCity둘 다 String 이라는 점에 주의하십시오.
  5. 문제 4의 DTO 생성자 파라미터 순서를 (Long orderId, String shippingCity, String customerName, BigDecimal totalAmount) 로 바꾸면 어떤 일이 생기는지 Projections.constructor 버전과 @QueryProjection 버전에서 각각 확인하고 차이를 주석으로 설명하세요.
  6. ExpressionUtils.as 와 스칼라 서브쿼리로 CustomerReviewDto(고객명, 그 고객이 쓴 후기 수) 를 조회하세요. 후기를 한 번도 안 쓴 고객은 0 이 나와야 합니다. (전체 30명 중 후기 작성자는 4명입니다)

다음 단계

프로젝션은 "무엇을 꺼낼 것인가" 였습니다. 5-10 절에서 여러 엔티티의 값을 하나의 DTO 로 모으면서 이미 join 을 썼는데, 그때 order_id = 1 이 두 줄로 나온 것을 봤습니다. 그게 조인의 본질입니다. 다음 스텝에서는 "어디서 꺼낼 것인가" — 조인을 다룹니다. onwhere 의 차이, fetch join, 그리고 이 코스에서 가장 위험한 함정인 fetch join + 페이징이 거기 있습니다.

Step 06 — 조인


실습 파일

이 스텝은 자바 파일 3개로 구성됩니다. Practice.java 의 예제를 [5-1] ~ [5-13] 순서대로 실행해 콘솔의 SQL 을 본문과 대조하고, Exercise.java 의 6문제를 직접 푼 뒤, Solution.java 로 채점하는 흐름입니다. 세 파일 모두 @SpringBootTest + @Transactional 테스트 클래스이므로 프로젝트의 src/test/java/com/example/shop/step05/ 에 그대로 넣고 실행하면 됩니다.

DTO 는 각 파일 안에 static 중첩 클래스로 들어 있습니다. @QueryProjection 이 붙은 DTO 는 테스트 소스에도 APT 가 걸려 있어야 Q타입이 생성됩니다. build.gradle 에 아래 한 줄이 없으면 cannot find symbol: QCustomerDto 가 납니다.

testAnnotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12:jpa'

Practice.java

본문 5-1 ~ 5-13 의 예제를 절 번호 주석으로 묶은 파일입니다. 절 번호가 본문 소제목과 1:1 로 대응하므로, 읽다가 막히면 같은 번호 블록을 찾아 실행해 보십시오.

  • [5-1]selectFrom(customer)select(customer.name, customer.city) 를 연달아 실행합니다. 두 SQL 의 select 절 길이 차이를 눈으로 비교하는 것이 목적입니다.
  • [5-6] 이 이 파일의 심장입니다. fieldsWithWrongName() 을 먼저 실행해 30건 전부 userName=null 이 찍히는 것을 확인한 뒤, 바로 아래 fieldsWithAlias() 를 실행해 SQL 이 한 글자도 안 바뀌었는데 값이 채워지는 것을 봅니다. 이 두 메서드는 반드시 순서대로 실행하십시오.
  • [5-7]constructorSwapped()SwappedDto 라는 별도 DTO 를 씁니다. 파라미터 순서만 뒤집힌 클래스로, 실행하면 userName=서울, city=김서준 이 나옵니다. 예외가 안 나는 것이 이 메서드의 관전 포인트입니다.
  • [5-12] 에는 quantity.multiply(unitPrice) 버전이 주석으로 남아 있습니다. 주석을 풀면 프로젝트 설정에 따라 No constructor found ... [Long, String, Integer, Integer] 가 날 수 있습니다. 타입 추론이 어긋나는 지점을 직접 확인하고 싶을 때만 푸십시오.
package com.example.shop.step05;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.ExpressionUtils;
import com.querydsl.core.types.Projections;
import com.querydsl.core.annotations.QueryProjection;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QOrderItem.orderItem;
import static com.example.shop.entity.QProduct.product;
import static com.example.shop.entity.QReview.review;

/**
 * Step 05 — 프로젝션과 DTO : 본문 예제 모음
 *
 * 실행 전 확인:
 *   application.yml 에 아래 두 줄이 켜져 있어야 SQL 과 바인딩이 모두 보입니다.
 *     logging.level.org.hibernate.SQL: debug
 *     logging.level.org.hibernate.orm.jdbc.bind: trace
 *
 *   build.gradle 에 아래가 있어야 @QueryProjection DTO 의 Q타입이 테스트 소스에서 생성됩니다.
 *     testAnnotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12:jpa'
 *
 * 이 파일의 목적은 "결과가 맞는지" 확인하는 것이 아니라
 * "어떤 SQL 이 나가는지" 를 눈으로 보는 것입니다. 콘솔의 SQL 을 본문과 한 줄씩 대조하십시오.
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // [5-1] 프로젝션이란 — 엔티티 통째 조회의 비용
    // =================================================================

    @Test
    @DisplayName("[5-1] 엔티티 통째 조회 — 전 컬럼 SELECT + 영속성 컨텍스트 등록")
    void entityFullFetch() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .fetch();

        System.out.println("조회 " + result.size() + "건");

        // 영속성 컨텍스트에 실제로 들어갔는지 확인합니다.
        // 엔티티로 읽으면 true, 프로젝션으로 읽으면 애초에 엔티티가 아니므로 확인 대상이 아닙니다.
        System.out.println("영속 상태? " + em.contains(result.get(0)));
    }

    @Test
    @DisplayName("[5-1] 프로젝션 — 필요한 컬럼만. select 절이 짧아진 것을 확인")
    void projectionTwoColumns() {
        List<Tuple> result = queryFactory
                .select(customer.name, customer.city)
                .from(customer)
                .fetch();

        System.out.println("조회 " + result.size() + "건");
        // 생성 SQL:
        //   select c1_0.name, c1_0.city from customers c1_0
        // 바로 위 entityFullFetch() 의 SQL 과 select 절 길이를 비교하십시오.
    }

    // =================================================================
    // [5-2] 단일 컬럼 프로젝션
    // =================================================================

    @Test
    @DisplayName("[5-2] 단일 컬럼 — List<String> 이 바로 나온다")
    void singleColumn() {
        List<String> names = queryFactory
                .select(customer.name)
                .from(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        System.out.println(names);
        // 기대: [김서준, 류하나, 정  훈, 배채영]  (VIP 4명)
    }

    @Test
    @DisplayName("[5-2] 단일 컬럼 — 숫자 타입")
    void singleColumnNumber() {
        List<Integer> points = queryFactory
                .select(customer.points)
                .from(customer)
                .orderBy(customer.points.desc())
                .limit(5)
                .fetch();

        System.out.println(points);
    }

    @Test
    @DisplayName("[5-2] selectDistinct — SQL 에 distinct 가 붙는다")
    void singleColumnDistinct() {
        List<String> cities = queryFactory
                .selectDistinct(customer.city)
                .from(customer)
                .orderBy(customer.city.asc())
                .fetch();

        System.out.println(cities);
        // 기대: [광주, 대구, 대전, 부산, 서울, 인천]
    }

    // =================================================================
    // [5-3] Tuple
    // =================================================================

    @Test
    @DisplayName("[5-3] Tuple — select 에 넣은 표현식을 그대로 키로 쓴다")
    void tupleProjection() {
        List<Tuple> result = queryFactory
                .select(customer.name, customer.city, customer.points)
                .from(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        for (Tuple t : result) {
            System.out.println(t.get(customer.name) + " / "
                             + t.get(customer.city) + " / "
                             + t.get(customer.points));
        }
    }

    @Test
    @DisplayName("[5-3] Tuple — select 에 없는 표현식으로 꺼내면 예외가 아니라 null")
    void tupleMissingKey() {
        Tuple t = queryFactory
                .select(customer.name, customer.city)
                .from(customer)
                .fetchFirst();

        // email 은 select 에 넣지 않았습니다. 예외가 아니라 null 이 나옵니다.
        System.out.println("email = " + t.get(customer.email));   // null
    }

    // =================================================================
    // [5-4] Projections.bean — setter 기반
    // =================================================================

    @Test
    @DisplayName("[5-4] Projections.bean — 기본 생성자 + setter 로 채운다")
    void projectionBean() {
        List<BeanDto> result = queryFactory
                .select(Projections.bean(BeanDto.class,
                        customer.name,
                        customer.city))
                .from(customer)
                .fetch();

        result.stream().limit(3).forEach(System.out::println);
        System.out.println("조회 " + result.size() + "건");
    }

    // =================================================================
    // [5-5] Projections.fields — 필드 직접 주입
    // =================================================================

    @Test
    @DisplayName("[5-5] Projections.fields — getter/setter 없이 필드에 직접 넣는다")
    void projectionFields() {
        List<FieldDto> result = queryFactory
                .select(Projections.fields(FieldDto.class,
                        customer.name,
                        customer.city))
                .from(customer)
                .fetch();

        result.stream().limit(3).forEach(System.out::println);
        // 생성 SQL 은 projectionBean() 과 완전히 동일합니다. 차이는 자바 매핑뿐입니다.
    }

    // =================================================================
    // [5-6] ⚠️ 핵심 함정 — 이름이 안 맞으면 조용히 null
    //       아래 두 메서드는 반드시 이 순서로 실행하십시오.
    // =================================================================

    @Test
    @DisplayName("[5-6] ⚠️ 이름 불일치 — 예외도 경고도 없이 userName 만 null")
    void fieldsWithWrongName() {
        List<CustomerDto> result = queryFactory
                .select(Projections.fields(CustomerDto.class,
                        customer.name,     // 표현식 이름은 "name". DTO 필드는 userName.
                        customer.city))
                .from(customer)
                .fetch();

        result.stream().limit(5).forEach(System.out::println);
        System.out.println("조회 " + result.size() + "건");

        // 기대 출력:
        //   CustomerDto(userName=null, city=서울)
        //   CustomerDto(userName=null, city=부산)
        //   ... 30건 전부 userName=null
        //
        // SQL 은 완벽합니다. name 컬럼을 정확히 읽어 왔습니다.
        // 그 값을 넣을 필드를 못 찾아서 "아무것도 하지 않고" 넘어간 것입니다.

        long nullCount = result.stream().filter(d -> d.getUserName() == null).count();
        System.out.println("userName 이 null 인 건수 = " + nullCount + " / " + result.size());
    }

    @Test
    @DisplayName("[5-6] 처방 1 — as() 별칭. 생성 SQL 은 한 글자도 안 바뀐다")
    void fieldsWithAlias() {
        List<CustomerDto> result = queryFactory
                .select(Projections.fields(CustomerDto.class,
                        customer.name.as("userName"),   // ← 이것 하나
                        customer.city))
                .from(customer)
                .fetch();

        result.stream().limit(5).forEach(System.out::println);

        // 생성 SQL: select c1_0.name, c1_0.city from customers c1_0
        // 바로 위 fieldsWithWrongName() 과 SQL 을 비교하십시오. 동일합니다.
        // as() 는 SQL 의 AS 가 아닙니다. DTO 필드를 찾기 위한 이름표일 뿐입니다.
    }

    @Test
    @DisplayName("[5-6] 처방 2 — ExpressionUtils.as() (as() 메서드가 없는 표현식용)")
    void fieldsWithExpressionUtils() {
        List<CustomerDto> result = queryFactory
                .select(Projections.fields(CustomerDto.class,
                        ExpressionUtils.as(customer.name, "userName"),
                        customer.city))
                .from(customer)
                .fetch();

        result.stream().limit(3).forEach(System.out::println);
        // customer.name.as("userName") 과 완전히 동등합니다. 짧은 쪽을 쓰면 됩니다.
    }

    @Test
    @DisplayName("[5-6] bean 도 똑같다 — setter 이름을 못 찾으면 조용히 스킵")
    void beanWithWrongName() {
        List<BeanWrongNameDto> result = queryFactory
                .select(Projections.bean(BeanWrongNameDto.class,
                        customer.name,     // setName 을 찾는다. DTO 에는 setUserName 뿐이다.
                        customer.city))
                .from(customer)
                .fetch();

        result.stream().limit(3).forEach(System.out::println);
        // 기대: userName=null 이 30건.
        // fields 는 필드 이름을, bean 은 setter 이름을 찾습니다.
        // 찾는 대상만 다를 뿐 "못 찾으면 조용히 넘어간다" 는 동일합니다.
    }

    // =================================================================
    // [5-7] Projections.constructor — 순서와 타입
    // =================================================================

    @Test
    @DisplayName("[5-7] constructor — 이름을 안 보므로 as() 없이도 정상")
    void projectionConstructor() {
        List<CtorDto> result = queryFactory
                .select(Projections.constructor(CtorDto.class,
                        customer.name,     // → 1번째 생성자 인자
                        customer.city))    // → 2번째 생성자 인자
                .from(customer)
                .fetch();

        result.stream().limit(3).forEach(System.out::println);
        // DTO 필드가 userName 인데도 정상입니다. 이름을 안 보기 때문입니다.
    }

    @Test
    @DisplayName("[5-7] constructor — 타입이 다르면 런타임에 즉시 터진다 (좋은 실패)")
    void projectionConstructorTypeMismatch() {
        try {
            queryFactory
                    .select(Projections.constructor(CtorDto.class,
                            customer.name,
                            customer.points))   // Integer 인데 생성자 2번째는 String
                    .from(customer)
                    .fetch();
        } catch (Exception e) {
            System.out.println(e.getClass().getName());
            System.out.println(e.getMessage());
            // com.querydsl.core.types.ExpressionException:
            //   No constructor found for class ... with parameters: [String, Integer]
        }
    }

    @Test
    @DisplayName("[5-7] ⚠️ 같은 타입 순서 뒤바뀜 — 예외 없이 값이 교차한다")
    void constructorSwapped() {
        // SwappedDto 의 생성자는 (String city, String userName) 순서입니다.
        // 쿼리는 (name, city) 순서 그대로 두었습니다.
        List<SwappedDto> result = queryFactory
                .select(Projections.constructor(SwappedDto.class,
                        customer.name,     // → city 자리에 들어간다
                        customer.city))    // → userName 자리에 들어간다
                .from(customer)
                .fetch();

        result.stream().limit(5).forEach(System.out::println);

        // 기대 출력:
        //   SwappedDto(userName=서울, city=김서준)
        //   SwappedDto(userName=부산, city=이지은)
        //   ... 30건 전부 교차
        //
        // ★ 이 메서드의 관전 포인트는 "예외가 안 난다" 는 것입니다.
        //   타입이 둘 다 String 이라 생성자를 정확히 찾아 호출합니다.
        //   null 은 눈에 띄기라도 하지만 뒤바뀐 값은 그럴듯해 보입니다. 이쪽이 더 위험합니다.
        //   그리고 이 함정은 as() 로 못 막습니다 — 애초에 이름을 안 보니까요.
    }

    // =================================================================
    // [5-8] @QueryProjection — 컴파일 시점 검증
    // =================================================================

    @Test
    @DisplayName("[5-8] @QueryProjection — new QCustomerQpDto(...) 로 컴파일 시점에 검증")
    void queryProjection() {
        List<CustomerQpDto> result = queryFactory
                .select(new QPractice_CustomerQpDto(customer.name, customer.city))
                .from(customer)
                .fetch();

        result.stream().limit(3).forEach(System.out::println);

        // 중첩 클래스의 Q타입 이름은 QPractice_CustomerQpDto 형태가 됩니다
        // (바깥 클래스명 + '_' + 중첩 클래스명). 별도 파일로 빼면 QCustomerQpDto 입니다.
        //
        // 아래 두 줄의 주석을 풀면 "컴파일이 안 됩니다". 그것이 이 절의 전부입니다.
        //   new QPractice_CustomerQpDto(customer.name);                    // 인자 개수 부족
        //   new QPractice_CustomerQpDto(customer.name, customer.points);   // 타입 불일치
    }

    // =================================================================
    // [5-10] 중첩 DTO 프로젝션
    // =================================================================

    @Test
    @DisplayName("[5-10] 여러 엔티티의 값을 하나의 DTO 로 — 조인 필요")
    void nestedProjection() {
        List<OrderLineDto> result = queryFactory
                .select(new QPractice_OrderLineDto(
                        order.id,
                        customer.name,
                        product.name,
                        orderItem.quantity))
                .from(orderItem)
                .join(orderItem.order, order)
                .join(order.customer, customer)
                .join(orderItem.product, product)
                .orderBy(order.id.asc(), product.id.asc())
                .limit(8)
                .fetch();

        result.forEach(System.out::println);

        // order_id = 1 이 두 줄인 것에 주목하십시오.
        // 주문 1건에 상품이 2개(1:N)라서 주문 헤더 정보가 반복됩니다.
        // 이 "행 뻥튀기(fan-out)" 는 Step 06 의 6-4 절에서 본격적으로 다룹니다.
    }

    @Test
    @DisplayName("[5-10] Projections 중첩 — SQL 은 평평하고 조립만 중첩된다")
    void projectionInsideProjection() {
        List<OrderWithCustomerDto> result = queryFactory
                .select(Projections.constructor(OrderWithCustomerDto.class,
                        order.id,
                        order.totalAmount,
                        Projections.constructor(CtorDto.class,
                                customer.name,
                                customer.city)))
                .from(order)
                .join(order.customer, customer)
                .limit(3)
                .fetch();

        result.forEach(System.out::println);
        // 생성 SQL 의 select 절은 컬럼 4개로 평평합니다.
        // 중첩은 자바 객체를 조립하는 단계에서만 일어납니다.
    }

    // =================================================================
    // [5-11] 서브쿼리 결과를 DTO 필드로
    // =================================================================

    @Test
    @DisplayName("[5-11] ExpressionUtils.as + 스칼라 서브쿼리")
    void subqueryProjection() {
        List<CustomerOrderCountDto> result = queryFactory
                .select(Projections.fields(CustomerOrderCountDto.class,
                        customer.name.as("userName"),
                        ExpressionUtils.as(
                                JPAExpressions.select(order.count())
                                              .from(order)
                                              .where(order.customer.eq(customer)),
                                "orderCount")))
                .from(customer)
                .orderBy(customer.id.asc())
                .limit(5)
                .fetch();

        result.forEach(System.out::println);
        // 기대: 전원 orderCount=20 (고객 30명 × 20건 = 주문 600건)
    }

    @Test
    @DisplayName("[5-11] ⚠️ 서브쿼리에 별칭을 안 붙이면 — orderCount 가 null")
    void subqueryProjectionWithoutAlias() {
        List<CustomerOrderCountDto> result = queryFactory
                .select(Projections.fields(CustomerOrderCountDto.class,
                        customer.name.as("userName"),
                        JPAExpressions.select(order.count())
                                      .from(order)
                                      .where(order.customer.eq(customer))))   // 별칭 없음
                .from(customer)
                .orderBy(customer.id.asc())
                .limit(5)
                .fetch();

        result.forEach(System.out::println);
        // 기대: orderCount=null 이 5건.
        // 서브쿼리 표현식에는 애초에 이름이라는 게 없으니 매칭할 필드를 못 찾습니다.
        // SQL 은 정상적으로 나가서 값도 가져왔는데, 그 값이 버려집니다.
        //
        // → 서브쿼리를 DTO 에 넣을 때는 이름 매칭 방식(fields/bean)을 피하는 게 안전합니다.
        //   이름이 없는 표현식이라도 "순서" 는 언제나 있으니까요.
    }

    // =================================================================
    // [5-12] 연산 결과를 DTO 로
    // =================================================================

    @Test
    @DisplayName("[5-12] 곱셈 프로젝션 — BigDecimal 을 왼쪽에 두는 것이 안전")
    void arithmeticProjection() {
        List<OrderItemAmountDto> result = queryFactory
                .select(new QPractice_OrderItemAmountDto(
                        orderItem.order.id,
                        orderItem.product.name,
                        orderItem.quantity,
                        orderItem.unitPrice.multiply(orderItem.quantity)))   // BigDecimal 먼저
                .from(orderItem)
                .join(orderItem.product, product)
                .orderBy(orderItem.unitPrice.multiply(orderItem.quantity).desc())
                .limit(5)
                .fetch();

        result.forEach(System.out::println);

        // 생성 SQL:
        //   select oi1_0.order_id, p1_0.name, oi1_0.quantity,
        //          oi1_0.unit_price * oi1_0.quantity
        //   from order_items oi1_0
        //   join products p1_0 on p1_0.product_id = oi1_0.product_id
        //   order by oi1_0.unit_price * oi1_0.quantity desc
        //   limit ?
        //
        // orderItem.order.id 는 조인을 만들지 않습니다 — order_id 는 order_items 의 FK 컬럼이라
        // 그대로 읽으면 됩니다. 반면 orderItem.product.name 은 products 를 봐야 하므로
        // 조인이 필요하고, 명시하지 않으면 Hibernate 가 암시적 조인을 만들어 넣습니다.
        // 어떤 조인이 생길지 통제할 수 없으니 항상 명시적으로 조인하십시오.

        // 아래 주석을 풀면 quantity(Integer) 를 왼쪽에 둔 버전입니다.
        // 타입 추론이 NumberExpression<Integer> 로 잡히면
        // "No constructor found ... [Long, String, Integer, Integer]" 가 날 수 있습니다.
        // 타입 추론이 어긋나는 지점을 직접 확인하고 싶을 때만 푸십시오.
        //
        // queryFactory.select(new QPractice_OrderItemAmountDto(
        //         orderItem.order.id, orderItem.product.name, orderItem.quantity,
        //         orderItem.quantity.multiply(orderItem.unitPrice)))
        //     .from(orderItem).join(orderItem.product, product).limit(5).fetch();
    }

    // =================================================================
    // [5-13] MySQL8 코스 대조 — as() 는 SQL 에 나가지 않는다
    // =================================================================

    @Test
    @DisplayName("[5-13] as() 는 SQL 별칭이 아니다 — 생성 SQL 에 as 가 없는 것을 확인")
    void aliasIsNotSqlAlias() {
        queryFactory
                .select(customer.name.as("userName"), customer.city)
                .from(customer)
                .limit(3)
                .fetch();

        // 생성 SQL:
        //   select c1_0.name, c1_0.city from customers c1_0 limit ?
        //
        // SQL 에는 as 가 없습니다.
        // SQL 의 AS 는 결과셋 컬럼 이름을 바꾸지만,
        // QueryDSL 의 as() 는 자바 객체 조립을 위한 표시일 뿐입니다.
        // MySQL8 코스에서 SELECT name AS user_name 을 쓰던 감각으로 접근하면 오해합니다.
    }

    @Test
    @DisplayName("[5-13] 참고 — 후기 작성 고객 수 (연습문제 6번 검산용)")
    void reviewWriterCount() {
        Long writers = queryFactory
                .select(customer.countDistinct())
                .from(review)
                .join(review.customer, customer)
                .fetchOne();

        System.out.println("후기를 쓴 고객 수 = " + writers);   // 기대: 4
    }

    // =================================================================
    // DTO 들 — 본문에 등장한 것을 모두 중첩 클래스로 담았습니다
    // =================================================================

    /** [5-4] bean 용 — 기본 생성자 + setter 필수 */
    public static class BeanDto {
        private String name;
        private String city;

        public BeanDto() {}

        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
        public String getCity() { return city; }
        public void setCity(String city) { this.city = city; }

        @Override public String toString() {
            return "BeanDto(name=" + name + ", city=" + city + ")";
        }
    }

    /** [5-5] fields 용 — getter/setter 불필요 */
    public static class FieldDto {
        private String name;
        private String city;

        @Override public String toString() {
            return "FieldDto(name=" + name + ", city=" + city + ")";
        }
    }

    /** [5-6] 이름이 안 맞는 DTO — 엔티티는 name, 여기는 userName */
    public static class CustomerDto {
        private String userName;
        private String city;

        public String getUserName() { return userName; }
        public String getCity() { return city; }

        @Override public String toString() {
            return "CustomerDto(userName=" + userName + ", city=" + city + ")";
        }
    }

    /** [5-6] bean 버전 — setUserName 만 있고 setName 이 없다 */
    public static class BeanWrongNameDto {
        private String userName;
        private String city;

        public BeanWrongNameDto() {}

        public void setUserName(String userName) { this.userName = userName; }
        public void setCity(String city) { this.city = city; }

        @Override public String toString() {
            return "BeanWrongNameDto(userName=" + userName + ", city=" + city + ")";
        }
    }

    /** [5-7] constructor 용 — final 필드 가능, 기본 생성자 불필요 */
    public static class CtorDto {
        private final String userName;
        private final String city;

        public CtorDto(String userName, String city) {
            this.userName = userName;
            this.city = city;
        }

        @Override public String toString() {
            return "CtorDto(userName=" + userName + ", city=" + city + ")";
        }
    }

    /** [5-7] 파라미터 순서가 뒤집힌 DTO — (city, userName) */
    public static class SwappedDto {
        private final String userName;
        private final String city;

        public SwappedDto(String city, String userName) {   // ← 순서 주의
            this.city = city;
            this.userName = userName;
        }

        @Override public String toString() {
            return "SwappedDto(userName=" + userName + ", city=" + city + ")";
        }
    }

    /** [5-8] @QueryProjection 용 */
    public static class CustomerQpDto {
        private final String userName;
        private final String city;

        @QueryProjection
        public CustomerQpDto(String userName, String city) {
            this.userName = userName;
            this.city = city;
        }

        @Override public String toString() {
            return "CustomerQpDto(userName=" + userName + ", city=" + city + ")";
        }
    }

    /** [5-10] 여러 엔티티의 값을 모은 DTO */
    public static class OrderLineDto {
        private final Long orderId;
        private final String customerName;
        private final String productName;
        private final int quantity;

        @QueryProjection
        public OrderLineDto(Long orderId, String customerName, String productName, int quantity) {
            this.orderId = orderId;
            this.customerName = customerName;
            this.productName = productName;
            this.quantity = quantity;
        }

        @Override public String toString() {
            return "OrderLineDto(orderId=" + orderId + ", customerName=" + customerName
                 + ", productName=" + productName + ", quantity=" + quantity + ")";
        }
    }

    /** [5-10] DTO 안에 DTO */
    public static class OrderWithCustomerDto {
        private final Long orderId;
        private final BigDecimal totalAmount;
        private final CtorDto customer;

        public OrderWithCustomerDto(Long orderId, BigDecimal totalAmount, CtorDto customer) {
            this.orderId = orderId;
            this.totalAmount = totalAmount;
            this.customer = customer;
        }

        @Override public String toString() {
            return "OrderWithCustomerDto(orderId=" + orderId
                 + ", totalAmount=" + totalAmount + ", customer=" + customer + ")";
        }
    }

    /** [5-11] 서브쿼리 결과를 담는 DTO */
    public static class CustomerOrderCountDto {
        private String userName;
        private Long orderCount;

        @Override public String toString() {
            return "CustomerOrderCountDto(userName=" + userName + ", orderCount=" + orderCount + ")";
        }
    }

    /** [5-12] 연산 결과를 담는 DTO */
    public static class OrderItemAmountDto {
        private final Long orderId;
        private final String productName;
        private final int quantity;
        private final BigDecimal lineAmount;

        @QueryProjection
        public OrderItemAmountDto(Long orderId, String productName,
                                  int quantity, BigDecimal lineAmount) {
            this.orderId = orderId;
            this.productName = productName;
            this.quantity = quantity;
            this.lineAmount = lineAmount;
        }

        @Override public String toString() {
            return "OrderItemAmountDto(orderId=" + orderId + ", productName=" + productName
                 + ", quantity=" + quantity + ", lineAmount=" + lineAmount + ")";
        }
    }
}

Exercise.java

본문 연습문제 6개를 담은 빈칸 채우기용 파일입니다. 각 문제는 // 문제 N. 주석 블록으로 구분되어 있고 // 여기에 작성: 아래가 비어 있습니다.

  • [문제 3] 만 예외적으로 틀린 코드가 이미 작성되어 있습니다. 여러분이 할 일은 새로 쓰는 게 아니라, 그 코드를 실행해 null 을 직접 확인한 뒤 고치는 것입니다. // SQL 은 바꾸지 마십시오 라는 제약이 붙어 있습니다 — 답은 하나로 좁혀집니다.
  • [문제 5] 는 DTO 를 두 벌 만들어야 합니다. 귀찮지만 이 문제가 이 스텝의 결론입니다. 두 버전을 실제로 컴파일해 보면 @QueryProjection 쪽만 IDE 가 빨간 줄을 긋는 경우를 볼 수 있습니다.
  • [문제 6] 의 "후기 작성자 4명" 은 MySQL8 코스 Step 077-11 절 결과와 같은 데이터입니다. 숫자가 다르면 시드가 최신이 아닙니다.
package com.example.shop.step05;

import com.querydsl.core.Tuple;
import com.querydsl.core.annotations.QueryProjection;
import com.querydsl.core.types.ExpressionUtils;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QReview.review;

/**
 * Step 05 — 프로젝션과 DTO : 연습문제 6문제
 *
 * 각 문제의 "여기에 작성:" 아래에 코드를 채워 넣고 실행하십시오.
 * 답이 맞았는지보다 **생성 SQL 이 기대한 모양인지** 를 먼저 확인하십시오.
 * 정답은 Solution.java 에 있습니다. 먼저 스스로 풀어보십시오.
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // 문제 1.
    //   customers 에서 GOLD 등급 고객의 "이메일만" 뽑아 List<String> 으로 받으세요.
    //
    //   요구사항:
    //     - Tuple 도 DTO 도 쓰지 마십시오. List<String> 이 바로 나와야 합니다.
    //     - 생성 SQL 의 select 절에 email 컬럼 "하나만" 나오는지 확인하십시오.
    //     - GOLD 는 9명입니다.
    // =================================================================

    @Test
    @DisplayName("문제 1 — GOLD 고객의 이메일만")
    void ex1() {
        // 여기에 작성:

    }

    // =================================================================
    // 문제 2.
    //   (a) Tuple 로 포인트 상위 5명의 "이름과 포인트" 를 조회하고 값을 꺼내 출력하세요.
    //   (b) 같은 결과를 Projections.constructor 로 다시 작성하세요.
    //       DTO 는 아래 NamePointDto 를 쓰십시오.
    //   (c) 왜 (a) 의 Tuple 을 리포지토리 밖으로 반환하면 안 되는지
    //       메서드 아래 주석으로 3줄 이내로 적으세요.
    // =================================================================

    @Test
    @DisplayName("문제 2 — Tuple 과 constructor 를 나란히")
    void ex2() {
        // (a) Tuple 버전 — 여기에 작성:


        // (b) Projections.constructor 버전 — 여기에 작성:

    }
    // (c) Tuple 을 밖으로 내보내면 안 되는 이유:
    //     →
    //     →

    // =================================================================
    // 문제 3.
    //   아래 코드는 실행은 되지만 userName 과 homeCity 가 "둘 다" null 로 나옵니다.
    //   실행해서 직접 확인한 뒤 고치십시오.
    //
    //   제약: 생성 SQL 을 바꾸지 마십시오.
    //         (select 절은 c1_0.name, c1_0.city 그대로여야 합니다)
    //         DTO 클래스도 고치지 마십시오.
    //
    //   힌트: 함정을 하나만 고치고 안심하면 안 됩니다. 필드가 두 개입니다.
    // =================================================================

    @Test
    @DisplayName("문제 3 — 조용한 null 을 고치기")
    void ex3() {
        // ↓ 이 코드가 문제입니다. 먼저 그대로 실행해 null 을 눈으로 확인하십시오.
        List<WrongNameDto> broken = queryFactory
                .select(Projections.fields(WrongNameDto.class,
                        customer.name,
                        customer.city))
                .from(customer)
                .fetch();
        broken.stream().limit(3).forEach(System.out::println);

        // 여기에 고친 코드를 작성:

    }

    // =================================================================
    // 문제 4.
    //   OrderSummaryDto 를 @QueryProjection 으로 만들고,
    //   총액 상위 5건의 주문을 조회하세요.
    //
    //   요구사항:
    //     - DTO 시그니처: (Long orderId, String customerName, String shippingCity, BigDecimal totalAmount)
    //     - customers 와 조인해야 customerName 을 얻을 수 있습니다.
    //     - 정렬은 totalAmount 내림차순.
    //
    //   주의: customerName 과 shippingCity 가 "둘 다 String" 입니다.
    //         이 사실이 문제 5로 이어집니다.
    //
    //   DTO 는 아래 OrderSummaryDto 를 그대로 쓰고, Q타입 이름은
    //   QExercise_OrderSummaryDto 입니다 (중첩 클래스이므로 바깥 클래스명이 접두사로 붙습니다).
    // =================================================================

    @Test
    @DisplayName("문제 4 — @QueryProjection 으로 주문 요약 DTO")
    void ex4() {
        // 여기에 작성:

    }

    // =================================================================
    // 문제 5.
    //   문제 4의 DTO 파라미터 순서를
    //     (Long orderId, String shippingCity, String customerName, BigDecimal totalAmount)
    //   로 바꾸면 어떤 일이 생기는지 두 방식에서 각각 확인하세요.
    //
    //   (a) Projections.constructor 버전 — 아래 SwappedSummaryDto 를 쓰고
    //       쿼리의 표현식 순서는 (order.id, customer.name, order.shippingCity, order.totalAmount)
    //       로 "그대로" 두십시오. 실행 결과를 관찰하십시오.
    //   (b) @QueryProjection 버전 — 문제 4의 OrderSummaryDto 생성자 순서를 실제로 바꿔 보고
    //       (바꾼 뒤 compileJava 를 다시 돌려야 합니다) 컴파일 에러가 나는지 확인하십시오.
    //   (c) 두 결과의 차이와, @QueryProjection 으로도 못 막는 경우가 무엇인지
    //       주석으로 설명하세요.
    // =================================================================

    @Test
    @DisplayName("문제 5 — 순서 뒤바뀜을 두 방식에서 비교")
    void ex5() {
        // (a) Projections.constructor 버전 — 여기에 작성:


        // (b) @QueryProjection 버전은 DTO 를 실제로 고쳐서 확인하십시오.
        //     고친 뒤 ./gradlew compileTestJava 를 돌립니다.
    }
    // (c) 차이와 한계:
    //     →
    //     →
    //     →

    // =================================================================
    // 문제 6.
    //   ExpressionUtils.as 와 스칼라 서브쿼리로
    //   CustomerReviewDto(고객명, 그 고객이 쓴 후기 수) 를 조회하세요.
    //
    //   요구사항:
    //     - 고객 30명 "전원" 이 결과에 나와야 합니다.
    //     - 후기를 한 번도 안 쓴 고객은 reviewCount 가 0 이어야 합니다 (null 아님).
    //     - 후기를 쓴 고객은 4명입니다. 나머지 26명은 0 이어야 합니다.
    //     - reviewCount 내림차순, 같으면 고객 id 오름차순으로 정렬하십시오.
    //
    //   힌트: 서브쿼리에는 .as() 메서드가 없습니다.
    //         count() 는 매칭이 없으면 무엇을 돌려줄까요?
    // =================================================================

    @Test
    @DisplayName("문제 6 — 서브쿼리 결과를 DTO 필드로")
    void ex6() {
        // 여기에 작성:

    }

    // =================================================================
    // 문제에서 쓰는 DTO 들
    // =================================================================

    /** 문제 2 (b) 용 */
    public static class NamePointDto {
        private final String name;
        private final Integer points;

        public NamePointDto(String name, Integer points) {
            this.name = name;
            this.points = points;
        }

        @Override public String toString() {
            return "NamePointDto(name=" + name + ", points=" + points + ")";
        }
    }

    /** 문제 3 용 — 고치지 마십시오 */
    public static class WrongNameDto {
        private String userName;
        private String homeCity;

        @Override public String toString() {
            return "WrongNameDto(userName=" + userName + ", homeCity=" + homeCity + ")";
        }
    }

    /** 문제 4 용 */
    public static class OrderSummaryDto {
        private final Long orderId;
        private final String customerName;
        private final String shippingCity;
        private final BigDecimal totalAmount;

        @QueryProjection
        public OrderSummaryDto(Long orderId, String customerName,
                               String shippingCity, BigDecimal totalAmount) {
            this.orderId = orderId;
            this.customerName = customerName;
            this.shippingCity = shippingCity;
            this.totalAmount = totalAmount;
        }

        @Override public String toString() {
            return "OrderSummaryDto(orderId=" + orderId + ", customerName=" + customerName
                 + ", shippingCity=" + shippingCity + ", totalAmount=" + totalAmount + ")";
        }
    }

    /** 문제 5 (a) 용 — 파라미터 순서가 (city, name) 으로 뒤집혀 있습니다 */
    public static class SwappedSummaryDto {
        private final Long orderId;
        private final String customerName;
        private final String shippingCity;
        private final BigDecimal totalAmount;

        public SwappedSummaryDto(Long orderId, String shippingCity,
                                 String customerName, BigDecimal totalAmount) {
            this.orderId = orderId;
            this.shippingCity = shippingCity;
            this.customerName = customerName;
            this.totalAmount = totalAmount;
        }

        @Override public String toString() {
            return "SwappedSummaryDto(orderId=" + orderId + ", customerName=" + customerName
                 + ", shippingCity=" + shippingCity + ", totalAmount=" + totalAmount + ")";
        }
    }

    /** 문제 6 용 */
    public static class CustomerReviewDto {
        private String userName;
        private Long reviewCount;

        @Override public String toString() {
            return "CustomerReviewDto(userName=" + userName + ", reviewCount=" + reviewCount + ")";
        }
    }
}

Solution.java

6문제의 정답과 해설 주석을 담은 파일입니다. Exercise.java 를 스스로 풀어본 뒤에 열어보십시오. 각 정답 위 주석에 기대 결과와 생성 SQL 이 함께 적혀 있어 채점표로 바로 쓸 수 있습니다.

  • [정답 3] 의 핵심은 city 에도 as("homeCity") 가 필요하다는 것입니다. name 만 고치고 넘어가면 homeCity 가 null 로 남습니다. 함정을 하나 고쳤다고 안심하면 안 되는 이유를 보여주는 문제입니다.
  • [정답 5] 가 이 파일의 하이라이트입니다. Projections.constructor 버전은 컴파일도 실행도 성공하고 값만 뒤바뀝니다. @QueryProjection 버전은 DTO 를 고치는 순간 QOrderSummaryDto 가 재생성되므로 new QOrderSummaryDto(order.id, customer.name, order.shippingCity, order.totalAmount) 자체는 여전히 컴파일됩니다 — 같은 타입끼리는 컴파일러도 못 잡습니다. 이 한계까지 정확히 이해하는 것이 이 문제의 목표입니다.
  • [정답 6]JPAExpressions.select(review.count()).from(review).where(review.customer.eq(customer))ExpressionUtils.as(..., "reviewCount") 로 감쌉니다. count() 는 매칭이 없으면 0 을 돌려주므로 COALESCE 가 필요 없습니다 — 같은 것을 leftJoin 으로 하면 null 이 나온다는 점을 Step 06 에서 대조합니다.
package com.example.shop.step05;

import com.example.shop.entity.Grade;
import com.querydsl.core.Tuple;
import com.querydsl.core.annotations.QueryProjection;
import com.querydsl.core.types.ExpressionUtils;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QReview.review;

/**
 * Step 05 — 프로젝션과 DTO : 연습문제 정답과 해설
 *
 * Exercise.java 를 스스로 풀어본 "뒤에" 열어보십시오.
 * 각 정답 위 주석에 기대 결과와 생성 SQL 이 적혀 있습니다.
 */
@SpringBootTest
@Transactional
class Solution {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // 정답 1 — GOLD 고객의 이메일만
    // =================================================================
    //
    // 해설:
    //   select() 에 표현식을 하나만 넣으면 그 표현식의 타입이 그대로 제네릭 타입이 됩니다.
    //   customer.email 은 StringPath 이므로 JPAQuery<String> 이 되고 List<String> 이 나옵니다.
    //   Tuple 로 감싸이는 것은 표현식이 "둘 이상" 일 때뿐입니다.
    //
    //   이 문제의 진짜 확인 포인트는 반환 타입이 아니라 생성 SQL 입니다.
    //   select c1_0.email 처럼 컬럼이 딱 하나여야 합니다.
    //   여기서 selectFrom(customer) 로 읽은 뒤 자바에서 getEmail() 을 부르면
    //   결과는 같지만 컬럼 9개를 다 읽고 엔티티 9개를 영속성 컨텍스트에 등록하고
    //   스냅샷 9벌을 만든 뒤 이메일만 쓰고 버리는 셈이 됩니다.
    //   "결과가 같으면 같은 코드" 가 아니라는 것이 이 코스의 전제입니다.
    //
    // 생성 SQL:
    //   select c1_0.email from customers c1_0 where c1_0.grade = ?
    //   바인딩: [1] GOLD
    // 기대 결과: 9건

    @Test
    @DisplayName("정답 1 — GOLD 고객의 이메일만")
    void sol1() {
        List<String> emails = queryFactory
                .select(customer.email)
                .from(customer)
                .where(customer.grade.eq(Grade.GOLD))
                .fetch();

        System.out.println(emails);
        System.out.println("조회 " + emails.size() + "건");   // 9
    }

    // =================================================================
    // 정답 2 — Tuple 과 constructor 를 나란히
    // =================================================================
    //
    // 해설:
    //   (a) 와 (b) 의 생성 SQL 은 완전히 동일합니다.
    //       select c1_0.name, c1_0.points from customers c1_0 order by c1_0.points desc limit ?
    //       즉 DB 입장에서는 아무 차이가 없습니다. 차이는 오직 "자바에서 무엇으로 받느냐" 입니다.
    //
    //   (c) Tuple 을 밖으로 내보내면 안 되는 이유는 세 가지입니다.
    //       1. Tuple 은 com.querydsl.core.Tuple 입니다. 이걸 반환 타입에 쓰는 순간
    //          서비스와 컨트롤러가 QueryDSL 에 컴파일 의존을 갖습니다.
    //          데이터 접근 기술을 바꾸거나 QueryDSL 버전을 올릴 때 전 계층이 영향을 받습니다.
    //       2. Tuple 에서 값을 꺼내려면 QCustomer.customer.name 같은 Q타입 상수가 필요합니다.
    //          컨트롤러가 Q타입을 import 하는 순간 계층 분리는 사실상 끝난 것입니다.
    //       3. Tuple 은 "무엇이 들어 있는지" 를 타입으로 알려주지 않습니다.
    //          t.get(customer.email) 이 null 을 돌려주는 것이 "값이 null" 인지
    //          "select 에 안 넣었음" 인지 호출자가 구분할 수 없습니다.
    //
    //       Tuple 은 리포지토리 내부에서만 쓰고, 경계를 넘을 때는 DTO 로 바꾸십시오.
    //       애초에 처음부터 DTO 로 받으면 변환 코드조차 필요 없습니다.

    @Test
    @DisplayName("정답 2 — Tuple 과 constructor")
    void sol2() {
        // (a) Tuple 버전
        List<Tuple> tuples = queryFactory
                .select(customer.name, customer.points)
                .from(customer)
                .orderBy(customer.points.desc())
                .limit(5)
                .fetch();

        for (Tuple t : tuples) {
            System.out.println(t.get(customer.name) + " / " + t.get(customer.points));
        }

        // (b) Projections.constructor 버전 — SQL 은 위와 완전히 동일합니다
        List<NamePointDto> dtos = queryFactory
                .select(Projections.constructor(NamePointDto.class,
                        customer.name,
                        customer.points))
                .from(customer)
                .orderBy(customer.points.desc())
                .limit(5)
                .fetch();

        dtos.forEach(System.out::println);
    }

    // =================================================================
    // 정답 3 — 조용한 null 을 고치기
    // =================================================================
    //
    // 해설:
    //   이 문제의 핵심은 "필드가 두 개" 라는 것입니다.
    //   WrongNameDto 의 필드는 userName 과 homeCity 이고,
    //   표현식의 이름은 name 과 city 입니다. 둘 다 안 맞습니다.
    //
    //   많은 사람이 customer.name.as("userName") 만 고치고 넘어갑니다.
    //   그러면 userName 은 채워지고 homeCity 는 여전히 null 입니다.
    //   출력에 값이 하나라도 보이면 "고쳤다" 고 착각하기 쉽습니다.
    //   이 함정이 위험한 이유가 정확히 여기 있습니다 —
    //   부분적으로 성공하기 때문에 부분적으로 실패한 것이 눈에 안 띕니다.
    //
    //   생성 SQL 을 바꾸지 말라는 제약이 붙은 이유는,
    //   "DTO 필드명에 맞춰 엔티티 필드를 고친다" 나 "Tuple 로 받아서 수동 매핑한다" 같은
    //   우회를 막고 as() 라는 정답으로 좁히기 위해서입니다.
    //
    //   as() 는 SQL 에 나가지 않으므로 고치기 전후의 생성 SQL 이 한 글자도 다르지 않습니다.
    //   이것을 직접 확인하는 것이 이 문제의 마지막 관문입니다.
    //
    // 생성 SQL (고치기 전/후 동일):
    //   select c1_0.name, c1_0.city from customers c1_0
    // 기대 결과: WrongNameDto(userName=김서준, homeCity=서울) ... 30건

    @Test
    @DisplayName("정답 3 — as() 를 두 곳 모두에")
    void sol3() {
        List<WrongNameDto> result = queryFactory
                .select(Projections.fields(WrongNameDto.class,
                        customer.name.as("userName"),     // ← 하나만 고치면 안 됩니다
                        customer.city.as("homeCity")))    // ← 이쪽도
                .from(customer)
                .fetch();

        result.stream().limit(3).forEach(System.out::println);

        long broken = result.stream()
                .filter(d -> d.toString().contains("null"))
                .count();
        System.out.println("null 이 남은 건수 = " + broken);   // 0 이어야 정답
    }

    // =================================================================
    // 정답 4 — @QueryProjection 으로 주문 요약 DTO
    // =================================================================
    //
    // 해설:
    //   @QueryProjection 을 생성자에 붙이면 APT 가 QSolution_OrderSummaryDto 를 만듭니다
    //   (중첩 클래스라 바깥 클래스명이 접두사로 붙습니다. 별도 파일이면 QOrderSummaryDto).
    //   생성된 클래스의 생성자 시그니처는
    //     (Expression<Long>, Expression<String>, Expression<String>, Expression<BigDecimal>)
    //   로 박혀 있어서, 인자 개수나 타입을 틀리면 컴파일이 안 됩니다.
    //
    //   customerName 을 얻으려면 orders 와 customers 를 조인해야 합니다.
    //   order.customer.name 처럼 연관을 타고 들어가도 동작하지만
    //   Hibernate 가 암시적 조인을 만들어 넣으므로 어떤 조인이 생길지 통제할 수 없습니다.
    //   join(order.customer, customer) 로 명시하는 습관을 들이십시오 (Step 06 에서 자세히).
    //
    // 생성 SQL:
    //   select o1_0.order_id, c1_0.name, o1_0.shipping_city, o1_0.total_amount
    //   from orders o1_0
    //   join customers c1_0 on c1_0.customer_id = o1_0.customer_id
    //   order by o1_0.total_amount desc
    //   limit ?
    //   바인딩: [1] 5

    @Test
    @DisplayName("정답 4 — @QueryProjection")
    void sol4() {
        List<OrderSummaryDto> result = queryFactory
                .select(new QSolution_OrderSummaryDto(
                        order.id,
                        customer.name,
                        order.shippingCity,
                        order.totalAmount))
                .from(order)
                .join(order.customer, customer)
                .orderBy(order.totalAmount.desc())
                .limit(5)
                .fetch();

        result.forEach(System.out::println);
    }

    // =================================================================
    // 정답 5 — 순서 뒤바뀜을 두 방식에서 비교  ★ 이 파일의 하이라이트
    // =================================================================
    //
    // 해설:
    //   (a) Projections.constructor 버전
    //       SwappedSummaryDto 의 생성자는 (Long, String shippingCity, String customerName, BigDecimal) 인데
    //       쿼리는 (order.id, customer.name, order.shippingCity, order.totalAmount) 순서 그대로입니다.
    //       두 String 파라미터의 "타입" 은 완벽히 일치하므로 생성자를 정확히 찾아 호출합니다.
    //       컴파일 성공, 실행 성공, 예외 없음. 그런데 고객명 자리에 도시가 들어갑니다.
    //
    //       기대 출력:
    //         SwappedSummaryDto(orderId=..., customerName=서울, shippingCity=정  훈, ...)
    //
    //       화면에는 "서울 님, 주문해 주셔서 감사합니다" 가 뜹니다.
    //       null 은 눈에 띄지만 뒤바뀐 값은 그럴듯해 보입니다. 이쪽이 더 오래 살아남습니다.
    //
    //   (b) @QueryProjection 버전
    //       DTO 생성자 순서를 바꾸면 QSolution_OrderSummaryDto 도 재생성되어
    //       생성자 시그니처가 (Expression<Long>, Expression<String>, Expression<String>, Expression<BigDecimal>)
    //       "그대로" 유지됩니다. 두 String 의 위치가 바뀐 것뿐이니까요.
    //       따라서 new QSolution_OrderSummaryDto(order.id, customer.name, order.shippingCity, order.totalAmount)
    //       는 여전히 컴파일됩니다.
    //
    //   (c) 결론 — @QueryProjection 의 한계
    //       @QueryProjection 이 잡아 주는 것은 "개수" 와 "타입" 입니다.
    //       ★ 같은 타입 파라미터끼리의 순서 교환은 컴파일러도 못 잡습니다. ★
    //       이건 QueryDSL 의 한계가 아니라 자바 타입 시스템의 한계입니다.
    //       String 두 개는 자바에게 구분 불가능한 값입니다.
    //
    //       그럼에도 @QueryProjection 이 나은 이유는,
    //       DTO 와 Q타입이 한 몸으로 움직여서 타입이 다른 대부분의 실수는 컴파일에서 걸리고,
    //       DTO 를 고치면 Q타입 재생성이 강제되어 "쿼리 쪽도 봐야 한다" 는 신호가 남기 때문입니다.
    //
    //       같은 타입 순서 교환까지 막으려면 타입 시스템에 정보를 더 줘야 합니다.
    //         - 값 객체를 씁니다: CustomerName, ShippingCity 를 각각 record 로 감쌉니다.
    //         - 또는 필드 순서를 절대 바꾸지 않는 것을 팀 규칙으로 못 박고,
    //           DTO 매핑 테스트에서 실제 값을 assert 합니다
    //           (예: assertThat(dto.getShippingCity()).isIn("서울","부산","대구","인천","광주","대전")).

    @Test
    @DisplayName("정답 5 (a) — constructor 는 조용히 값이 교차한다")
    void sol5a() {
        List<SwappedSummaryDto> result = queryFactory
                .select(Projections.constructor(SwappedSummaryDto.class,
                        order.id,
                        customer.name,          // → shippingCity 자리로 들어간다
                        order.shippingCity,     // → customerName 자리로 들어간다
                        order.totalAmount))
                .from(order)
                .join(order.customer, customer)
                .orderBy(order.totalAmount.desc())
                .limit(5)
                .fetch();

        result.forEach(System.out::println);
        // 예외가 안 나는 것이 이 메서드의 관전 포인트입니다.
    }

    @Test
    @DisplayName("정답 5 (b) — 타입이 다르면 @QueryProjection 이 컴파일에서 잡는다")
    void sol5b() {
        // 아래 주석을 풀면 컴파일 에러가 납니다.
        // 이것이 @QueryProjection 이 실제로 잡아 주는 범위입니다.
        //
        // new QSolution_OrderSummaryDto(order.id, customer.name, order.shippingCity);
        //   → error: constructor QSolution_OrderSummaryDto cannot be applied to given types;
        //            actual and formal argument lists differ in length
        //
        // new QSolution_OrderSummaryDto(order.id, customer.name, order.totalAmount, order.shippingCity);
        //   → error: incompatible types:
        //            NumberPath<BigDecimal> cannot be converted to Expression<String>
        //
        // 반면 customer.name 과 order.shippingCity 의 자리를 맞바꾸는 것은
        // 둘 다 Expression<String> 이므로 컴파일됩니다. 이것이 한계입니다.

        System.out.println("주석을 풀어 컴파일 에러를 직접 확인하십시오.");
    }

    // =================================================================
    // 정답 6 — 서브쿼리 결과를 DTO 필드로
    // =================================================================
    //
    // 해설:
    //   포인트가 두 개입니다.
    //
    //   1. 서브쿼리에는 .as() 메서드가 없습니다.
    //      JPAExpressions.select(...) 가 돌려주는 것은 JPQLQuery 이고
    //      StringPath 처럼 as() 를 갖고 있지 않습니다.
    //      그래서 ExpressionUtils.as(표현식, "이름") 으로 바깥에서 감싸 이름을 붙입니다.
    //      이걸 빼먹으면 5-11 절에서 본 것처럼 reviewCount 가 조용히 null 이 됩니다.
    //      SQL 은 정상적으로 나가서 값도 가져오는데 넣을 자리를 못 찾아 버려집니다.
    //
    //   2. count() 는 매칭이 없으면 0 을 돌려줍니다. COALESCE 가 필요 없습니다.
    //      이것은 스칼라 서브쿼리이기 때문입니다 — 서브쿼리가 독립적으로 실행되어
    //      "0건을 세었다" 는 결과 0 을 반환합니다.
    //      같은 것을 leftJoin 으로 하면 다릅니다.
    //        leftJoin(customer.reviews, review) 후 review.count() 를 하면
    //        NULL 확장 행 때문에 값이 1 이 되거나(count(*) 계열),
    //        MySQL8 코스 Step 07 의 7-3 절에서 본 "COUNT(*) 함정" 과 같은 문제가 생깁니다.
    //      이 대조는 Step 06 에서 다시 다룹니다.
    //
    //   3. 이름 매칭 방식(fields)을 쓸 거면 두 표현식 모두에 이름이 필요합니다.
    //      customer.name 은 as("userName"), 서브쿼리는 ExpressionUtils.as(..., "reviewCount").
    //      사실 이 쿼리는 Projections.constructor 로 쓰는 게 더 안전합니다 —
    //      서브쿼리처럼 "이름이 없는 표현식" 을 다룰 때는 순서 기반이 자연스럽습니다.
    //
    // 생성 SQL:
    //   select c1_0.name,
    //          (select count(r1_0.review_id) from reviews r1_0
    //            where r1_0.customer_id = c1_0.customer_id)
    //   from customers c1_0
    //   order by 2 desc, c1_0.customer_id asc
    //
    // 기대 결과: 30건. 상위 4명은 reviewCount > 0, 나머지 26명은 0.

    @Test
    @DisplayName("정답 6 — ExpressionUtils.as + 스칼라 서브쿼리")
    void sol6() {
        var reviewCount = JPAExpressions
                .select(review.count())
                .from(review)
                .where(review.customer.eq(customer));

        List<CustomerReviewDto> result = queryFactory
                .select(Projections.fields(CustomerReviewDto.class,
                        customer.name.as("userName"),
                        ExpressionUtils.as(reviewCount, "reviewCount")))
                .from(customer)
                .orderBy(customer.id.asc())
                .fetch();

        // 정렬 기준이 서브쿼리 결과라 SQL 로 하려면 orderBy 에 같은 서브쿼리를 한 번 더 넣어야 합니다
        // (JPQL 은 select 별칭을 order by 에서 참조할 수 없습니다).
        // 여기서는 결과 검증이 목적이므로 자바에서 정렬합니다.
        result.sort((a, b) -> Long.compare(b.getReviewCount(), a.getReviewCount()));

        result.stream().limit(8).forEach(System.out::println);

        long writers = result.stream().filter(d -> d.getReviewCount() > 0).count();
        long zeros   = result.stream().filter(d -> d.getReviewCount() == 0).count();
        System.out.println("후기 작성 고객 = " + writers + "명, 0건 고객 = " + zeros + "명");
        // 기대: 4명 / 26명. null 이 하나라도 있으면 ExpressionUtils.as 를 빠뜨린 것입니다.
    }

    /** 정답 6 의 constructor 버전 — 서브쿼리에는 이쪽이 더 안전합니다 */
    @Test
    @DisplayName("정답 6 (별해) — constructor 로 쓰면 이름이 필요 없다")
    void sol6Alternative() {
        var reviewCount = JPAExpressions
                .select(review.count())
                .from(review)
                .where(review.customer.eq(customer));

        List<ReviewCountDto> result = queryFactory
                .select(Projections.constructor(ReviewCountDto.class,
                        customer.name,
                        reviewCount))          // as() 불필요 — 순서로 매칭하므로
                .from(customer)
                .orderBy(customer.id.asc())
                .fetch();

        result.stream().limit(8).forEach(System.out::println);
        // 이름 매칭이 없으니 as() 를 빠뜨려 null 이 되는 사고 자체가 발생하지 않습니다.
        // 서브쿼리를 DTO 에 넣을 때 constructor/@QueryProjection 을 권하는 이유입니다.
    }

    // =================================================================
    // DTO 들
    // =================================================================

    public static class NamePointDto {
        private final String name;
        private final Integer points;

        public NamePointDto(String name, Integer points) {
            this.name = name;
            this.points = points;
        }

        @Override public String toString() {
            return "NamePointDto(name=" + name + ", points=" + points + ")";
        }
    }

    public static class WrongNameDto {
        private String userName;
        private String homeCity;

        @Override public String toString() {
            return "WrongNameDto(userName=" + userName + ", homeCity=" + homeCity + ")";
        }
    }

    public static class OrderSummaryDto {
        private final Long orderId;
        private final String customerName;
        private final String shippingCity;
        private final BigDecimal totalAmount;

        @QueryProjection
        public OrderSummaryDto(Long orderId, String customerName,
                               String shippingCity, BigDecimal totalAmount) {
            this.orderId = orderId;
            this.customerName = customerName;
            this.shippingCity = shippingCity;
            this.totalAmount = totalAmount;
        }

        @Override public String toString() {
            return "OrderSummaryDto(orderId=" + orderId + ", customerName=" + customerName
                 + ", shippingCity=" + shippingCity + ", totalAmount=" + totalAmount + ")";
        }
    }

    public static class SwappedSummaryDto {
        private final Long orderId;
        private final String customerName;
        private final String shippingCity;
        private final BigDecimal totalAmount;

        public SwappedSummaryDto(Long orderId, String shippingCity,
                                 String customerName, BigDecimal totalAmount) {
            this.orderId = orderId;
            this.shippingCity = shippingCity;
            this.customerName = customerName;
            this.totalAmount = totalAmount;
        }

        @Override public String toString() {
            return "SwappedSummaryDto(orderId=" + orderId + ", customerName=" + customerName
                 + ", shippingCity=" + shippingCity + ", totalAmount=" + totalAmount + ")";
        }
    }

    public static class CustomerReviewDto {
        private String userName;
        private Long reviewCount;

        public String getUserName() { return userName; }
        public Long getReviewCount() { return reviewCount; }

        @Override public String toString() {
            return "CustomerReviewDto(userName=" + userName + ", reviewCount=" + reviewCount + ")";
        }
    }

    public static class ReviewCountDto {
        private final String userName;
        private final Long reviewCount;

        public ReviewCountDto(String userName, Long reviewCount) {
            this.userName = userName;
            this.reviewCount = reviewCount;
        }

        @Override public String toString() {
            return "ReviewCountDto(userName=" + userName + ", reviewCount=" + reviewCount + ")";
        }
    }
}