Step 06 — 조인

학습 목표

  • 연관 기반 조인과 세타 조인을 구분하고 각각의 생성 SQL 을 읽는다
  • join / leftJoin / on 을 MySQL 의 JOIN / LEFT JOIN / ON 과 1:1 로 대응시킨다
  • 1:N 조인의 fan-out 이 집계를 망가뜨리는 것을 숫자로 재현하고 방어한다
  • LEFT JOIN 의 필터를 where 에 두면 INNER JOIN 으로 퇴화하는 것을 건수로 확인한다
  • fetchJoin() 으로 N+1 이 사라지는 것을 쿼리 개수로 증명한다
  • 컬렉션 fetch join 에 페이징을 붙이면 전건을 메모리로 읽는다는 것을 경고 로그와 생성 SQL 로 확인하고 세 가지 방법으로 고친다
  • MultipleBagFetchException 의 원인과 처방을 안다

선행 스텝: Step 05 — 프로젝션과 DTO 예상 소요: 120분

📌 이 스텝은 MySQL8 코스 Step 07 — 조인 을 QueryDSL 로 다시 쓰는 스텝입니다. 같은 스키마, 같은 데이터, 같은 결과입니다. 절마다 대응되는 SQL 을 나란히 놓았습니다. SQL 쪽을 먼저 읽고 오면 절반은 이미 아는 내용입니다.

그리고 절반은 JPA 에만 있는 이야기입니다. fetchJoin(), MultipleBagFetchException, "컬렉션 fetch join + 페이징" 은 SQL 에 없는 개념입니다. SQL 을 아무리 잘 알아도 이건 따로 배워야 합니다. 6-7 절부터가 그 부분입니다.

본문은 아래 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.QPayment.payment;
import static com.example.shop.entity.QReview.review;
import static com.example.shop.entity.QEmployee.employee;

6-1. 연관 기반 조인 vs 세타 조인

QueryDSL 에는 조인을 쓰는 방법이 두 가지 있습니다.

연관 기반 조인 — 엔티티의 관계를 타고 간다

List<Tuple> result = queryFactory
        .select(order.id, customer.name)
        .from(order)
        .join(order.customer, customer)     // ← 연관 경로 + 별칭
        .limit(3)
        .fetch();

결과

select o1_0.order_id, c1_0.name
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
limit ?
바인딩: [1] 3
1 / 류하나
2 / 정  훈
3 / 안지수

join(order.customer, customer) 의 두 인자를 정확히 구분하십시오.

  • 첫 번째 order.customer연관 경로. "orders 에서 customer 로 가는 관계" 입니다. @ManyToOne 매핑에 조인 조건(customer_id = customer_id)이 이미 적혀 있으므로 on 이 필요 없습니다.
  • 두 번째 customer별칭. 조인해 온 대상을 앞으로 이 이름으로 부르겠다는 선언입니다. 이걸 줘야 selectwhere 에서 customer.name 을 쓸 수 있습니다.

별칭을 빼고 join(order.customer) 만 쓰면 조인은 되지만 그 대상을 참조할 방법이 없어집니다.

세타 조인 — 관계 없이 곱집합에서 걸러낸다

List<Tuple> result = queryFactory
        .select(order.id, customer.name)
        .from(order, customer)                     // ← from 에 둘을 나열
        .where(order.customer.eq(customer))        // ← 조인 조건을 where 로
        .limit(3)
        .fetch();

결과

select o1_0.order_id, c1_0.name
from orders o1_0,
     customers c1_0
where o1_0.customer_id = c1_0.customer_id
limit ?
바인딩: [1] 3
1 / 류하나
2 / 정  훈
3 / 안지수

결과는 같지만 SQL 이 다릅니다. from a, b where 조건 — MySQL8 코스 Step 077-7 절에서 "콤마 조인 대신 명시적 JOIN ... ON 을 쓰라" 고 경고했던 그 형태입니다.

연관 기반 조인세타 조인
문법.join(order.customer, customer).from(order, customer).where(...)
생성 SQLjoin ... on ...from a, b where ...
연관 매핑필요불필요
외부 조인leftJoin 가능불가능 (on 으로 우회 — 6-6 절)
조건 누락 시문법상 불가능곱집합 폭발

⚠️ 함정 — 세타 조인에서 where 를 빠뜨리면 곱집합입니다 .from(order, customer) 만 쓰고 where(order.customer.eq(customer)) 를 안 쓰면 주문 600 × 고객 30 = 18,000 행이 나옵니다. 예외는 나지 않습니다. 우리 데이터는 작아서 그냥 느릴 뿐이지만, 주문 100만 건이면 서버가 멈춥니다. 연관 매핑이 있으면 언제나 연관 기반 조인을 쓰십시오. 세타 조인은 연관이 없는 엔티티를 이어야 할 때만(6-6 절) 쓰는 마지막 수단입니다.


6-2. join / innerJoin / leftJoin / rightJoin

MySQL8 코스 Step 07 — 조인7-1 절 첫 예제입니다. "주문에 고객 이름을 붙인다."

SQL (MySQL8 코스 7-1)

SELECT o.order_id, o.order_date, c.name, c.grade, o.total_amount
FROM orders o
INNER JOIN customers c ON c.customer_id = o.customer_id
ORDER BY o.order_id
LIMIT 5;

QueryDSL

List<Tuple> result = queryFactory
        .select(order.id, order.orderDate, customer.name,
                customer.grade, order.totalAmount)
        .from(order)
        .join(order.customer, customer)
        .orderBy(order.id.asc())
        .limit(5)
        .fetch();

결과

select o1_0.order_id, o1_0.order_date, c1_0.name, c1_0.grade, 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.order_id asc
limit ?
바인딩: [1] 5
1 | 2024-02-07T13:07 | 류하나 | GOLD   | 1836000.00
2 | 2024-03-15T02:14 | 정  훈 | GOLD   | 6663900.00
3 | 2024-04-21T15:21 | 안지수 | GOLD   |  658000.00
4 | 2024-05-28T04:28 | 한지호 | BRONZE |  837000.00
5 | 2024-07-04T17:35 | 배채영 | GOLD   | 1194000.00

MySQL8 코스의 7-1 결과와 완전히 같습니다. 같은 데이터니까요.

네 가지 조인 메서드는 SQL 과 1:1 로 대응합니다.

QueryDSL생성 SQL의미
.join(a, b)join ... on ...INNER JOIN
.innerJoin(a, b)join ... on ...join완전히 동일
.leftJoin(a, b)left join ... on ...왼쪽 전부 보존
.rightJoin(a, b)right join ... on ...오른쪽 전부 보존

joininnerJoin 은 이름만 다른 같은 메서드입니다. SQL 에서 INNER 를 생략할 수 있는 것과 같습니다.

leftJoin 으로 바꾸면 어떻게 될까요? "상품이 없는 카테고리도 보고 싶다" 는 요구입니다. 우리 카테고리 17개 중 대분류 5개에는 상품이 직접 매달려 있지 않습니다.

SQL (MySQL8 코스 7-3)

SELECT cat.category_id, cat.name, p.product_id, p.name
FROM categories cat
LEFT JOIN products p ON p.category_id = cat.category_id
WHERE cat.parent_id IS NULL
ORDER BY cat.category_id;

QueryDSL

List<Tuple> result = queryFactory
        .select(category.id, category.name, product.id, product.name)
        .from(category)
        .leftJoin(category.products, product)
        .where(category.parent.isNull())
        .orderBy(category.id.asc())
        .fetch();

결과

select c1_0.category_id, c1_0.name, p1_0.product_id, p1_0.name
from categories c1_0
left join products p1_0 on c1_0.category_id = p1_0.category_id
where c1_0.parent_id is null
order by c1_0.category_id asc
조회 5건
1 | 패션   | null | null
2 | 디지털 | null | null
3 | 식품   | null | null
4 | 리빙   | null | null
5 | 도서   | null | null

카테고리는 남았고 상품 자리는 NULL 로 채워졌습니다(NULL 확장). join 이었다면 이 5줄이 통째로 사라집니다. 직접 바꿔서 확인해 보십시오.

💡 실무 팁 — rightJoin 은 거의 쓰지 마십시오 MySQL8 코스 7-9 절에서 한 이야기가 QueryDSL 에도 그대로 적용됩니다. 사람은 왼쪽에서 오른쪽으로 읽습니다. "전부 남길 대상을 from 에 놓고 leftJoin" 하는 흐름이 훨씬 읽기 쉽습니다. rightJoin 을 보면 대개 leftJoin 으로 뒤집을 후보입니다.


6-3. 다중 조인 — 5개 테이블

MySQL8 코스 Step 077-2 절입니다. "어떤 고객이 어떤 상품을 어느 카테고리에서 몇 개 샀나."

SQL (MySQL8 코스 7-2)

SELECT o.order_id, c.name, p.name, cat.name, oi.quantity, oi.unit_price
FROM orders o
JOIN customers c    ON c.customer_id   = o.customer_id
JOIN order_items oi ON oi.order_id     = o.order_id
JOIN products p     ON p.product_id    = oi.product_id
JOIN categories cat ON cat.category_id = p.category_id
ORDER BY o.order_id, p.product_id
LIMIT 8;

QueryDSL

List<Tuple> result = queryFactory
        .select(order.id, customer.name, product.name,
                category.name, orderItem.quantity, orderItem.unitPrice)
        .from(order)
        .join(order.customer, customer)
        .join(order.orderItems, orderItem)
        .join(orderItem.product, product)
        .join(product.category, category)
        .orderBy(order.id.asc(), product.id.asc())
        .limit(8)
        .fetch();

결과

select o1_0.order_id, c1_0.name, p1_0.name, c2_0.name,
       oi1_0.quantity, oi1_0.unit_price
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
join products p1_0 on p1_0.product_id = oi1_0.product_id
join categories c2_0 on c2_0.category_id = p1_0.category_id
order by o1_0.order_id asc, p1_0.product_id asc
limit ?
바인딩: [1] 8
1 | 류하나 | 27인치 4K 모니터      | 주변기기 | 3 |  459000.00
1 | 류하나 | 원목 4인 식탁         | 가구     | 1 |  459000.00
2 | 정  훈 | 베이직 옥스퍼드 셔츠  | 남성의류 | 2 |   39000.00
2 | 정  훈 | 게이밍 노트북 RTX4060 | 노트북   | 3 | 2190000.00
2 | 정  훈 | 콜드브루 원액 1L      | 가공식품 | 1 |   15900.00
3 | 안지수 | 인체공학 사무용 의자  | 가구     | 2 |  329000.00
4 | 한지호 | 슬림핏 치노 팬츠      | 남성의류 | 3 |   49000.00
4 | 한지호 | 보급형 노트북 15      | 노트북   | 1 |  690000.00

MySQL8 코스 7-2 의 결과와 한 줄도 다르지 않습니다.

별칭 규칙을 눈여겨 보십시오. customerscategories 가 둘 다 c 로 시작해서 Hibernate 가 c1_0, c2_0 으로 번호를 매겼습니다. 어느 쪽이 무엇인지는 from/join 등장 순서로 결정됩니다. 로그를 읽을 때 헷갈리기 쉬운 지점이니 on 절의 컬럼명으로 확인하는 습관을 들이십시오.

order_id = 1 이 두 줄인 것에 다시 주목하십시오. 주문 1건에 상품이 2개(1:N)입니다. 주문 헤더 정보(고객명)가 상품 수만큼 반복됩니다. 다음 절의 사고가 여기서 시작됩니다.


6-4. ⚠️ fan-out — 1:N 조인이 집계를 망가뜨린다

김민수(고객 1번)의 주문 총액을 구해 봅시다. 주문만 조인하면 정상입니다.

Tuple correct = queryFactory
        .select(order.count(), order.totalAmount.sum())
        .from(order)
        .join(order.customer, customer)
        .where(customer.id.eq(1L))
        .fetchOne();

결과

select count(o1_0.order_id), sum(o1_0.total_amount)
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
where c1_0.customer_id = ?
바인딩: [1] 1
주문 수 = 20, 총액 = 24300000.00

이제 "상품명도 같이 보고 싶어서" orderItems 조인을 하나 추가합니다. 집계식은 한 글자도 바꾸지 않았습니다.

Tuple wrong = queryFactory
        .select(order.count(), order.totalAmount.sum())
        .from(order)
        .join(order.customer, customer)
        .join(order.orderItems, orderItem)          // ← 이 한 줄만 추가
        .where(customer.id.eq(1L))
        .fetchOne();

결과

select count(o1_0.order_id), sum(o1_0.total_amount)
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
where c1_0.customer_id = ?
바인딩: [1] 1
주문 수 = 41, 총액 = 49860000.00
주문 수총액
orderItems 조인 없음 (정답)2024,300,000
orderItems 조인 추가4149,860,000
차이+21+25,560,000

주문이 20건에서 41건으로 늘었고 총액이 2배가 됐습니다. 예외는 없습니다. 경고도 없습니다. 매출 리포트에 2배 숫자가 찍힐 뿐입니다.

이유는 6-3 절에서 본 것과 같습니다. orders : order_items 는 1:N 이라, 주문 1건에 상품이 2개면 그 주문 행이 2번 복제됩니다. 김민수의 주문 20건에 딸린 order_items 가 41건이니 조인 결과는 41행이고, 각 주문의 total_amount 가 그 주문의 상품 개수만큼 반복해서 더해집니다.

⚠️ 함정 — fan-out(행 뻥튀기) 조인을 하나 추가하는 것은 "컬럼을 추가하는 일" 이 아니라 "행의 단위를 바꾸는 일" 입니다. orderItems 를 조인한 순간 한 행의 의미가 "주문 하나" 에서 "주문 상품 한 줄" 로 바뀌었습니다. 집계식은 그대로인데 집계 대상이 바뀐 것입니다.

집계 전에 언제나 자문하십시오 — "지금 한 행은 무엇의 단위인가?"

방어 1 — countDistinct

Tuple result = queryFactory
        .select(order.countDistinct(), order.totalAmount.sum())
        .from(order)
        .join(order.customer, customer)
        .join(order.orderItems, orderItem)
        .where(customer.id.eq(1L))
        .fetchOne();

결과

select count(distinct o1_0.order_id), sum(o1_0.total_amount)
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
where c1_0.customer_id = ?
주문 수 = 20  ← 고쳐짐
총액 = 49860000.00  ← 여전히 틀림

count 는 고쳐졌지만 sum 은 그대로입니다. countDistinct 는 "서로 다른 order_id 의 개수" 를 세니까 맞습니다. 그런데 sumdistinct 를 붙이면 다른 주문인데 금액이 우연히 같으면 한 번만 더합니다 — 더 큰 사고가 납니다. MySQL8 코스 7-11 절의 경고와 같은 이야기입니다.

방어 2 — 집계는 fan-out 없는 쿼리로 분리

// 금액 합계는 orderItems 를 조인하지 않은 쿼리로 따로 구한다
BigDecimal total = queryFactory
        .select(order.totalAmount.sum())
        .from(order)
        .where(order.customer.id.eq(1L))
        .fetchOne();

결과

select sum(o1_0.total_amount)
from orders o1_0
where o1_0.customer_id = ?
총액 = 24300000.00  ← 정답

방어 3 — 합계를 line 단위로 다시 정의

total_amount 를 더하는 대신 quantity × unit_price 를 더하면 행 단위와 집계 단위가 일치합니다.

BigDecimal lineTotal = queryFactory
        .select(orderItem.unitPrice.multiply(orderItem.quantity).sum())
        .from(order)
        .join(order.orderItems, orderItem)
        .where(order.customer.id.eq(1L))
        .fetchOne();

결과

select sum(oi1_0.unit_price * oi1_0.quantity)
from orders o1_0
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
where o1_0.customer_id = ?
총액 = 24300000.00  ← 정답

세 방법 중 방어 3이 가장 안전합니다. 집계식의 단위가 행의 단위와 같으면 fan-out 자체가 문제가 되지 않습니다.


6-5. ⚠️ on vs where — 이 스텝의 SQL 쪽 핵심

MySQL8 코스 Step 077-4 절, 실무에서 가장 많이 틀리는 조인 함정입니다. QueryDSL 로도 똑같이 틀립니다.

"모든 카테고리와 그 카테고리의 고가 상품(100만원 이상)" 을 보려고 합니다. 카테고리는 17개고, 100만원 이상 상품은 6개인데 서로 다른 6개 카테고리에 흩어져 있습니다.

(A) 조건을 on

List<Tuple> onVersion = queryFactory
        .select(category.name, product.name, product.price)
        .from(category)
        .leftJoin(category.products, product)
        .on(product.price.goe(new BigDecimal("1000000")))    // ← on
        .orderBy(category.id.asc())
        .fetch();

결과

select c1_0.name, p1_0.name, p1_0.price
from categories c1_0
left join products p1_0
       on c1_0.category_id = p1_0.category_id
      and p1_0.price >= ?
order by c1_0.category_id asc
바인딩: [1] 1000000
조회 17건

패션     | null                  | null
디지털   | null                  | null
식품     | null                  | null
리빙     | null                  | null
도서     | null                  | null
남성의류 | null                  | null
여성의류 | null                  | null
노트북   | 게이밍 노트북 RTX4060 | 2190000.00
...

17건. 조건에 맞는 상품 6건 + 조건에 맞는 상품이 없는 카테고리 11건(NULL 확장) = 17.

(B) 조건을 where

List<Tuple> whereVersion = queryFactory
        .select(category.name, product.name, product.price)
        .from(category)
        .leftJoin(category.products, product)
        .where(product.price.goe(new BigDecimal("1000000")))  // ← where
        .orderBy(category.id.asc())
        .fetch();

결과

select c1_0.name, p1_0.name, p1_0.price
from categories c1_0
left join products p1_0 on c1_0.category_id = p1_0.category_id
where p1_0.price >= ?
order by c1_0.category_id asc
바인딩: [1] 1000000
조회 6건

노트북   | 게이밍 노트북 RTX4060 | 2190000.00
노트북   | 크리에이터 노트북 16  | 2450000.00
...

6건. LEFT JOIN 을 써 놓고 INNER JOIN 결과를 얻었습니다.

조건 위치생성 SQL결과
(A)onleft join ... on 조인조건 and 필터17건 (카테고리 전부 보존)
(B)whereleft join ... on 조인조건 + where 필터6건 (INNER JOIN 과 동일)

11건이 사라진 이유는 NULL 확장입니다. where 는 조인이 끝난 뒤 적용됩니다. NULL 확장된 행의 p1_0.price 는 NULL 이고, NULL >= 1000000 은 참도 거짓도 아닌 UNKNOWN 이라 where 를 통과하지 못하고 탈락합니다.

⚠️ 핵심 규칙 — LEFT JOIN 에서 오른쪽 조건은 on 에, 왼쪽 조건은 where

  • "모든 카테고리 + 그들의 고가 상품(없으면 NULL)" 을 원하면 → on
  • "고가 상품이 있는 카테고리만" 을 원하면 → where (또는 그냥 join)

전형적인 실수는 leftJoin 을 써 놓고 오른쪽 테이블 조건을 where 에 걸어서, 자기도 모르게 INNER JOIN 을 만들어 놓고 "왜 LEFT JOIN 인데 행이 안 남지?" 라고 헤매는 것입니다.

QueryDSL 에서 특히 위험한 이유가 하나 더 있습니다. .on(...).where(...)둘 다 BooleanExpression 을 받습니다. 조건을 변수로 빼서 BooleanExpression cond = product.price.goe(...) 로 만들어 두면 .on(cond).where(cond) 도 컴파일됩니다. 리팩터링하다가 조건을 옮기는 순간 결과가 바뀌는데 컴파일러는 아무 말도 하지 않습니다.

예외가 하나 있습니다. whereisNull() 을 두는 것은 정상입니다. NULL 확장을 일부러 노리는 안티 조인이기 때문입니다 (6-12 절).


6-6. 연관 없는 엔티티의 on 조인

여기까지는 전부 연관 매핑이 있는 엔티티끼리의 조인이었습니다. 연관이 없는 두 엔티티를 이으려면 join(엔티티) + on(조건) 형태를 씁니다.

억지스럽지만 명확한 예로, "상품명과 카테고리명이 같은 경우" 를 이어 보겠습니다.

List<Tuple> result = queryFactory
        .select(category.name, product.name)
        .from(category)
        .leftJoin(product).on(product.name.eq(category.name))   // ← 연관 경로 없음
        .orderBy(category.id.asc())
        .limit(5)
        .fetch();

결과

select c1_0.name, p1_0.name
from categories c1_0
left join products p1_0 on p1_0.name = c1_0.name
order by c1_0.category_id asc
limit ?
바인딩: [1] 5
패션   | null
디지털 | null
식품   | null
리빙   | null
도서   | null

leftJoin(product) — 인자가 하나입니다. 연관 경로가 아니라 엔티티 그 자체입니다. 그래서 조인 조건이 없고, on(...) 으로 직접 줘야 합니다.

생성 SQL 의 on 절을 보십시오. on p1_0.name = c1_0.name 뿐입니다. 연관 기반 조인이었다면 on c1_0.category_id = p1_0.category_id and ... 처럼 매핑에서 온 조건이 먼저 붙었을 텐데, 여기엔 그게 없습니다.

실무에서 쓰이는 예는 이런 것들입니다.

// 코드 테이블처럼 FK 없이 값으로만 이어진 경우
.leftJoin(codeItem).on(codeItem.groupCode.eq("ORDER_STATUS")
                 .and(codeItem.code.eq(order.status.stringValue())))

💡 실무 팁 — 연관이 없다고 세타 조인으로 도망가지 마십시오 .from(category, product).where(product.name.eq(category.name)) 로도 같은 결과를 얻습니다. 하지만 세타 조인은 외부 조인을 못 합니다. 위 예제의 leftJoin 을 세타 조인으로는 표현할 수 없습니다. join(엔티티).on(조건) 은 세타 조인의 상위 호환입니다. 이쪽을 쓰십시오.


6-7. fetch join — N+1 을 없애는 JPA 고유 기능

여기서부터는 SQL 에 없는 이야기입니다.

주문 10건을 읽고 각 주문의 고객 이름을 출력해 봅시다. @ManyToOneLAZY 이므로 order.getCustomer() 는 프록시입니다.

List<Order> orders = queryFactory
        .selectFrom(order)
        .limit(10)
        .fetch();

for (Order o : orders) {
    System.out.println(o.getId() + " / " + o.getCustomer().getName());   // ← 여기서 프록시 초기화
}

결과 — 쿼리가 11개 나갑니다

-- 1번째: 주문 목록
select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
limit ?

-- 2번째: 1번 주문의 고객
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 where c1_0.customer_id = ?
바인딩: [1] 18

-- 3번째: 2번 주문의 고객
select c1_0.customer_id, ... from customers c1_0 where c1_0.customer_id = ?
바인딩: [1] 5

-- ... 11번째까지 반복
총 쿼리 수 = 11 (1 + 10)

이것이 N+1 입니다. 결과는 완벽하게 맞습니다. 느릴 뿐입니다. 그리고 개발 환경에서 10건일 때는 아무도 눈치채지 못합니다.

fetchJoin() 을 붙이면 이렇게 됩니다.

List<Order> orders = queryFactory
        .selectFrom(order)
        .join(order.customer, customer).fetchJoin()     // ← .fetchJoin()
        .limit(10)
        .fetch();

for (Order o : orders) {
    System.out.println(o.getId() + " / " + o.getCustomer().getName());
}

결과 — 쿼리가 1개

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount,
       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 orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
limit ?
바인딩: [1] 10
총 쿼리 수 = 1
쿼리 수select 컬럼
일반 join11 (1 + 10)주문 컬럼만
join + fetchJoin()1주문 + 고객 컬럼 전부

joinfetchJoin() 의 차이를 정확히 이해하십시오.

  • 일반 join: 조인은 하지만 select 에는 왼쪽 엔티티만 담습니다. 고객 정보는 나중에 프록시가 초기화될 때 따로 읽습니다.
  • fetchJoin(): 조인한 엔티티의 컬럼까지 한 번에 select 해서 영속성 컨텍스트에 채웁니다. 프록시가 아니라 실제 객체가 들어 있으니 추가 쿼리가 없습니다.

fetchJoin() 의 중요한 제약이 하나 있습니다. fetch join 대상에는 on 을 걸 수 없습니다.

.leftJoin(order.customer, customer).on(customer.grade.eq(Grade.VIP)).fetchJoin()

이건 논리적으로 위험합니다. 연관 컬렉션의 일부만 로딩해서 영속성 컨텍스트에 넣으면, 같은 트랜잭션 안에서 그 컬렉션을 다시 읽는 코드가 "잘려 있는 컬렉션" 을 보게 됩니다. JPA 표준은 이를 금지하고, Hibernate 는 버전에 따라 예외를 내거나 경고합니다. fetch join 은 "연관 전체를 통째로 가져오는 것" 이라고 이해하십시오.

💡 실무 팁 — DTO 프로젝션이면 fetch join 이 필요 없습니다 Step 05 에서 배운 대로 DTO 로 받으면 애초에 프록시가 없습니다. N+1 도 없습니다. 조회 전용이면 DTO 프로젝션이 먼저이고, fetch join 은 "엔티티가 꼭 필요할 때" 의 도구입니다. 순서를 거꾸로 잡는 경우가 많습니다.


6-8. ⚠️ 컬렉션 fetch join + 페이징 = 메모리에서 페이징

이 스텝, 어쩌면 이 코스 전체에서 가장 중요한 함정입니다.

주문 목록 페이지를 만든다고 합시다. 주문마다 상품 목록도 같이 보여줘야 하니 orderItems 를 fetch join 하고, 페이징을 위해 offset / limit 을 붙입니다. 아주 자연스러운 코드입니다.

List<Order> orders = queryFactory
        .selectFrom(order)
        .join(order.orderItems, orderItem).fetchJoin()    // ← 컬렉션 fetch join
        .offset(0)
        .limit(10)
        .fetch();

System.out.println("결과 = " + orders.size() + "건");

컴파일: 성공. 실행: 성공. 결과: 10건. 화면도 정상입니다.

그런데 로그를 보십시오.

결과

WARN 12345 --- [    Test worker] org.hibernate.orm.query :
    HHH90003004: firstResult/maxResults specified with collection fetch;
    applying in memory
select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount,
       oi1_0.order_id, oi1_0.order_item_id, oi1_0.product_id,
       oi1_0.quantity, oi1_0.unit_price
from orders o1_0
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
결과 = 10건

생성 SQL 에 limit 이 없습니다.

offset(0).limit(10) 을 분명히 썼는데 SQL 에는 limit 절이 통째로 사라졌습니다. Hibernate 는 order_items 1,200행 × 조인된 주문 정보를 전부 읽어서 힙에 올린 뒤, 자바 메모리에서 중복을 제거하고 앞의 10건을 잘라 돌려줬습니다.

왜 SQL 로 페이징할 수 없나

1:N 조인을 하면 행이 뻥튀기됩니다(6-4 절). 주문 600건에 상품 1,200건이니 조인 결과는 1,200행입니다. 여기에 limit 10 을 걸면 "주문 10건" 이 아니라 "조인 행 10개" 가 잘립니다. 주문 1번에 상품이 2개, 2번에 3개... 라면 10행은 주문 4~5건 정도밖에 안 됩니다. 게다가 마지막 주문은 상품이 잘린 채로 들어옵니다 — 불완전한 엔티티입니다.

Hibernate 는 그 결과를 돌려줄 수 없으니, 페이징을 포기하고 전건을 읽어 메모리에서 자릅니다. 결과의 정확성을 지키기 위해 성능을 버린 것입니다.

경고 코드는 Hibernate 버전에 따라 다릅니다

Hibernate로거코드메시지
6.x (이 코스)org.hibernate.orm.queryHHH90003004firstResult/maxResults specified with collection fetch; applying in memory
5.xorg.hibernate.hql.internal.ast.QueryTranslatorImplHHH000104firstResult/maxResults specified with collection fetch; applying in memory!

메시지는 거의 같지만 코드와 로거 이름이 다릅니다. 5.x 시절 자료를 보고 HHH000104 로 로그를 검색하면 Hibernate 6 환경에서는 아무것도 안 나옵니다. 운영 로그 알람을 걸 때는 양쪽 코드를 모두 등록하거나, 메시지 본문으로 잡으십시오.

⚠️ 함정 — 이 경고를 못 보고 지나가기가 너무 쉽습니다

  • WARN 레벨입니다. 애플리케이션은 정상 동작합니다.
  • 결과는 정확합니다. 10건 달라면 10건 줍니다. 테스트도 통과합니다.
  • 개발 DB 에 주문이 600건이면 체감 차이가 없습니다.
  • 운영에 나가서 주문이 60만 건이 되면 그때 OutOfMemoryError 가 납니다.

OOM 시나리오: 주문 60만 건 × 상품 평균 2건 = 조인 행 120만 개. 엔티티 하나당 대략 수백 바이트라고만 잡아도 수백 MB 가 한 요청에 힙으로 들어옵니다. 동시 요청이 몇 개만 겹치면 힙이 터집니다. 첫 페이지를 보든 마지막 페이지를 보든 매번 전건을 읽습니다.

처방 ① — 컬렉션 fetch join 을 빼고 배치 페치

가장 간단합니다. 컬렉션은 fetch join 하지 말고 @BatchSize 또는 전역 설정으로 묶어서 읽습니다.

List<Order> orders = queryFactory
        .selectFrom(order)
        .join(order.customer, customer).fetchJoin()   // ToOne 만 fetch join
        .offset(0)
        .limit(10)
        .fetch();

for (Order o : orders) {
    System.out.println(o.getOrderItems().size());     // 여기서 컬렉션 초기화
}
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

결과 — 쿼리 2개

-- 1번째: 주문 + 고객. limit 이 정상적으로 들어갑니다
select o1_0.order_id, o1_0.customer_id, ..., c1_0.customer_id, c1_0.name, ...
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
limit ?

-- 2번째: 10건의 주문에 딸린 상품을 in 절로 한 번에
select oi1_0.order_id, oi1_0.order_item_id, oi1_0.product_id,
       oi1_0.quantity, oi1_0.unit_price
from order_items oi1_0
where oi1_0.order_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
바인딩: [1] 10 / [1..10] 1,2,3,4,5,6,7,8,9,10
총 쿼리 수 = 2

limit 이 SQL 에 들어갔고, 컬렉션은 in 절로 한 번에 읽었습니다. default_batch_fetch_size 가 없었다면 이 두 번째 쿼리가 10개로 쪼개졌을 겁니다(N+1). 특정 연관에만 적용하려면 엔티티에 @BatchSize(size = 100) 을 붙입니다.

@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<OrderItem> orderItems = new ArrayList<>();

처방 ② — ToOne 만 fetch join 하고 페이징

@ManyToOne / @OneToOne 은 fetch join 해도 행이 늘지 않습니다. 주문 1건에 고객은 1명뿐이니 조인해도 여전히 600행입니다. 행 수가 그대로면 SQL 로 페이징해도 안전합니다.

List<Order> orders = queryFactory
        .selectFrom(order)
        .join(order.customer, customer).fetchJoin()   // ToOne — 안전
        .orderBy(order.id.asc())
        .offset(20)
        .limit(10)
        .fetch();

결과 — 경고 없음, limit 정상

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount,
       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 orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
order by o1_0.order_id asc
offset ? rows fetch first ? rows only
바인딩: [1] 20, [2] 10
조회 10건 — WARN 없음

ToOne 과 컬렉션의 구분은 이 절에서 가장 중요한 표입니다.

연관 종류fetch join 시 행 수페이징
@ManyToOneorder.customer그대로 (1:1 대응)안전
@OneToOneorder.receipt그대로안전
@OneToManyorder.orderItems늘어남 (fan-out)⚠️ 메모리 페이징
@ManyToMany늘어남⚠️ 메모리 페이징

"fetch join 은 페이징과 못 쓴다" 는 말은 정확하지 않습니다. "컬렉션 fetch join 은 페이징과 못 쓴다" 가 정확합니다. ToOne 은 얼마든지 같이 씁니다.

처방 ③ — ID 를 먼저 페이징하고 in 으로 컬렉션 로딩 (2단계 조회)

정렬이나 조건이 복잡해서 배치 페치로도 부족할 때 쓰는 방법입니다.

// 1단계 — ID 만 페이징으로. fan-out 이 없으니 limit 이 정확히 동작합니다.
List<Long> ids = queryFactory
        .select(order.id)
        .from(order)
        .where(order.status.eq(OrderStatus.DELIVERED))
        .orderBy(order.orderDate.desc())
        .offset(0)
        .limit(10)
        .fetch();

결과

select o1_0.order_id
from orders o1_0
where o1_0.status = ?
order by o1_0.order_date desc
offset ? rows fetch first ? rows only
바인딩: [1] DELIVERED, [2] 0, [3] 10
조회 10건 — [587, 571, 559, 543, 531, 519, 503, 487, 475, 463]
// 2단계 — 그 ID 들로 컬렉션까지 fetch join. limit 이 없으니 경고가 안 납니다.
List<Order> orders = queryFactory
        .selectFrom(order)
        .join(order.orderItems, orderItem).fetchJoin()
        .join(order.customer, customer).fetchJoin()
        .where(order.id.in(ids))
        .orderBy(order.orderDate.desc())
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount,
       oi1_0.order_id, oi1_0.order_item_id, oi1_0.product_id,
       oi1_0.quantity, oi1_0.unit_price,
       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 orders o1_0
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
where o1_0.order_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
order by o1_0.order_date desc
조회 10건 — WARN 없음. 읽은 조인 행 = 21행 (1,200행이 아님)

핵심은 1단계에서 fan-out 이 없다는 것입니다. order.id 만 select 하면 조인이 없으니 limit 10 이 정확히 주문 10건을 자릅니다. 2단계는 in 으로 딱 그 10건만 읽으므로 limit 이 필요 없고, 따라서 경고도 없습니다.

세 처방 비교

쿼리 수읽는 행복잡도적합한 상황
① 배치 페치1 + 연관 수필요한 만큼낮음대부분의 경우. 기본값
② ToOne 만 fetch join1 (+지연 로딩)페이지 크기낮음컬렉션이 화면에 필요 없을 때
③ 2단계 조회2페이지 크기중간정렬·조건이 복잡할 때

💡 실무 팁 — default_batch_fetch_size 를 기본으로 켜 두십시오 100~1000 사이가 일반적입니다. 이 한 줄이 대부분의 N+1 을 조용히 해결합니다. 그리고 팀 규칙으로 이렇게 정하십시오: "fetchJoin() 옆에 limit 이 보이면 리뷰에서 멈춘다." 컬렉션인지 ToOne 인지 확인하기 전에는 통과시키지 않는 겁니다. 이 함정은 코드 리뷰가 아니면 운영에서 잡히기 때문입니다.


6-9. ⚠️ 컬렉션 fetch join 은 하나만 — MultipleBagFetchException

주문에는 orderItems 도 있고 payments 도 있습니다. 둘 다 fetch join 해 봅시다.

List<Order> orders = queryFactory
        .selectFrom(order)
        .join(order.orderItems, orderItem).fetchJoin()
        .join(order.payments, payment).fetchJoin()      // ← 두 번째 컬렉션
        .fetch();

결과 — 애플리케이션 기동조차 못 하거나 쿼리 실행 시점에 터집니다

org.hibernate.loader.MultipleBagFetchException:
    cannot simultaneously fetch multiple bags:
    [com.example.shop.entity.Order.orderItems, com.example.shop.entity.Order.payments]

	at org.hibernate.query.sqm.internal.SqmUtil.verifyMultipleBagFetches(...)
	at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.<init>(...)
	...

왜 금지인가 — 카테시안 곱

주문 1건에 상품 3개, 결제 1건이 있다고 합시다. orderItemspayments 를 동시에 조인하면 3 × 1 = 3행이 됩니다. 결제가 2건이면 3 × 2 = 6행입니다.

Hibernate 는 이 6행에서 orderItems 를 복원할 때 각 상품이 2번씩 나타나는 것을 봅니다. List순서와 중복을 보존하는 컬렉션이라, Hibernate 는 이 중복이 "진짜 데이터인지 조인 때문인지" 판단할 수 없습니다. orderItems.size() 가 3인지 6인지 결정할 근거가 없는 것입니다. 그래서 판단을 포기하고 예외를 던집니다.

"bag" 은 Hibernate 용어로 순서가 없고 중복을 허용하는 List 매핑을 뜻합니다. @OrderColumn 이 없는 List<T> 가 bag 입니다.

처방 ① — ListSet 으로

@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private Set<OrderItem> orderItems = new LinkedHashSet<>();   // List → Set

@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private Set<Payment> payments = new LinkedHashSet<>();

Set 은 중복을 허용하지 않으므로 Hibernate 가 안심하고 중복을 제거합니다. 예외가 사라집니다.

하지만 문제가 사라진 것은 아닙니다. DB 에서 읽어 오는 행 수는 여전히 3 × 2 = 6행입니다. 컬렉션이 3개, 4개로 늘면 곱집합이 기하급수적으로 커집니다. Set 은 "예외를 없애는" 것이지 "곱집합을 없애는" 것이 아닙니다. 그리고 LinkedHashSet 이 아니면 순서가 보장되지 않아 화면 정렬이 깨집니다.

처방 ② — 하나만 fetch join 하고 나머지는 배치 페치 (권장)

List<Order> orders = queryFactory
        .selectFrom(order)
        .join(order.orderItems, orderItem).fetchJoin()   // 하나만
        .where(order.id.loe(10L))
        .fetch();

// payments 는 지연 로딩 + default_batch_fetch_size 로 in 절 묶음 조회
orders.forEach(o -> System.out.println(o.getPayments().size()));

결과 — 쿼리 2개

select o1_0.order_id, ..., oi1_0.order_item_id, ...
from orders o1_0
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
where o1_0.order_id <= ?

select p1_0.order_id, p1_0.payment_id, p1_0.amount,
       p1_0.method, p1_0.paid_at, p1_0.status
from payments p1_0
where p1_0.order_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
총 쿼리 수 = 2

행 수가 곱해지지 않습니다. 컬렉션이 여러 개면 이쪽이 정답입니다.

💡 정리하면 규칙은 하나입니다 — 컬렉션 fetch join 은 최대 하나. 그리고 그 하나에도 페이징을 붙이지 마십시오(6-8 절). 두 규칙을 합치면 이렇게 됩니다: "컬렉션 fetch join 은 하나만, 페이징 없이." 이 조건을 못 맞추겠으면 배치 페치를 쓰십시오.


6-10. distinct() 와 fetch join — Hibernate 6 에서 달라진 것

Hibernate 5 시절에는 컬렉션 fetch join 뒤에 distinct() 를 붙이는 것이 필수였습니다.

List<Order> orders = queryFactory
        .selectFrom(order).distinct()
        .join(order.orderItems, orderItem).fetchJoin()
        .where(order.id.loe(3L))
        .fetch();

주문 3건에 상품이 각각 2, 3, 1개면 조인 결과는 6행입니다. distinct() 없이 fetch() 하면 같은 Order 객체가 6번 담긴 리스트가 나왔습니다 (영속성 컨텍스트 덕분에 객체 자체는 동일했지만, 리스트에는 6개가 들어 있었습니다).

Hibernate 6 부터 엔티티 쿼리는 기본적으로 중복을 제거합니다.

List<Order> withoutDistinct = queryFactory
        .selectFrom(order)
        .join(order.orderItems, orderItem).fetchJoin()
        .where(order.id.loe(3L))
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount,
       oi1_0.order_id, oi1_0.order_item_id, oi1_0.product_id,
       oi1_0.quantity, oi1_0.unit_price
from orders o1_0
join order_items oi1_0 on o1_0.order_id = oi1_0.order_id
where o1_0.order_id <= ?
바인딩: [1] 3
읽은 조인 행 = 6, 결과 리스트 크기 = 3   ← distinct() 없이도 3

distinct() 를 붙여도 결과는 같습니다. 붙일 이유가 없어졌습니다.

Hibernate 5Hibernate 6 (이 코스)
컬렉션 fetch join 후 distinct 없이중복된 엔티티가 리스트에 들어옴자동 제거
distinct() 의 역할필수 (자바 쪽 중복 제거)사실상 불필요
SQL 에 distinct 전달hibernate.query.passDistinctThrough=false 로 제어해당 설정 제거됨

hibernate.query.passDistinctThrough 는 Hibernate 5 에서 "자바에서만 중복 제거하고 SQL 에는 distinct 를 보내지 마라" 는 뜻으로 쓰던 설정입니다. 엔티티 fetch join 에서 SQL distinct 는 성능만 깎고 의미가 없었기 때문입니다. Hibernate 6 에서는 이 설정이 제거됐습니다. 설정해도 무시됩니다.

⚠️ 주의 — distinct() 를 아무 데나 붙이지 마십시오 엔티티 조회에서는 필요 없어졌지만, DTO 프로젝션이나 스칼라 조회에서는 여전히 의미가 있습니다. select(customer.city).distinct() 는 SQL 에 distinct 를 넣고 실제로 중복을 제거합니다. "Hibernate 6 이니까 distinct 는 필요 없다" 로 일반화하면 안 됩니다. 정확히는 "엔티티 쿼리에서 fetch join 때문에 붙이던 distinct 가 필요 없어졌다" 입니다.


6-11. 셀프 조인 — 같은 엔티티를 두 번

Employeemanager 로 자기 자신을 참조합니다. 사원과 관리자를 나란히 보려면 같은 엔티티를 다른 별칭으로 두 번 등장시켜야 합니다.

기본 인스턴스 employee 하나로는 안 됩니다. Step 02 에서 본 대로 new QEmployee("별칭") 으로 별칭을 직접 만듭니다.

QEmployee manager = new QEmployee("manager");     // ← 별칭 생성

List<Tuple> result = queryFactory
        .select(employee.id, employee.name, employee.position,
                manager.name, manager.position)
        .from(employee)
        .leftJoin(employee.manager, manager)
        .orderBy(employee.id.asc())
        .limit(10)
        .fetch();

결과

select e1_0.employee_id, e1_0.name, e1_0.position, m1_0.name, m1_0.position
from employees e1_0
left join employees m1_0 on m1_0.employee_id = e1_0.manager_id
order by e1_0.employee_id asc
limit ?
바인딩: [1] 10
 1 | 정한별 | CEO    | null   | null
 2 | 김코드 | 본부장 | 정한별 | CEO
 3 | 이세일 | 본부장 | 정한별 | CEO
 4 | 오지원 | 본부장 | 정한별 | CEO
 5 | 박서버 | 팀장   | 김코드 | 본부장
 6 | 최화면 | 팀장   | 김코드 | 본부장
 7 | 강매출 | 팀장   | 이세일 | 본부장
 8 | 윤사람 | 팀장   | 오지원 | 본부장
 9 | 한백엔 | 시니어 | 박서버 | 팀장
10 | 임쿼리 | 주니어 | 박서버 | 팀장

📌 MySQL8 코스 Step 077-6 절과 같은 결과입니다. 거기서는 FROM employees e LEFT JOIN employees m ON m.employee_id = e.manager_id 로 썼습니다.

별칭 "manager" 를 Hibernate 가 m1_0 으로 변환했습니다. leftJoin 이라서 관리자가 없는 CEO(정한별)도 NULL 로 남았습니다. join 이었다면 CEO 가 결과에서 사라집니다.

⚠️ 함정 — employee 를 두 번 쓰면 조인이 안 됩니다

.from(employee).leftJoin(employee.manager, employee)   // ← 같은 별칭

별칭이 같으니 Hibernate 는 e1_0 하나만 만듭니다. 컴파일은 됩니다. 실행하면 JPQL 파싱 단계에서 중복 별칭 에러가 나거나, 조건에 따라 엉뚱한 자기 자신 비교가 됩니다. 셀프 조인에는 반드시 new QEmployee("...") 로 별도 별칭을 만드십시오.

"같은 부서에서 나보다 급여가 높은 사람 수" 처럼 자기 그룹 안에서 비교할 때도 셀프 조인을 씁니다.

QEmployee higher = new QEmployee("higher");

List<Tuple> result = queryFactory
        .select(employee.name, employee.dept, employee.salary, higher.count())
        .from(employee)
        .leftJoin(higher)
        .on(higher.dept.eq(employee.dept)
                .and(higher.salary.gt(employee.salary)))     // 연관 없는 on 조인
        .groupBy(employee.id, employee.name, employee.dept, employee.salary)
        .orderBy(employee.dept.asc(), employee.salary.desc())
        .limit(6)
        .fetch();

결과

select e1_0.name, e1_0.dept, e1_0.salary, count(h1_0.employee_id)
from employees e1_0
left join employees h1_0
       on h1_0.dept = e1_0.dept
      and h1_0.salary > e1_0.salary
group by e1_0.employee_id, e1_0.name, e1_0.dept, e1_0.salary
order by e1_0.dept asc, e1_0.salary desc
limit ?
바인딩: [1] 6
김코드 | 개발본부 | 9500000.00 | 0
박서버 | 개발본부 | 7200000.00 | 1
최화면 | 개발본부 | 7000000.00 | 2
한백엔 | 개발본부 | 5800000.00 | 3
조리액 | 개발본부 | 5600000.00 | 4
임쿼리 | 개발본부 | 4200000.00 | 5

"나보다 높은 사람 수 + 1" 이 부서 내 급여 순위입니다. 여기서 leftJoin 이 필수인 이유는 1등이 매칭 0건이기 때문입니다 — join 이면 부서 1등이 결과에서 사라집니다. 그리고 higher.count() 를 쓴 것에 주목하십시오. count() (즉 count(*)) 를 썼다면 NULL 확장 행도 세어서 1등이 1 로 나옵니다. MySQL8 코스 7-3 절의 "COUNT(*) 함정" 과 완전히 같은 이야기입니다.


6-12. 안티 조인 — 짝이 없는 것 찾기

leftJoin 후 오른쪽 PK 가 NULL 인 행만 남기면 "짝이 없는 행" 이 나옵니다. MySQL8 코스 Step 07 7-5 절의 LEFT JOIN ... IS NULL 입니다.

SQL (MySQL8 코스 7-5)

SELECT c.customer_id, c.name, c.grade
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;

QueryDSL

List<Tuple> result = queryFactory
        .select(customer.id, customer.name, customer.grade)
        .from(customer)
        .leftJoin(customer.orders, order)
        .where(order.id.isNull())               // ← NULL 확장을 노린다
        .orderBy(customer.id.asc())
        .fetch();

결과

select c1_0.customer_id, c1_0.name, c1_0.grade
from customers c1_0
left join orders o1_0 on c1_0.customer_id = o1_0.customer_id
where o1_0.order_id is null
order by c1_0.customer_id asc
조회 0건

0건입니다. 시드 데이터가 고객 30명 전원에게 주문 20건씩 배정했기 때문입니다. "아무것도 안 나오는 것" 이 정답인 쿼리입니다. 데이터 검증에서 안티 조인이 쓰이는 전형입니다.

후기로 바꾸면 결과가 나옵니다.

List<Tuple> result = queryFactory
        .select(customer.id, customer.name, customer.grade)
        .from(customer)
        .leftJoin(review).on(review.customer.eq(customer))
        .where(review.id.isNull())
        .orderBy(customer.id.asc())
        .limit(8)
        .fetch();

결과

select c1_0.customer_id, c1_0.name, c1_0.grade
from customers c1_0
left join reviews r1_0 on r1_0.customer_id = c1_0.customer_id
where r1_0.review_id is null
order by c1_0.customer_id asc
limit ?
바인딩: [1] 8
 2 | 이지은 | GOLD
 3 | 박철수 | SILVER
 4 | 최영희 | BRONZE
 5 | 정  훈 | GOLD
 6 | 강소라 | VIP
 7 | 윤대현 | BRONZE
 8 | 임수진 | SILVER
 9 | 한지호 | BRONZE
... (총 26건)

30명 중 26명이 후기를 한 번도 안 썼습니다 (작성자는 4명).

⚠️ 함정 — isNull() 대상은 반드시 NOT NULL 컬럼(대개 PK)이어야 합니다 where(review.title.isNull()) 로 쓰면 완전히 다른 결과가 나옵니다. title 은 NULL 을 허용하므로, "후기가 없는 고객" 뿐 아니라 "후기는 썼는데 제목이 비어 있는 고객" 까지 딸려옵니다. 안티 조인의 isNull() 은 언제나 PK 에 겁니다.

6-5 절에서 "LEFT JOIN 의 오른쪽 조건을 where 에 두면 안 된다" 고 했는데 여기서는 where(review.id.isNull()) 을 쓰고 있습니다. 모순이 아닙니다. 안티 조인은 NULL 확장된 행만 골라내는 것이 목적이므로, 조인 후에 적용되는 where 가 정확히 필요한 도구입니다.

같은 것을 exists 로도 쓸 수 있습니다. Step 07 에서 다룹니다.

.where(JPAExpressions.selectOne().from(review)
        .where(review.customer.eq(customer)).notExists())

6-13. 조인 종류별 대조표

하려는 것SQLQueryDSL
INNER JOINJOIN orders o ON ....join(order.customer, customer)
LEFT JOINLEFT JOIN orders o ON ....leftJoin(order.customer, customer)
RIGHT JOINRIGHT JOIN ....rightJoin(...) (거의 안 씀)
세타 조인FROM a, b WHERE ....from(a, b).where(...)
연관 없는 조인LEFT JOIN b ON b.x = a.y.leftJoin(b).on(b.x.eq(a.y))
조인 조건 추가ON ... AND cond.on(cond)
조인 후 필터WHERE cond.where(cond)
셀프 조인JOIN employees m ON ...new QEmployee("manager") + .leftJoin(...)
안티 조인LEFT JOIN ... WHERE b.pk IS NULL.leftJoin(...).where(b.id.isNull())
fan-out 방어COUNT(DISTINCT ...).countDistinct()
fetch join— (SQL 에 없음).join(...).fetchJoin()
배치 페치— (SQL 에 없음)@BatchSize / default_batch_fetch_size

마지막 두 줄이 이 스텝의 절반입니다. fetchJoin() 은 SQL 문법이 아니라 "조인한 엔티티까지 영속성 컨텍스트에 채운다" 는 JPA 의 지시입니다. SQL 만 보면 그냥 join 인데, select 절에 오른쪽 컬럼이 전부 들어가 있는 것으로 구분합니다.


정리

개념핵심
연관 기반 조인join(order.customer, customer). 경로 + 별칭. on 불필요
세타 조인from(a, b).where(...). 외부 조인 불가. 조건 빠뜨리면 곱집합
join = innerJoin같은 메서드. SQL 의 INNER 생략과 동일
leftJoin왼쪽 전부 보존 + NULL 확장
다중 조인별칭이 c1_0, c2_0 처럼 번호로 구분됨
⚠️ fan-out1:N 조인은 행의 단위를 바꾼다. sum 이 중복 합산됨 (20건 → 41건, 2배)
fan-out 방어countDistinct (count 만), 쿼리 분리, 행 단위에 맞는 집계식
⚠️ on vs whereLEFT JOIN 필터를 where 에 두면 INNER JOIN 으로 퇴화 (17건 → 6건)
연관 없는 on 조인leftJoin(product).on(...). 인자 1개 + on
fetchJoin()조인 대상까지 영속성 컨텍스트에 채움. N+1 11개 → 1개
⚠️ 컬렉션 fetch join + 페이징HHH90003004 경고 + limit 없는 SQL + 전건 메모리 로딩
Hibernate 버전 차6.x HHH90003004 / 5.x HHH000104
페이징 안전 여부ToOne fetch join = 안전, 컬렉션 fetch join = 위험
처방 3가지① 배치 페치 ② ToOne 만 fetch join ③ ID 페이징 후 in
⚠️ 컬렉션 fetch join 2개MultipleBagFetchException. 원인은 카테시안 곱
처방ListSet (예외만 없앰) 또는 하나만 fetch join + 배치 페치
distinct()Hibernate 6 은 엔티티 쿼리 중복을 자동 제거. passDistinctThrough제거됨
셀프 조인new QEmployee("manager") 로 별칭 생성. 기본 인스턴스 재사용 금지
안티 조인leftJoin(...).where(오른쪽PK.isNull()). PK 에만 걸 것

이 스텝의 함정 4가지

  1. fan-out — 조인 하나 추가로 집계가 2배가 된다. 예외 없음.
  2. on vs where — 둘 다 BooleanExpression 을 받아서 컴파일러가 못 잡는다. 17건이 6건이 된다.
  3. 컬렉션 fetch join + 페이징WARN 한 줄만 찍고 전건을 힙에 올린다. 결과는 정확하다.
  4. 컬렉션 fetch join 2개MultipleBagFetchException. 이건 그나마 터져 주니 다행이다.

3번만 기억한다면 이 스텝은 성공입니다.


연습문제

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

  1. orderscustomers 를 조인해서 서울에 사는 고객의 주문 중 금액 상위 5건을 조회하세요. (주문번호, 고객명, 금액) — MySQL8 코스 Step 07 연습문제 1번과 같은 문제입니다. 생성 SQL 이 join customers c1_0 on ... 인지 확인하십시오.
  2. "고객별 주문 수와 주문 총액" 을 구하되, orderItems 를 조인한 버전과 안 한 버전을 둘 다 작성해 숫자가 어떻게 달라지는지 확인하고 이유를 주석으로 쓰세요. 그다음 올바른 값이 나오는 버전을 쓰세요.
  3. (on vs where) "모든 고객 + 그 고객의 배송완료(DELIVERED) 주문" 을 조회하는 쿼리를 조건을 on 에 둔 버전과 where 에 둔 버전으로 각각 작성하고, 건수 차이를 확인하세요. on 버전이 258건, where 버전이 240건이 나와야 합니다 (MySQL8 코스 7-4 절과 동일).
  4. 주문 20건을 조회하면서 고객 정보를 fetch join 하세요. 그다음 fetch join 없이 같은 코드를 돌려 쿼리 개수를 세고 비교하세요. (21개 vs 1개)
  5. 아래 코드는 실행되고 결과도 맞지만 로그에 경고가 찍히고 SQL 에 limit 이 없습니다. 원인을 설명하고 세 가지 방법으로 각각 고치세요.
    queryFactory.selectFrom(order)
        .join(order.orderItems, orderItem).fetchJoin()
        .offset(0).limit(20)
        .fetch();
  6. Employee 셀프 조인으로 부하 직원이 한 명도 없는 사원(말단) 을 안티 조인으로 찾으세요. 힌트: 6-11 절의 조인 방향과 반대입니다. 자신의 employee_id 가 누군가의 manager_id 로 쓰이지 않는 사원입니다. (10명이 나옵니다)
  7. orderspaymentsleftJoin 해서 결제가 아예 없는 주문을 안티 조인으로 찾고, 그 개수가 PENDING 주문 수와 일치하는지 검산하세요. (양쪽 다 60건이어야 합니다)

다음 단계

조인은 "여러 테이블의 행을 옆으로 붙이는" 방법이었습니다. 그런데 6-4 절의 fan-out 방어에서 이미 "집계를 별도 쿼리로 분리" 하는 이야기가 나왔고, 6-12 절 끝에서는 안티 조인을 notExists 로도 쓸 수 있다고 했습니다. 그 "별도 쿼리" 를 SQL 안에서 쓰는 방법이 서브쿼리입니다. 다음 스텝에서는 JPAExpressions 로 스칼라·in·exists 서브쿼리를 쓰고, JPA 가 FROM 절 서브쿼리를 지원하지 않는다는 제약과 그 우회법을 다룹니다.

Step 07 — 서브쿼리


실습 파일

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

이 스텝은 쿼리 개수를 세는 예제가 많습니다. Hibernate 의 Statistics 를 켜야 정확히 셀 수 있습니다.

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        default_batch_fetch_size: 100

세 파일 모두 queryCount() 헬퍼를 갖고 있어, 이 설정만 켜면 그대로 동작합니다.

Practice.java

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

  • [6-4]fanOutCorrect()fanOutWrong()fanOutCountDistinct() 순으로 반드시 이 순서대로 실행하십시오. 20/24,300,000 → 41/49,860,000 → 20/49,860,000 으로 숫자가 변하는 것을 눈으로 좇는 것이 목적입니다. 세 번째에서 count 는 고쳐졌는데 sum 은 그대로인 것이 이 절의 핵심입니다.
  • [6-5]onVersion()whereVersion()BooleanExpression 변수를 하나만 선언해 .on(cond).where(cond) 에 각각 넘깁니다. 코드 차이가 메서드 이름 하나뿐인데 결과가 17건과 6건으로 갈리는 것을 보여주기 위한 구성입니다.
  • [6-8] 이 이 파일의 심장입니다. collectionFetchJoinWithPaging() 을 실행하면 콘솔에 HHH90003004 경고가 찍히고, 바로 아래 SQL 에 limit없습니다. 경고가 안 보이면 logging.level.org.hibernate.orm.query: warn 이 꺼져 있는지 확인하십시오. 이어지는 fix1BatchFetch(), fix2ToOneOnly(), fix3TwoStep() 이 세 처방입니다.
  • [6-9]multipleBagFetch()일부러 예외를 내는 메서드입니다. try/catch 로 감싸 예외 메시지를 출력하도록 해 뒀으니 테스트는 통과합니다. MultipleBagFetchException 의 실제 메시지 형태를 눈으로 확인하십시오.
  • [6-10]distinctNotNeeded() 는 "읽은 조인 행 6, 결과 리스트 3" 을 출력합니다. Hibernate 5 에서 같은 코드를 돌리면 결과 리스트가 6이 됩니다.
package com.example.shop.step06;

import com.example.shop.entity.Grade;
import com.example.shop.entity.Order;
import com.example.shop.entity.OrderStatus;
import com.example.shop.entity.QEmployee;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.Session;
import org.hibernate.stat.Statistics;
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.QCategory.category;
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.example.shop.entity.QPayment.payment;
import static com.example.shop.entity.QProduct.product;
import static com.example.shop.entity.QReview.review;

/**
 * Step 06 — 조인 : 본문 예제 모음
 *
 * 실행 전 확인 (application.yml):
 *   logging.level.org.hibernate.SQL: debug
 *   logging.level.org.hibernate.orm.jdbc.bind: trace
 *   logging.level.org.hibernate.orm.query: warn      ← 6-8 절의 HHH90003004 경고를 보려면 필수
 *   spring.jpa.properties.hibernate.generate_statistics: true
 *   spring.jpa.properties.hibernate.default_batch_fetch_size: 100
 *
 * 이 파일의 목적은 결과 검증이 아니라 "어떤 SQL 이 나가는가" 를 보는 것입니다.
 * 특히 6-8 절은 SQL 에 limit 이 "없는 것" 을 확인하는 것이 전부입니다.
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    /** 지금까지 나간 쿼리 개수. 6-7, 6-8 절에서 씁니다. */
    private long queryCount() {
        Statistics stats = em.unwrap(Session.class)
                             .getSessionFactory()
                             .getStatistics();
        return stats.getPrepareStatementCount();
    }

    /** 영속성 컨텍스트를 비웁니다. 1차 캐시 때문에 쿼리가 안 나가는 것을 막습니다. */
    private void clearContext() {
        em.flush();
        em.clear();
    }

    // =================================================================
    // [6-1] 연관 기반 조인 vs 세타 조인
    // =================================================================

    @Test
    @DisplayName("[6-1] 연관 기반 조인 — join ... on ...")
    void associationJoin() {
        List<Tuple> result = queryFactory
                .select(order.id, customer.name)
                .from(order)
                .join(order.customer, customer)     // 연관 경로 + 별칭
                .limit(3)
                .fetch();

        result.forEach(t -> System.out.println(t.get(order.id) + " / " + t.get(customer.name)));

        // 생성 SQL:
        //   from orders o1_0
        //   join customers c1_0 on c1_0.customer_id = o1_0.customer_id
        //
        // 첫 인자 order.customer 는 "연관 경로" 입니다. @ManyToOne 매핑에 조인 조건이 이미 있으므로
        // on 을 쓸 필요가 없습니다.
        // 둘째 인자 customer 는 "별칭" 입니다. 이걸 줘야 select/where 에서 customer.name 을 쓸 수 있습니다.
    }

    @Test
    @DisplayName("[6-1] 세타 조인 — from a, b where ...")
    void thetaJoin() {
        List<Tuple> result = queryFactory
                .select(order.id, customer.name)
                .from(order, customer)                   // from 에 둘을 나열
                .where(order.customer.eq(customer))      // 조인 조건을 where 로
                .limit(3)
                .fetch();

        result.forEach(t -> System.out.println(t.get(order.id) + " / " + t.get(customer.name)));

        // 생성 SQL:
        //   from orders o1_0, customers c1_0
        //   where o1_0.customer_id = c1_0.customer_id
        //
        // 결과는 연관 기반 조인과 같지만 SQL 이 다릅니다.
        // MySQL8 코스 Step 07 의 7-7 절에서 "콤마 조인 대신 명시적 JOIN ON 을 쓰라" 고 경고한 형태입니다.
        //
        // ★ where 를 빠뜨리면 600 × 30 = 18,000 행이 나옵니다. 예외는 없습니다.
        //   연관 매핑이 있으면 언제나 연관 기반 조인을 쓰십시오.
    }

    // =================================================================
    // [6-2] join / innerJoin / leftJoin / rightJoin
    // =================================================================

    @Test
    @DisplayName("[6-2] INNER JOIN — MySQL8 코스 7-1 절과 같은 결과")
    void innerJoinBasic() {
        List<Tuple> result = queryFactory
                .select(order.id, order.orderDate, customer.name,
                        customer.grade, order.totalAmount)
                .from(order)
                .join(order.customer, customer)
                .orderBy(order.id.asc())
                .limit(5)
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(order.id) + " | " + t.get(order.orderDate) + " | "
              + t.get(customer.name) + " | " + t.get(customer.grade) + " | "
              + t.get(order.totalAmount)));

        // 기대 (MySQL8 코스 7-1 과 동일):
        //   1 | 2024-02-07T13:07 | 류하나 | GOLD   | 1836000.00
        //   2 | 2024-03-15T02:14 | 정  훈 | GOLD   | 6663900.00
        //   ...
    }

    @Test
    @DisplayName("[6-2] join 과 innerJoin 은 같은 메서드다")
    void joinEqualsInnerJoin() {
        long a = queryFactory.select(order.count())
                .from(order).join(order.customer, customer).fetchOne();
        long b = queryFactory.select(order.count())
                .from(order).innerJoin(order.customer, customer).fetchOne();

        System.out.println("join = " + a + ", innerJoin = " + b);   // 600 / 600
        // 생성 SQL 도 완전히 동일합니다. SQL 에서 INNER 를 생략할 수 있는 것과 같습니다.
    }

    @Test
    @DisplayName("[6-2] LEFT JOIN — 상품이 없는 대분류도 남는다 (MySQL8 7-3)")
    void leftJoinBasic() {
        List<Tuple> result = queryFactory
                .select(category.id, category.name, product.id, product.name)
                .from(category)
                .leftJoin(category.products, product)
                .where(category.parent.isNull())          // 대분류만
                .orderBy(category.id.asc())
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(category.id) + " | " + t.get(category.name) + " | "
              + t.get(product.id) + " | " + t.get(product.name)));

        // 기대: 5건. 상품 컬럼은 전부 null (NULL 확장).
        // leftJoin 을 join 으로 바꾸면 이 5줄이 통째로 사라집니다. 직접 해 보십시오.
    }

    // =================================================================
    // [6-3] 다중 조인 — 5개 테이블 (MySQL8 7-2)
    // =================================================================

    @Test
    @DisplayName("[6-3] 5개 테이블 조인 — 별칭이 c1_0, c2_0 으로 나뉜다")
    void multiJoin() {
        List<Tuple> result = queryFactory
                .select(order.id, customer.name, product.name,
                        category.name, orderItem.quantity, orderItem.unitPrice)
                .from(order)
                .join(order.customer, customer)
                .join(order.orderItems, orderItem)
                .join(orderItem.product, product)
                .join(product.category, category)
                .orderBy(order.id.asc(), product.id.asc())
                .limit(8)
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(order.id) + " | " + t.get(customer.name) + " | "
              + t.get(product.name) + " | " + t.get(category.name) + " | "
              + t.get(orderItem.quantity) + " | " + t.get(orderItem.unitPrice)));

        // customers 와 categories 가 둘 다 c 로 시작해서 Hibernate 가 c1_0, c2_0 으로 번호를 매깁니다.
        // 어느 쪽이 무엇인지는 on 절의 컬럼명으로 확인하십시오.
        //
        // ★ order_id = 1 이 두 줄인 것에 주목. 주문 1건에 상품 2개(1:N).
        //   다음 절의 사고가 여기서 시작됩니다.
    }

    // =================================================================
    // [6-4] ⚠️ fan-out — 반드시 아래 세 메서드를 이 순서로 실행하십시오
    // =================================================================

    @Test
    @DisplayName("[6-4] ① 정답 — orderItems 조인 없음")
    void fanOutCorrect() {
        Tuple t = queryFactory
                .select(order.count(), order.totalAmount.sum())
                .from(order)
                .join(order.customer, customer)
                .where(customer.id.eq(1L))
                .fetchOne();

        System.out.println("주문 수 = " + t.get(order.count())
                         + ", 총액 = " + t.get(order.totalAmount.sum()));
        // 기대: 주문 수 = 20, 총액 = 24300000.00
    }

    @Test
    @DisplayName("[6-4] ② ⚠️ orderItems 조인 한 줄 추가 — 집계식은 그대로인데 2배가 된다")
    void fanOutWrong() {
        Tuple t = queryFactory
                .select(order.count(), order.totalAmount.sum())
                .from(order)
                .join(order.customer, customer)
                .join(order.orderItems, orderItem)          // ← 이 한 줄만 추가
                .where(customer.id.eq(1L))
                .fetchOne();

        System.out.println("주문 수 = " + t.get(order.count())
                         + ", 총액 = " + t.get(order.totalAmount.sum()));
        // 기대: 주문 수 = 41, 총액 = 49860000.00
        //
        // 예외 없음. 경고 없음. 매출 리포트에 2배 숫자가 찍힐 뿐입니다.
        //
        // ★ 조인 추가는 "컬럼 추가" 가 아니라 "행의 단위를 바꾸는 일" 입니다.
        //   한 행의 의미가 "주문 하나" 에서 "주문 상품 한 줄" 로 바뀌었습니다.
        //   집계 전에 언제나 자문하십시오 — "지금 한 행은 무엇의 단위인가?"
    }

    @Test
    @DisplayName("[6-4] ③ 방어 1 — countDistinct 는 count 만 고친다")
    void fanOutCountDistinct() {
        Tuple t = queryFactory
                .select(order.countDistinct(), order.totalAmount.sum())
                .from(order)
                .join(order.customer, customer)
                .join(order.orderItems, orderItem)
                .where(customer.id.eq(1L))
                .fetchOne();

        System.out.println("주문 수 = " + t.get(order.countDistinct())
                         + ", 총액 = " + t.get(order.totalAmount.sum()));
        // 기대: 주문 수 = 20 (고쳐짐), 총액 = 49860000.00 (여전히 틀림)
        //
        // sum 에 distinct 를 붙이면 더 큰 사고가 납니다 —
        // 다른 주문인데 금액이 우연히 같으면 한 번만 더해집니다.
        // MySQL8 코스 7-11 절의 경고와 같은 이야기입니다.
    }

    @Test
    @DisplayName("[6-4] 방어 2 — 집계 쿼리를 분리한다")
    void fanOutSeparateQuery() {
        BigDecimal total = queryFactory
                .select(order.totalAmount.sum())
                .from(order)
                .where(order.customer.id.eq(1L))
                .fetchOne();

        System.out.println("총액 = " + total);   // 24300000.00 — 정답
    }

    @Test
    @DisplayName("[6-4] 방어 3 — 행 단위에 맞는 집계식 (가장 안전)")
    void fanOutLineLevelSum() {
        BigDecimal total = queryFactory
                .select(orderItem.unitPrice.multiply(orderItem.quantity).sum())
                .from(order)
                .join(order.orderItems, orderItem)
                .where(order.customer.id.eq(1L))
                .fetchOne();

        System.out.println("총액 = " + total);   // 24300000.00 — 정답
        // 집계식의 단위가 행의 단위와 같으면 fan-out 자체가 문제가 되지 않습니다.
    }

    // =================================================================
    // [6-5] ⚠️ on vs where — 조건 변수를 하나만 두고 위치만 바꿉니다
    // =================================================================

    /** 두 메서드가 공유하는 조건. 코드 차이가 메서드 이름 하나뿐임을 보이기 위한 구성입니다. */
    private static final BooleanExpression EXPENSIVE =
            product.price.goe(new BigDecimal("1000000"));

    @Test
    @DisplayName("[6-5] (A) 조건을 on 에 — 17건. 카테고리 전부 보존")
    void onVersion() {
        List<Tuple> result = queryFactory
                .select(category.name, product.name, product.price)
                .from(category)
                .leftJoin(category.products, product)
                .on(EXPENSIVE)                       // ← on
                .orderBy(category.id.asc())
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(category.name) + " | " + t.get(product.name) + " | " + t.get(product.price)));
        System.out.println("조회 " + result.size() + "건");

        // 기대: 17건 = 조건에 맞는 상품 6건 + 조건에 맞는 상품이 없는 카테고리 11건(NULL 확장)
        // 생성 SQL:
        //   left join products p1_0
        //          on c1_0.category_id = p1_0.category_id
        //         and p1_0.price >= ?
    }

    @Test
    @DisplayName("[6-5] (B) ⚠️ 조건을 where 에 — 6건. INNER JOIN 으로 퇴화")
    void whereVersion() {
        List<Tuple> result = queryFactory
                .select(category.name, product.name, product.price)
                .from(category)
                .leftJoin(category.products, product)
                .where(EXPENSIVE)                    // ← where. 변수는 위와 완전히 같다
                .orderBy(category.id.asc())
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(category.name) + " | " + t.get(product.name) + " | " + t.get(product.price)));
        System.out.println("조회 " + result.size() + "건");

        // 기대: 6건.
        // where 는 조인이 "끝난 뒤" 적용됩니다.
        // NULL 확장 행의 p1_0.price 는 NULL 이고 NULL >= 1000000 은 UNKNOWN 이라 탈락합니다.
        //
        // ★ QueryDSL 에서 특히 위험한 이유:
        //   on() 과 where() 는 둘 다 BooleanExpression 을 받습니다.
        //   위 EXPENSIVE 변수를 어느 쪽에 넘겨도 컴파일됩니다.
        //   리팩터링하다 조건을 옮기는 순간 17건이 6건이 되는데 컴파일러는 침묵합니다.
        //
        // 핵심 규칙: LEFT JOIN 에서 오른쪽 조건은 on 에, 왼쪽 조건은 where 에.
        // 예외: where 에 isNull() 을 두는 안티 조인(6-12)은 정상입니다.
    }

    // =================================================================
    // [6-6] 연관 없는 엔티티의 on 조인
    // =================================================================

    @Test
    @DisplayName("[6-6] leftJoin(엔티티).on(조건) — 인자 1개 + on")
    void joinWithoutAssociation() {
        List<Tuple> result = queryFactory
                .select(category.name, product.name)
                .from(category)
                .leftJoin(product).on(product.name.eq(category.name))   // 연관 경로 없음
                .orderBy(category.id.asc())
                .limit(5)
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(category.name) + " | " + t.get(product.name)));

        // 생성 SQL: left join products p1_0 on p1_0.name = c1_0.name
        // on 절에 매핑에서 온 조건이 없습니다. 우리가 준 조건뿐입니다.
        //
        // 세타 조인(.from(category, product).where(...))으로도 같은 결과를 얻지만
        // 세타 조인은 외부 조인을 못 합니다. 위 leftJoin 은 세타 조인으로 표현할 수 없습니다.
        // join(엔티티).on(조건) 은 세타 조인의 상위 호환입니다.
    }

    // =================================================================
    // [6-7] fetch join — N+1 을 쿼리 개수로 증명
    // =================================================================

    @Test
    @DisplayName("[6-7] ⚠️ fetch join 없이 — 쿼리 11개 (1 + N)")
    void nPlusOne() {
        clearContext();
        long before = queryCount();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .limit(10)
                .fetch();

        for (Order o : orders) {
            System.out.println(o.getId() + " / " + o.getCustomer().getName());   // 프록시 초기화
        }

        System.out.println("총 쿼리 수 = " + (queryCount() - before));   // 기대: 11
        // 결과는 완벽하게 맞습니다. 느릴 뿐입니다.
        // 개발 환경에서 10건일 때는 아무도 눈치채지 못합니다.
    }

    @Test
    @DisplayName("[6-7] fetchJoin() — 쿼리 1개")
    void fetchJoinToOne() {
        clearContext();
        long before = queryCount();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()     // ← .fetchJoin()
                .limit(10)
                .fetch();

        for (Order o : orders) {
            System.out.println(o.getId() + " / " + o.getCustomer().getName());
        }

        System.out.println("총 쿼리 수 = " + (queryCount() - before));   // 기대: 1

        // 일반 join: 조인은 하지만 select 에는 왼쪽 엔티티만 담습니다.
        // fetchJoin(): 조인한 엔티티의 컬럼까지 한 번에 select 해서 영속성 컨텍스트를 채웁니다.
        //              프록시가 아니라 실제 객체가 들어 있으니 추가 쿼리가 없습니다.
        //
        // 생성 SQL 의 select 절에 c1_0.* 가 전부 들어간 것을 확인하십시오. 그것이 구분점입니다.
        //
        // 제약: fetch join 대상에는 on 을 걸 수 없습니다.
        //       연관의 일부만 로딩해 영속성 컨텍스트에 넣으면 "잘려 있는 컬렉션" 이 되기 때문입니다.
        //       fetch join 은 "연관 전체를 통째로 가져오는 것" 이라고 이해하십시오.
    }

    // =================================================================
    // [6-8] ⚠️⚠️ 이 코스 전체에서 가장 중요한 함정
    // =================================================================

    @Test
    @DisplayName("[6-8] ⚠️ 컬렉션 fetch join + 페이징 — SQL 에 limit 이 없다")
    void collectionFetchJoinWithPaging() {
        clearContext();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.orderItems, orderItem).fetchJoin()    // 컬렉션 fetch join
                .offset(0)
                .limit(10)
                .fetch();

        System.out.println("결과 = " + orders.size() + "건");

        // ★ 콘솔에서 반드시 확인할 것 두 가지:
        //
        //   1) 경고 로그
        //      WARN org.hibernate.orm.query :
        //        HHH90003004: firstResult/maxResults specified with collection fetch;
        //        applying in memory
        //
        //      Hibernate 5 에서는 코드가 달랐습니다:
        //        HHH000104: firstResult/maxResults specified with collection fetch;
        //        applying in memory!
        //      로거도 org.hibernate.hql.internal.ast.QueryTranslatorImpl 이었습니다.
        //      운영 로그 알람에는 두 코드를 모두 등록하거나 메시지 본문으로 잡으십시오.
        //
        //   2) 생성 SQL 에 limit 이 "없다"
        //      select ... from orders o1_0 join order_items oi1_0 on ...
        //      (끝. limit 절이 통째로 사라졌습니다.)
        //
        // 무슨 일이 일어났나:
        //   1:N 조인은 행을 뻥튀기합니다(6-4). 주문 600 × 상품 → 조인 결과 1,200행.
        //   여기에 limit 10 을 걸면 "주문 10건" 이 아니라 "조인 행 10개" 가 잘립니다.
        //   마지막 주문은 상품이 잘린 채로 들어옵니다 — 불완전한 엔티티입니다.
        //   그래서 Hibernate 는 페이징을 포기하고 전건을 읽어 메모리에서 자릅니다.
        //   결과의 정확성을 지키기 위해 성능을 버린 것입니다.
        //
        // 왜 못 보고 지나가나:
        //   - WARN 레벨이고 애플리케이션은 정상 동작합니다.
        //   - 결과는 정확합니다. 10건 달라면 10건 줍니다. 테스트도 통과합니다.
        //   - 개발 DB 에 주문이 600건이면 체감 차이가 없습니다.
        //   - 운영에서 60만 건이 되면 그때 OutOfMemoryError 가 납니다.
        //     주문 60만 × 상품 평균 2 = 조인 행 120만 개가 한 요청에 힙으로 들어옵니다.
        //     첫 페이지를 보든 마지막 페이지를 보든 매번 전건을 읽습니다.
    }

    @Test
    @DisplayName("[6-8] 처방 ① 배치 페치 — limit 이 SQL 에 들어간다. 쿼리 2개")
    void fix1BatchFetch() {
        clearContext();
        long before = queryCount();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()   // ToOne 만 fetch join
                .offset(0)
                .limit(10)
                .fetch();

        // 컬렉션은 지연 로딩 + default_batch_fetch_size 로 in 절 묶음 조회
        orders.forEach(o -> System.out.println(o.getId() + " → 상품 " + o.getOrderItems().size() + "개"));

        System.out.println("총 쿼리 수 = " + (queryCount() - before));   // 기대: 2

        // 2번째 쿼리:
        //   select ... from order_items oi1_0 where oi1_0.order_id in (?,?,?,?,?,?,?,?,?,?)
        //
        // default_batch_fetch_size 가 없었다면 이 쿼리가 10개로 쪼개집니다(N+1).
        // 특정 연관에만 적용하려면 엔티티에 @BatchSize(size = 100) 을 붙입니다.
    }

    @Test
    @DisplayName("[6-8] 처방 ② ToOne 만 fetch join — 경고 없음, limit 정상")
    void fix2ToOneOnly() {
        clearContext();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()   // ToOne — 행이 안 늘어난다
                .orderBy(order.id.asc())
                .offset(20)
                .limit(10)
                .fetch();

        System.out.println("결과 = " + orders.size() + "건 (WARN 없음)");

        // 생성 SQL 끝:
        //   offset ? rows fetch first ? rows only
        //   바인딩: [1] 20, [2] 10
        //
        // ★ 이 절에서 가장 중요한 구분:
        //
        //   @ManyToOne / @OneToOne  → fetch join 해도 행 수 그대로 → 페이징 안전
        //   @OneToMany / @ManyToMany → fetch join 하면 행이 늘어남 → 페이징 위험
        //
        // "fetch join 은 페이징과 못 쓴다" 는 부정확합니다.
        // "컬렉션 fetch join 은 페이징과 못 쓴다" 가 정확합니다.
    }

    @Test
    @DisplayName("[6-8] 처방 ③ 2단계 조회 — ID 페이징 후 in 으로 컬렉션 로딩")
    void fix3TwoStep() {
        clearContext();

        // 1단계 — ID 만. 조인이 없으니 fan-out 도 없고 limit 이 정확히 동작합니다.
        List<Long> ids = queryFactory
                .select(order.id)
                .from(order)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .orderBy(order.orderDate.desc())
                .offset(0)
                .limit(10)
                .fetch();

        System.out.println("1단계 ID = " + ids);
        // 생성 SQL: select o1_0.order_id from orders o1_0 where ...
        //           offset ? rows fetch first ? rows only

        // 2단계 — 그 ID 들로 컬렉션까지 fetch join. limit 이 없으니 경고가 안 납니다.
        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.orderItems, orderItem).fetchJoin()
                .join(order.customer, customer).fetchJoin()
                .where(order.id.in(ids))
                .orderBy(order.orderDate.desc())
                .fetch();

        System.out.println("2단계 결과 = " + orders.size() + "건 (WARN 없음)");

        // 핵심은 1단계에 fan-out 이 없다는 것입니다.
        // order.id 만 select 하면 조인이 없으니 limit 10 이 정확히 주문 10건을 자릅니다.
        // 2단계는 in 으로 딱 그 10건만 읽으므로 limit 이 필요 없고, 따라서 경고도 없습니다.
        //
        // 세 처방 비교:
        //   ① 배치 페치   : 쿼리 1+연관수, 복잡도 낮음  → 대부분의 경우. 기본값.
        //   ② ToOne 만    : 쿼리 1(+지연), 복잡도 낮음  → 컬렉션이 화면에 필요 없을 때
        //   ③ 2단계 조회  : 쿼리 2,       복잡도 중간  → 정렬·조건이 복잡할 때
    }

    // =================================================================
    // [6-9] ⚠️ 컬렉션 fetch join 은 하나만
    // =================================================================

    @Test
    @DisplayName("[6-9] ⚠️ 컬렉션 두 개 fetch join — MultipleBagFetchException")
    void multipleBagFetch() {
        try {
            queryFactory
                    .selectFrom(order)
                    .join(order.orderItems, orderItem).fetchJoin()
                    .join(order.payments, payment).fetchJoin()      // 두 번째 컬렉션
                    .fetch();

            System.out.println("예외가 안 났습니다 — 엔티티가 Set 으로 매핑돼 있는지 확인하십시오.");
        } catch (Exception e) {
            System.out.println(e.getClass().getName());
            System.out.println(e.getMessage());

            // org.hibernate.loader.MultipleBagFetchException:
            //   cannot simultaneously fetch multiple bags:
            //   [com.example.shop.entity.Order.orderItems,
            //    com.example.shop.entity.Order.payments]
        }

        // 왜 금지인가 — 카테시안 곱:
        //   주문 1건에 상품 3개, 결제 2건이면 조인 결과는 3 × 2 = 6행입니다.
        //   Hibernate 가 이 6행에서 orderItems 를 복원할 때 각 상품이 2번씩 나타나는 것을 봅니다.
        //   List 는 순서와 중복을 보존하는 컬렉션이라, 이 중복이 "진짜 데이터" 인지
        //   "조인 때문" 인지 판단할 근거가 없습니다.
        //   orderItems.size() 가 3인지 6인지 결정할 수 없으므로 예외를 던집니다.
        //
        //   "bag" 은 @OrderColumn 이 없는 List<T> 매핑을 뜻하는 Hibernate 용어입니다.
    }

    @Test
    @DisplayName("[6-9] 처방 ② 하나만 fetch join + 나머지는 배치 페치 (권장)")
    void oneCollectionFetchJoin() {
        clearContext();
        long before = queryCount();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.orderItems, orderItem).fetchJoin()   // 하나만
                .where(order.id.loe(10L))
                .fetch();

        orders.forEach(o -> System.out.println(o.getId() + " → 결제 " + o.getPayments().size() + "건"));

        System.out.println("총 쿼리 수 = " + (queryCount() - before));   // 기대: 2

        // 처방 ① 인 "List → Set" 은 예외를 없애 주지만 곱집합은 그대로입니다.
        // DB 에서 읽는 행 수가 3 × 2 = 6행인 것은 변하지 않습니다.
        // 컬렉션이 3개, 4개로 늘면 기하급수적으로 커집니다.
        // Set 은 "예외를 없애는" 것이지 "곱집합을 없애는" 것이 아닙니다.
        //
        // 규칙: 컬렉션 fetch join 은 최대 하나. 그리고 그 하나에도 페이징을 붙이지 말 것.
    }

    // =================================================================
    // [6-10] distinct() 와 fetch join — Hibernate 6 에서 달라진 것
    // =================================================================

    @Test
    @DisplayName("[6-10] Hibernate 6 은 distinct() 없이도 중복을 제거한다")
    void distinctNotNeeded() {
        clearContext();

        List<Order> withoutDistinct = queryFactory
                .selectFrom(order)
                .join(order.orderItems, orderItem).fetchJoin()
                .where(order.id.loe(3L))
                .fetch();

        long joinRows = queryFactory
                .select(orderItem.count())
                .from(order).join(order.orderItems, orderItem)
                .where(order.id.loe(3L))
                .fetchOne();

        System.out.println("읽은 조인 행 = " + joinRows
                         + ", 결과 리스트 크기 = " + withoutDistinct.size());
        // 기대: 읽은 조인 행 = 6, 결과 리스트 크기 = 3
        //
        // Hibernate 5 에서 같은 코드를 돌리면 결과 리스트가 6이 됩니다.
        // 그래서 5 시절에는 .distinct() 가 필수였습니다.
        //
        // hibernate.query.passDistinctThrough 는 Hibernate 5 에서
        // "자바에서만 중복 제거하고 SQL 에는 distinct 를 보내지 마라" 는 뜻이었습니다.
        // Hibernate 6 에서는 이 설정이 제거됐습니다. 설정해도 무시됩니다.
    }

    @Test
    @DisplayName("[6-10] 주의 — 스칼라/DTO 조회에서는 distinct 가 여전히 유효하다")
    void distinctStillMattersForScalar() {
        List<String> cities = queryFactory
                .select(customer.city).distinct()
                .from(customer)
                .fetch();

        System.out.println(cities + " (" + cities.size() + "건)");   // 6건
        // 생성 SQL 에 distinct 가 실제로 들어갑니다.
        //
        // "Hibernate 6 이니까 distinct 는 필요 없다" 로 일반화하면 안 됩니다.
        // 정확히는 "엔티티 쿼리에서 fetch join 때문에 붙이던 distinct 가 필요 없어졌다" 입니다.
    }

    // =================================================================
    // [6-11] 셀프 조인
    // =================================================================

    @Test
    @DisplayName("[6-11] 셀프 조인 — new QEmployee(\"manager\") 로 별칭 생성 (MySQL8 7-6)")
    void selfJoin() {
        QEmployee manager = new QEmployee("manager");     // ← 별칭 생성

        List<Tuple> result = queryFactory
                .select(employee.id, employee.name, employee.position,
                        manager.name, manager.position)
                .from(employee)
                .leftJoin(employee.manager, manager)
                .orderBy(employee.id.asc())
                .limit(10)
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(employee.id) + " | " + t.get(employee.name) + " | "
              + t.get(employee.position) + " | " + t.get(manager.name) + " | "
              + t.get(manager.position)));

        // 생성 SQL: left join employees m1_0 on m1_0.employee_id = e1_0.manager_id
        // 별칭 "manager" 를 Hibernate 가 m1_0 으로 변환했습니다.
        // leftJoin 이라 관리자가 없는 CEO(정한별)도 null 로 남습니다.
        //
        // ★ .from(employee).leftJoin(employee.manager, employee) 처럼 같은 별칭을 쓰면
        //   Hibernate 는 e1_0 하나만 만듭니다. 컴파일은 되지만 실행에서 깨집니다.
        //   셀프 조인에는 반드시 new QEmployee("...") 로 별도 별칭을 만드십시오.
    }

    @Test
    @DisplayName("[6-11] 셀프 조인 — 부서 내 급여 순위 (MySQL8 7-6 두 번째 예제)")
    void selfJoinRanking() {
        QEmployee higher = new QEmployee("higher");

        List<Tuple> result = queryFactory
                .select(employee.name, employee.dept, employee.salary, higher.count())
                .from(employee)
                .leftJoin(higher)
                .on(higher.dept.eq(employee.dept)
                        .and(higher.salary.gt(employee.salary)))    // 연관 없는 on 조인
                .groupBy(employee.id, employee.name, employee.dept, employee.salary)
                .orderBy(employee.dept.asc(), employee.salary.desc())
                .limit(6)
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(employee.name) + " | " + t.get(employee.dept) + " | "
              + t.get(employee.salary) + " | " + t.get(higher.count())));

        // "나보다 높은 사람 수 + 1" 이 부서 내 급여 순위입니다.
        //
        // leftJoin 이 필수인 이유: 1등은 매칭 0건이라 join 이면 결과에서 사라집니다.
        // higher.count() 를 쓴 이유: count() (= count(*)) 를 쓰면 NULL 확장 행도 세어
        //   1등이 1 로 나옵니다. MySQL8 코스 7-3 절의 "COUNT(*) 함정" 과 같은 이야기입니다.
    }

    // =================================================================
    // [6-12] 안티 조인
    // =================================================================

    @Test
    @DisplayName("[6-12] 안티 조인 — 주문이 한 번도 없는 고객 (0명이 정답)")
    void antiJoinNoOrders() {
        List<Tuple> result = queryFactory
                .select(customer.id, customer.name, customer.grade)
                .from(customer)
                .leftJoin(customer.orders, order)
                .where(order.id.isNull())               // NULL 확장을 노린다
                .orderBy(customer.id.asc())
                .fetch();

        System.out.println("조회 " + result.size() + "건");   // 기대: 0
        // 시드 데이터가 고객 30명 전원에게 주문 20건씩 배정했기 때문입니다.
        // "아무것도 안 나오는 것" 이 정답인 쿼리입니다.
        // 데이터 검증에서 안티 조인이 쓰이는 전형입니다.
    }

    @Test
    @DisplayName("[6-12] 안티 조인 — 후기를 한 번도 안 쓴 고객 (26명)")
    void antiJoinNoReviews() {
        List<Tuple> result = queryFactory
                .select(customer.id, customer.name, customer.grade)
                .from(customer)
                .leftJoin(review).on(review.customer.eq(customer))
                .where(review.id.isNull())
                .orderBy(customer.id.asc())
                .fetch();

        result.stream().limit(8).forEach(t -> System.out.println(
                t.get(customer.id) + " | " + t.get(customer.name) + " | " + t.get(customer.grade)));
        System.out.println("조회 " + result.size() + "건");   // 기대: 26

        // ★ isNull() 대상은 반드시 NOT NULL 컬럼(대개 PK)이어야 합니다.
        //   where(review.title.isNull()) 로 쓰면 title 이 NULL 을 허용하므로
        //   "후기는 썼는데 제목이 비어 있는 고객" 까지 딸려옵니다.
        //
        // 6-5 절에서 "LEFT JOIN 오른쪽 조건을 where 에 두지 말라" 고 했는데
        // 여기서는 where(review.id.isNull()) 을 씁니다. 모순이 아닙니다.
        // 안티 조인은 NULL 확장 행만 골라내는 것이 목적이므로
        // 조인 후에 적용되는 where 가 정확히 필요한 도구입니다.
    }

    @Test
    @DisplayName("[6-12] 안티 조인 — 결제가 없는 주문 (60건 = PENDING 주문 수)")
    void antiJoinNoPayments() {
        long antiJoin = queryFactory
                .select(order.count())
                .from(order)
                .leftJoin(order.payments, payment)
                .where(payment.id.isNull())
                .fetchOne();

        long pending = queryFactory
                .select(order.count())
                .from(order)
                .where(order.status.eq(OrderStatus.PENDING))
                .fetchOne();

        System.out.println("안티 조인 = " + antiJoin + ", PENDING = " + pending);
        // 기대: 60 / 60. MySQL8 코스 7-5 절과 동일합니다.
    }
}

Exercise.java

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

  • [문제 2][문제 3]한 메서드 안에 쿼리를 두 벌 써야 합니다. 귀찮게 느껴지겠지만, 두 숫자를 나란히 출력해 놓고 보는 것이 이 스텝의 학습 방식입니다. 하나만 쓰고 "맞는 것 같다" 로 넘어가면 배우는 게 없습니다.
  • [문제 4] 는 쿼리 개수를 세야 합니다. 파일 하단의 queryCount() 헬퍼를 쓰십시오. em.flush(); em.clear(); 로 영속성 컨텍스트를 비우고 시작하지 않으면 1차 캐시 때문에 쿼리가 안 나가서 개수가 틀어집니다. 이 부분은 이미 작성해 두었습니다.
  • [문제 5] 만 예외적으로 문제 코드가 이미 작성되어 있습니다. 먼저 그대로 실행해 HHH90003004 경고와 limit 없는 SQL 을 직접 확인한 뒤 고치십시오. 세 처방을 각각 별도 메서드(ex5Fix1, ex5Fix2, ex5Fix3)로 나눠 두었습니다.
  • [문제 6] 의 힌트 "조인 방향이 반대" 가 이 파일에서 가장 어려운 대목입니다. 6-11 절은 leftJoin(employee.manager, manager) 였습니다. 부하를 찾으려면 조인 조건을 반대로 걸어야 하는데, employee.manager 라는 연관 경로로는 그 방향을 표현할 수 없습니다. 연관 없는 on 조인(6-6 절)이 필요합니다.
package com.example.shop.step06;

import com.example.shop.entity.Order;
import com.example.shop.entity.OrderStatus;
import com.example.shop.entity.QEmployee;
import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.Session;
import org.hibernate.stat.Statistics;
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.util.List;

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.example.shop.entity.QPayment.payment;

/**
 * Step 06 — 조인 : 연습문제 7문제
 *
 * 각 문제의 "여기에 작성:" 아래에 코드를 채워 넣고 실행하십시오.
 * 답이 맞았는지보다 "생성 SQL 이 기대한 모양인지" 를 먼저 확인하십시오.
 * 특히 문제 3과 문제 5는 SQL 을 보지 않으면 푼 의미가 없습니다.
 *
 * 정답은 Solution.java 에 있습니다. 먼저 스스로 풀어보십시오.
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    /** 지금까지 나간 쿼리 개수. 문제 4에서 씁니다. */
    private long queryCount() {
        Statistics stats = em.unwrap(Session.class)
                             .getSessionFactory()
                             .getStatistics();
        return stats.getPrepareStatementCount();
    }

    /** 영속성 컨텍스트를 비웁니다. 이걸 안 하면 1차 캐시 때문에 쿼리 개수가 틀어집니다. */
    private void clearContext() {
        em.flush();
        em.clear();
    }

    // =================================================================
    // 문제 1.
    //   orders 와 customers 를 조인해서
    //   "서울에 사는 고객의 주문" 중 금액 상위 5건을 조회하세요.
    //
    //   요구사항:
    //     - 출력: 주문번호, 고객명, 금액
    //     - customer.city 가 "서울" 인 고객만
    //     - totalAmount 내림차순 상위 5건
    //     - 생성 SQL 이 "join customers c1_0 on ..." 인지 확인하십시오.
    //       (세타 조인으로 풀지 마십시오)
    //
    //   MySQL8 코스 Step 07 연습문제 1번과 같은 문제입니다.
    // =================================================================

    @Test
    @DisplayName("문제 1 — 서울 고객의 고액 주문 상위 5건")
    void ex1() {
        // 여기에 작성:

    }

    // =================================================================
    // 문제 2.
    //   "고객 1번의 주문 수와 주문 총액" 을 구하되,
    //   orderItems 를 조인한 버전과 안 한 버전을 "둘 다" 작성해 숫자를 비교하세요.
    //
    //   (a) orderItems 조인 없이 — 정답
    //   (b) orderItems 를 조인 — 숫자가 어떻게 변하나?
    //   (c) countDistinct 를 써 보면 무엇이 고쳐지고 무엇이 안 고쳐지나?
    //   (d) 왜 그런지, 그리고 (b) 에서도 올바른 총액을 얻으려면 어떻게 해야 하는지
    //       주석으로 설명하고 그 코드를 작성하세요.
    //
    //   힌트: 조인을 추가하는 것은 "컬럼 추가" 가 아니라 "행의 단위를 바꾸는 일" 입니다.
    // =================================================================

    @Test
    @DisplayName("문제 2 — fan-out 을 숫자로 확인하고 고치기")
    void ex2() {
        // (a) orderItems 조인 없이 — 여기에 작성:


        // (b) orderItems 조인 추가 — 여기에 작성:


        // (c) countDistinct 버전 — 여기에 작성:


        // (d) 올바른 총액을 얻는 버전 — 여기에 작성:

    }
    // (d) 설명:
    //     →
    //     →

    // =================================================================
    // 문제 3. (on vs where)
    //   "모든 고객 + 그 고객의 배송완료(DELIVERED) 주문" 을 조회하는 쿼리를
    //   조건을 on 에 둔 버전과 where 에 둔 버전으로 각각 작성하고 건수를 비교하세요.
    //
    //   요구사항:
    //     - customers 를 기준(from)으로 orders 를 leftJoin
    //     - 조건: order.status = DELIVERED
    //     - 각각 결과 "건수" 를 출력하십시오
    //
    //   기대: on 버전 258건, where 버전 240건 (MySQL8 코스 7-4 절과 동일한 숫자)
    //         차이 18은 무엇을 의미할까요? 주석으로 답하세요.
    //
    //   ★ 조건을 BooleanExpression 변수로 한 번만 선언하고
    //     .on(cond) 와 .where(cond) 에 각각 넘겨 보십시오.
    //     둘 다 컴파일된다는 사실이 이 문제의 진짜 교훈입니다.
    // =================================================================

    @Test
    @DisplayName("문제 3 — on 258건 vs where 240건")
    void ex3() {
        // (a) 조건을 on 에 — 여기에 작성:


        // (b) 조건을 where 에 — 여기에 작성:

    }
    // 차이 18의 의미:
    //     →

    // =================================================================
    // 문제 4.
    //   주문 20건을 조회하면서 각 주문의 고객 이름을 출력하세요.
    //   (a) fetch join 없이  (b) fetch join 으로
    //   각각 쿼리 개수를 세어 비교하십시오.
    //
    //   기대: (a) 21개 (1 + 20)   (b) 1개
    //
    //   clearContext() 와 queryCount() 는 이미 호출해 두었습니다.
    //   그 사이를 채우십시오.
    // =================================================================

    @Test
    @DisplayName("문제 4 — N+1 을 쿼리 개수로 증명")
    void ex4() {
        // (a) fetch join 없이
        clearContext();
        long before1 = queryCount();

        // 여기에 작성:

        System.out.println("(a) 쿼리 수 = " + (queryCount() - before1));

        // (b) fetch join 으로
        clearContext();
        long before2 = queryCount();

        // 여기에 작성:

        System.out.println("(b) 쿼리 수 = " + (queryCount() - before2));
    }

    // =================================================================
    // 문제 5.
    //   아래 코드는 실행되고 결과도 맞지만 로그에 경고가 찍히고 SQL 에 limit 이 없습니다.
    //
    //   (a) 먼저 그대로 실행해 경고 코드와 SQL 을 눈으로 확인하십시오.
    //   (b) 원인을 주석으로 설명하십시오.
    //   (c) 세 가지 방법으로 각각 고치십시오 (ex5Fix1 / ex5Fix2 / ex5Fix3).
    //
    //   힌트: ① 배치 페치  ② ToOne 만 fetch join  ③ ID 페이징 후 in
    // =================================================================

    @Test
    @DisplayName("문제 5 (a) — 문제 코드를 그대로 실행해 경고를 확인")
    void ex5Broken() {
        clearContext();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.orderItems, orderItem).fetchJoin()
                .offset(0)
                .limit(20)
                .fetch();

        System.out.println("결과 = " + orders.size() + "건");

        // 콘솔에서 확인할 것:
        //   - 경고 코드:
        //   - SQL 에 limit 이 있나요?
    }
    // (b) 원인:
    //     →
    //     →

    @Test
    @DisplayName("문제 5 (c-1) — 처방 ① 배치 페치")
    void ex5Fix1() {
        clearContext();
        // 여기에 작성:

    }

    @Test
    @DisplayName("문제 5 (c-2) — 처방 ② ToOne 만 fetch join")
    void ex5Fix2() {
        clearContext();
        // 여기에 작성:

    }

    @Test
    @DisplayName("문제 5 (c-3) — 처방 ③ 2단계 조회")
    void ex5Fix3() {
        clearContext();
        // 1단계: ID 만 페이징으로 — 여기에 작성:


        // 2단계: 그 ID 들로 fetch join — 여기에 작성:

    }

    // =================================================================
    // 문제 6.
    //   Employee 셀프 조인으로 "부하 직원이 한 명도 없는 사원(말단)" 을 안티 조인으로 찾으세요.
    //
    //   요구사항:
    //     - 출력: employee_id, 이름, 직급
    //     - 결과는 10명입니다 (9~18번 사원)
    //
    //   힌트: 6-11 절은 leftJoin(employee.manager, manager) 였습니다.
    //         이번에는 조인 방향이 "반대" 입니다.
    //         자신의 employee_id 가 누군가의 manager_id 로 쓰이지 않는 사원을 찾는 것입니다.
    //
    //         그런데 employee.manager 라는 연관 경로로는 그 방향을 표현할 수 없습니다.
    //         (그 경로는 "나의 관리자" 방향입니다)
    //         Employee 에 @OneToMany subordinates 매핑이 없으므로
    //         6-6 절의 "연관 없는 on 조인" 이 필요합니다.
    // =================================================================

    @Test
    @DisplayName("문제 6 — 부하가 없는 사원 (10명)")
    void ex6() {
        QEmployee subordinate = new QEmployee("subordinate");

        // 여기에 작성:

    }

    // =================================================================
    // 문제 7.
    //   orders 를 payments 와 leftJoin 해서 "결제가 아예 없는 주문" 을 안티 조인으로 찾고,
    //   그 개수가 PENDING 주문 수와 일치하는지 검산하세요.
    //
    //   요구사항:
    //     - 안티 조인으로 센 개수와 PENDING 주문 수를 나란히 출력
    //     - 양쪽 다 60건이어야 합니다
    //     - isNull() 을 payment 의 "어느 필드" 에 걸어야 할까요? 이유를 주석으로.
    // =================================================================

    @Test
    @DisplayName("문제 7 — 결제 없는 주문 = PENDING 주문 (60 = 60)")
    void ex7() {
        // 여기에 작성:

    }
    // isNull() 을 그 필드에 건 이유:
    //     →
}

Solution.java

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

  • [정답 2]orderItems 를 조인한 버전이 주문 수 1,200 / 총액 약 2배가 나오는 것을 보여줍니다. 그리고 countDistinct 로는 count 만 고쳐지고 sum 은 여전히 틀린다는 것을 숫자로 확인시킵니다. 최종 정답은 "집계 쿼리를 분리" 또는 "unitPrice.multiply(quantity).sum()" 입니다.
  • [정답 3] 의 258/240 은 MySQL8 코스 7-4 절의 숫자와 정확히 같습니다. 차이 18은 "배송완료 주문이 하나도 없는 고객 18명" 입니다. 두 코스의 숫자가 일치하는 것을 확인하면 "QueryDSL 은 SQL 을 만드는 도구일 뿐" 이라는 것이 체감됩니다.
  • [정답 5] 가 이 파일의 하이라이트입니다. 세 처방의 쿼리 수와 읽은 행 수를 표로 정리해 뒀습니다. ①번(배치 페치)이 대부분의 경우 정답이라는 것, ②번은 컬렉션이 화면에 필요 없을 때만 쓴다는 것, ③번은 정렬·조건이 복잡할 때의 마지막 수단이라는 것을 구분하십시오. 그리고 어느 처방을 쓰든 "ToOne fetch join 은 페이징해도 안전하다" 는 사실은 변하지 않습니다.
  • [정답 6]leftJoin(subordinate).on(subordinate.manager.eq(employee)) 로 씁니다. employee.manager 라는 연관 경로를 쓰지 못한다는 것이 포인트입니다 — 그 경로는 "나의 관리자" 방향이지 "나의 부하" 방향이 아니기 때문입니다. Employee@OneToMany subordinates 매핑이 있었다면 연관 경로로도 되지만, 우리 엔티티에는 없으므로 6-6 절의 on 조인이 유일한 방법입니다. 결과는 9~18번 사원 10명입니다.
  • [정답 7] 의 60 = 60 검산은 MySQL8 코스 7-5 절과 동일합니다. payment.id.isNull() 로 걸어야 하며, payment.status.isNull() 로 쓰면 "결제는 있는데 status 가 NULL 인 행" 이라는 다른 의미가 됩니다 (우리 스키마에서 status 는 NOT NULL 이라 결과는 우연히 같지만, 의미가 다릅니다).
package com.example.shop.step06;

import com.example.shop.entity.Order;
import com.example.shop.entity.OrderStatus;
import com.example.shop.entity.QEmployee;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.Session;
import org.hibernate.stat.Statistics;
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.QEmployee.employee;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QOrderItem.orderItem;
import static com.example.shop.entity.QPayment.payment;

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

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    private long queryCount() {
        Statistics stats = em.unwrap(Session.class)
                             .getSessionFactory()
                             .getStatistics();
        return stats.getPrepareStatementCount();
    }

    private void clearContext() {
        em.flush();
        em.clear();
    }

    // =================================================================
    // 정답 1 — 서울 고객의 고액 주문 상위 5건
    // =================================================================
    //
    // 해설:
    //   연관 기반 조인 join(order.customer, customer) 을 씁니다.
    //   from(order, customer).where(order.customer.eq(customer)) 로 푼 세타 조인도
    //   결과는 같지만 생성 SQL 이 "from orders o1_0, customers c1_0" 형태가 되어
    //   문제의 요구를 만족하지 못합니다.
    //
    //   조건 customer.city.eq("서울") 은 where 에 둡니다.
    //   INNER JOIN 이므로 on 에 두든 where 에 두든 결과가 같지만,
    //   "이건 조인 조건이 아니라 필터" 라는 의도를 드러내려면 where 가 맞습니다.
    //   (LEFT JOIN 이었다면 위치에 따라 결과가 달라집니다 — 정답 3 참고)
    //
    //   customer.city 는 왼쪽(from)이 아니라 조인 대상 쪽 조건인데 where 여도 되는 이유는
    //   INNER JOIN 에서는 on 필터와 where 필터가 논리적으로 동등하기 때문입니다.
    //   위치가 결과를 바꾸는 것은 OUTER JOIN 뿐입니다.
    //
    // 생성 SQL:
    //   select o1_0.order_id, c1_0.name, o1_0.total_amount
    //   from orders o1_0
    //   join customers c1_0 on c1_0.customer_id = o1_0.customer_id
    //   where c1_0.city = ?
    //   order by o1_0.total_amount desc
    //   limit ?

    @Test
    @DisplayName("정답 1 — 서울 고객의 고액 주문 상위 5건")
    void sol1() {
        List<Tuple> result = queryFactory
                .select(order.id, customer.name, order.totalAmount)
                .from(order)
                .join(order.customer, customer)
                .where(customer.city.eq("서울"))
                .orderBy(order.totalAmount.desc())
                .limit(5)
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(order.id) + " | " + t.get(customer.name) + " | " + t.get(order.totalAmount)));
    }

    // =================================================================
    // 정답 2 — fan-out 을 숫자로 확인하고 고치기
    // =================================================================
    //
    // 해설:
    //   (a) 20건 / 24,300,000  ← 정답
    //   (b) 41건 / 49,860,000  ← orderItems 조인 한 줄로 2배가 됨
    //   (c) 20건 / 49,860,000  ← countDistinct 로 count 만 고쳐짐
    //
    //   왜 이런 일이 생기나:
    //     orders : order_items 는 1:N 입니다.
    //     주문 1건에 상품이 2개면 그 주문 행이 2번 복제됩니다.
    //     고객 1번의 주문 20건에 딸린 order_items 가 41건이므로 조인 결과는 41행이고,
    //     각 주문의 total_amount 가 그 주문의 상품 개수만큼 반복해서 더해집니다.
    //
    //     ★ 조인을 추가하는 것은 "컬럼을 추가하는 일" 이 아니라 "행의 단위를 바꾸는 일" 입니다.
    //       조인 전에는 한 행이 "주문 하나" 였고, 조인 후에는 "주문 상품 한 줄" 입니다.
    //       집계식은 그대로인데 집계 대상이 바뀐 것입니다.
    //
    //   왜 countDistinct 로는 부족한가:
    //     count(distinct order_id) 는 "서로 다른 주문의 개수" 이므로 맞습니다.
    //     그런데 sum(distinct total_amount) 는 안 됩니다 —
    //     서로 다른 주문인데 금액이 우연히 같으면 한 번만 더해집니다.
    //     count 는 DISTINCT 로 고쳐지지만 sum 은 DISTINCT 로 "더 크게" 망가집니다.
    //
    //   올바른 총액을 얻는 방법 두 가지:
    //     ① 집계 쿼리를 분리한다 — fan-out 이 없는 쿼리로 따로 구한다
    //     ② 행 단위에 맞는 집계식을 쓴다 — unitPrice * quantity 를 더한다
    //
    //     ②가 더 안전합니다. 집계식의 단위가 행의 단위와 같으면
    //     fan-out 자체가 문제가 되지 않기 때문입니다.
    //     ①은 "이 쿼리에서는 조인하지 않는다" 는 규칙에 의존하는데,
    //     나중에 누가 조인을 하나 추가하면 다시 깨집니다.

    @Test
    @DisplayName("정답 2 — 네 가지 버전을 나란히")
    void sol2() {
        // (a) 정답 — orderItems 조인 없음
        Tuple a = queryFactory
                .select(order.count(), order.totalAmount.sum())
                .from(order).join(order.customer, customer)
                .where(customer.id.eq(1L))
                .fetchOne();
        System.out.println("(a) 주문 " + a.get(order.count())
                         + " / 총액 " + a.get(order.totalAmount.sum()));
        // 20 / 24300000.00

        // (b) orderItems 조인 — 2배가 된다
        Tuple b = queryFactory
                .select(order.count(), order.totalAmount.sum())
                .from(order).join(order.customer, customer)
                .join(order.orderItems, orderItem)
                .where(customer.id.eq(1L))
                .fetchOne();
        System.out.println("(b) 주문 " + b.get(order.count())
                         + " / 총액 " + b.get(order.totalAmount.sum()));
        // 41 / 49860000.00

        // (c) countDistinct — count 만 고쳐진다
        Tuple c = queryFactory
                .select(order.countDistinct(), order.totalAmount.sum())
                .from(order).join(order.customer, customer)
                .join(order.orderItems, orderItem)
                .where(customer.id.eq(1L))
                .fetchOne();
        System.out.println("(c) 주문 " + c.get(order.countDistinct())
                         + " / 총액 " + c.get(order.totalAmount.sum()));
        // 20 / 49860000.00  ← sum 은 여전히 틀림

        // (d-1) 집계 쿼리 분리
        BigDecimal d1 = queryFactory
                .select(order.totalAmount.sum())
                .from(order)
                .where(order.customer.id.eq(1L))
                .fetchOne();
        System.out.println("(d-1) 총액 " + d1);   // 24300000.00

        // (d-2) 행 단위에 맞는 집계식 — 가장 안전
        BigDecimal d2 = queryFactory
                .select(orderItem.unitPrice.multiply(orderItem.quantity).sum())
                .from(order).join(order.orderItems, orderItem)
                .where(order.customer.id.eq(1L))
                .fetchOne();
        System.out.println("(d-2) 총액 " + d2);   // 24300000.00
    }

    // =================================================================
    // 정답 3 — on 258건 vs where 240건
    // =================================================================
    //
    // 해설:
    //   숫자는 MySQL8 코스 Step 07 의 7-4 절과 정확히 같습니다.
    //   같은 데이터, 같은 SQL 이니 당연합니다.
    //   "QueryDSL 은 SQL 을 만드는 도구일 뿐" 이라는 것이 여기서 체감됩니다.
    //
    //   (a) 조건을 on 에 → 258건
    //       status = DELIVERED 는 "오른쪽 행을 매칭할지 말지" 를 결정하는 데만 쓰입니다.
    //       배송완료 주문이 없는 고객도 LEFT JOIN 규칙에 따라 NULL 확장으로 한 줄 남습니다.
    //       240(배송완료 주문) + 18(배송완료 주문이 하나도 없는 고객) = 258
    //
    //   (b) 조건을 where 에 → 240건
    //       조인이 다 끝난 뒤 필터가 적용됩니다.
    //       NULL 확장 행의 status 는 NULL 이고 NULL = 'DELIVERED' 는 UNKNOWN 이라 탈락합니다.
    //       결국 배송완료 주문만 남아 240 — LEFT JOIN 이 INNER JOIN 으로 퇴화했습니다.
    //
    //   ★ 차이 18 = "배송완료 주문이 하나도 없는 고객 18명"
    //     이 18명은 (a) 에서는 order 컬럼이 전부 null 인 행으로 남고, (b) 에서는 사라집니다.
    //
    //   ★★ 이 문제의 진짜 교훈은 아래 DELIVERED 변수입니다.
    //     BooleanExpression 하나를 .on() 에 넘기든 .where() 에 넘기든 "둘 다 컴파일됩니다."
    //     조건을 변수로 빼서 재사용하는 흔한 리팩터링이
    //     258을 240으로 바꿔 놓는데 컴파일러도 IDE 도 아무 말을 하지 않습니다.
    //     이것이 이 코스가 말하는 "조용히 틀리는 코드" 입니다.
    //
    //   핵심 규칙: LEFT JOIN 에서 오른쪽 조건은 on 에, 왼쪽 조건은 where 에.
    //     "모든 고객 + 그들의 배송완료 주문(없으면 NULL)" 을 원하면 → on
    //     "배송완료 주문이 있는 고객만" 을 원하면 → where (또는 그냥 join)

    private static final BooleanExpression DELIVERED =
            order.status.eq(OrderStatus.DELIVERED);

    /**
     * 주의 — 건수를 셀 때 count(order.id) 를 쓰면 안 됩니다.
     * count(컬럼) 은 그 컬럼이 NULL 인 행을 세지 않으므로 NULL 확장 행 18개가 빠져
     * on 버전도 240 이 나옵니다. MySQL8 코스 7-3 절의 "COUNT(*) 함정" 을 거꾸로 뒤집은 형태입니다.
     * "행 수" 를 정확히 세려면 결과 리스트의 크기를 보거나 Wildcard.count 를 씁니다.
     */
    @Test
    @DisplayName("정답 3 — 조건 변수 하나를 on 과 where 에 각각 넘긴다")
    void sol3() {
        List<Tuple> onVersion = queryFactory
                .select(customer.id, customer.name, order.id, order.status)
                .from(customer)
                .leftJoin(customer.orders, order)
                .on(DELIVERED)
                .fetch();

        List<Tuple> whereVersion = queryFactory
                .select(customer.id, customer.name, order.id, order.status)
                .from(customer)
                .leftJoin(customer.orders, order)
                .where(DELIVERED)
                .fetch();

        System.out.println("on = " + onVersion.size() + "행, where = " + whereVersion.size() + "행");
        // 기대: on = 258행, where = 240행. 차이 18.

        long nullExpanded = onVersion.stream().filter(t -> t.get(order.id) == null).count();
        System.out.println("NULL 확장 행 = " + nullExpanded + "행");   // 18
        // 이 18행이 "배송완료 주문이 하나도 없는 고객" 입니다.
    }

    // =================================================================
    // 정답 4 — N+1 을 쿼리 개수로 증명
    // =================================================================
    //
    // 해설:
    //   (a) 21개 = 1(주문 목록) + 20(주문마다 고객 프록시 초기화)
    //   (b) 1개
    //
    //   @ManyToOne 은 LAZY 이므로 order.getCustomer() 는 프록시입니다.
    //   getName() 을 호출하는 순간 초기화되면서 SELECT 가 나갑니다.
    //   같은 고객이 여러 주문에 걸쳐 있으면 1차 캐시 덕분에 쿼리가 줄어들 수도 있습니다 —
    //   20건이 20명의 서로 다른 고객이 아니면 21개보다 적게 나올 수 있습니다.
    //   그래서 clearContext() 로 시작하는 것이 중요합니다.
    //
    //   fetchJoin() 은 조인한 엔티티의 컬럼까지 한 번에 select 해서
    //   영속성 컨텍스트를 채웁니다. 프록시가 아니라 실제 객체가 들어 있으니 추가 쿼리가 없습니다.
    //
    //   일반 join 과의 차이를 SQL 로 구분하는 방법:
    //     select 절에 c1_0.* 컬럼들이 들어가 있으면 fetch join,
    //     o1_0.* 만 있으면 일반 join 입니다. SQL 의 from/join 부분은 똑같이 생겼습니다.
    //
    //   ★ 조회 전용이면 DTO 프로젝션(Step 05)이 먼저입니다.
    //     DTO 로 받으면 애초에 프록시가 없으니 N+1 도 없습니다.
    //     fetch join 은 "엔티티가 꼭 필요할 때" 의 도구입니다. 순서를 거꾸로 잡는 경우가 많습니다.

    @Test
    @DisplayName("정답 4 — 21개 vs 1개")
    void sol4() {
        // (a) fetch join 없이
        clearContext();
        long before1 = queryCount();

        List<Order> without = queryFactory
                .selectFrom(order)
                .limit(20)
                .fetch();
        without.forEach(o -> o.getCustomer().getName());

        System.out.println("(a) 쿼리 수 = " + (queryCount() - before1));   // 21

        // (b) fetch join 으로
        clearContext();
        long before2 = queryCount();

        List<Order> with = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()
                .limit(20)
                .fetch();
        with.forEach(o -> o.getCustomer().getName());

        System.out.println("(b) 쿼리 수 = " + (queryCount() - before2));   // 1
    }

    // =================================================================
    // 정답 5 — 컬렉션 fetch join + 페이징  ★ 이 파일의 하이라이트
    // =================================================================
    //
    // (b) 원인:
    //   1:N 조인은 행을 뻥튀기합니다. 주문 600건에 상품 1,200건이니 조인 결과는 1,200행입니다.
    //   여기에 limit 20 을 걸면 "주문 20건" 이 아니라 "조인 행 20개" 가 잘립니다.
    //   주문 1번에 상품 2개, 2번에 3개... 라면 20행은 주문 8~9건 정도밖에 안 되고
    //   마지막 주문은 상품이 잘린 채로 들어옵니다 — 불완전한 엔티티입니다.
    //
    //   Hibernate 는 그런 결과를 돌려줄 수 없으니 페이징을 포기하고
    //   전건을 읽어 메모리에서 자릅니다. 정확성을 지키기 위해 성능을 버린 것입니다.
    //
    //   경고 코드:
    //     Hibernate 6.x : HHH90003004  (로거 org.hibernate.orm.query)
    //     Hibernate 5.x : HHH000104    (로거 org.hibernate.hql.internal.ast.QueryTranslatorImpl)
    //   메시지는 거의 같지만 코드와 로거가 다릅니다.
    //   5.x 자료를 보고 HHH000104 로 로그 알람을 걸면 Hibernate 6 환경에서는 안 잡힙니다.
    //
    //   OOM 시나리오:
    //     주문 60만 × 상품 평균 2 = 조인 행 120만 개.
    //     엔티티 하나당 수백 바이트만 잡아도 수백 MB 가 한 요청에 힙으로 들어옵니다.
    //     동시 요청 몇 개면 힙이 터집니다.
    //     그리고 첫 페이지를 보든 마지막 페이지를 보든 매번 전건을 읽습니다.
    //
    // (c) 세 처방 비교:
    //
    //   | 처방              | 쿼리 수      | 읽는 행     | 복잡도 | 적합한 상황               |
    //   |-------------------|-------------|------------|-------|--------------------------|
    //   | ① 배치 페치        | 1 + 연관 수  | 필요한 만큼 | 낮음   | 대부분. 기본값으로 삼을 것 |
    //   | ② ToOne 만        | 1 (+지연)   | 페이지 크기 | 낮음   | 컬렉션이 화면에 없을 때    |
    //   | ③ 2단계 조회       | 2           | 페이지 크기 | 중간   | 정렬·조건이 복잡할 때      |
    //
    //   ★ 어느 처방을 쓰든 변하지 않는 사실:
    //     @ManyToOne / @OneToOne fetch join 은 페이징해도 안전합니다. 행 수가 늘지 않으니까요.
    //     @OneToMany / @ManyToMany fetch join 만 위험합니다.
    //     "fetch join 은 페이징과 못 쓴다" 는 부정확하고,
    //     "컬렉션 fetch join 은 페이징과 못 쓴다" 가 정확합니다.

    @Test
    @DisplayName("정답 5 ① — 배치 페치. limit 이 SQL 에 들어가고 쿼리 2개")
    void sol5Fix1() {
        clearContext();
        long before = queryCount();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()   // ToOne 만
                .offset(0)
                .limit(20)
                .fetch();

        orders.forEach(o -> o.getOrderItems().size());        // 컬렉션은 배치로 한 번에

        System.out.println("쿼리 수 = " + (queryCount() - before));   // 2
        // 2번째: select ... from order_items where order_id in (?,?,...20개)
        // default_batch_fetch_size 가 없으면 이게 20개로 쪼개집니다(N+1).
    }

    @Test
    @DisplayName("정답 5 ② — ToOne 만 fetch join. 경고 없음")
    void sol5Fix2() {
        clearContext();

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()
                .orderBy(order.id.asc())
                .offset(0)
                .limit(20)
                .fetch();

        System.out.println("결과 = " + orders.size() + "건 (WARN 없음)");
        // 생성 SQL 끝: offset ? rows fetch first ? rows only
        // 컬렉션을 화면에서 안 쓴다면 이게 가장 단순합니다.
    }

    @Test
    @DisplayName("정답 5 ③ — 2단계 조회. ID 페이징 후 in")
    void sol5Fix3() {
        clearContext();

        // 1단계 — 조인이 없으니 fan-out 도 없고 limit 이 정확히 동작합니다.
        List<Long> ids = queryFactory
                .select(order.id)
                .from(order)
                .orderBy(order.id.asc())
                .offset(0)
                .limit(20)
                .fetch();

        // 2단계 — in 으로 딱 그 20건만. limit 이 없으니 경고도 없습니다.
        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.orderItems, orderItem).fetchJoin()
                .where(order.id.in(ids))
                .orderBy(order.id.asc())
                .fetch();

        System.out.println("결과 = " + orders.size() + "건 (WARN 없음)");
        // 정렬이나 조건이 복잡해서 배치 페치로 부족할 때의 방법입니다.
        // 쿼리가 하나 늘지만 읽는 행은 페이지 크기만큼으로 고정됩니다.
    }

    // =================================================================
    // 정답 6 — 부하가 없는 사원 (10명)
    // =================================================================
    //
    // 해설:
    //   이 문제의 핵심은 "연관 경로로는 풀 수 없다" 는 것입니다.
    //
    //   6-11 절은 leftJoin(employee.manager, manager) 였습니다.
    //   employee.manager 는 "나의 관리자" 방향의 경로입니다.
    //   우리가 필요한 것은 "나의 부하" 방향인데,
    //   Employee 엔티티에 @OneToMany subordinates 매핑이 없으므로 탈 경로가 없습니다.
    //
    //   그래서 6-6 절의 "연관 없는 on 조인" 을 씁니다:
    //     leftJoin(subordinate).on(subordinate.manager.eq(employee))
    //   "subordinate 의 관리자가 나인 행" 을 붙이는 것입니다.
    //   조인 조건의 방향이 6-11 절과 정확히 반대입니다.
    //
    //   그다음 where(subordinate.id.isNull()) 로 "그런 행이 없었던" 사원만 남깁니다.
    //   전형적인 안티 조인입니다.
    //
    //   만약 엔티티에 @OneToMany subordinates 를 추가한다면
    //   leftJoin(employee.subordinates, subordinate) 로 더 간결하게 쓸 수 있습니다.
    //   매핑을 추가할지는 별개의 설계 판단입니다 —
    //   양방향 매핑은 편의를 주지만 관리 비용도 늘립니다.
    //
    // 생성 SQL:
    //   select e1_0.employee_id, e1_0.name, e1_0.position
    //   from employees e1_0
    //   left join employees s1_0 on s1_0.manager_id = e1_0.employee_id
    //   where s1_0.employee_id is null
    //   order by e1_0.employee_id asc
    //
    // 기대: 10건 (9~18번 사원). MySQL8 코스 Step 07 연습문제 4번과 같은 답입니다.

    @Test
    @DisplayName("정답 6 — 연관 없는 on 조인 + 안티 조인")
    void sol6() {
        QEmployee subordinate = new QEmployee("subordinate");

        List<Tuple> result = queryFactory
                .select(employee.id, employee.name, employee.position)
                .from(employee)
                .leftJoin(subordinate).on(subordinate.manager.eq(employee))   // 방향이 반대
                .where(subordinate.id.isNull())
                .orderBy(employee.id.asc())
                .fetch();

        result.forEach(t -> System.out.println(
                t.get(employee.id) + " | " + t.get(employee.name) + " | " + t.get(employee.position)));
        System.out.println("조회 " + result.size() + "건");   // 10
    }

    // =================================================================
    // 정답 7 — 결제 없는 주문 = PENDING 주문 (60 = 60)
    // =================================================================
    //
    // 해설:
    //   isNull() 은 payment.id (PK) 에 겁니다.
    //
    //   왜 PK 인가:
    //     안티 조인의 원리는 "LEFT JOIN 후 오른쪽이 NULL 확장된 행만 남기는" 것입니다.
    //     NULL 확장된 행은 오른쪽 "모든" 컬럼이 NULL 입니다.
    //     그러니 오른쪽의 어느 컬럼으로 판정해도 될 것 같지만 그렇지 않습니다.
    //
    //     NULL 을 허용하는 컬럼으로 판정하면
    //     "짝은 있는데 그 컬럼 값이 NULL 인 행" 까지 딸려옵니다.
    //     PK 는 정의상 NOT NULL 이므로 "PK 가 NULL 이다" = "짝이 없었다" 가 정확히 성립합니다.
    //
    //     우리 스키마에서 payments.status 는 NOT NULL 이라
    //     payment.status.isNull() 로 써도 결과가 우연히 같습니다.
    //     하지만 의미가 다릅니다. 나중에 status 를 nullable 로 바꾸는 순간 조용히 틀립니다.
    //     ★ 안티 조인의 isNull() 은 언제나 PK 에 겁니다. 예외 없이.
    //
    //   왜 60건인가:
    //     시드 데이터에서 PENDING 주문 60건에는 결제가 없고,
    //     나머지 540건에는 결제가 1건씩 있습니다. 600 = 60 + 540.
    //     안티 조인 결과가 PENDING 주문 수와 정확히 일치하면 데이터가 의도대로 들어간 것입니다.
    //     MySQL8 코스 7-5 절과 같은 검산입니다.
    //
    //   6-5 절의 "LEFT JOIN 오른쪽 조건을 where 에 두지 말라" 와 모순되지 않습니다.
    //   안티 조인은 NULL 확장 행만 골라내는 것이 목적이므로
    //   조인 후에 적용되는 where 가 정확히 필요한 도구입니다.
    //
    // 생성 SQL:
    //   select count(o1_0.order_id)
    //   from orders o1_0
    //   left join payments p1_0 on o1_0.order_id = p1_0.order_id
    //   where p1_0.payment_id is null

    @Test
    @DisplayName("정답 7 — 60 = 60 검산")
    void sol7() {
        long antiJoin = queryFactory
                .select(order.count())
                .from(order)
                .leftJoin(order.payments, payment)
                .where(payment.id.isNull())        // ← PK 에 건다
                .fetchOne();

        long pending = queryFactory
                .select(order.count())
                .from(order)
                .where(order.status.eq(OrderStatus.PENDING))
                .fetchOne();

        System.out.println("안티 조인 = " + antiJoin + ", PENDING = " + pending);
        System.out.println(antiJoin == pending ? "일치 — 데이터 정상" : "불일치 — 시드를 확인하십시오");
        // 기대: 60 / 60

        // 실제 목록도 확인해 보면 전부 PENDING 입니다.
        List<Tuple> rows = queryFactory
                .select(order.id, order.status, order.totalAmount)
                .from(order)
                .leftJoin(order.payments, payment)
                .where(payment.id.isNull())
                .orderBy(order.id.asc())
                .limit(8)
                .fetch();

        rows.forEach(t -> System.out.println(
                t.get(order.id) + " | " + t.get(order.status) + " | " + t.get(order.totalAmount)));
        // 7, 17, 27, 37, 47, 57, 67, 77 ... 전부 PENDING
    }
}