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 03 의 selectFrom(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
컬럼 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
컬럼이 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.name 은 StringPath 이므로 select(...) 의 반환 타입이 JPAQuery<String> 으로 결정됩니다.
customer.points 는 NumberPath<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 에 없었음
결과
⚠️ 함정 — Tuple 을 컨트롤러나 API 응답으로 내보내지 마십시오
Tuple 은 com.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=대구)
...
동작 순서는 이렇습니다.
- SQL 로
name, city 두 컬럼을 읽는다
- 행마다
new CustomerDto() — 기본 생성자 호출
- select 에 넣은 표현식의 이름(
name, city)으로 setter 이름을 조립한다 (setName, setCity)
- 리플렉션으로 그 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 과 완전히 같습니다. 차이는 자바 쪽 매핑 방식뿐입니다.
| bean | fields |
|---|
| 값 주입 경로 | 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 이 나갔을 때.
근본 원인은 fields 도 bean 도 "런타임 문자열 이름 매칭" 이라는 것입니다.
타입 안전을 위해 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)을 찾습니다.
찾는 대상만 다를 뿐 못 찾았을 때 조용히 넘어가는 것은 동일합니다.
💡 실무 팁 — 이 함정을 구조적으로 막는 법
- DTO 필드 이름을 엔티티 필드 이름과 같게 짓는다. 화면 이름은 프레젠테이션 계층에서 바꾼다.
- 이름이 다를 수밖에 없으면 반드시
as() 를 붙인다. 예외 없이.
- 가장 확실한 방법은 아예 이름 매칭을 쓰지 않는 것 —
@QueryProjection (5-8 절)입니다.
- 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. 네 방식 비교
| bean | fields | constructor | @QueryProjection |
|---|
| 검증 시점 | 런타임 | 런타임 | 런타임 | 컴파일 |
| 매칭 기준 | setter 이름 | 필드 이름 | 순서 + 타입 | 순서 + 타입 (컴파일 강제) |
| 필요한 것 | 기본 생성자 + setter | 기본 생성자 | 해당 시그니처 생성자 | 생성자 + @QueryProjection + APT |
final 필드 | ✗ | ✗ | ✓ | ✓ |
| 이름 불일치 시 | 조용히 null | 조용히 null | 영향 없음 | 영향 없음 |
| 같은 타입 순서 바뀌면 | 영향 없음 | 영향 없음 | 조용히 값 뒤바뀜 | 조용히 값 뒤바뀜 (단, DTO 와 동기화됨) |
| 타입 불일치 시 | 런타임 예외 | 런타임 예외 | 런타임 예외 | 컴파일 에러 |
| 인자 개수 틀리면 | 영향 없음(무시) | 영향 없음(무시) | 런타임 예외 | 컴파일 에러 |
| DTO 의 QueryDSL 의존 | 없음 | 없음 | 없음 | 있음 |
| 권장 상황 | 레거시 JavaBean DTO | 짧은 내부 전용 DTO (as() 필수) | 불변 DTO, 외부 모듈 DTO | 기본값으로 권장 |
세 줄 요약입니다.
@QueryProjection 을 기본으로 쓰십시오. 컴파일러가 잡아 주는 게 가장 쌉니다.
- DTO 에 QueryDSL 의존을 넣을 수 없으면
constructor 를 쓰고, 파라미터 순서를 절대 안 건드립니다.
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.name 과 c1_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_id 는 order_items 테이블에 이미 있는 FK 컬럼이라, Hibernate 가 조인 없이 그대로 읽습니다.
반면 orderItem.product.name 은 products 테이블을 봐야 하므로 조인이 필요합니다.
위에서 join(orderItem.product, product) 를 명시하지 않았다면
Hibernate 6 가 암시적 조인(implicit join)을 만들어 넣습니다 —
어떤 종류의 조인이 만들어지는지 통제할 수 없으니, 연관을 타고 들어갈 때는 명시적으로 조인하십시오.
둘째, multiply 의 결과 타입입니다.
orderItem.quantity 는 NumberPath<Integer>, orderItem.unitPrice 는 NumberPath<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 로 쓰면 이렇습니다.
| 하려는 것 | SQL | QueryDSL |
|---|
| 전 컬럼 | SELECT * FROM customers | selectFrom(customer) |
| 컬럼 하나 | SELECT name FROM customers | select(customer.name).from(customer) |
| 컬럼 여러 개 | SELECT name, city FROM customers | select(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 cnt | ExpressionUtils.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() 로 못 막음 |
@QueryProjection | QXxxDto 생성. 컴파일 시점 타입·개수 검증. DTO 가 QueryDSL 에 의존 |
| 중첩 DTO | Projections 를 중첩. SQL 은 평평하고 조립만 중첩된다 |
| 연산 프로젝션 | unitPrice.multiply(quantity) — BigDecimal 을 왼쪽에 |
이 스텝의 함정 2가지
fields/bean 의 이름 불일치 → 조용히 null. SQL 은 정상이고 값도 왔는데 넣을 자리를 못 찾는다.
constructor 의 순서 뒤바뀜 → 조용히 값 교차. 타입이 같으면 예외조차 안 난다.
둘 다 @QueryProjection 을 쓰면 상당 부분 사라집니다.
연습문제
Exercise.java 에 6문제가 있습니다. 정답은 Solution.java.
customers 에서 GOLD 등급 고객의 이메일만 뽑아 List<String> 으로 받으세요.
생성 SQL 에 email 컬럼 하나만 나오는지 확인하십시오.
Tuple 로 도시별 고객 이름과 포인트를 조회하고(포인트 상위 5명),
Tuple 에서 값을 꺼내 출력하세요. 그다음 같은 결과를 Projections.constructor 로 다시 작성하고
왜 Tuple 을 반환하면 안 되는지 주석으로 적으세요.
- 아래 코드는
userName 이 전부 null 로 나옵니다. SQL 을 바꾸지 말고 고치세요.
queryFactory.select(Projections.fields(CustomerDto.class, // DTO 필드: userName, homeCity
customer.name, customer.city))
.from(customer).fetch();
OrderSummaryDto(Long orderId, String customerName, String shippingCity, BigDecimal totalAmount) 를
@QueryProjection 으로 만들고, 금액 상위 5건의 주문을 조회하세요.
customerName 과 shippingCity 가 둘 다 String 이라는 점에 주의하십시오.
- 문제 4의 DTO 생성자 파라미터 순서를
(Long orderId, String shippingCity, String customerName, BigDecimal totalAmount) 로
바꾸면 어떤 일이 생기는지 Projections.constructor 버전과 @QueryProjection 버전에서 각각 확인하고
차이를 주석으로 설명하세요.
ExpressionUtils.as 와 스칼라 서브쿼리로
CustomerReviewDto(고객명, 그 고객이 쓴 후기 수) 를 조회하세요.
후기를 한 번도 안 쓴 고객은 0 이 나와야 합니다. (전체 30명 중 후기 작성자는 4명입니다)
다음 단계
프로젝션은 "무엇을 꺼낼 것인가" 였습니다.
5-10 절에서 여러 엔티티의 값을 하나의 DTO 로 모으면서 이미 join 을 썼는데,
그때 order_id = 1 이 두 줄로 나온 것을 봤습니다. 그게 조인의 본질입니다.
다음 스텝에서는 "어디서 꺼낼 것인가" — 조인을 다룹니다.
on 과 where 의 차이, 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 07 의
7-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 + ")";
}
}
}