Step 08 — 집계와 그룹핑

학습 목표

  • count/sum/avg/max/min 을 Path 에서 직접 호출하고 반환 타입을 정확히 안다
  • 집계 결과를 Tuple 로 꺼낼 때 표현식 객체를 재사용하는 관례를 익힌다
  • groupBy / having 을 쓰고 where 와의 실행 시점 차이를 설명한다
  • count(컬럼) 이 NULL 을 세지 않는 것sum() 이 0건에서 null 인 것을 재현한다
  • GroupBy.transform()DB 가 아니라 애플리케이션 메모리에서 그룹핑한다는 것을 생성 SQL 로 확인한다
  • 조인 fan-out 이 집계를 어떻게 망가뜨리는지 보고 countDistinct() 로 고친다

선행 스텝: Step 07 — 서브쿼리 예상 소요: 90분


8-0. 이 스텝의 위치

📌 MySQL8 코스 Step 06 — 집계함수와 GROUP BY 와 나란히 진행합니다. 그 스텝에서 SQL 로 배운 COUNT/SUM/AVG, GROUP BY, HAVING, ONLY_FULL_GROUP_BY 를 QueryDSL 로 다시 씁니다. 같은 데이터, 같은 숫자가 나옵니다.

집계는 "행 여러 개 → 값 하나" 로 접는 연산입니다. Step 03 까지의 조회가 "행 1개 → 값 여러 개" 였다면 정확히 반대 방향입니다.

QueryDSL 에서 집계가 까다로운 지점은 문법이 아닙니다. 타입입니다.

  • avg()Double 을 돌려줍니다. BigDecimal 컬럼이어도 Double 입니다.
  • sum() 은 원래 타입을 유지합니다. BigDecimal 컬럼이면 BigDecimal 입니다.
  • count() 는 항상 Long 입니다.
  • 결과는 대부분 Tuple 이고, Tuple.get()표현식 객체를 키로 씁니다.

그리고 이 스텝에는 이 코스에서 가장 오해가 많은 API 가 하나 있습니다. GroupBy.transform() 입니다. 이름과 달리 DB 의 group by 를 만들지 않습니다. 8-8 절에서 생성 SQL 로 직접 확인합니다.

이 스텝의 코드는 아래 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;
import static com.example.shop.entity.QCategory.category;
import static com.example.shop.entity.QReview.review;
import static com.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;
import static com.querydsl.core.group.GroupBy.set;
import static com.querydsl.core.group.GroupBy.map;
import static com.querydsl.core.group.GroupBy.sum;
import static com.querydsl.core.group.GroupBy.avg;

8-1. 집계 함수 — Path 에서 바로 나옵니다

QueryDSL 의 집계 함수는 별도 유틸이 아니라 Path 객체의 메서드입니다. order.totalAmountNumberPath<BigDecimal> 이므로 .sum() 이 바로 나옵니다.

Tuple stats = queryFactory
        .select(order.count(),
                order.totalAmount.sum(),
                order.totalAmount.avg(),
                order.totalAmount.max(),
                order.totalAmount.min())
        .from(order)
        .fetchOne();

결과

select count(o1_0.order_id),
       sum(o1_0.total_amount),
       avg(o1_0.total_amount),
       max(o1_0.total_amount),
       min(o1_0.total_amount)
from orders o1_0
count = 600
sum   = 764598000.00
avg   = 1274330.0
max   = 6663900.00
min   = 8900.00

📌 MySQL8 Step 06 — 6-1 절SELECT COUNT(*), SUM(...), AVG(...) FROM ... 과 정확히 같은 값입니다.

반환 타입 표 — 여기가 함정의 출발점입니다

QueryDSL생성 SQL반환 타입비고
order.count()count(o1_0.order_id)Long엔티티 count → PK 를 셈
customer.phone.count()count(c1_0.phone)LongNULL 을 세지 않음 (8-6)
order.totalAmount.sum()sum(o1_0.total_amount)BigDecimal원래 타입 유지
customer.points.sum()sum(c1_0.points)Integer원래 타입 유지
order.totalAmount.avg()avg(o1_0.total_amount)Double항상 Double
order.totalAmount.max()max(o1_0.total_amount)BigDecimal원래 타입 유지
order.totalAmount.min()min(o1_0.total_amount)BigDecimal원래 타입 유지
order.countDistinct()count(distinct o1_0.order_id)Longfan-out 대응 (8-9)

avg() 만 타입이 바뀝니다. BigDecimal 컬럼의 평균이 Double 로 나옵니다. 이것이 8-5 절의 ClassCastException 의 원인입니다.

count() 는 무엇을 세는가

order.count() 는 SQL 로 count(o1_0.order_id) 가 됩니다. count(*) 가 아닙니다. order_id 는 PK 라 NULL 이 될 수 없으므로 결과는 count(*) 와 같습니다. 하지만 NULL 이 가능한 컬럼을 세면 이야기가 달라집니다. 8-6 절에서 봅니다.

💡 실무 팁 — 엔티티 count 와 컬럼 count 를 구분해서 읽으십시오

order.count()               // count(o1_0.order_id)  — 행 수
order.totalAmount.count()   // count(o1_0.total_amount) — NULL 아닌 금액의 수

두 번째는 total_amount 가 NOT NULL 이라 지금은 같은 값이 나옵니다. nullable 컬럼이라면 다릅니다. "몇 건인가" 를 묻는다면 엔티티 count 를 쓰십시오.


8-2. 집계 결과는 Tuple

집계 표현식을 여러 개 select 하면 결과 타입은 Tuple 입니다. groupBy 가 없으면 전체가 한 그룹이므로 Tuple 1건이 나옵니다.

Tuple t = queryFactory
        .select(customer.count(), customer.points.avg())
        .from(customer)
        .fetchOne();

Long cnt = t.get(customer.count());
Double avg = t.get(customer.points.avg());

결과

select count(c1_0.customer_id), avg(c1_0.points)
from customers c1_0
cnt = 30
avg = 5959.0

Tuple.get() 은 표현식 객체를 키로 씁니다

여기가 처음 쓸 때 가장 어색한 부분입니다. t.get(0) 처럼 인덱스로도 꺼낼 수 있지만, 인덱스는 select 순서가 바뀌면 조용히 틀립니다. 표현식으로 꺼내는 것이 정석입니다.

t.get(customer.points.avg())    // ⭕ 표현식으로
t.get(1)                        // ⚠️ 인덱스로 — 순서 바뀌면 틀림

그런데 customer.points.avg() 는 호출할 때마다 새 객체를 만듭니다. 그래도 값이 꺼내지는 이유는 Tuple 내부가 equals() 로 매칭하기 때문입니다. 동작은 합니다. 다만 코드가 이렇게 됩니다.

// 장황하고 오타에 취약합니다
System.out.println(t.get(customer.points.avg()));
System.out.println(t.get(customer.points.sum()));
System.out.println(t.get(customer.points.max()));

표현식을 변수로 뽑아 두는 것이 관례입니다.

NumberExpression<Double>  avgPoints = customer.points.avg();
NumberExpression<Integer> sumPoints = customer.points.sum();
NumberExpression<Long>    cntAll    = customer.count();

Tuple t = queryFactory
        .select(cntAll, avgPoints, sumPoints)
        .from(customer)
        .fetchOne();

Long   c = t.get(cntAll);
Double a = t.get(avgPoints);
Integer s = t.get(sumPoints);

결과

select count(c1_0.customer_id), avg(c1_0.points), sum(c1_0.points)
from customers c1_0
c = 30
a = 5959.0
s = 178770

💡 실무 팁 — 집계가 3개를 넘으면 DTO 프로젝션으로 가십시오 Tuple 은 편하지만 타입 정보가 런타임까지 미뤄집니다. 집계가 늘어나면 Step 05 의 Projections.constructor@QueryProjection 으로 바꾸십시오.

queryFactory
    .select(Projections.constructor(PointStat.class,
            customer.count(), customer.points.avg(), customer.points.sum()))
    .from(customer)
    .fetchOne();

생성자 시그니처가 컴파일 타임에 검증되므로, avg()Double 이라는 사실을 컴파일러가 대신 기억해 줍니다. 8-5 의 ClassCastException 이 원천 차단됩니다.


8-3. groupBy — 등급별 고객 수와 평균 포인트

📌 MySQL8 Step 06 — 6-4 절GROUP BY 입니다.

SQL 로 먼저 씁니다.

SELECT grade, COUNT(*) AS cnt, AVG(points) AS avg_points
FROM customers
GROUP BY grade
ORDER BY grade;

QueryDSL 로 옮깁니다.

List<Tuple> byGrade = queryFactory
        .select(customer.grade, customer.count(), customer.points.avg())
        .from(customer)
        .groupBy(customer.grade)
        .orderBy(customer.grade.asc())
        .fetch();

결과

select c1_0.grade, count(c1_0.customer_id), avg(c1_0.points)
from customers c1_0
group by c1_0.grade
order by c1_0.grade
+--------+-----+------------+
| grade  | cnt | avg_points |
+--------+-----+------------+
| BRONZE |   9 |   952.2222 |
| SILVER |   8 |  3100.0    |
| GOLD   |   9 |  7600.0    |
| VIP    |   4 | 19250.0    |
+--------+-----+------------+
조회 4행 (합계 30명, 전체 평균 5959.0)

ORDER BY grade 는 ENUM 의 선언 순서로 정렬됩니다(BRONZE < SILVER < GOLD < VIP). 알파벳 순이 아닙니다. MySQL 의 ENUM 정렬 규칙이 그대로 적용됩니다.

여러 컬럼으로 묶기

List<Tuple> byGradeCity = queryFactory
        .select(customer.grade, customer.city, customer.count())
        .from(customer)
        .groupBy(customer.grade, customer.city)
        .orderBy(customer.grade.asc(), customer.city.asc())
        .fetch();

결과

select c1_0.grade, c1_0.city, count(c1_0.customer_id)
from customers c1_0
group by c1_0.grade, c1_0.city
order by c1_0.grade, c1_0.city
BRONZE 광주 1
BRONZE 대구 1
BRONZE 대전 1
BRONZE 부산 2
BRONZE 서울 1
...
조회 18행

주문 상태별 매출

List<Tuple> byStatus = queryFactory
        .select(order.status, order.count(), order.totalAmount.sum(), order.totalAmount.avg())
        .from(order)
        .groupBy(order.status)
        .orderBy(order.count().desc())
        .fetch();

결과

select o1_0.status, count(o1_0.order_id), sum(o1_0.total_amount), avg(o1_0.total_amount)
from orders o1_0
group by o1_0.status
order by count(o1_0.order_id) desc
+-----------+-----+---------------+------------+
| status    | cnt | sum           | avg        |
+-----------+-----+---------------+------------+
| DELIVERED | 240 | 326280000.00  | 1359500.0  |
| SHIPPED   | 120 | 162834000.00  | 1356950.0  |
| PAID      | 120 | 132630000.00  | 1105250.0  |
| CANCELLED |  60 |  47742000.00  |  795700.0  |
| PENDING   |  60 |  95112000.00  | 1585200.0  |
+-----------+-----+---------------+------------+
조회 5행 (합계 600건 / 764,598,000)

orderBy(order.count().desc()) 처럼 집계 표현식으로 정렬할 수 있습니다. SQL 에서도 order by count(...) desc 로 나갑니다.


8-4. having — 그룹을 거른다

wherehaving거르는 시점이 다릅니다.

FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
        ↑                    ↑
   개별 행을 거름        그룹을 거름
   (집계 전)             (집계 후, 집계 함수 사용 가능)

"주문 5건 이상인 고객" 을 찾습니다.

List<Tuple> heavy = queryFactory
        .select(order.customer.id, order.count())
        .from(order)
        .groupBy(order.customer.id)
        .having(order.count().goe(5))
        .orderBy(order.customer.id.asc())
        .fetch();

결과

select o1_0.customer_id, count(o1_0.order_id)
from orders o1_0
group by o1_0.customer_id
having count(o1_0.order_id) >= ?
order by o1_0.customer_id
바인딩: [1] 5
조회 30행 — 전원 20건씩이므로 아무도 걸러지지 않음

시드 데이터가 규칙적이라 30명 모두 통과합니다. 판별력 있는 조건으로 바꿉니다.

취소 제외 주문 합계가 5천만 원 이상인 고객

List<Tuple> bigSpenders = queryFactory
        .select(order.customer.id, order.count(), order.totalAmount.sum())
        .from(order)
        .where(order.status.ne(OrderStatus.CANCELLED))          // ① 개별 행 필터 (집계 전)
        .groupBy(order.customer.id)
        .having(order.totalAmount.sum().goe(new BigDecimal("50000000")))  // ② 그룹 필터 (집계 후)
        .orderBy(order.totalAmount.sum().desc())
        .fetch();

결과

select o1_0.customer_id, count(o1_0.order_id), sum(o1_0.total_amount)
from orders o1_0
where o1_0.status <> ?
group by o1_0.customer_id
having sum(o1_0.total_amount) >= ?
order by sum(o1_0.total_amount) desc
바인딩: [1] CANCELLED, [2] 50000000
조회 3행
   8  임수진   20   58449000.00
   5  정  훈   20   51568000.00
  21  황도윤   20   50248500.00

📌 MySQL8 Step 08 — 8-3 절 의 파생 테이블 예제와 같은 인물입니다. 거기서는 인라인 뷰로 풀었지만, JPQL 에서는 인라인 뷰를 못 쓰므로 having 으로 풉니다 (Step 07 의 7-7).

⚠️ 함정 — 집계와 무관한 조건을 having 에 쓰는 것

// ❌ 동작은 하지만 비효율
.groupBy(order.customer.id)
.having(order.status.ne(OrderStatus.CANCELLED))

이렇게 쓰면 CANCELLED 주문까지 전부 그룹핑한 뒤 버립니다. where 로 옮기면 그룹핑 대상 자체가 줄고 인덱스도 탈 수 있습니다. QueryDSL 은 having(...) 에 아무 BooleanExpression 이나 받아 주므로 막아 주지 않습니다.

  • having 에 써야 할 것: order.count().goe(5), order.totalAmount.sum().goe(...) — 집계 결과 조건
  • where 로 옮겨야 할 것: order.status.ne(...), order.orderDate.after(...) — 개별 행 조건

💡 having 에는 별칭을 못 씁니다 MySQL 은 HAVING 고객수 >= 3 처럼 select 별칭을 허용하는 확장을 갖고 있지만, QueryDSL 에서는 애초에 별칭이 아니라 표현식 객체를 넘기므로 이 문제가 없습니다. having(order.count().goe(5)) 에서 order.count() 는 select 절의 것과 같은 표현식이고, QueryDSL 이 같은 SQL 조각을 양쪽에 만들어 줍니다.


8-5. ⚠️ 집계 결과의 Tuple 다루기 — ClassCastException

이 절이 이 스텝의 첫 번째 함정입니다.

증상

totalAmountBigDecimal 입니다. 그러니 평균도 BigDecimal 이겠거니 하고 이렇게 씁니다.

// ⚠️ 컴파일이 됩니다
Tuple t = queryFactory
        .select(order.totalAmount.sum(), order.totalAmount.avg())
        .from(order)
        .fetchOne();

BigDecimal sum = t.get(order.totalAmount.sum());   // ⭕ BigDecimal 맞음
BigDecimal avg = (BigDecimal) t.get(0);            // ⚠️ 인덱스 접근 + 캐스팅

결과

java.lang.ClassCastException: class java.lang.Double cannot be cast to class java.math.BigDecimal
	at com.example.shop.step08.Practice.avgTypeTrap(Practice.java:88)

avg() 는 SQL 의 avg() 를 그대로 부르고, JDBC 는 그 결과를 Double 로 돌려줍니다. QueryDSL 의 avg() 시그니처도 NumberExpression<Double> 입니다. 컬럼이 BigDecimal 이든 Integer 든 평균은 항상 Double 입니다.

왜 컴파일러가 못 잡았나

표현식으로 꺼내면 잡습니다.

BigDecimal avg = t.get(order.totalAmount.avg());
//         ^^^^ 컴파일 에러: incompatible types: Double cannot be converted to BigDecimal

컴파일 에러가 나는 것이 정상이고 좋은 일입니다. 타입이 맞지 않는다고 알려 준 것이니까요. 문제는 위 예제처럼 인덱스 접근 + 수동 캐스팅을 하는 순간 컴파일러가 손을 뗀다는 점입니다. Tuple.get(int) 의 반환 타입은 Object 입니다.

⚠️ 함정 — Tuple.get(int) 는 타입 안전성을 통째로 버립니다 QueryDSL 을 쓰는 이유가 타입 안전인데, t.get(0) 한 줄로 그것이 무효화됩니다. 게다가 select 절 순서가 바뀌면 캐스팅이 성공하면서 값만 틀리는 최악의 경우도 생깁니다. 예: select(sum, avg)select(avg, sum) 으로 바꿨는데 둘 다 숫자라 조용히 통과. Tuple.get(int) 는 쓰지 마십시오.

두 번째 증상 — 장황함

표현식으로 꺼내는 것이 정답이지만, 매번 새로 만들면 이렇게 됩니다.

// 동작은 하지만 읽기 나쁩니다
Long   cnt = t.get(order.count());
BigDecimal sum = t.get(order.totalAmount.sum());
Double avg = t.get(order.totalAmount.avg());
BigDecimal max = t.get(order.totalAmount.max());
BigDecimal min = t.get(order.totalAmount.min());

order.totalAmount.sum() 을 select 절과 get 에서 각각 한 번씩, 총 두 번 씁니다. 한쪽만 .sum().avg() 로 잘못 고치면 null 이 조용히 나옵니다. (매칭 실패)

처방 1 — 표현식을 상수로 뽑는다

private static final NumberExpression<Long>       ORDER_CNT = order.count();
private static final NumberExpression<BigDecimal> AMT_SUM   = order.totalAmount.sum();
private static final NumberExpression<Double>     AMT_AVG   = order.totalAmount.avg();

Tuple t = queryFactory
        .select(ORDER_CNT, AMT_SUM, AMT_AVG)
        .from(order)
        .fetchOne();

Long       cnt = t.get(ORDER_CNT);
BigDecimal sum = t.get(AMT_SUM);
Double     avg = t.get(AMT_AVG);      // 타입이 코드에 박혀 있으므로 헷갈릴 여지가 없음

표현식 객체는 불변이므로 static final 로 둬도 안전합니다.

처방 2 — DTO 프로젝션 (권장)

public record OrderStat(Long count, BigDecimal sum, Double avg) {}

OrderStat stat = queryFactory
        .select(Projections.constructor(OrderStat.class,
                order.count(),
                order.totalAmount.sum(),
                order.totalAmount.avg()))
        .from(order)
        .fetchOne();

결과

select count(o1_0.order_id), sum(o1_0.total_amount), avg(o1_0.total_amount)
from orders o1_0
OrderStat[count=600, sum=764598000.00, avg=1274330.0]

record 의 생성자 시그니처가 컴파일 타임에 검증됩니다. avgBigDecimal 로 선언하면 그 자리에서 컴파일이 깨집니다.

⚠️ Projections.constructor 는 타입이 맞아야 합니다 record OrderStat(Long count, BigDecimal sum, BigDecimal avg) 로 잘못 선언하면 com.querydsl.core.types.ExpressionException런타임에 납니다. 생성자 인자 타입과 표현식 타입을 대조하기 때문입니다. @QueryProjection 을 쓰면 이것도 컴파일 타임으로 당겨집니다 (Step 05).


8-6. 집계와 NULL — count(컬럼) 은 NULL 을 세지 않습니다

📌 MySQL8 Step 06 — 6-2, 6-3 절부록 A — NULL 완전 정복 과 같은 주제입니다.

customers 30명 중 전화번호가 NULL 인 고객이 3명입니다.

Tuple t = queryFactory
        .select(customer.count(),           // 엔티티 count → PK
                customer.phone.count(),     // 컬럼 count  → NULL 제외
                customer.city.countDistinct())
        .from(customer)
        .fetchOne();

결과

select count(c1_0.customer_id), count(c1_0.phone), count(distinct c1_0.city)
from customers c1_0
customer.count()             = 30
customer.phone.count()       = 27   ← NULL 3명이 빠졌다
customer.city.countDistinct() = 8

30 과 27. 컴파일도 되고 예외도 없습니다. 리포트 숫자만 틀립니다.

형태생성 SQL세는 것
customer.count()count(c1_0.customer_id)행 수 (PK 는 NOT NULL)
customer.phone.count()count(c1_0.phone)NULL 이 아닌 phone 의 수
customer.city.countDistinct()count(distinct c1_0.city)서로 다른 city 의 수 (NULL 제외)

⚠️ 함정 — "고객 수" 를 세면서 nullable 컬럼을 세는 것

.select(customer.phone.count())     // ❌ "전화번호가 있는 고객 수"
.select(customer.count())           // ⭕ "고객 수"

두 줄은 비슷하게 생겼고 둘 다 Long 을 돌려줍니다. 값만 다릅니다. "몇 명인가" 를 묻는다면 엔티티 count 를 쓰십시오. 그리고 nullable 컬럼을 셀 때는 그 의미가 "NULL 아닌 것의 수" 라는 것을 항상 의식하십시오.

avg() 도 NULL 을 무시합니다

count(*) 를 제외한 모든 집계 함수가 NULL 을 건너뜁니다. avg() 에서 이게 특히 미묘합니다.

AVG(col) 은 정확히 SUM(col) / COUNT(col) 입니다. 분모가 전체 행 수가 아니라 "NULL 이 아닌 행 수" 입니다.

Tuple t = queryFactory
        .select(customer.count(),
                customer.points.avg(),                      // 30으로 나눔 (points 는 NOT NULL)
                customer.points.sum())
        .from(customer)
        .fetchOne();

결과

select count(c1_0.customer_id), avg(c1_0.points), sum(c1_0.points)
from customers c1_0
count = 30
avg   = 5959.0
sum   = 178770

178770 / 30 = 5959.0points 가 NOT NULL 이므로 일치합니다.

만약 points 가 nullable 이고 5명이 NULL 이라면 분모는 25가 되어 평균이 올라갑니다. "평균" 을 리포트에 낼 때는 분모가 무엇인지 반드시 확인하십시오.

그룹핑 컬럼이 NULL 이면

groupBy 대상에 NULL 이 있으면 NULL 도 하나의 그룹이 됩니다.

List<Tuple> byPhone = queryFactory
        .select(customer.phone.isNull(), customer.count())
        .from(customer)
        .groupBy(customer.phone.isNull())
        .fetch();

결과

select c1_0.phone is null, count(c1_0.customer_id)
from customers c1_0
group by c1_0.phone is null
false 27
true   3

employees.manager_id 처럼 실제로 NULL 을 가진 컬럼으로 묶으면 NULL 그룹이 직접 보입니다.

List<Tuple> byManager = queryFactory
        .select(employee.manager.id, employee.count())
        .from(employee)
        .groupBy(employee.manager.id)
        .orderBy(employee.manager.id.asc().nullsFirst())
        .fetch();

결과

null  1     ← CEO. 이 NULL 그룹이 Step 07 의 NOT IN 함정의 원인이었습니다
   1  3
   2  2
   3  3
   ...
조회 9행 (NULL 그룹 1 + 관리자 8명)

8-7. sum() 이 0건일 때 null 을 돌려줍니다

0 이 아닙니다. null 입니다.

BigDecimal total = queryFactory
        .select(order.totalAmount.sum())
        .from(order)
        .where(order.shippingCity.eq("제주"))       // 해당 행이 0건
        .fetchOne();

System.out.println(total);

결과

select sum(o1_0.total_amount)
from orders o1_0
where o1_0.shipping_city = ?
바인딩: [1] 제주
null

SQL 표준이 그렇습니다. "더할 것이 없으면 합계는 정의되지 않는다" 로 봅니다. count() 만 0건에서 0 을 돌려주고, sum / avg / max / min 은 전부 null 입니다.

함수대상 0건일 때
count()0
sum()null
avg()null
max() / min()null

NPE 로 이어지는 경로

// ⚠️ 운영에서 터지는 전형
BigDecimal total = queryFactory
        .select(order.totalAmount.sum())
        .from(order)
        .where(order.customer.eq(someCustomer),
               order.orderDate.between(from, to))
        .fetchOne();

BigDecimal fee = total.multiply(new BigDecimal("0.03"));   // 💥 NullPointerException

해당 기간에 주문이 하나도 없는 고객이 등장하는 순간 터집니다. 개발 데이터에는 항상 주문이 있으니 통과하고, 신규 가입자가 배치에 들어오는 날 터집니다.

처방 1 — coalesce

BigDecimal total = queryFactory
        .select(order.totalAmount.sum().coalesce(BigDecimal.ZERO))
        .from(order)
        .where(order.shippingCity.eq("제주"))
        .fetchOne();

결과

select coalesce(sum(o1_0.total_amount), ?)
from orders o1_0
where o1_0.shipping_city = ?
바인딩: [1] 0, [2] 제주
0

coalesce 는 SQL 의 COALESCE 로 번역되어 DB 에서 NULL 을 대체합니다. Expressions 와 조합한 더 복잡한 형태는 Step 13 — 고급 표현식 에서 다룹니다.

처방 2 — 자바 쪽에서 방어

BigDecimal total = Optional.ofNullable(
        queryFactory.select(order.totalAmount.sum()).from(order)
                .where(order.shippingCity.eq("제주"))
                .fetchOne()
).orElse(BigDecimal.ZERO);

둘 다 유효합니다. coalesce 쪽이 SQL 에 의도가 드러나서 더 낫습니다.

⚠️ 함정 — groupBy 가 있으면 이 문제가 숨습니다

.select(order.customer.id, order.totalAmount.sum())
.from(order).groupBy(order.customer.id)

이 쿼리는 주문이 0건인 고객의 행 자체를 만들지 않습니다. 그룹이 없으니까요. 그래서 sumnull 인 행을 볼 일이 없고, "이 문제는 나와 상관없다" 고 착각하게 됩니다. 그러다 leftJoin 을 붙여 "주문 0건 고객도 포함" 시키는 순간 null 이 등장합니다.

.select(customer.id, order.totalAmount.sum().coalesce(BigDecimal.ZERO))
.from(customer).leftJoin(order).on(order.customer.eq(customer))
.groupBy(customer.id)

leftJoin + 집계 조합에는 coalesce 를 습관적으로 붙이십시오.


8-8. GroupBy.transform() — 강력하지만 DB 의 group by 가 아닙니다

이 절이 이 스텝의 하이라이트입니다.

문제 상황

"고객별 주문 목록" 을 Map<Long, List<OrderDto>> 로 받고 싶습니다. 평범하게 쓰면 조인 결과를 받아 자바에서 직접 묶어야 합니다.

List<Tuple> rows = queryFactory
        .select(customer.id, order.id, order.totalAmount)
        .from(order)
        .innerJoin(order.customer, customer)
        .fetch();

Map<Long, List<OrderDto>> map = rows.stream()
        .collect(Collectors.groupingBy(
                t -> t.get(customer.id),
                Collectors.mapping(t -> new OrderDto(t.get(order.id), t.get(order.totalAmount)),
                                   Collectors.toList())));

QueryDSL 은 이 조립 과정을 API 로 제공합니다.

transform 사용법

import static com.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;

Map<Long, List<OrderDto>> byCustomer = queryFactory
        .from(order)                                    // ← select() 가 없다!
        .innerJoin(order.customer, customer)
        .transform(groupBy(customer.id).as(
                list(Projections.constructor(OrderDto.class, order.id, order.totalAmount))
        ));

결과hibernate.SQL 로그

select c1_0.customer_id, o1_0.order_id, o1_0.total_amount
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
Map 크기 = 30
byCustomer.get(1L).size() = 20
byCustomer.get(1L).get(0) = OrderDto[orderId=1, totalAmount=1116000.00]

생성 SQL 에 group by 가 없습니다

위 SQL 을 다시 보십시오.

select c1_0.customer_id, o1_0.order_id, o1_0.total_amount
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id

group by 가 한 글자도 없습니다.

transform조인 결과 600행을 전부 애플리케이션으로 가져온 다음, 자바 메모리에서 묶습니다. DB 의 GROUP BY 와는 아무 관계가 없습니다.

                 QueryDSL transform 의 실제 동작
   ┌─────────────────────────────────────────────────────┐
   │  DB                                                 │
   │    select ... from orders join customers            │  ← group by 없음
   │    → 600행 전부 반환                                 │
   └──────────────────────┬──────────────────────────────┘
                          │  600행이 네트워크를 타고 넘어옴
   ┌──────────────────────▼──────────────────────────────┐
   │  애플리케이션 (JVM 힙)                               │
   │    600행을 읽으며 Map<Long, List<OrderDto>> 조립     │  ← 여기서 그룹핑
   │    → Map 크기 30                                     │
   └─────────────────────────────────────────────────────┘

⚠️ 함정 — GroupBy.groupBy 라는 이름이 groupBy() 절과 완전히 다릅니다

queryFactory.select(...).from(order).groupBy(order.customer.id)   // ⭕ SQL 의 GROUP BY
queryFactory.from(order).transform(GroupBy.groupBy(customer.id))  // ⚠️ 메모리 그룹핑

이름이 같아서 "DB 에서 그룹핑되겠지" 라고 오해하기 딱 좋습니다. transform 은 결과 집합 변환기(ResultTransformer)입니다. SQL 을 바꾸지 않습니다. 로그에 group by 가 없는 것을 반드시 눈으로 확인하십시오.

언제 써도 되고 언제 안 되는가

상황판단
조인 결과가 수백~수천 행이고 실제로 전부 필요함써도 됨 — 자바 조립 코드를 줄여 줌
상세 화면: 주문 1건 + 주문상세 5건써도 됨
페이징된 목록(20건)의 연관 데이터 조립써도 됨
전체 주문 60만 건을 고객별로 묶기쓰면 안 됨 — 60만 행이 전부 힙으로
그룹별 합계만 필요 (원본 행은 불필요)쓰면 안 됨 — DB 의 groupBy 로 5행만 받으면 됨
그룹 수만 세면 됨쓰면 안 됨countDistinct() 로 1행

판단 기준은 하나입니다: "원본 행이 실제로 필요한가?" 필요 없으면 DB 에서 접어서 가져오십시오. 필요하면 transform 이 편합니다.

같은 질문을 DB 그룹핑으로

"고객별 주문 건수" 만 필요하다면 600행을 가져올 이유가 없습니다.

List<Tuple> counts = queryFactory
        .select(customer.id, order.count())
        .from(order)
        .innerJoin(order.customer, customer)
        .groupBy(customer.id)
        .fetch();

결과

select c1_0.customer_id, count(o1_0.order_id)
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
group by c1_0.customer_id
조회 30행 — 네트워크로 30행만 넘어옴 (transform 은 600행)

600행 → 30행. 20배 차이입니다. 주문이 60만 건이면 60만 행 → 30행, 2만 배입니다.

transformselect() 없이 from() 으로 시작합니다

queryFactory.from(order)...transform(...)     // ⭕
queryFactory.select(...).from(order)...       // ❌ transform 이 없음

transform 이 무엇을 뽑을지는 groupBy(...).as(...) 안에서 결정되므로, 바깥에 select() 를 두면 의미가 중복됩니다. QueryDSL 은 JPAQueryFactory.from(...) 이 돌려주는 JPAQuery 에만 transform 을 둡니다.

GroupBy 표현식 목록

메서드결과 타입설명
GroupBy.list(expr)List<T>그룹의 값들을 리스트로. 중복 유지
GroupBy.set(expr)Set<T>중복 제거
GroupBy.map(k, v)Map<K, V>그룹 안에서 다시 맵으로
GroupBy.sum(expr)숫자메모리에서 합산
GroupBy.avg(expr)Double메모리에서 평균
GroupBy.min(expr) / max(expr)메모리에서 최소/최대
GroupBy.count()Long메모리에서 개수
// 고객별 주문 건수와 합계 — 메모리 집계 버전
Map<Long, Tuple> stats = queryFactory
        .from(order)
        .innerJoin(order.customer, customer)
        .transform(groupBy(customer.id).as(
                GroupBy.count(),
                GroupBy.sum(order.totalAmount)
        ));

결과

select c1_0.customer_id, o1_0.order_id, o1_0.total_amount
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
Map 크기 = 30
stats.get(1L) → [20, 25478000.00]

⚠️ GroupBy.sum()order.totalAmount.sum() 은 완전히 다릅니다

실행 위치전송 행 수
order.totalAmount.sum() + groupBy()DB그룹 수만큼 (30)
GroupBy.sum(order.totalAmount) + transform()JVM원본 행 수만큼 (600)

집계만 필요하면 DB 쪽을 쓰십시오. GroupBy.sum 은 이미 transform 으로 원본을 다 가져오는 상황에서 "이왕 가져온 김에 합계도" 일 때만 의미가 있습니다.

💡 실무 팁 — transform 은 fan-out 을 자동으로 정리해 줍니다 OrderOrderItem 처럼 1:N 조인을 하면 주문이 상세 개수만큼 중복됩니다(Step 06). transform(groupBy(order.id).as(list(...))) 를 쓰면 그 중복이 자연스럽게 정리됩니다. distinctSet 을 수동으로 다룰 필요가 없어집니다. 이것이 transform 의 진짜 장점입니다. DB 부하를 줄이는 도구가 아니라 조립 코드를 없애는 도구로 이해하십시오.


8-9. fan-out 과 집계 — 조인이 만드는 잘못된 숫자

📌 Step 06 — 조인 의 행 뻥튀기가 집계에서 어떻게 드러나는지 봅니다.

"고객별 주문 건수" 를 구하는데 orderItems 까지 조인해 버렸습니다.

// ⚠️ 잘못된 코드
List<Tuple> wrong = queryFactory
        .select(customer.id, order.count())
        .from(customer)
        .innerJoin(order).on(order.customer.eq(customer))
        .innerJoin(orderItem).on(orderItem.order.eq(order))     // ← 여기서 뻥튀기
        .groupBy(customer.id)
        .orderBy(customer.id.asc())
        .fetch();

결과

select c1_0.customer_id, count(o1_0.order_id)
from customers c1_0
join orders o1_0 on o1_0.customer_id = c1_0.customer_id
join order_items oi1_0 on oi1_0.order_id = o1_0.order_id
group by c1_0.customer_id
order by c1_0.customer_id
   1  40      ← 20이어야 하는데 40
   2  40
   3  40
   ...
조회 30행 (합계 1200 = order_items 전체 행 수)

40 은 주문 건수가 아니라 주문상세 건수입니다. 주문 600건이 주문상세 1,200건과 조인되어 1,200행이 되었고, count(o1_0.order_id) 는 그 1,200행을 센 것입니다.

에러는 없습니다. 숫자가 그럴듯하게 나옵니다. 리포트가 두 배로 부풀어 있습니다.

처방 1 — countDistinct()

List<Tuple> fixed = queryFactory
        .select(customer.id, order.countDistinct())
        .from(customer)
        .innerJoin(order).on(order.customer.eq(customer))
        .innerJoin(orderItem).on(orderItem.order.eq(order))
        .groupBy(customer.id)
        .orderBy(customer.id.asc())
        .fetch();

결과

select c1_0.customer_id, count(distinct o1_0.order_id)
from customers c1_0
join orders o1_0 on o1_0.customer_id = c1_0.customer_id
join order_items oi1_0 on oi1_0.order_id = o1_0.order_id
group by c1_0.customer_id
order by c1_0.customer_id
   1  20      ← 정답
   2  20
   ...

처방 2 — 필요 없는 조인을 지운다 (더 나음)

애초에 orderItem 이 필요 없었습니다.

List<Tuple> best = queryFactory
        .select(customer.id, order.count())
        .from(customer)
        .innerJoin(order).on(order.customer.eq(customer))
        .groupBy(customer.id)
        .fetch();

결과

select c1_0.customer_id, count(o1_0.order_id)
from customers c1_0
join orders o1_0 on o1_0.customer_id = c1_0.customer_id
group by c1_0.customer_id
   1  20      ← 정답, 그리고 조인 하나가 사라짐

sum() 에서는 distinct 로도 못 고칩니다

countcountDistinct 로 고쳐지지만 sum 은 그렇지 않습니다.

// ⚠️ 총액이 부풀어 오른다
List<Tuple> wrongSum = queryFactory
        .select(customer.id, order.totalAmount.sum())
        .from(customer)
        .innerJoin(order).on(order.customer.eq(customer))
        .innerJoin(orderItem).on(orderItem.order.eq(order))
        .groupBy(customer.id)
        .fetch();

결과

   1  50956000.00      ← 실제 25478000.00 의 2배

같은 주문의 total_amount 가 상세 개수만큼 반복 더해집니다. sumDistinct 같은 것을 써도 금액이 같은 서로 다른 주문이 하나로 합쳐져 더 틀립니다.

⚠️ 함정 — sum 의 fan-out 은 distinct 로 고칠 수 없습니다 이 경우 선택지는 두 가지뿐입니다.

  1. 불필요한 조인을 제거한다 (대부분 이게 답입니다)
  2. 서브쿼리로 분리한다 — 합계는 orders 만으로 구하고, 상세 조건은 exists 로 (Step 07)
.select(customer.id, order.totalAmount.sum())
.from(customer)
.innerJoin(order).on(order.customer.eq(customer))
.where(selectOne().from(orderItem).where(orderItem.order.eq(order)).exists())
.groupBy(customer.id)

집계 쿼리를 쓸 때는 "조인 후 행 수가 몇인가" 를 먼저 계산하십시오. 그 숫자를 모르면 집계 결과도 믿을 수 없습니다.

조인 후 행 수를 확인하는 습관

Long joinedRows = queryFactory
        .select(order.count())
        .from(customer)
        .innerJoin(order).on(order.customer.eq(customer))
        .innerJoin(orderItem).on(orderItem.order.eq(order))
        .fetchOne();

결과

1200      ← "고객 30 × 주문 20 = 600" 을 기대했다면 여기서 이상을 알아챌 수 있습니다

8-10. ONLY_FULL_GROUP_BY — JPQL 이 먼저 막아 줍니다

📌 MySQL8 Step 06 — 6-6 절 의 주제입니다.

MySQL 8 의 기본 sql_mode 에는 ONLY_FULL_GROUP_BY 가 켜져 있습니다. GROUP BY 에 없고 집계 함수로도 안 감싼 컬럼을 select 하면 에러를 냅니다.

SELECT city, name, COUNT(*) FROM customers GROUP BY city;
-- ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause ...

QueryDSL 에서 같은 것을 시도하면 어떻게 될까요?

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

결과

select c1_0.city, c1_0.name, count(c1_0.customer_id)
from customers c1_0
group by c1_0.city
org.hibernate.exception.SQLGrammarException: JDBC exception executing SQL query
  Caused by: java.sql.SQLSyntaxErrorException:
    Expression #2 of SELECT list is not in GROUP BY clause and contains
    nonaggregated column 'shop.c1_0.name' which is not functionally dependent
    on columns in GROUP BY clause;
    this is incompatible with sql_mode=only_full_group_by

QueryDSL 은 막지 않고 그대로 SQL 을 만들어 보냅니다. MySQL 이 거부합니다.

왜 이 문제가 JPA 에서는 덜 나타나는가

JPQL 은 원래 group by 에 없는 컬럼의 select 를 명세상 허용하지 않습니다. 그리고 실무에서 JPA 를 쓰면 이런 코드를 자연스럽게 안 쓰게 됩니다. groupBy 결과를 Tuple 이나 DTO 로 받게 되므로, 필요한 컬럼을 groupBy 목록에도 넣는 것이 자연스럽기 때문입니다.

// ⭕ 정상 — 필요한 컬럼을 groupBy 에 함께 넣는다
.select(customer.city, customer.count())
.groupBy(customer.city)

// ⭕ 정상 — 여러 컬럼을 함께 묶는다
.select(customer.id, customer.name, order.count())
.groupBy(customer.id, customer.name)

그래도 마주치는 경우

  1. 엔티티를 통째로 select 하면서 groupBy — 가장 흔합니다.
// ⚠️ 위험
.select(customer, order.count())
.from(customer).innerJoin(order).on(order.customer.eq(customer))
.groupBy(customer.id)

select(customer)customers모든 컬럼을 select 합니다. group by c1_0.customer_id 뿐인데 name, city, points 가 select 절에 있습니다.

MySQL 8 은 이 경우를 함수 종속성(functional dependency) 으로 인식해 통과시킵니다. customer_id 가 PK 이므로 나머지 컬럼이 결정되기 때문입니다. 그래서 잘 동작합니다. 그런데 이게 함정입니다.

⚠️ 함정 — PK 로 묶었을 때만 통과합니다

.select(customer, order.count())
.groupBy(customer.city)      // ← PK 가 아닌 컬럼으로 묶으면

이건 즉시 ERROR 1055 입니다. 함수 종속성이 성립하지 않으니까요. groupBy(customer.id) 는 되고 groupBy(customer.city) 는 안 되는데, 코드만 봐서는 왜 하나는 되고 하나는 안 되는지 알 수 없습니다. 원인은 QueryDSL 이 아니라 MySQL 의 함수 종속성 판정입니다. 그리고 이 판정은 DB 마다 다릅니다. PostgreSQL 은 더 엄격합니다. 이식성 문제입니다.

  1. 집계 결과에 대표값을 하나 붙이고 싶을 때
// "도시별 고객 수와 아무 고객 이름 하나"
.select(customer.city, customer.count(), customer.name.max())   // ⭕ max 로 감싸면 통과
.groupBy(customer.city)

MySQL 의 ANY_VALUE() 에 대응하는 QueryDSL API 는 없습니다. max() / min() 으로 감싸는 것이 표준적이고 이식성도 좋은 우회입니다.

💡 실무 팁 — groupBy 에 넣을 컬럼을 select 에서 역산하십시오 "select 절의 비집계 컬럼 = groupBy 목록" 이 되도록 맞추면 어느 DB 에서든 안전합니다. 엔티티를 통째로 select 하는 집계 쿼리는 가급적 피하고, 필요한 컬럼만 나열하십시오. 그러면 이 문제가 아예 발생하지 않습니다.


8-11. case 로 조건부 집계 — 피벗

"등급별 매출을 한 행으로" 만들고 싶습니다. groupBy 로 하면 4행이 나오는데, 리포트에서는 한 행에 4개 컬럼으로 놓고 싶을 때가 있습니다.

SQL 의 관용구는 SUM(CASE WHEN ... THEN ... ELSE 0 END) 입니다. QueryDSL 에서는 CaseBuilder 를 씁니다.

import com.querydsl.core.types.dsl.CaseBuilder;

NumberExpression<Integer> vipPoints = new CaseBuilder()
        .when(customer.grade.eq(Grade.VIP)).then(customer.points)
        .otherwise(0);

NumberExpression<Integer> goldPoints = new CaseBuilder()
        .when(customer.grade.eq(Grade.GOLD)).then(customer.points)
        .otherwise(0);

NumberExpression<Integer> silverPoints = new CaseBuilder()
        .when(customer.grade.eq(Grade.SILVER)).then(customer.points)
        .otherwise(0);

NumberExpression<Integer> bronzePoints = new CaseBuilder()
        .when(customer.grade.eq(Grade.BRONZE)).then(customer.points)
        .otherwise(0);

Tuple pivot = queryFactory
        .select(vipPoints.sum(), goldPoints.sum(), silverPoints.sum(), bronzePoints.sum())
        .from(customer)
        .fetchOne();

결과

select sum(case when c1_0.grade = ? then c1_0.points else 0 end),
       sum(case when c1_0.grade = ? then c1_0.points else 0 end),
       sum(case when c1_0.grade = ? then c1_0.points else 0 end),
       sum(case when c1_0.grade = ? then c1_0.points else 0 end)
from customers c1_0
바인딩: [1] VIP, [2] GOLD, [3] SILVER, [4] BRONZE
VIP    = 77000
GOLD   = 68400
SILVER = 24800
BRONZE =  8570
합계     178770   ← 8-2 의 sum(points) 와 일치

조건부 count

"등급별 인원" 도 같은 방식으로 한 행에 넣을 수 있습니다.

NumberExpression<Integer> vipCount = new CaseBuilder()
        .when(customer.grade.eq(Grade.VIP)).then(1).otherwise(0);

Tuple counts = queryFactory
        .select(vipCount.sum(), customer.count())
        .from(customer)
        .fetchOne();

결과

select sum(case when c1_0.grade = ? then 1 else 0 end), count(c1_0.customer_id)
from customers c1_0
바인딩: [1] VIP
VIP = 4 / 전체 = 30

⚠️ 함정 — otherwise(0) 대신 otherwise(null) 을 쓰면 결과가 달라집니다 SUM 은 NULL 을 무시하므로 합계는 같습니다. 하지만 COUNT 로 바꾸면 다릅니다.

// otherwise(0) + count() → 항상 전체 행 수 (0도 세니까)
// otherwise(null) + count() → 조건에 맞는 행 수

조건부 집계는 sum 을 쓰는 것이 안전합니다. count 와 섞으면 헷갈립니다.

💡 CaseBuilderExpressions 의 전체 사용법은 Step 13 — 고급 표현식 에서 다룹니다. 여기서는 "집계와 조합하면 피벗이 된다" 는 것만 기억하십시오.


8-12. MySQL8 Step 06 대조표

질문MySQL8 SQLQueryDSL 6
행 수COUNT(*)customer.count()count(c1_0.customer_id)
컬럼 countCOUNT(phone)customer.phone.count()
중복 제거 countCOUNT(DISTINCT city)customer.city.countDistinct()
합계SUM(total_amount)order.totalAmount.sum()BigDecimal
평균AVG(total_amount)order.totalAmount.avg()Double
그룹핑GROUP BY grade.groupBy(customer.grade)
그룹 필터HAVING COUNT(*) >= 5.having(order.count().goe(5))
집계로 정렬ORDER BY COUNT(*) DESC.orderBy(order.count().desc())
NULL 대체COALESCE(SUM(x), 0).sum().coalesce(BigDecimal.ZERO)
조건부 집계SUM(CASE WHEN ... THEN ... ELSE 0 END)new CaseBuilder().when(...).then(...).otherwise(0).sum()
대표값ANY_VALUE(name)대응 API 없음customer.name.max()
그룹 문자열 결합GROUP_CONCAT(name)대응 API 없음Expressions.stringTemplate (Step 13)
소계WITH ROLLUP대응 API 없음 → 네이티브 쿼리
결과를 Map 으로(SQL 로 불가)transform(groupBy(...).as(...)) — JPA 고유

개념 대조

MySQL SQLQueryDSL
COUNT(*) 표기있음없음 — 엔티티 count 가 PK count 로 번역
ONLY_FULL_GROUP_BY 위반ERROR 1055그대로 SQL 을 보내 MySQL 이 거부
HAVING 에 별칭가능 (MySQL 확장)별칭 개념 없음 — 표현식 객체를 그대로
0건일 때 SUMNULLnull자바에서 NPE 로 이어짐
평균 타입DECIMAL 유지항상 Double ← 차이
메모리 그룹핑개념 없음transform() ← QueryDSL 고유, group by 아님

정리

개념핵심
집계 함수Path 의 메서드. order.totalAmount.sum()
count() 반환항상 Long. 엔티티 count 는 count(PK) 로 번역
sum() / max() / min()원래 타입 유지 (BigDecimal 이면 BigDecimal)
avg()항상 Double — BigDecimal 컬럼이어도
Tuple.get()표현식 객체를 키로. 변수로 뽑아 두는 것이 관례
Tuple.get(int)반환 타입 Object. 쓰지 말 것 — 순서 바뀌면 조용히 틀림
groupBySQL 의 GROUP BY. 여러 컬럼 가능
having그룹 필터. 집계와 무관한 조건은 where
count(컬럼)NULL 을 세지 않음. customer.count() 30 vs phone.count() 27
avg() 의 분모COUNT(col) — NULL 아닌 행 수
sum() 0건0 이 아니라 null → NPE. coalesce(BigDecimal.ZERO)
leftJoin + 집계coalesce 를 습관적으로
transform()애플리케이션 메모리 그룹핑. 생성 SQL 에 group by 없음
transform 시작select() 없이 from() 으로
GroupBy.list/set/map/sum/avg전부 JVM 에서 계산
transform 의 진짜 가치DB 부하 절감이 아니라 조립 코드 제거 + fan-out 정리
fan-out + countcountDistinct() 로 고침
fan-out + sumdistinct 로 못 고침 → 조인 제거 또는 서브쿼리
ONLY_FULL_GROUP_BYQueryDSL 이 막지 않음. MySQL 이 ERROR 1055
PK 로 groupByMySQL 함수 종속성으로 통과. 다른 DB 에서는 깨질 수 있음
조건부 집계new CaseBuilder().when(...).then(...).otherwise(0).sum()

연습문제

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

  1. 도시별 고객 수와 평균 포인트를 조회하십시오. 고객 수 내림차순, 같으면 도시 오름차순. 표현식을 변수로 뽑아 Tuple 에서 꺼내십시오. (기대: 8행, 서울 10명)
  2. 전체 고객 수와 전화번호가 있는 고객 수를 한 쿼리로 조회하고, 두 값이 다른 이유를 설명하십시오. (기대: 30 / 27)
  3. 취소를 제외한 주문 합계가 4천만 원 이상인 고객을 조회하십시오. wherehaving 을 각각 어디에 써야 하는지 판단하십시오. (기대: 8행)
  4. shipping_city'제주' 인 주문의 합계를 구하되, 0건이어도 null 이 아니라 0 이 나오도록 작성하십시오. 생성 SQL 에 coalesce 가 들어갔는지 확인하십시오.
  5. 고객별 주문 목록을 Map<Long, List<OrderDto>> 로 받으십시오. transform 을 쓰고, 생성 SQL 에 group by 가 없다는 것을 로그로 확인한 뒤 그 이유를 주석으로 쓰십시오. (기대: Map 크기 30)
  6. 문제 5 와 같은 조인에서 고객별 주문 건수만 필요하다면 어떻게 써야 하는지 작성하고, 문제 5 와 네트워크로 넘어오는 행 수를 비교하십시오. (기대: 600행 vs 30행)
  7. 고객 → 주문 → 주문상세를 모두 조인한 상태에서 "고객별 주문 건수" 를 구하십시오. 먼저 count() 로 써서 틀린 답(40) 을 확인하고, countDistinct() 로 고친 뒤, 마지막으로 불필요한 조인을 제거한 버전까지 세 개를 모두 작성하십시오.

다음 단계

집계까지 왔으니 이제 결과를 정렬하고 잘라서 화면에 내보낼 차례입니다. 8-3 에서 orderBy(order.count().desc()) 처럼 집계 표현식으로 정렬해 봤는데, 정렬은 그 자체로 따로 다룰 만큼 함정이 많습니다.

다음 스텝에서는 orderBy / offset / limit, QueryDSL 5.0 에서 폐기된 fetchCount() 대응, 그리고 정렬 컬럼에 함수를 씌워 인덱스를 죽이는 문제를 다룹니다. fetchJoin 과 페이징을 같이 쓸 때 전건이 메모리로 올라오는 문제(Step 06)도 여기서 다시 만납니다.

Step 09 — 정렬과 페이징


실습 파일

이 스텝은 자바 파일 세 개로 구성됩니다. 모두 @SpringBootTest + @Transactional 테스트 클래스이므로, 프로젝트의 src/test/java/com/example/shop/step08/ 에 그대로 복사해 넣고 실행하면 됩니다. Practice.java 로 8-1 ~ 8-12 절의 예제를 확인하고, Exercise.java 의 7문제를 직접 푼 뒤, Solution.java 로 맞춰 보는 순서입니다. 세 파일 모두 데이터를 변경하지 않습니다.

이 스텝은 특히 로그를 보지 않으면 배울 수 없습니다. 8-8 절의 transform 은 "생성 SQL 에 group by 가 없다" 는 것이 핵심인데, 그것은 오직 로그로만 확인할 수 있습니다. 반드시 SQL 로그를 켜 두십시오.

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

Practice.java

본문 8-1 ~ 8-12 절의 모든 예제를 // [8-3] 절 제목 주석과 함께 담은 테스트 클래스입니다.

  • avgTypeTrap()의도적으로 ClassCastException 을 일으키는 코드입니다. assertThatThrownBy 로 감싸 두었으므로 테스트는 통과하지만, 예외 메시지를 반드시 읽으십시오. class java.lang.Double cannot be cast to class java.math.BigDecimal 이 나와야 정상입니다.
  • countIgnoresNull() 에서 30 과 27 을 눈으로 확인하십시오. 같은 Long 타입이고 코드도 비슷하게 생겼는데 값이 다릅니다.
  • sumOfNothingIsNull()null 을 반환하는 것을 확인하고, 바로 다음 sumWithCoalesce()0 을 반환하는 것과 대조합니다. 생성 SQL 의 coalesce(sum(...), ?) 를 확인하십시오.
  • transformDoesNotGroupInDb() 가 이 파일의 핵심입니다. 실행 후 로그에서 group by 를 찾아보십시오. 없습니다. 바로 아래 dbGroupByComparison() 의 SQL 에는 group by 가 있습니다. 두 로그를 나란히 놓고 비교하십시오.
  • fanOutBreaksCount() / fanOutFixedByDistinct() / fanOutFixedByRemovingJoin() 은 같은 질문에 대한 세 답입니다. 첫 번째가 40, 나머지 둘이 20 을 냅니다.
  • fanOutBreaksSumUnfixable()sumdistinct 로 고쳐지지 않는다는 것을 보여 줍니다. 이 경우 답은 "조인을 지우는 것" 뿐입니다.
package com.example.shop.step08;

import com.example.shop.entity.Grade;
import com.example.shop.entity.OrderStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.group.GroupBy;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.NumberExpression;
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 java.util.Map;
import java.util.Optional;

import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QEmployee.employee;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QOrderItem.orderItem;
import static com.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;
import static com.querydsl.jpa.JPAExpressions.selectOne;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * Step 08 — 집계와 그룹핑 : 본문 예제 모음
 *
 * <p>본문 8-1 ~ 8-12 절의 모든 예제를 절 번호 주석과 함께 담았습니다.
 *
 * <p><b>이 스텝은 로그를 보지 않으면 배울 수 없습니다.</b>
 * 특히 8-8 절의 transform 은 "생성 SQL 에 group by 가 없다" 는 것이 핵심인데,
 * 그것은 오직 hibernate.SQL 로그로만 확인할 수 있습니다.
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    /** 8-5 절 처방 1 — 표현식을 상수로 뽑아 둔다. Q타입 표현식은 불변이라 static final 로 안전하다. */
    private static final NumberExpression<Long>       ORDER_CNT = order.count();
    private static final NumberExpression<BigDecimal> AMT_SUM   = order.totalAmount.sum();
    private static final NumberExpression<Double>     AMT_AVG   = order.totalAmount.avg();
    private static final NumberExpression<BigDecimal> AMT_MAX   = order.totalAmount.max();
    private static final NumberExpression<BigDecimal> AMT_MIN   = order.totalAmount.min();

    /** 8-5 절 처방 2 — DTO 프로젝션용 record. avg 가 Double 이라는 사실이 시그니처에 박혀 있다. */
    public record OrderStat(Long count, BigDecimal sum, Double avg) {}

    /** 8-8 절 transform 용 DTO. */
    public record OrderDto(Long orderId, BigDecimal totalAmount) {}

    // =================================================================
    // [8-1] 집계 함수 — Path 에서 바로 나온다
    // =================================================================

    @Test
    @DisplayName("[8-1] count / sum / avg / max / min")
    void basicAggregates() {
        Tuple stats = queryFactory
                .select(ORDER_CNT, AMT_SUM, AMT_AVG, AMT_MAX, AMT_MIN)
                .from(order)
                .fetchOne();

        // 생성 SQL:
        //   select count(o1_0.order_id), sum(o1_0.total_amount), avg(o1_0.total_amount),
        //          max(o1_0.total_amount), min(o1_0.total_amount)
        //   from orders o1_0
        System.out.println("count = " + stats.get(ORDER_CNT));   // 600
        System.out.println("sum   = " + stats.get(AMT_SUM));     // 764598000.00
        System.out.println("avg   = " + stats.get(AMT_AVG));     // 1274330.0  ← Double!
        System.out.println("max   = " + stats.get(AMT_MAX));     // 6663900.00
        System.out.println("min   = " + stats.get(AMT_MIN));     // 8900.00

        assertThat(stats.get(ORDER_CNT)).isEqualTo(600L);
    }

    @Test
    @DisplayName("[8-1] 엔티티 count 는 count(*) 가 아니라 count(PK) 로 번역된다")
    void entityCountIsPkCount() {
        Long cnt = queryFactory.select(order.count()).from(order).fetchOne();

        // 생성 SQL: select count(o1_0.order_id) from orders o1_0
        //           ↑ count(*) 가 아니다. order_id 는 PK 라 NOT NULL 이므로 결과는 같다.
        assertThat(cnt).isEqualTo(600L);
    }

    // =================================================================
    // [8-2] 집계 결과는 Tuple
    // =================================================================

    @Test
    @DisplayName("[8-2] groupBy 없으면 Tuple 1건")
    void singleTuple() {
        Tuple t = queryFactory
                .select(customer.count(), customer.points.avg())
                .from(customer)
                .fetchOne();

        Long cnt = t.get(customer.count());
        Double avg = t.get(customer.points.avg());

        // 새로 만든 표현식으로도 값이 꺼내진다. Tuple 내부가 equals() 로 매칭하기 때문.
        // 동작은 하지만 장황하고 오타에 취약하다 → 변수로 뽑는 것이 관례.
        assertThat(cnt).isEqualTo(30L);
        assertThat(avg).isEqualTo(5959.0);
    }

    @Test
    @DisplayName("[8-2] 표현식을 변수로 뽑아 두는 관례")
    void expressionAsVariable() {
        NumberExpression<Double>  avgPoints = customer.points.avg();
        NumberExpression<Integer> sumPoints = customer.points.sum();
        NumberExpression<Long>    cntAll    = customer.count();

        Tuple t = queryFactory
                .select(cntAll, avgPoints, sumPoints)
                .from(customer)
                .fetchOne();

        assertThat(t.get(cntAll)).isEqualTo(30L);
        assertThat(t.get(avgPoints)).isEqualTo(5959.0);
        assertThat(t.get(sumPoints)).isEqualTo(178770);
    }

    // =================================================================
    // [8-3] groupBy
    // =================================================================

    @Test
    @DisplayName("[8-3] 등급별 고객 수와 평균 포인트 (4행)")
    void groupByGrade() {
        NumberExpression<Long>   cnt = customer.count();
        NumberExpression<Double> avg = customer.points.avg();

        List<Tuple> byGrade = queryFactory
                .select(customer.grade, cnt, avg)
                .from(customer)
                .groupBy(customer.grade)
                .orderBy(customer.grade.asc())
                .fetch();

        // 생성 SQL:
        //   select c1_0.grade, count(c1_0.customer_id), avg(c1_0.points)
        //   from customers c1_0
        //   group by c1_0.grade
        //   order by c1_0.grade
        //
        // ENUM 은 선언 순서로 정렬된다 (BRONZE < SILVER < GOLD < VIP). 알파벳 순이 아니다.
        byGrade.forEach(t -> System.out.printf("%-7s %2d %10.4f%n",
                t.get(customer.grade), t.get(cnt), t.get(avg)));

        assertThat(byGrade).hasSize(4);
    }

    @Test
    @DisplayName("[8-3] 여러 컬럼으로 그룹핑 (18행)")
    void groupByMultipleColumns() {
        List<Tuple> rows = queryFactory
                .select(customer.grade, customer.city, customer.count())
                .from(customer)
                .groupBy(customer.grade, customer.city)
                .orderBy(customer.grade.asc(), customer.city.asc())
                .fetch();

        assertThat(rows).hasSize(18);
    }

    @Test
    @DisplayName("[8-3] 주문 상태별 매출 (5행). 집계 표현식으로 정렬")
    void groupByOrderStatus() {
        NumberExpression<Long> cnt = order.count();

        List<Tuple> byStatus = queryFactory
                .select(order.status, cnt, order.totalAmount.sum(), order.totalAmount.avg())
                .from(order)
                .groupBy(order.status)
                .orderBy(cnt.desc())         // ← order by count(...) desc
                .fetch();

        byStatus.forEach(t -> System.out.printf("%-10s %3d %15s%n",
                t.get(order.status), t.get(cnt), t.get(order.totalAmount.sum())));

        assertThat(byStatus).hasSize(5);
    }

    // =================================================================
    // [8-4] having
    // =================================================================

    @Test
    @DisplayName("[8-4] having — 주문 5건 이상 (전원 통과, 30행)")
    void havingLooseCondition() {
        List<Tuple> heavy = queryFactory
                .select(order.customer.id, order.count())
                .from(order)
                .groupBy(order.customer.id)
                .having(order.count().goe(5))
                .orderBy(order.customer.id.asc())
                .fetch();

        // 시드 데이터가 규칙적이라 30명 모두 20건씩. 아무도 걸러지지 않는다.
        assertThat(heavy).hasSize(30);
    }

    @Test
    @DisplayName("[8-4] where(집계 전) + having(집계 후) — 합계 5천만 이상 (3행)")
    void whereAndHaving() {
        NumberExpression<BigDecimal> sum = order.totalAmount.sum();

        List<Tuple> bigSpenders = queryFactory
                .select(order.customer.id, order.count(), sum)
                .from(order)
                .where(order.status.ne(OrderStatus.CANCELLED))            // ① 개별 행 필터
                .groupBy(order.customer.id)
                .having(sum.goe(new BigDecimal("50000000")))              // ② 그룹 필터
                .orderBy(sum.desc())
                .fetch();

        // ⚠️ order.status.ne(...) 를 having 에 쓰면 동작은 하지만
        //    CANCELLED 주문까지 전부 그룹핑한 뒤 버린다. where 로 옮겨야 한다.
        bigSpenders.forEach(t -> System.out.printf("%3d %2d %15s%n",
                t.get(order.customer.id), t.get(order.count()), t.get(sum)));

        assertThat(bigSpenders).hasSize(3);
    }

    // =================================================================
    // [8-5] ⚠️ Tuple 다루기 — ClassCastException
    // =================================================================

    @Test
    @DisplayName("[8-5] ⚠️ avg() 는 Double 이다 — 인덱스 접근 + 캐스팅은 런타임에 터진다")
    void avgTypeTrap() {
        Tuple t = queryFactory
                .select(order.totalAmount.sum(), order.totalAmount.avg())
                .from(order)
                .fetchOne();

        // ⭕ sum 은 BigDecimal 이 맞다
        BigDecimal sum = t.get(order.totalAmount.sum());
        assertThat(sum).isNotNull();

        // ⚠️ Tuple.get(int) 의 반환 타입은 Object. 컴파일러가 손을 뗀다.
        assertThatThrownBy(() -> {
            @SuppressWarnings("unused")
            BigDecimal avg = (BigDecimal) t.get(1);
        }).isInstanceOf(ClassCastException.class)
          .hasMessageContaining("java.lang.Double");

        // ⭕ 표현식으로 꺼내면 컴파일 타임에 잡힌다.
        //    아래 줄의 주석을 풀면 컴파일 에러가 납니다. 그게 정상이고 좋은 일입니다.
        // BigDecimal wrong = t.get(order.totalAmount.avg());
        Double avg = t.get(order.totalAmount.avg());
        assertThat(avg).isEqualTo(1274330.0);
    }

    @Test
    @DisplayName("[8-5] 처방 1 — 표현식을 상수로")
    void fixWithConstants() {
        Tuple t = queryFactory
                .select(ORDER_CNT, AMT_SUM, AMT_AVG)
                .from(order)
                .fetchOne();

        Long       cnt = t.get(ORDER_CNT);
        BigDecimal sum = t.get(AMT_SUM);
        Double     avg = t.get(AMT_AVG);      // 타입이 상수 선언에 박혀 있어 헷갈릴 여지가 없다

        System.out.println(cnt + " / " + sum + " / " + avg);
    }

    @Test
    @DisplayName("[8-5] 처방 2 — DTO 프로젝션 (권장)")
    void fixWithProjection() {
        OrderStat stat = queryFactory
                .select(Projections.constructor(OrderStat.class,
                        order.count(),
                        order.totalAmount.sum(),
                        order.totalAmount.avg()))
                .from(order)
                .fetchOne();

        // record 생성자 시그니처가 컴파일 타임에 검증된다.
        // avg 를 BigDecimal 로 선언하면 그 자리에서 컴파일이 깨진다.
        System.out.println(stat);
        assertThat(stat.count()).isEqualTo(600L);
    }

    // =================================================================
    // [8-6] 집계와 NULL
    // =================================================================

    @Test
    @DisplayName("[8-6] ⚠️ count(컬럼) 은 NULL 을 세지 않는다 — 30 vs 27")
    void countIgnoresNull() {
        Tuple t = queryFactory
                .select(customer.count(),            // count(c1_0.customer_id) — PK
                        customer.phone.count(),      // count(c1_0.phone)       — NULL 제외
                        customer.city.countDistinct())
                .from(customer)
                .fetchOne();

        System.out.println("customer.count()              = " + t.get(customer.count()));
        System.out.println("customer.phone.count()        = " + t.get(customer.phone.count()));
        System.out.println("customer.city.countDistinct() = " + t.get(customer.city.countDistinct()));

        assertThat(t.get(customer.count())).isEqualTo(30L);
        assertThat(t.get(customer.phone.count())).isEqualTo(27L);   // 전화번호 NULL 3명이 빠졌다
    }

    @Test
    @DisplayName("[8-6] avg 의 분모는 count(col) 이다")
    void avgDenominator() {
        Tuple t = queryFactory
                .select(customer.count(), customer.points.avg(), customer.points.sum())
                .from(customer)
                .fetchOne();

        // points 는 NOT NULL 이므로 178770 / 30 = 5959.0 이 정확히 맞는다.
        // nullable 이었다면 분모가 줄어 평균이 올라갔을 것이다.
        assertThat(t.get(customer.points.sum())).isEqualTo(178770);
        assertThat(t.get(customer.points.avg())).isEqualTo(5959.0);
    }

    @Test
    @DisplayName("[8-6] 그룹핑 컬럼이 NULL 이면 NULL 도 하나의 그룹이 된다")
    void nullBecomesItsOwnGroup() {
        List<Tuple> byManager = queryFactory
                .select(employee.manager.id, employee.count())
                .from(employee)
                .groupBy(employee.manager.id)
                .orderBy(employee.manager.id.asc().nullsFirst())
                .fetch();

        // 첫 행이 null 그룹(CEO 1명). 이 NULL 이 Step 07 의 NOT IN 함정의 원인이었다.
        byManager.forEach(t -> System.out.println(t.get(employee.manager.id) + " " + t.get(employee.count())));
        assertThat(byManager).hasSize(9);   // NULL 그룹 1 + 관리자 8
    }

    // =================================================================
    // [8-7] sum() 이 0건일 때 null
    // =================================================================

    @Test
    @DisplayName("[8-7] ⚠️ 0건이면 sum 은 0 이 아니라 null 이다")
    void sumOfNothingIsNull() {
        BigDecimal total = queryFactory
                .select(order.totalAmount.sum())
                .from(order)
                .where(order.shippingCity.eq("제주"))     // 해당 행이 0건
                .fetchOne();

        System.out.println("sum = " + total);   // null
        assertThat(total).isNull();

        // 이 값을 그대로 쓰면 NPE:
        //   BigDecimal fee = total.multiply(new BigDecimal("0.03"));  // 💥
    }

    @Test
    @DisplayName("[8-7] 처방 1 — coalesce (DB 에서 대체)")
    void sumWithCoalesce() {
        BigDecimal total = queryFactory
                .select(order.totalAmount.sum().coalesce(BigDecimal.ZERO))
                .from(order)
                .where(order.shippingCity.eq("제주"))
                .fetchOne();

        // 생성 SQL: select coalesce(sum(o1_0.total_amount), ?) from orders o1_0 where ...
        assertThat(total).isEqualByComparingTo(BigDecimal.ZERO);
    }

    @Test
    @DisplayName("[8-7] 처방 2 — 자바 쪽 Optional")
    void sumWithOptional() {
        BigDecimal total = Optional.ofNullable(
                queryFactory.select(order.totalAmount.sum()).from(order)
                        .where(order.shippingCity.eq("제주"))
                        .fetchOne()
        ).orElse(BigDecimal.ZERO);

        // 둘 다 유효하지만 coalesce 쪽이 SQL 에 의도가 드러나서 낫다.
        // 그리고 groupBy 로 여러 행이 나올 때는 coalesce 만 통한다.
        assertThat(total).isEqualByComparingTo(BigDecimal.ZERO);
    }

    @Test
    @DisplayName("[8-7] leftJoin + 집계에는 coalesce 를 습관적으로")
    void leftJoinNeedsCoalesce() {
        List<Tuple> rows = queryFactory
                .select(customer.id, order.totalAmount.sum().coalesce(BigDecimal.ZERO))
                .from(customer)
                .leftJoin(order).on(order.customer.eq(customer))
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        assertThat(rows).hasSize(30);
    }

    // =================================================================
    // [8-8] GroupBy.transform() — 메모리 그룹핑
    // =================================================================

    @Test
    @DisplayName("[8-8] ⚠️ transform 은 DB 의 group by 를 만들지 않는다 — 로그를 보라")
    void transformDoesNotGroupInDb() {
        Map<Long, List<OrderDto>> byCustomer = queryFactory
                .from(order)                                    // ← select() 가 없다
                .innerJoin(order.customer, customer)
                .transform(groupBy(customer.id).as(
                        list(Projections.constructor(OrderDto.class, order.id, order.totalAmount))
                ));

        // 생성 SQL:
        //   select c1_0.customer_id, o1_0.order_id, o1_0.total_amount
        //   from orders o1_0
        //   join customers c1_0 on c1_0.customer_id = o1_0.customer_id
        //
        // ⚠️ group by 가 한 글자도 없다!
        //    조인 결과 600행을 전부 JVM 으로 가져와 자바 메모리에서 묶는다.
        System.out.println("Map 크기 = " + byCustomer.size());               // 30
        System.out.println("1번 고객 주문 수 = " + byCustomer.get(1L).size()); // 20

        assertThat(byCustomer).hasSize(30);
        assertThat(byCustomer.get(1L)).hasSize(20);
    }

    @Test
    @DisplayName("[8-8] 같은 질문을 DB group by 로 — 600행 vs 30행")
    void dbGroupByComparison() {
        List<Tuple> counts = queryFactory
                .select(customer.id, order.count())
                .from(order)
                .innerJoin(order.customer, customer)
                .groupBy(customer.id)
                .fetch();

        // 생성 SQL:
        //   select c1_0.customer_id, count(o1_0.order_id)
        //   from orders o1_0
        //   join customers c1_0 on c1_0.customer_id = o1_0.customer_id
        //   group by c1_0.customer_id
        //
        // ← group by 가 있다. 네트워크로 30행만 넘어온다 (transform 은 600행).
        assertThat(counts).hasSize(30);
    }

    @Test
    @DisplayName("[8-8] GroupBy.count / sum — 전부 JVM 에서 계산된다")
    void groupByAggregatesInMemory() {
        Map<Long, Tuple> stats = queryFactory
                .from(order)
                .innerJoin(order.customer, customer)
                .transform(groupBy(customer.id).as(
                        GroupBy.count(),
                        GroupBy.sum(order.totalAmount)
                ));

        // 생성 SQL 에 여전히 group by 가 없다. sum 도 JVM 에서 돈다.
        //   GroupBy.sum(order.totalAmount)  → JVM,  전송 600행
        //   order.totalAmount.sum() + groupBy() → DB, 전송 30행
        assertThat(stats).hasSize(30);
        System.out.println("1번 고객 = " + stats.get(1L));
    }

    @Test
    @DisplayName("[8-8] transform 의 진짜 장점 — fan-out 을 자동으로 정리한다")
    void transformCleansFanOut() {
        // Order → OrderItem 1:N 조인. 주문이 상세 개수만큼 중복되지만
        // transform 이 order.id 로 묶어 주므로 중복이 자연스럽게 사라진다.
        Map<Long, List<Long>> itemsByOrder = queryFactory
                .from(orderItem)
                .innerJoin(orderItem.order, order)
                .transform(groupBy(order.id).as(list(orderItem.id)));

        assertThat(itemsByOrder).hasSize(600);   // 주문 600건
        System.out.println("주문 1번의 상세 = " + itemsByOrder.get(1L));
    }

    // =================================================================
    // [8-9] fan-out 과 집계
    // =================================================================

    @Test
    @DisplayName("[8-9] ⚠️ 불필요한 조인이 count 를 두 배로 만든다 (40)")
    void fanOutBreaksCount() {
        List<Tuple> wrong = queryFactory
                .select(customer.id, order.count())
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .innerJoin(orderItem).on(orderItem.order.eq(order))    // ← 뻥튀기
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        // 40 은 주문 건수가 아니라 주문상세 건수다. 에러 없음. 숫자만 두 배.
        System.out.println("1번 고객(잘못) = " + wrong.get(0).get(order.count()));
        assertThat(wrong.get(0).get(order.count())).isEqualTo(40L);
    }

    @Test
    @DisplayName("[8-9] 처방 1 — countDistinct (20)")
    void fanOutFixedByDistinct() {
        List<Tuple> fixed = queryFactory
                .select(customer.id, order.countDistinct())
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .innerJoin(orderItem).on(orderItem.order.eq(order))
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        // 생성 SQL: count(distinct o1_0.order_id)
        assertThat(fixed.get(0).get(order.countDistinct())).isEqualTo(20L);
    }

    @Test
    @DisplayName("[8-9] 처방 2 — 불필요한 조인 제거 (더 나음, 20)")
    void fanOutFixedByRemovingJoin() {
        List<Tuple> best = queryFactory
                .select(customer.id, order.count())
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        assertThat(best.get(0).get(order.count())).isEqualTo(20L);
    }

    @Test
    @DisplayName("[8-9] ⚠️ sum 의 fan-out 은 distinct 로 고칠 수 없다")
    void fanOutBreaksSumUnfixable() {
        NumberExpression<BigDecimal> sum = order.totalAmount.sum();

        List<Tuple> wrongSum = queryFactory
                .select(customer.id, sum)
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .innerJoin(orderItem).on(orderItem.order.eq(order))
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        // 같은 주문의 total_amount 가 상세 개수만큼 반복 더해진다.
        // sumDistinct 를 써도 "금액이 같은 서로 다른 주문" 이 하나로 합쳐져 더 틀린다.
        // 답은 조인을 지우거나, 상세 조건을 exists 로 분리하는 것뿐이다.
        System.out.println("1번 고객(부풀린 합계) = " + wrongSum.get(0).get(sum));

        // 처방: exists 로 분리
        List<Tuple> fixed = queryFactory
                .select(customer.id, sum)
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .where(selectOne().from(orderItem).where(orderItem.order.eq(order)).exists())
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        System.out.println("1번 고객(정상 합계) = " + fixed.get(0).get(sum));
    }

    @Test
    @DisplayName("[8-9] 조인 후 행 수를 먼저 확인하는 습관")
    void checkJoinedRowCount() {
        Long joinedRows = queryFactory
                .select(order.count())
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .innerJoin(orderItem).on(orderItem.order.eq(order))
                .fetchOne();

        // 600 을 기대했는데 1200 이다. 여기서 이상을 알아챌 수 있다.
        System.out.println("조인 후 행 수 = " + joinedRows);
        assertThat(joinedRows).isEqualTo(1200L);
    }

    // =================================================================
    // [8-10] ONLY_FULL_GROUP_BY
    // =================================================================

    @Test
    @DisplayName("[8-10] QueryDSL 은 막지 않는다. MySQL 이 ERROR 1055 를 낸다")
    void onlyFullGroupBy() {
        // QueryDSL 은 그대로 SQL 을 만들어 보낸다.
        //   select c1_0.city, c1_0.name, count(c1_0.customer_id)
        //   from customers c1_0 group by c1_0.city
        assertThatThrownBy(() ->
                queryFactory
                        .select(customer.city, customer.name, customer.count())
                        .from(customer)
                        .groupBy(customer.city)
                        .fetch()
        ).hasMessageContaining("only_full_group_by");
    }

    @Test
    @DisplayName("[8-10] PK 로 묶으면 통과한다 — MySQL 의 함수 종속성 판정")
    void functionalDependencyPasses() {
        // select(customer) 는 customers 의 모든 컬럼을 select 하지만
        // group by 가 PK 이므로 MySQL 이 함수 종속성으로 통과시킨다.
        // ⚠️ groupBy(customer.city) 로 바꾸면 즉시 ERROR 1055.
        // ⚠️ PostgreSQL 등 다른 DB 에서는 판정이 다를 수 있다 (이식성 문제).
        List<Tuple> rows = queryFactory
                .select(customer, order.count())
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .groupBy(customer.id)
                .fetch();

        assertThat(rows).hasSize(30);
    }

    @Test
    @DisplayName("[8-10] ANY_VALUE 대응은 max()/min() 로 감싸기")
    void anyValueWorkaround() {
        List<Tuple> rows = queryFactory
                .select(customer.city, customer.count(), customer.name.max())
                .from(customer)
                .groupBy(customer.city)
                .orderBy(customer.city.asc())
                .fetch();

        assertThat(rows).hasSize(8);
    }

    // =================================================================
    // [8-11] case 로 조건부 집계 — 피벗
    // =================================================================

    @Test
    @DisplayName("[8-11] 등급별 포인트를 한 행으로 피벗")
    void conditionalAggregatePivot() {
        NumberExpression<Integer> vip = new CaseBuilder()
                .when(customer.grade.eq(Grade.VIP)).then(customer.points).otherwise(0);
        NumberExpression<Integer> gold = new CaseBuilder()
                .when(customer.grade.eq(Grade.GOLD)).then(customer.points).otherwise(0);
        NumberExpression<Integer> silver = new CaseBuilder()
                .when(customer.grade.eq(Grade.SILVER)).then(customer.points).otherwise(0);
        NumberExpression<Integer> bronze = new CaseBuilder()
                .when(customer.grade.eq(Grade.BRONZE)).then(customer.points).otherwise(0);

        Tuple pivot = queryFactory
                .select(vip.sum(), gold.sum(), silver.sum(), bronze.sum())
                .from(customer)
                .fetchOne();

        // 생성 SQL:
        //   select sum(case when c1_0.grade = ? then c1_0.points else 0 end), ... (4개)
        //   from customers c1_0
        System.out.println("VIP    = " + pivot.get(vip.sum()));       // 77000
        System.out.println("GOLD   = " + pivot.get(gold.sum()));      // 68400
        System.out.println("SILVER = " + pivot.get(silver.sum()));    // 24800
        System.out.println("BRONZE = " + pivot.get(bronze.sum()));    //  8570

        assertThat(pivot.get(vip.sum())).isEqualTo(77000);
    }

    @Test
    @DisplayName("[8-11] 조건부 count — sum(case when ... then 1 else 0 end)")
    void conditionalCount() {
        NumberExpression<Integer> vipCount = new CaseBuilder()
                .when(customer.grade.eq(Grade.VIP)).then(1).otherwise(0);

        Tuple counts = queryFactory
                .select(vipCount.sum(), customer.count())
                .from(customer)
                .fetchOne();

        // ⚠️ otherwise(0) + count() 를 쓰면 0도 세어 항상 전체 행 수가 나온다.
        //    조건부 집계는 sum 을 쓰는 것이 안전하다.
        assertThat(counts.get(vipCount.sum())).isEqualTo(4);
        assertThat(counts.get(customer.count())).isEqualTo(30L);
    }
}

Exercise.java

「연습문제」 절의 7문제를 담은 빈칸 파일입니다. 각 문항이 // 문제 N. 주석 블록으로 되어 있고 // 여기에 작성: 아래가 비어 있습니다.

  • 문항마다 기대 결과가 명시되어 있습니다. 건수뿐 아니라 생성 SQL 의 모양까지 맞아야 정답입니다.
  • 문제 2 는 값 두 개를 구하는 것이 목적이 아니라, 왜 다른지를 설명하는 것이 목적입니다. 주석으로 이유를 직접 써 보십시오.
  • 문제 4 는 coalesce 가 SQL 에 들어갔는지를 로그로 확인해야 합니다. 자바 쪽 Optional 로 처리했다면 SQL 에는 coalesce 가 없을 것입니다. 둘 다 유효하지만 다른 답입니다.
  • 문제 5 와 6 은 짝입니다. 네트워크로 넘어오는 행 수를 세어 보는 것이 핵심입니다. transform 쪽은 600행, groupBy 쪽은 30행입니다.
  • 문제 7 은 세 버전을 모두 작성해야 합니다. 틀린 답(40)을 먼저 직접 만들어 보는 것이 목적이므로, 건너뛰지 마십시오.
package com.example.shop.step08;

import com.example.shop.entity.OrderStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.NumberExpression;
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 java.util.Map;

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.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;

/**
 * Step 08 — 집계와 그룹핑 : 연습문제 7문제
 *
 * <p>각 문제의 "여기에 작성:" 아래를 채우십시오. 정답은 Solution.java 에 있습니다.
 *
 * <p><b>건수가 맞아도 생성 SQL 이 의도한 모양이 아니면 틀린 것입니다.</b>
 * 특히 문제 4(coalesce)와 문제 5(group by 부재)는 로그를 봐야만 확인할 수 있습니다.
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    /** 문제 5 에서 쓸 DTO. */
    public record OrderDto(Long orderId, BigDecimal totalAmount) {}

    // -----------------------------------------------------------------
    // 문제 1.
    //   도시별 고객 수와 평균 포인트를 조회하십시오.
    //
    //   요구사항
    //     - 정렬: 고객 수 내림차순, 같으면 도시 오름차순
    //     - 집계 표현식을 변수로 뽑아 두고 tuple.get(변수) 로 꺼낼 것
    //       (Tuple.get(int) 는 쓰지 마십시오)
    //     - avg 를 어떤 타입으로 받아야 하는지 주의
    //
    //   기대 결과: 8행. 서울 10명이 첫 행
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 1. 도시별 고객 수와 평균 포인트 (8행)")
    void q1_customersByCity() {
        // 여기에 작성:

    }

    // -----------------------------------------------------------------
    // 문제 2.
    //   전체 고객 수와 전화번호가 있는 고객 수를 한 쿼리로 조회하십시오.
    //
    //   요구사항
    //     - 두 값을 하나의 Tuple 로 받을 것
    //     - 생성 SQL 에서 두 count 가 어떻게 다르게 번역되는지 확인
    //     - 왜 값이 다른지를 아래에 주석으로 직접 쓰십시오
    //
    //   기대 결과: 30 / 27
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 2. 전체 고객 수 vs 전화번호 있는 고객 수 (30 / 27)")
    void q2_countWithNull() {
        // 여기에 작성:

        // 왜 다른가? (직접 쓰십시오)
        //

    }

    // -----------------------------------------------------------------
    // 문제 3.
    //   취소(CANCELLED)를 제외한 주문 합계가 4천만 원 이상인 고객을 조회하십시오.
    //
    //   요구사항
    //     - customer_id, 주문 건수, 합계를 select
    //     - 정렬: 합계 내림차순
    //     - "취소 제외" 조건과 "합계 4천만 이상" 조건 중
    //       어느 것이 where 이고 어느 것이 having 인지 스스로 판단할 것
    //     - 판단 근거를 주석으로 쓰십시오
    //
    //   기대 결과: 8행
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 3. 합계 4천만 이상 고객 (8행)")
    void q3_whereVsHaving() {
        // 여기에 작성:

    }

    // -----------------------------------------------------------------
    // 문제 4.
    //   shipping_city 가 '제주' 인 주문의 합계를 구하십시오.
    //
    //   요구사항
    //     - 해당 주문이 0건이지만 결과가 null 이 아니라 0 이어야 함
    //     - 생성 SQL 에 coalesce 가 들어갔는지 로그로 확인할 것
    //     - (참고) 자바 Optional 로 처리하면 SQL 에는 coalesce 가 없습니다.
    //       둘 다 유효하지만 다른 답입니다. 어느 쪽을 골랐는지 주석으로 밝히십시오.
    //
    //   기대 결과: 0
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 4. 0건일 때 sum 을 0 으로 (coalesce)")
    void q4_coalesce() {
        // 여기에 작성:

    }

    // -----------------------------------------------------------------
    // 문제 5.
    //   고객별 주문 목록을 Map<Long, List<OrderDto>> 로 받으십시오.
    //
    //   요구사항
    //     - GroupBy.transform 을 쓸 것
    //     - transform 은 select() 없이 from() 으로 시작합니다
    //     - 실행 후 로그에서 group by 를 찾아보십시오. 없습니다.
    //       왜 없는지를 주석으로 쓰십시오.
    //     - 네트워크로 몇 행이 넘어오는지 생각해 보십시오
    //
    //   기대 결과: Map 크기 30, 각 고객의 리스트 크기 20
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 5. transform 으로 Map 만들기 (Map 크기 30)")
    void q5_transform() {
        // 여기에 작성:

        // 생성 SQL 에 group by 가 없는 이유는?
        //

    }

    // -----------------------------------------------------------------
    // 문제 6.
    //   문제 5 와 같은 조인에서 "고객별 주문 건수" 만 필요하다면
    //   어떻게 써야 하는지 작성하십시오.
    //
    //   요구사항
    //     - DB 의 groupBy 를 쓸 것
    //     - 생성 SQL 에 group by 가 있는지 확인
    //     - 문제 5 와 비교해 네트워크로 넘어오는 행 수를 주석으로 정리하십시오
    //
    //   기대 결과: 30행. (문제 5 는 600행이 넘어오고, 이 문제는 30행)
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 6. DB groupBy 로 건수만 (30행)")
    void q6_dbGroupBy() {
        // 여기에 작성:

        // 넘어오는 행 수 비교:
        //   문제 5 (transform)  →
        //   문제 6 (groupBy)    →

    }

    // -----------------------------------------------------------------
    // 문제 7.
    //   고객 → 주문 → 주문상세를 모두 조인한 상태에서
    //   "고객별 주문 건수" 를 구하십시오.
    //
    //   요구사항 — 세 버전을 모두 작성할 것
    //     (a) count() 로 써서 틀린 답(40)을 직접 확인
    //     (b) countDistinct() 로 고쳐서 20 을 얻기
    //     (c) 불필요한 조인을 제거해서 20 을 얻기
    //     - (a) 를 건너뛰지 마십시오. 틀린 답을 직접 만들어 보는 것이 목적입니다.
    //     - 세 버전의 생성 SQL 을 나란히 비교하십시오
    //
    //   기대 결과: (a) 40, (b) 20, (c) 20
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 7. fan-out 과 count — 세 버전 (40 / 20 / 20)")
    void q7_fanOut() {
        // 여기에 작성: (a) count() — 틀린 답

        // 여기에 작성: (b) countDistinct() — 고친 답

        // 여기에 작성: (c) 조인 제거 — 더 나은 답

    }
}

Solution.java

Exercise.java 의 정답과 해설입니다. 답만 있는 게 아니라 왜 그렇게 푸는지가 주석으로 붙어 있습니다.

  • 각 답 아래에 생성 SQL 이 주석으로 붙어 있습니다. 자기 콘솔의 로그와 한 글자씩 대조하십시오.
  • 문제 2 의 주석에서 count(c1_0.customer_id)count(c1_0.phone)왜 다른 SQL 로 번역되는지를 설명합니다. customer.count()count(*) 가 아니라는 점이 핵심입니다.
  • 문제 4 의 주석에서 coalesce 방식과 Optional 방식의 차이를 정리합니다. 전자는 DB 에서 NULL 을 대체하고 후자는 자바에서 대체합니다. groupBy 가 붙어 여러 행이 나올 때는 전자만 통합니다.
  • 문제 5, 6 의 주석이 이 파일에서 가장 깁니다. transform 이 언제 옳고 언제 그른지를 "원본 행이 실제로 필요한가" 라는 하나의 기준으로 정리합니다. transform 을 성능 최적화 도구로 오해하는 것이 이 API 를 둘러싼 가장 흔한 착각입니다.
  • 문제 7 의 주석에서 count / sum / avg 각각이 fan-out 에 어떻게 반응하는지 표로 정리합니다. countdistinct 로 고쳐지고 나머지는 안 됩니다.
package com.example.shop.step08;

import com.example.shop.entity.OrderStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.NumberExpression;
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 java.util.Map;

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.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;
import static org.assertj.core.api.Assertions.assertThat;

/**
 * Step 08 — 집계와 그룹핑 : 연습문제 정답과 해설
 *
 * <p>답만 보지 말고 주석을 반드시 읽으십시오.
 * 이 스텝의 함정은 대부분 "숫자가 그럴듯하게 나오는데 틀린" 형태입니다.
 */
@SpringBootTest
@Transactional
class Solution {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    public record OrderDto(Long orderId, BigDecimal totalAmount) {}

    // =================================================================
    // 문제 1. 도시별 고객 수와 평균 포인트 (8행)
    // =================================================================
    @Test
    @DisplayName("A1. 도시별 고객 수와 평균 포인트")
    void a1_customersByCity() {
        NumberExpression<Long>   cnt = customer.count();
        NumberExpression<Double> avg = customer.points.avg();

        List<Tuple> rows = queryFactory
                .select(customer.city, cnt, avg)
                .from(customer)
                .groupBy(customer.city)
                .orderBy(cnt.desc(), customer.city.asc())
                .fetch();

        rows.forEach(t -> System.out.printf("%-4s %2d %10.2f%n",
                t.get(customer.city), t.get(cnt), t.get(avg)));

        assertThat(rows).hasSize(8);
        assertThat(rows.get(0).get(customer.city)).isEqualTo("서울");
        assertThat(rows.get(0).get(cnt)).isEqualTo(10L);

        // 생성 SQL
        //   select c1_0.city, count(c1_0.customer_id), avg(c1_0.points)
        //   from customers c1_0
        //   group by c1_0.city
        //   order by count(c1_0.customer_id) desc, c1_0.city
        //
        // ── 해설 ────────────────────────────────────────────────
        // 세 가지가 포인트입니다.
        //
        // 1) 표현식을 변수로 뽑았습니다.
        //    cnt 를 select 절과 orderBy 와 tuple.get() 세 곳에서 씁니다.
        //    매번 customer.count() 를 새로 만들어도 동작은 하지만
        //    한 곳만 실수로 고치면 조용히 null 이 나오거나 SQL 이 달라집니다.
        //
        // 2) avg 는 Double 로 받아야 합니다.
        //    points 는 Integer 인데 avg 는 Double 입니다.
        //    NumberExpression<Integer> avg = customer.points.avg(); 는 컴파일 에러입니다.
        //    이 컴파일 에러가 나는 것이 좋은 일입니다. 타입이 다르다고 알려 준 것이니까요.
        //
        // 3) orderBy 에 집계 표현식을 그대로 넣을 수 있습니다.
        //    SQL 로는 order by count(...) desc 가 됩니다.
        //    MySQL 은 select 별칭으로도 정렬할 수 있지만 QueryDSL 에는 별칭 개념이 없으므로
        //    표현식을 그대로 재사용합니다. 오히려 이쪽이 안전합니다.
        //
        // 도시 8종은 MySQL8 코스 Step 06 의 6-4 절과 같습니다
        // (서울 10 / 부산 5 / 인천 4 / 대구 3 / 광주 2 / 대전 2 / 수원 2 / 울산 2).
    }

    // =================================================================
    // 문제 2. 전체 고객 수 vs 전화번호 있는 고객 수 (30 / 27)
    // =================================================================
    @Test
    @DisplayName("A2. count(*) 와 count(col) 의 차이")
    void a2_countWithNull() {
        NumberExpression<Long> total = customer.count();
        NumberExpression<Long> withPhone = customer.phone.count();

        Tuple t = queryFactory
                .select(total, withPhone)
                .from(customer)
                .fetchOne();

        assertThat(t.get(total)).isEqualTo(30L);
        assertThat(t.get(withPhone)).isEqualTo(27L);

        // 생성 SQL
        //   select count(c1_0.customer_id), count(c1_0.phone)
        //   from customers c1_0
        //
        // ── 해설 ────────────────────────────────────────────────
        // 왜 다른가?
        //
        // customer.count() 는 SQL 로 count(c1_0.customer_id) 가 됩니다.
        // count(*) 가 아닙니다. QueryDSL 은 엔티티 count 를 PK count 로 번역합니다.
        // customer_id 는 PK 라 NOT NULL 이므로 결과적으로 count(*) 와 같은 30 이 나옵니다.
        //
        // customer.phone.count() 는 count(c1_0.phone) 입니다.
        // SQL 의 COUNT(col) 은 NULL 을 세지 않습니다. 전화번호가 NULL 인 고객 3명이 빠져 27 입니다.
        //
        // 이 둘은 코드가 비슷하게 생겼고 반환 타입도 똑같이 Long 입니다.
        // 컴파일러가 구분해 줄 방법이 없습니다. 값만 다릅니다.
        // "고객이 몇 명인가" 를 묻는 리포트에서 phone.count() 를 쓰면 3명이 조용히 사라집니다.
        //
        // 규칙:
        //   "몇 건/몇 명인가"  → 엔티티 count (customer.count())
        //   "값이 있는 것이 몇 개인가" → 컬럼 count (customer.phone.count())
        //   "몇 종류인가"      → countDistinct (customer.city.countDistinct())
        //
        // 그리고 이것은 count 만의 문제가 아닙니다.
        // count(*) 를 제외한 모든 집계 함수가 NULL 을 건너뜁니다.
        // AVG(col) 은 정확히 SUM(col) / COUNT(col) 이므로,
        // 분모가 "전체 행 수" 가 아니라 "NULL 이 아닌 행 수" 입니다.
        // 평균을 리포트에 낼 때는 분모가 무엇인지 반드시 확인하십시오.
        // (MySQL8 코스 부록 A — NULL 완전 정복 참조)
    }

    // =================================================================
    // 문제 3. 합계 4천만 이상 고객 (8행)
    // =================================================================
    @Test
    @DisplayName("A3. where 와 having 의 분담")
    void a3_whereVsHaving() {
        NumberExpression<Long>       cnt = order.count();
        NumberExpression<BigDecimal> sum = order.totalAmount.sum();

        List<Tuple> rows = queryFactory
                .select(order.customer.id, cnt, sum)
                .from(order)
                .where(order.status.ne(OrderStatus.CANCELLED))      // ← 개별 행 조건
                .groupBy(order.customer.id)
                .having(sum.goe(new BigDecimal("40000000")))        // ← 집계 결과 조건
                .orderBy(sum.desc())
                .fetch();

        assertThat(rows).hasSize(8);

        // 생성 SQL
        //   select o1_0.customer_id, count(o1_0.order_id), sum(o1_0.total_amount)
        //   from orders o1_0
        //   where o1_0.status <> ?
        //   group by o1_0.customer_id
        //   having sum(o1_0.total_amount) >= ?
        //   order by sum(o1_0.total_amount) desc
        //
        // ── 해설 ────────────────────────────────────────────────
        // 판단 근거는 "그 조건이 개별 행에 대한 것인가, 그룹에 대한 것인가" 입니다.
        //
        //   FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
        //           ↑                    ↑
        //      개별 행을 거름        그룹을 거름
        //
        //   "취소가 아닌 주문"     → 주문 한 건 한 건을 보고 판단 가능 → WHERE
        //   "합계가 4천만 이상"    → 그룹을 만들어 봐야 알 수 있음     → HAVING
        //
        // 반대로 쓰면 어떻게 되나?
        //
        //   having(order.status.ne(CANCELLED))
        //     → 문법적으로는 통과합니다. QueryDSL 이 막지 않습니다.
        //       하지만 CANCELLED 주문까지 전부 그룹핑한 뒤 버리므로 낭비이고,
        //       무엇보다 결과가 달라집니다. 그룹 안에 CANCELLED 가 섞인 채로 sum 이 계산되니까요.
        //       (having 은 그룹 전체에 대한 조건이므로 "이 그룹의 status" 라는 게 정의되지 않습니다.
        //        MySQL 은 ONLY_FULL_GROUP_BY 로 이것도 거부할 가능성이 높습니다.)
        //
        //   where(sum.goe(...))
        //     → 이건 아예 불가능합니다. WHERE 는 집계 전에 실행되므로
        //       그 시점에 sum(...) 이라는 값이 존재하지 않습니다.
        //       MySQL 이 "Invalid use of group function" 으로 거부합니다.
        //
        // 성능 관점도 있습니다.
        // where 로 미리 거르면 그룹핑 대상 행 수 자체가 줄고, status 에 인덱스가 있으면 탈 수도 있습니다.
        // having 은 이미 만들어진 그룹을 버리는 것이라 그런 이득이 없습니다.
        //
        // 규칙 한 줄:
        //   집계 함수가 들어가는 조건만 having. 나머지는 전부 where.
    }

    // =================================================================
    // 문제 4. 0건일 때 sum 을 0 으로 (coalesce)
    // =================================================================
    @Test
    @DisplayName("A4. coalesce 로 NULL 을 0 으로")
    void a4_coalesce() {
        BigDecimal total = queryFactory
                .select(order.totalAmount.sum().coalesce(BigDecimal.ZERO))
                .from(order)
                .where(order.shippingCity.eq("제주"))
                .fetchOne();

        assertThat(total).isEqualByComparingTo(BigDecimal.ZERO);

        // 생성 SQL
        //   select coalesce(sum(o1_0.total_amount), ?)
        //   from orders o1_0
        //   where o1_0.shipping_city = ?
        //   바인딩: [1] 0, [2] 제주
        //
        // ── 해설 ────────────────────────────────────────────────
        // 선택: coalesce (DB 쪽 처리)
        //
        // SQL 표준에서 SUM 은 대상이 0건이면 0 이 아니라 NULL 을 돌려줍니다.
        // "더할 것이 없으면 합계는 정의되지 않는다" 는 입장입니다.
        // count() 만 0건에서 0 을 돌려주고, sum / avg / max / min 은 전부 null 입니다.
        //
        // 이 null 이 자바로 넘어와서 그대로 계산에 쓰이면 NPE 입니다.
        //   BigDecimal fee = total.multiply(new BigDecimal("0.03"));  // 💥
        // 개발 데이터에는 항상 주문이 있으니 통과하고,
        // 신규 가입자나 신규 지역이 배치에 들어오는 날 터집니다.
        //
        // coalesce 방식 vs Optional 방식
        //
        //   coalesce(BigDecimal.ZERO)
        //     - DB 에서 대체됩니다. SQL 에 의도가 드러납니다.
        //     - groupBy 로 여러 행이 나올 때도 각 행마다 적용됩니다.  ← 결정적
        //   Optional.ofNullable(...).orElse(BigDecimal.ZERO)
        //     - 자바에서 대체됩니다. SQL 은 그대로입니다.
        //     - fetchOne() 으로 값 하나를 받을 때만 통합니다.
        //       List<Tuple> 로 여러 행을 받으면 행마다 일일이 처리해야 합니다.
        //
        // 그래서 coalesce 를 권합니다. 특히 leftJoin + 집계 조합에서는 사실상 필수입니다.
        //
        //   .select(customer.id, order.totalAmount.sum().coalesce(BigDecimal.ZERO))
        //   .from(customer)
        //   .leftJoin(order).on(order.customer.eq(customer))
        //   .groupBy(customer.id)
        //
        // 여기서 coalesce 를 빼면 "주문이 없는 고객" 행의 합계가 null 로 나옵니다.
        //
        // 한 가지 더: groupBy 만 쓰면 이 문제가 숨습니다.
        // 주문이 0건인 고객은 그룹 자체가 안 생기므로 null 을 볼 일이 없습니다.
        // 그러다 leftJoin 을 붙이는 순간 등장합니다. "우리는 괜찮다" 는 착각을 조심하십시오.
    }

    // =================================================================
    // 문제 5. transform 으로 Map 만들기 (Map 크기 30)
    // =================================================================
    @Test
    @DisplayName("A5. transform — 애플리케이션 메모리 그룹핑")
    void a5_transform() {
        Map<Long, List<OrderDto>> byCustomer = queryFactory
                .from(order)                                    // select() 없이 from() 으로 시작
                .innerJoin(order.customer, customer)
                .transform(groupBy(customer.id).as(
                        list(Projections.constructor(OrderDto.class, order.id, order.totalAmount))
                ));

        assertThat(byCustomer).hasSize(30);
        assertThat(byCustomer.get(1L)).hasSize(20);

        // 생성 SQL
        //   select c1_0.customer_id, o1_0.order_id, o1_0.total_amount
        //   from orders o1_0
        //   join customers c1_0 on c1_0.customer_id = o1_0.customer_id
        //
        //   ⚠️ group by 가 없습니다.
        //
        // ── 해설 ────────────────────────────────────────────────
        // group by 가 없는 이유:
        //
        // transform 은 쿼리를 바꾸는 API 가 아니라 결과 집합 변환기(ResultTransformer)입니다.
        // DB 는 조인 결과 600행을 그대로 돌려주고, QueryDSL 이 그 600행을 JVM 에서 순회하며
        // Map<Long, List<OrderDto>> 를 조립합니다.
        //
        //   DB                          네트워크        JVM
        //   600행 반환  ───────────────────────────>  600행 읽으며 Map 조립 → 30개 키
        //
        // 이름이 GroupBy.groupBy 라서 SQL 의 GROUP BY 를 만들 것 같지만 아닙니다.
        // 두 API 를 구분하십시오.
        //
        //   queryFactory.select(...).from(order).groupBy(order.customer.id)   → SQL 의 GROUP BY
        //   queryFactory.from(order).transform(GroupBy.groupBy(customer.id))  → JVM 메모리 그룹핑
        //
        // 그러면 transform 은 나쁜 API 인가? 아닙니다. 목적이 다를 뿐입니다.
        //
        // transform 의 진짜 가치는 두 가지입니다.
        //   1) 조립 코드 제거.
        //      직접 쓰면 stream().collect(Collectors.groupingBy(...)) + mapping 이 필요합니다.
        //      transform 은 그걸 쿼리 선언 안으로 밀어 넣습니다.
        //   2) fan-out 자동 정리.
        //      1:N 조인으로 부모가 중복되는 문제를 groupBy 키가 자연스럽게 흡수합니다.
        //      distinct 나 수동 Set 처리가 필요 없어집니다.
        //
        // 반대로 transform 이 해 주지 않는 것:
        //   - DB 부하를 줄여 주지 않습니다. 원본 행이 전부 넘어옵니다.
        //   - 네트워크 전송량을 줄여 주지 않습니다.
        //   - GroupBy.sum / avg / count 도 전부 JVM 에서 돕니다.
        //
        // 판단 기준 한 줄:
        //   "원본 행이 실제로 필요한가?"
        //     필요하다  → transform 이 편하다 (상세 화면, 페이징된 목록의 연관 데이터)
        //     필요없다  → DB 에서 접어라 (합계만, 건수만 필요한 경우)
        //
        // 여기서는 각 고객의 주문 20건이 실제로 다 필요하므로 transform 이 적절합니다.
        // 만약 주문이 60만 건이었다면 60만 행이 힙으로 올라옵니다. OOM 후보입니다.
    }

    // =================================================================
    // 문제 6. DB groupBy 로 건수만 (30행)
    // =================================================================
    @Test
    @DisplayName("A6. DB group by — 600행 vs 30행")
    void a6_dbGroupBy() {
        List<Tuple> counts = queryFactory
                .select(customer.id, order.count())
                .from(order)
                .innerJoin(order.customer, customer)
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        assertThat(counts).hasSize(30);
        assertThat(counts.get(0).get(order.count())).isEqualTo(20L);

        // 생성 SQL
        //   select c1_0.customer_id, count(o1_0.order_id)
        //   from orders o1_0
        //   join customers c1_0 on c1_0.customer_id = o1_0.customer_id
        //   group by c1_0.customer_id
        //   order by c1_0.customer_id
        //
        //   ← group by 가 있습니다.
        //
        // ── 해설 ────────────────────────────────────────────────
        // 넘어오는 행 수 비교
        //
        //   문제 5 (transform) → DB 가 600행 반환. JVM 이 600행을 읽어 30개 키로 접음.
        //   문제 6 (groupBy)   → DB 가 30행 반환. JVM 은 30행만 읽음.
        //
        //   600 : 30 = 20배 차이입니다.
        //
        // 그런데 이 비율은 데이터가 커질수록 벌어집니다.
        //   주문 600건    → 600행 vs 30행    (20배)
        //   주문 60만 건  → 60만행 vs 30행   (2만 배)
        //
        // 그룹 수는 고객 수에 비례해 늘지만, 원본 행 수는 주문 수에 비례해 늡니다.
        // "건수만 필요한데 transform 을 썼다" 는 실수는 데이터가 작을 때 아무 증상이 없습니다.
        // 그러다 서비스가 커지면 어느 날 힙이 터집니다. 코드는 그대로인데요.
        //
        // 그리고 DB 쪽 집계에는 부수적 이득도 있습니다.
        //   - 인덱스로 그룹핑을 처리할 수 있습니다 (idx_orders_customer).
        //   - 정렬도 DB 가 처리합니다.
        //   - 네트워크 전송량과 직렬화 비용이 줄어듭니다.
        //
        // 규칙:
        //   집계 결과만 필요하면 DB 의 groupBy.
        //   원본 행 목록이 필요하면 transform.
        //   둘 다 필요하면 두 쿼리로 나누는 것도 방법입니다.
    }

    // =================================================================
    // 문제 7. fan-out 과 count — 세 버전 (40 / 20 / 20)
    // =================================================================
    @Test
    @DisplayName("A7. fan-out 이 집계를 망가뜨린다")
    void a7_fanOut() {
        // (a) count() — 틀린 답
        NumberExpression<Long> cnt = order.count();
        List<Tuple> wrong = queryFactory
                .select(customer.id, cnt)
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .innerJoin(orderItem).on(orderItem.order.eq(order))
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        // (b) countDistinct() — 고친 답
        NumberExpression<Long> cntDistinct = order.countDistinct();
        List<Tuple> fixed = queryFactory
                .select(customer.id, cntDistinct)
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .innerJoin(orderItem).on(orderItem.order.eq(order))
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        // (c) 조인 제거 — 더 나은 답
        List<Tuple> best = queryFactory
                .select(customer.id, cnt)
                .from(customer)
                .innerJoin(order).on(order.customer.eq(customer))
                .groupBy(customer.id)
                .orderBy(customer.id.asc())
                .fetch();

        assertThat(wrong.get(0).get(cnt)).isEqualTo(40L);           // 틀림
        assertThat(fixed.get(0).get(cntDistinct)).isEqualTo(20L);   // 맞음
        assertThat(best.get(0).get(cnt)).isEqualTo(20L);            // 맞음

        // 생성 SQL (a)
        //   select c1_0.customer_id, count(o1_0.order_id)
        //   from customers c1_0
        //   join orders o1_0 on o1_0.customer_id = c1_0.customer_id
        //   join order_items oi1_0 on oi1_0.order_id = o1_0.order_id
        //   group by c1_0.customer_id
        //
        // 생성 SQL (b) — count(distinct o1_0.order_id) 로 바뀜
        // 생성 SQL (c) — order_items 조인이 사라짐
        //
        // ── 해설 ────────────────────────────────────────────────
        // (a) 가 40 인 이유
        //
        // 주문 600건이 주문상세 1,200건과 1:N 조인되어 결과가 1,200행이 됩니다.
        // 주문 1건당 상세가 평균 2건이므로 각 주문이 2번씩 등장합니다.
        // count(o1_0.order_id) 는 그 1,200행을 셉니다. 고객당 20 × 2 = 40.
        //
        // 에러는 없습니다. 40 이라는 그럴듯한 숫자가 나옵니다.
        // "우리 고객은 평균 40건 주문한다" 는 리포트가 그대로 나갑니다.
        //
        // fan-out 에 대한 집계 함수별 반응
        //
        //   함수            증상                   distinct 로 고쳐지나?
        //   count(col)      행 수만큼 부풀음         ⭕ countDistinct()
        //   sum(col)        값이 반복 더해짐         ❌ 고칠 수 없음
        //   avg(col)        분모가 부풀어 왜곡       ❌ 고칠 수 없음
        //   max/min(col)    영향 없음               (반복돼도 최댓값은 같음)
        //
        // sum 이 왜 못 고쳐지는가?
        //   sumDistinct 를 쓰면 "금액이 같은 서로 다른 주문" 이 하나로 합쳐집니다.
        //   주문 A 가 10만원, 주문 B 도 10만원이면 distinct 가 둘을 하나로 봅니다.
        //   원래 20만원인데 10만원이 됩니다. 더 틀린 답입니다.
        //   시드 데이터에는 금액이 같은 주문이 실제로 여럿 있으므로 바로 드러납니다.
        //
        // 그래서 sum 의 fan-out 대처는 두 가지뿐입니다.
        //   1. 불필요한 조인을 제거한다  ← 대부분 이게 답입니다
        //   2. 서브쿼리로 분리한다
        //      .where(selectOne().from(orderItem).where(orderItem.order.eq(order)).exists())
        //      → 조인 대신 exists 로 조건만 걸면 행이 부풀지 않습니다 (Step 07)
        //
        // (c) 가 가장 좋은 이유
        //   "고객별 주문 건수" 라는 질문에 order_items 는 애초에 필요 없었습니다.
        //   countDistinct 는 잘못 짠 조인을 사후에 수습하는 것이고,
        //   조인 제거는 원인 자체를 없애는 것입니다.
        //   조인이 하나 줄었으니 실행 계획도 단순해집니다.
        //
        // 예방 습관:
        //   집계 쿼리를 쓰기 전에 "조인 후 행 수가 몇인가" 를 먼저 세어 보십시오.
        //
        //   queryFactory.select(order.count())
        //       .from(customer)
        //       .innerJoin(order).on(...)
        //       .innerJoin(orderItem).on(...)
        //       .fetchOne();          // → 1200
        //
        //   600 을 기대했는데 1200 이 나오면 그 자리에서 이상을 알아챌 수 있습니다.
        //   이 숫자를 모르면 집계 결과도 믿을 수 없습니다.
    }
}