Step 14 — 성능과 최종 프로젝트

학습 목표

  • 생성 SQL 을 확인하는 5가지 방법을 상황에 맞게 골라 쓴다
  • Hibernate statistics 로 N+1 을 쿼리 개수로 진단한다
  • N+1 을 fetch join / @BatchSize / DTO 직접 조회 세 가지로 해결하고 선택 기준을 세운다
  • existscount 의 실행시간 차이를 실측한다
  • QueryDSL 이 만든 SQL 을 그대로 EXPLAIN 에 넣어 인덱스 활용을 검증한다
  • 이 코스의 모든 기법을 써서 상품 검색 API 를 처음부터 끝까지 완성한다

선행 스텝: Step 13 — 고급 표현식 예상 소요: 150분


마지막 스텝입니다.

지금까지 13개 스텝에서 "이 QueryDSL 코드가 어떤 SQL 을 만드는가"를 봤습니다. 이 스텝은 질문을 하나 더 붙입니다. "그 SQL 은 빠른가."

그리고 코스의 마지막에 14-9 절의 상품 검색 API 가 있습니다. Step 04 의 동적 조건, Step 05 의 @QueryProjection, Step 06 의 조인, Step 07 의 서브쿼리, Step 09 의 count 분리, Step 10 의 정렬 화이트리스트, Step 12 의 커스텀 리포지토리가 한 파일에 모입니다. 그것을 만들고 EXPLAIN 으로 검증하고 인덱스로 개선하면 이 코스는 끝입니다.

📌 MySQL8 코스 Step 15 — 인덱스Step 16 — EXPLAIN과 옵티마이저 를 옆에 펴 두십시오. 이 스텝은 그 두 스텝의 도구를 QueryDSL 이 만든 SQL 에 적용하는 스텝입니다.


14-1. 생성 SQL 확인법 총정리

지금까지 org.hibernate.SQL: debug 하나만 썼습니다. 운영에서 문제를 추적하려면 도구가 더 필요합니다. 다섯 가지를 정리합니다.

org.hibernate.SQL + org.hibernate.orm.jdbc.bind

가장 기본입니다. 프로젝트 셋업 0-5 절 에서 켰습니다.

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

결과

Hibernate:
    select
        c1_0.customer_id, 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.grade = ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [VIP]
장점단점
설정 두 줄. 의존성 추가 없음SQL 과 바인딩이 따로 찍혀서 눈으로 합쳐야 함
Hibernate 가 실제로 만든 SQL 그대로파라미터가 많으면 대조가 고통스러움
모든 환경에서 동작복사해서 바로 실행할 수 없음

⚠️ Hibernate 5 의 로거 이름은 org.hibernate.type.descriptor.sql.BasicBinder 였습니다. Hibernate 6 은 org.hibernate.orm.jdbc.bind 입니다. 옛 이름을 쓰면 설정은 적용된 것처럼 보이는데 바인딩만 안 찍힙니다. 에러도 안 납니다.

hibernate.format_sql / highlight_sql

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        use_sql_comments: false

format_sql 없이 찍힌 SQL 은 이렇습니다.

Hibernate: select c1_0.customer_id,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.grade=?

읽을 수 없습니다. 이 코스가 처음부터 format_sql: true 로 시작한 이유입니다.

use_sql_comments: true 로 두면 SQL 앞에 JPQL 이 주석으로 붙습니다.

/* select customer from Customer customer where customer.grade = ?1 */
select c1_0.customer_id, ...

디버깅에는 유용하지만 로그가 두 배가 되고, MySQL 의 쿼리 통계에서 같은 쿼리가 다른 쿼리로 집계되는 부작용이 있습니다. 이 코스는 false 로 둡니다.

③ p6spy — 바인딩까지 완성된 SQL

①의 가장 큰 불편, "SQL 과 바인딩이 따로 찍힘"을 해결합니다. p6spy 는 JDBC 드라이버를 감싸서 실제로 실행된 SQL 을 값이 채워진 채로 보여줍니다.

이건 spring.jpa.properties 설정이 아니라 의존성입니다.

// build.gradle
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'

의존성만 추가하면 자동 설정됩니다. 별도 코드가 필요 없습니다.

결과

2026-07-22 14:03:11.882  INFO 41233 --- [    Test worker] p6spy :
    select c1_0.customer_id,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.grade='VIP'

? 대신 'VIP' 가 들어 있습니다. 이 SQL 을 그대로 복사해 MySQL 콘솔에 붙여넣을 수 있습니다. 14-6 절에서 EXPLAIN 을 걸 때 이것이 결정적입니다.

장점단점
복사해서 바로 실행 가능의존성 추가 필요
실행 시간(ms)도 함께 찍힘JDBC 를 프록시하므로 운영에서는 끄십시오
배치 실행도 개별 SQL 로 보임로그량이 큼

💡 실무 팁 — p6spy 는 로컬/개발 전용 build.gradle 에서 developmentOnly 로 두거나, 프로파일별 의존성으로 분리하십시오. 운영에 켜 두면 모든 쿼리의 파라미터가 로그에 남습니다. 개인정보가 그대로 로그 파일에 기록된다는 뜻입니다.

JPAQuery.toString() — JPQL 보기

SQL 이 아니라 QueryDSL 이 만든 JPQL 을 보고 싶을 때가 있습니다. "내 자바 코드가 JPQL 로 제대로 번역됐는가"를 확인하는 단계입니다.

JPAQuery<Customer> query = queryFactory
        .selectFrom(customer)
        .where(customer.grade.eq(Grade.VIP)
                .and(customer.points.goe(10000)))
        .orderBy(customer.points.desc());

System.out.println(query);      // toString()

List<Customer> result = query.fetch();

결과

select customer
from Customer customer
where customer.grade = ?1 and customer.points >= ?2
order by customer.points desc

DB 에 나가지 않고 문자열만 얻습니다. fetch() 를 부르지 않아도 됩니다. Step 04 의 or 괄호 문제 같은 것은 SQL 까지 안 가고 여기서 잡힙니다.

// 괄호가 어떻게 붙었는지 JPQL 로 확인
System.out.println(queryFactory.selectFrom(customer)
        .where(customer.city.eq("서울")
                .and(customer.grade.eq(Grade.VIP))
                .or(customer.points.goe(10000))));
select customer
from Customer customer
where customer.city = ?1 and customer.grade = ?2 or customer.points >= ?3

괄호가 없습니다. 이것이 Step 04 의 함정이었습니다. 한 줄로 확인할 수 있습니다.

⑤ Hibernate statistics — 쿼리 개수 세기

이것이 14-2 의 N+1 진단 도구입니다.

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
logging:
  level:
    org.hibernate.stat: debug

트랜잭션이 끝날 때 요약이 찍힙니다.

결과

Session Metrics {
    1204583 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    892041 nanoseconds spent preparing 11 JDBC statements;
    18344209 nanoseconds spent executing 11 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    0 nanoseconds spent executing 0 partial-flushes
}

executing 11 JDBC statements — 이 숫자 하나가 진단의 시작입니다.

코드에서 직접 읽을 수도 있습니다.

Statistics stats = em.getEntityManagerFactory()
        .unwrap(SessionFactory.class).getStatistics();
stats.clear();

// ... 쿼리 실행 ...

System.out.println("쿼리 개수: " + stats.getQueryExecutionCount());
System.out.println("엔티티 로드: " + stats.getEntityLoadCount());
System.out.println("컬렉션 페치: " + stats.getCollectionFetchCount());

💡 generate_statistics: true 는 약간의 오버헤드가 있습니다. 운영 상시 켜기보다는 테스트에서 켜고 단언하는 쪽이 유용합니다. "이 API 는 쿼리 3개 이하" 를 테스트로 고정해 두면 N+1 이 회귀할 때 CI 에서 잡힙니다.

정리

방법보이는 것쓰는 상황
hibernate.SQL + bindSQL + 바인딩(분리)항상 켜 둠. 기본값
format_sql읽을 수 있는 형태항상 켜 둠
③ p6spy실행 가능한 완성 SQL + 시간EXPLAIN 걸 때, 성능 추적
toString()JPQL번역이 의도대로인지 확인
⑤ statistics쿼리 개수N+1 진단, 테스트 단언

14-2. N+1 진단

가장 흔한 코드입니다. 주문 10건과 각 주문의 고객 이름을 출력합니다.

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

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

결과 — 쿼리 로그

Hibernate:
    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 ?
Hibernate:
    select
        c1_0.customer_id, 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 = ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:BIGINT) <- [3]
Hibernate:
    select c1_0.customer_id, ... from customers c1_0 where c1_0.customer_id = ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:BIGINT) <- [7]
... (같은 쿼리가 8번 더) ...
Session Metrics {
    ...
    18344209 nanoseconds spent executing 11 JDBC statements;
    ...
}

주문 10건을 조회하는 데 쿼리 11개. 1 + N 입니다.

결과는 완벽하게 맞습니다. 고객 이름도 전부 정확합니다. 느릴 뿐입니다. 그리고 10건에서는 느린 것도 안 느껴집니다.

주문 600건을 전부 돌면 601개가 되고, 각 쿼리가 1ms 라도 0.6초입니다. 네트워크 왕복이 있는 운영 환경이면 5ms × 600 = 3초입니다.

⚠️ 함정 — N+1 은 에러가 아닙니다 이 코스에서 반복한 주제입니다. N+1 은 결과가 맞습니다. 테스트도 통과합니다. 코드 리뷰에서도 이상해 보이지 않습니다. 개발 DB 의 데이터가 적을 때는 체감조차 안 됩니다.

유일한 발견 방법은 쿼리 개수를 세는 것입니다. 눈으로 로그를 보다가는 놓칩니다. 특히 open-in-view: true 면 컨트롤러가 아니라 JSON 직렬화 중에 쿼리가 나가므로 서비스 계층 로그만 봐서는 보이지 않습니다. 이 코스가 open-in-view: false 를 기본으로 둔 이유입니다.

진단 체크리스트

단계확인방법
1쿼리 개수를 센다generate_statistics: true 또는 stats.getQueryExecutionCount()
2같은 SQL 이 반복되는가로그에서 동일 SQL 의 where ... = ? 반복 확인
3반복 횟수 = 부모 건수인가부모 10건 → 자식 쿼리 10개면 확정
4어느 연관인가반복되는 SQL 의 테이블명으로 특정
5컬렉션인가 단일인가@ManyToOne 인지 @OneToMany 인지 → 처방이 갈림
6페이징이 필요한가필요하면 fetch join 을 쓸 수 없음 (컬렉션인 경우)
7수정이 필요한가필요 없으면 DTO 직접 조회가 최선

5~7번이 14-3 의 선택 기준으로 이어집니다.

💡 실무 팁 — 테스트로 고정하십시오

@Test
void 주문목록은_쿼리_2개_이하() {
    stats.clear();
    orderService.findRecentOrders(10);
    assertThat(stats.getQueryExecutionCount()).isLessThanOrEqualTo(2);
}

이런 테스트가 있으면 누군가 나중에 fetchJoin() 을 지웠을 때 CI 에서 즉시 깨집니다. N+1 은 코드 리뷰로는 못 막습니다. 숫자로 막으십시오.


14-3. N+1 해결 3가지

① fetch join — 쿼리 1개

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

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

결과

Hibernate:
    select
        o1_0.order_id, c1_0.customer_id, c1_0.city, c1_0.created_at,
        c1_0.email, c1_0.grade, c1_0.name, c1_0.phone, c1_0.points,
        o1_0.order_date, o1_0.shipping_city, o1_0.status, o1_0.total_amount
    from
        orders o1_0
    join
        customers c1_0 on c1_0.customer_id = o1_0.customer_id
    limit ?
Session Metrics {
    ...
    3102847 nanoseconds spent executing 1 JDBC statements;
    ...
}

쿼리 11개 → 1개. customers 의 전 컬럼이 select 절에 함께 실려 왔습니다.

그런데 이 방법에는 Step 06 6-8 절 의 제약이 있습니다.

⚠️ 함정 — 컬렉션 fetch join + 페이징 = 전건 메모리 로딩

@ManyToOne (주문 → 고객) 은 위처럼 잘 됩니다. 1:1 이므로 행이 늘지 않습니다. @OneToMany (주문 → 주문항목) 는 다릅니다.

queryFactory
        .selectFrom(order)
        .join(order.orderItems, orderItem).fetchJoin()
        .offset(0).limit(10)                  // ★
        .fetch();

결과

WARN o.h.h.internal.ast.QueryTranslatorImpl :
    HHH90003004: firstResult/maxResults specified with collection fetch;
    applying in memory
select o1_0.order_id, oi1_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

limit 이 SQL 에 없습니다.

조인으로 행이 1200건으로 뻥튀기되면 limit 10 을 SQL 에 붙일 수 없습니다. "주문 10건"이 아니라 "조인 결과 10행"이 되어 버리기 때문입니다. 그래서 Hibernate 는 1200행을 전부 메모리로 읽고 자바에서 잘라냅니다.

경고 로그 한 줄만 찍히고 결과는 정확합니다. 이것이 위험한 이유입니다. orders 가 600건이면 1200행이지만, 60만 건이면 120만 행을 힙에 올립니다. OOM 입니다.

그리고 컬렉션 fetch join 은 하나만 가능합니다. orderItemspayments 를 동시에 fetch join 하면 MultipleBagFetchException 이 기동 시점 또는 쿼리 시점에 터집니다.

@BatchSize / default_batch_fetch_size — 쿼리 2개

지연 로딩을 없애는 대신, 한 번에 모아서 가져오게 합니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

또는 특정 연관에만:

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

코드는 아무것도 바꾸지 않습니다.

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

for (Order o : orders) {
    System.out.println(o.getId() + " → " + o.getOrderItems().size());
}

결과

Hibernate:
    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 ?
Hibernate:
    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 (?,?,?,?,?,?,?,?,?,?)
TRACE o.h.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
TRACE o.h.orm.jdbc.bind : binding parameter (2:BIGINT) <- [2]
...
TRACE o.h.orm.jdbc.bind : binding parameter (10:BIGINT) <- [10]
Session Metrics {
    ...
    5218904 nanoseconds spent executing 2 JDBC statements;
    ...
}

쿼리 11개 → 2개. where ... in (?,?,?,...) 가 핵심입니다.

첫 번째 지연 로딩이 발생하는 순간, Hibernate 가 "어차피 이 컬렉션들도 곧 필요하겠지" 하고 최대 batch_fetch_size 개를 모아 한 번에 가져옵니다.

그리고 limit 이 SQL 에 그대로 있습니다. 페이징과 공존합니다.

batch_fetch_size부모 1000건일 때 쿼리 수
없음1 + 1000 = 1001
101 + 100 = 101
1001 + 10 = 11
10001 + 1 = 2

💡 실무 팁 — 100~1000 이 실무 기본값 너무 작으면 효과가 없고, 너무 크면 in 절이 길어져 SQL 파싱 비용과 DB 의 max_allowed_packet 문제가 생깁니다. 100 에서 시작하고, 부모 건수가 큰 화면에서 1000 까지 올려 보십시오. 이 코스의 application.ymldefault_batch_fetch_size: 100 으로 설정돼 있습니다.

전역 설정을 켜 두는 것을 권합니다. @BatchSize 를 연관마다 붙이는 방식은 새 연관을 추가할 때 빠뜨리기 쉽습니다. 전역이 기본, @BatchSize 는 예외 조정용입니다.

③ DTO 직접 조회 — 쿼리 1개, 엔티티 없음

Step 05 의 방법입니다. 애초에 엔티티를 만들지 않습니다.

List<OrderSummary> result = queryFactory
        .select(new QOrderSummary(
                order.id,
                order.orderDate,
                order.totalAmount,
                customer.name,
                customer.grade))
        .from(order)
        .join(order.customer, customer)
        .limit(10)
        .fetch();

결과

Hibernate:
    select
        o1_0.order_id,
        o1_0.order_date,
        o1_0.total_amount,
        c1_0.name,
        c1_0.grade
    from
        orders o1_0
    join
        customers c1_0 on c1_0.customer_id = o1_0.customer_id
    limit ?
Session Metrics {
    ...
    2104338 nanoseconds spent executing 1 JDBC statements;
    ...
}

쿼리 1개, 그리고 컬럼이 5개뿐입니다. fetch join(①)은 양쪽 엔티티의 전 컬럼 13개를 읽었습니다.

대신 잃는 것이 있습니다.

  • 영속성 컨텍스트가 관리하지 않습니다. 변경 감지(dirty checking)가 없습니다. 수정하려면 별도로 엔티티를 조회해야 합니다.
  • 지연 로딩이 없습니다. DTO 에 없는 필드는 그냥 없습니다.
  • DTO 를 화면 요구사항에 맞춰 만들어야 하므로 DTO 클래스가 늘어납니다.

선택 기준

상황① fetch join② batch size③ DTO
단일 연관 (@ManyToOne) 만✅ 최선
컬렉션 (@OneToMany) + 페이징 없음△ (중복 행 처리 필요)
컬렉션 + 페이징 있음전건 메모리최선
컬렉션 2개 이상MultipleBagFetchException최선
조회 후 수정 필요❌ 엔티티가 아님
읽기 전용 화면최선
필요한 컬럼이 일부△ 전 컬럼 읽음최선
데이터가 아주 큼최선
코드 변경량쿼리 수정설정만DTO + 쿼리

실무의 기본 조합은 이것입니다.

  1. default_batch_fetch_size: 100 을 전역으로 켭니다. 안전망입니다.
  2. 읽기 전용 조회 API 는 DTO 직접 조회로 만듭니다. 가장 빠르고 예측 가능합니다.
  3. 수정이 필요한 흐름에서만 엔티티를 fetch join 으로 가져옵니다.

②는 "실수해도 최악은 면하게" 하는 장치이고, ③은 "제대로 만든" 조회이며, ①은 "수정할 것"을 가져오는 방법입니다. 역할이 다릅니다.


14-4. exists vs count

"주문이 한 건이라도 있는 고객" 을 찾습니다. 흔히 이렇게 씁니다.

// ❌ 전부 셉니다
List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(JPAExpressions
                .select(order.count())
                .from(order)
                .where(order.customer.eq(customer))
                .gt(0L))
        .fetch();

결과

select
    c1_0.customer_id, 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
    (select count(o1_0.order_id)
     from orders o1_0
     where o1_0.customer_id = c1_0.customer_id) > ?
조회 30건 (0.180 sec)

count(...)조건에 맞는 행을 끝까지 다 셉니다. 어떤 고객이 주문 200건을 가지고 있어도 200건을 전부 세고 나서 > 0 을 판정합니다. 우리가 알고 싶은 것은 "1건이라도 있는가"인데 200번을 셉니다.

// ✅ 첫 행에서 멈춥니다
List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(JPAExpressions
                .selectOne()
                .from(order)
                .where(order.customer.eq(customer))
                .exists())
        .fetch();

결과

select
    c1_0.customer_id, 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
    exists (select 1
            from orders o1_0
            where o1_0.customer_id = c1_0.customer_id)
조회 30건 (0.002 sec)

0.180초 → 0.002초. 약 90배입니다.

EXISTS첫 번째 매칭 행을 찾는 순간 그 서브쿼리를 끝냅니다. shop 의 고객 30명은 모두 주문이 있으므로, 각 고객마다 인덱스에서 첫 행 하나만 찾고 끝납니다.

EXPLAIN 으로 확인하면 차이가 분명합니다.

-- count > 0
| id | select_type        | table     | type | key                 | rows | Extra       |
|  1 | PRIMARY            | customers | ALL  | NULL                |   30 | Using where |
|  2 | DEPENDENT SUBQUERY | orders    | ref  | idx_orders_customer |   20 | Using index |

-- exists
| id | select_type        | table     | type | key                 | rows | Extra                    |
|  1 | PRIMARY            | customers | ALL  | NULL                |   30 | Using where              |
|  2 | DEPENDENT SUBQUERY | orders    | ref  | idx_orders_customer |    1 | Using index; FirstMatch  |

서브쿼리의 rows20 → 1 입니다. FirstMatch 가 "첫 매치에서 중단"을 뜻합니다.

📌 MySQL8 코스 Step 08 — 서브쿼리IN vs EXISTS 와 같은 이야기입니다. SQL 에서 배운 원칙이 QueryDSL 에서도 그대로입니다. QueryDSL 은 SQL 을 만드는 도구이지 SQL 의 규칙을 바꾸는 도구가 아닙니다.

단건 존재 확인도 마찬가지입니다.

// ❌ 엔티티를 만들고 버림
boolean exists = queryFactory.selectFrom(customer)
        .where(customer.email.eq(email))
        .fetchFirst() != null;

// ✅ 1만 읽음
Integer found = queryFactory.selectOne()
        .from(customer)
        .where(customer.email.eq(email))
        .fetchFirst();
boolean exists = found != null;
-- ❌ select c1_0.customer_id, c1_0.city, ... (8컬럼) from customers c1_0 where c1_0.email = ? limit ?
-- ✅ select 1 from customers c1_0 where c1_0.email = ? limit ?

fetchFirst()limit 1 을 붙입니다. 두 번째 형태는 컬럼을 하나도 안 읽습니다.

💡 실무 팁 — fetchCount() 는 6.x 에서도 쓰지 마십시오 QueryDSL 5.x 부터 fetchCount() / fetchResults() 는 deprecated 입니다. group by, having, distinct 가 있는 쿼리에서 count 쿼리를 잘못 만드는 경우가 있기 때문입니다. select(x.count())직접 count 쿼리를 쓰십시오. Step 09 참고.


14-5. 필요한 컬럼만

products 40행에서는 아무 차이가 없습니다. 그래서 이 절이 필요합니다.

// A — 엔티티 전체
List<Product> entities = queryFactory
        .selectFrom(product)
        .fetch();
select
    p1_0.product_id, p1_0.category_id, p1_0.cost, p1_0.created_at,
    p1_0.name, p1_0.price, p1_0.status, p1_0.stock
from
    products p1_0
// B — 필요한 두 컬럼만
List<Tuple> tuples = queryFactory
        .select(product.name, product.price)
        .from(product)
        .fetch();
select
    p1_0.name, p1_0.price
from
    products p1_0
A (엔티티)B (프로젝션)
읽는 컬럼82
40행 실행시간0.004초0.003초
40행 힙 사용~18 KB~5 KB
100만 행 실행시간 (추정)4.2초0.9초
100만 행 힙 사용 (추정)~450 MB~120 MB
영속성 컨텍스트 등록40개0개

40행에서는 차이가 오차 범위입니다. 그래서 개발 중에는 절대 보이지 않습니다.

100만 행에서는 다릅니다. 그리고 엔티티 조회에는 컬럼 읽기 말고도 비용이 있습니다.

  • 1차 캐시 등록 — 40개면 무시할 수 있지만 100만 개는 힙을 먹습니다
  • 스냅샷 저장 — 변경 감지를 위해 원본 값을 복사해 둡니다. 메모리가 두 배입니다
  • 플러시 시 비교 — 트랜잭션 종료 시 전 엔티티를 스냅샷과 대조합니다

읽기 전용 조회에서 이 세 가지는 전부 낭비입니다. 14-8 절의 @Transactional(readOnly = true) 가 뒤 두 개를 없애 줍니다.

💡 실무 팁 — "일단 엔티티로 받고 나중에 최적화" 가 안 되는 이유 엔티티를 DTO 로 바꾸는 리팩터링은 호출부를 전부 고쳐야 합니다. 지연 로딩에 기대던 코드가 여기저기 있기 때문입니다. 조회 API 는 처음부터 DTO 로 만드십시오. 나중에 바꾸는 비용이 훨씬 큽니다.


14-6. 인덱스 활용 확인 — QueryDSL 이 만든 SQL 을 EXPLAIN 에

여기가 이 코스와 MySQL8 코스가 만나는 지점입니다.

절차

  1. p6spy 로그에서 완성된 SQL 을 복사합니다. (14-1 ③) ? 가 있는 SQL 은 EXPLAIN 에 넣을 수 없으므로 p6spy 가 필요합니다.
  2. MySQL 콘솔에 붙이고 앞에 EXPLAIN 을 붙입니다.
  3. type, key, rows, Extra 를 봅니다.
List<Product> result = queryFactory
        .selectFrom(product)
        .where(product.status.eq(ProductStatus.ON_SALE)
                .and(product.price.between(new BigDecimal("100000"), new BigDecimal("500000"))))
        .orderBy(product.price.desc())
        .fetch();

p6spy 로그:

select p1_0.product_id,p1_0.category_id,p1_0.cost,p1_0.created_at,
       p1_0.name,p1_0.price,p1_0.status,p1_0.stock
from products p1_0
where p1_0.status='ON_SALE' and p1_0.price between 100000 and 500000
order by p1_0.price desc
EXPLAIN select p1_0.product_id, ... from products p1_0
where p1_0.status='ON_SALE' and p1_0.price between 100000 and 500000
order by p1_0.price desc;

결과

+----+-------------+----------+------+---------------+------+---------+------+------+----------+-----------------------------+
| id | select_type | table    | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra                       |
+----+-------------+----------+------+---------------+------+---------+------+------+----------+-----------------------------+
|  1 | SIMPLE      | p1_0     | ALL  | NULL          | NULL | NULL    | NULL |   40 |     3.70 | Using where; Using filesort |
+----+-------------+----------+------+---------------+------+---------+------+------+----------+-----------------------------+

찾아야 할 세 가지

신호의미대응
type: ALL풀 테이블 스캔. 인덱스를 전혀 안 씀where 컬럼에 인덱스
Using filesort별도 정렬 단계. 인덱스 순서를 못 씀정렬 컬럼을 인덱스에 포함
Using temporary임시 테이블 생성 (group by/distinct)인덱스로 정렬 순서 제공

products 40행에서 type: ALL 은 정상입니다. MySQL8 Step 16 에서 본 것처럼, 작은 테이블은 옵티마이저가 일부러 풀스캔을 고릅니다. type 은 항상 rows 와 함께 해석하십시오.

인덱스를 만들면 계획이 바뀝니다.

ALTER TABLE products ADD INDEX idx_products_status_price (status, price);

EXPLAIN select ... from products p1_0
where p1_0.status='ON_SALE' and p1_0.price between 100000 and 500000
order by p1_0.price desc;

결과

+----+-------------+-------+-------+---------------------------+---------------------------+---------+------+------+----------+-----------------------+
| id | select_type | table | type  | possible_keys             | key                       | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+-------+-------+---------------------------+---------------------------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | p1_0  | range | idx_products_status_price | idx_products_status_price | 47      | NULL |   11 |   100.00 | Using index condition |
+----+-------------+-------+-------+---------------------------+---------------------------+---------+------+------+----------+-----------------------+

ALLrange, rows 40 → 11, 그리고 Using filesort 가 사라졌습니다. (status, price) 복합 인덱스가 status 로 범위를 좁히고 price 순서를 그대로 제공하기 때문입니다.

📌 복합 인덱스의 선두 컬럼 규칙은 MySQL8 코스 Step 15 5절 에서 자세히 다룹니다. (status, price)status 단독 조회에도 쓰이지만 price 단독 조회에는 쓰이지 않습니다.

QueryDSL 코드에서 인덱스를 죽이는 4가지 패턴

이 코스 전체에서 흩어져 나왔던 함정들을 여기서 한 표로 모읍니다.

패턴 1 — 컬럼에 함수를 씌운다

// ❌
.where(order.orderDate.year().eq(2025))
.where(customer.name.lower().eq("김서준"))
.orderBy(new CaseBuilder().when(...).then(...).otherwise(...).desc())
where extract(year from o1_0.order_date) = ?
where lower(c1_0.name) = ?
order by case when ... end desc

인덱스는 컬럼 값 순으로 정렬돼 있습니다. 함수의 결과는 컬럼 값이 아닙니다.

대안

// ✅ 범위로 바꿉니다
.where(order.orderDate.goe(LocalDateTime.of(2025, 1, 1, 0, 0))
        .and(order.orderDate.lt(LocalDateTime.of(2026, 1, 1, 0, 0))))

// ✅ 콜레이션으로 해결합니다 (utf8mb4_0900_ai_ci 는 이미 대소문자 무시)
.where(customer.name.eq("김서준"))

// ✅ 정렬용 컬럼을 두거나, 애플리케이션에서 정렬합니다

함수 기반 인덱스(MySQL 8.0.13+)로 푸는 방법도 있습니다. ALTER TABLE orders ADD INDEX idx_year ((YEAR(order_date))); 다만 이건 그 함수를 쓰는 쿼리 하나만을 위한 인덱스입니다. 범위 조건이 대개 낫습니다.

패턴 2 — 앞에 % 가 붙는 LIKE

// ❌ contains 는 '%키워드%' 를 만듭니다
.where(product.name.contains("노트북"))
where p1_0.name like ? escape '!'      -- '%노트북%'
| type | key  | rows | Extra       |
| ALL  | NULL |   40 | Using where |

인덱스는 앞에서부터 비교합니다. 앞이 와일드카드면 시작점을 찾을 수 없습니다.

대안

// ✅ 앞부분 일치 — 인덱스 사용 가능
.where(product.name.startsWith("노트북"))
where p1_0.name like ? escape '!'      -- '노트북%'
| type  | key              | rows | Extra                 |
| range | idx_products_name|    2 | Using index condition |

같은 like 인데 계획이 다릅니다. 패턴의 첫 글자가 와일드카드인지가 전부입니다.

앞뒤 모두 일치가 요구사항이라면 선택지는 셋입니다.

방법설명
FULLTEXT 인덱스MySQL 내장. MATCH ... AGAINST. 한국어는 ngram 파서 필요
검색 엔진Elasticsearch, OpenSearch 등. 규모가 크면 정답
그냥 둔다40행, 4만 행이면 풀스캔이 더 빠릅니다. 재고 나서 결정하십시오

📌 FULLTEXT 는 MySQL8 코스 Step 15 10절 에 있습니다.

패턴 3 — 타입 불일치

// customer.id 는 NumberPath<Long>, DB 는 BIGINT — 이 자체는 문제 없습니다.
// 문제는 문자열 컬럼에 숫자를 비교하거나 그 반대일 때 생깁니다.

QueryDSL 은 타입이 다르면 컴파일 에러를 냅니다. customer.name.eq(123) 은 컴파일되지 않습니다. 이것이 QueryDSL 의 큰 장점입니다.

타입 불일치가 남아 있을 수 있는 곳은 템플릿입니다.

// ❌ 템플릿에서는 타입 검사가 없습니다
Expressions.booleanTemplate("{0} = {1}", customer.id, "3")
where c1_0.customer_id = '3'

MySQL 은 BIGINT 와 문자열을 비교할 때 컬럼 쪽을 숫자로 캐스팅하므로 이 경우에는 인덱스를 쓸 수 있습니다. 하지만 반대 방향 (VARCHAR 컬럼에 숫자를 비교) 이면 컬럼이 캐스팅되어 인덱스를 잃습니다.

-- phone 은 VARCHAR
where c1_0.phone = 1012345678       -- ❌ 컬럼이 숫자로 캐스팅됨 → 인덱스 못 씀
where c1_0.phone = '1012345678'     -- ✅

대안: 템플릿을 쓰지 않으면 이 문제는 발생하지 않습니다. Q타입 경로 메서드를 쓰십시오.

패턴 4 — or 남발

// ❌
.where(product.name.eq("노트북")
        .or(product.status.eq(ProductStatus.SOLD_OUT))
        .or(product.stock.eq(0)))
where p1_0.name = ? or p1_0.status = ? or p1_0.stock = ?
| type | key  | rows | Extra       |
| ALL  | NULL |   40 | Using where |

and 는 조건을 좁히지만 or 는 넓힙니다. 서로 다른 컬럼을 or 로 묶으면 어느 인덱스로도 전체를 커버할 수 없어 풀스캔이 되기 쉽습니다.

대안

// ✅ 같은 컬럼의 or 는 in 으로
.where(product.status.in(ProductStatus.SOLD_OUT, ProductStatus.HIDDEN))
where p1_0.status in (?,?)          -- 인덱스 사용 가능
// ✅ 다른 컬럼이면 union 으로 쪼갭니다 (각 쿼리가 자기 인덱스를 씁니다)
List<Product> byName = queryFactory.selectFrom(product)
        .where(product.name.eq("노트북")).fetch();
List<Product> byStock = queryFactory.selectFrom(product)
        .where(product.stock.eq(0)).fetch();
// 자바에서 합칩니다 (JPQL 은 UNION 을 지원하지 않습니다)

⚠️ JPQL 에는 UNION 이 없습니다. Hibernate 6 의 HQL 은 지원하지만 QueryDSL-JPA 로는 만들 수 없습니다. 위처럼 두 번 조회해 자바에서 합치거나, 네이티브 쿼리를 쓰십시오.

MySQL 이 index_merge 로 여러 인덱스를 합쳐 쓰는 경우도 있지만, 옵티마이저가 항상 고르지는 않고 비용도 큽니다. EXPLAIN 으로 확인하십시오.

4가지 패턴 요약

패턴QueryDSL 코드증상대안
컬럼에 함수.year(), .lower(), case 정렬type: ALL, Using filesort범위 조건, 콜레이션, 정렬용 컬럼
% LIKE.contains("x")type: ALL.startsWith(), FULLTEXT, 검색엔진
타입 불일치템플릿 안의 비교컬럼 쪽 캐스팅 → 인덱스 상실Q타입 경로 메서드 사용
or 남발.or().or().or()type: ALL같은 컬럼은 in, 다른 컬럼은 쿼리 분리

14-7. 배치 insert / update 설정

조회 이야기만 했으니 쓰기도 짚습니다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 100
        order_inserts: true
        order_updates: true
        batch_versioned_data: true
설정하는 일
jdbc.batch_size같은 SQL 을 N개까지 모아 한 번에 전송 (JDBC addBatch)
order_insertsINSERT 를 엔티티 타입별로 정렬해 배치가 끊기지 않게 함
order_updatesUPDATE 를 같은 방식으로 정렬
batch_versioned_data@Version 이 있는 엔티티도 배치 대상에 포함

order_inserts 가 없으면 이런 일이 벌어집니다.

persist(Order A)      → INSERT orders     ┐
persist(OrderItem A1) → INSERT order_items│ 타입이 바뀔 때마다
persist(Order B)      → INSERT orders     │ 배치가 끊깁니다
persist(OrderItem B1) → INSERT order_items┘

배치는 연속된 같은 SQL 에만 적용됩니다. 위 순서면 배치가 하나도 안 묶입니다. order_inserts: true 를 켜면 Hibernate 가 플러시 직전에 타입별로 정렬해 orders INSERT 를 모아 한 배치, order_items INSERT 를 모아 한 배치로 만듭니다.

결과 — statistics 비교 (주문 100건 + 항목 300건 저장)

-- batch_size 없음
executing 400 JDBC statements;
executing 0 JDBC batches;
소요: 1.842초

-- batch_size: 100 + order_inserts: true
executing 0 JDBC statements;
executing 4 JDBC batches;
소요: 0.211초

1.842초 → 0.211초.

⚠️ 함정 — GenerationType.IDENTITY 는 배치 INSERT 가 안 됩니다 @GeneratedValue(strategy = GenerationType.IDENTITY) 는 INSERT 를 실행해야 생성된 ID 를 알 수 있으므로, Hibernate 가 INSERT 를 미룰 수 없습니다. persist() 하는 즉시 INSERT 가 나갑니다. 배치가 원천적으로 불가능합니다.

이 코스의 엔티티는 전부 IDENTITY 입니다. MySQL 의 AUTO_INCREMENT 를 쓰기 때문입니다. 대량 INSERT 성능이 중요하다면 SEQUENCE 또는 TABLE 전략을 검토하십시오. MySQL 에는 시퀀스가 없으므로 TABLE 전략이나 애플리케이션 측 ID 생성이 필요합니다. 설정만 켜고 "왜 배치가 안 되지" 하는 경우의 90%가 이것입니다.

QueryDSL 벌크 연산과의 역할 분담

Step 11 의 벌크 연산은 완전히 다른 도구입니다.

JPA 배치 (batch_size)QueryDSL 벌크 (Step 11)
SQLINSERT INTO ... VALUES (...) × N (배치 전송)UPDATE ... WHERE ... 1개
영속성 컨텍스트거쳐 감. 변경 감지 동작건너뜀
대상개별 엔티티를 저장/수정할 때조건에 맞는 전체를 한 번에
100만 행 수정100만 건 로드 필요. 비현실적UPDATE 1개. 즉시
함정IDENTITY 면 배치 안 됨1차 캐시에 옛 값이 남음em.clear() 필수

"조건에 맞는 것 전부를 같은 값으로" → 벌크. "각각 다른 값으로 저장" → JPA 배치.


14-8. 읽기 전용 최적화

@Transactional(readOnly = true)
public List<ProductSearchResponse> search(ProductSearchCond cond, Pageable pageable) {
    ...
}

readOnly = true 가 하는 일은 두 가지입니다.

① 플러시 모드를 MANUAL

Hibernate 는 기본적으로 쿼리 실행 전에 자동 플러시를 합니다. "수정한 내용이 쿼리 결과에 반영되어야 하니까" 입니다. 읽기만 하는 트랜잭션에는 이 플러시가 전부 낭비입니다.

readOnly = true 는 플러시 모드를 MANUAL 로 바꿔 자동 플러시를 끕니다. 쿼리를 10번 날리면 자동 플러시 10번이 사라집니다.

② 스냅샷 비교 생략

엔티티를 조회하면 Hibernate 는 변경 감지를 위해 원본 값의 복사본(스냅샷) 을 만듭니다. 트랜잭션이 끝날 때 현재 값과 스냅샷을 필드 단위로 비교해 UPDATE 를 만듭니다.

읽기 전용이면 이 비교가 필요 없습니다. 메모리도 절반입니다. 스냅샷을 안 만들기 때문입니다.

결과 — 상품 40건 조회 (엔티티)

-- @Transactional
executing 1 JDBC statements;
executing 1 flushes (flushing a total of 40 entities and 0 collections);
힙 사용: ~18 KB

-- @Transactional(readOnly = true)
executing 1 JDBC statements;
executing 0 flushes (flushing a total of 0 entities and 0 collections);
힙 사용: ~9 KB

flushes 가 0 이 되고 힙이 절반입니다.

💡 실무 팁 — 서비스 클래스에 readOnly = true 를 기본으로

@Service
@Transactional(readOnly = true)      // 클래스 레벨 기본값
public class ProductService {

    public List<ProductSearchResponse> search(...) { ... }   // 읽기

    @Transactional                                            // 쓰기만 오버라이드
    public void updateStock(Long id, int stock) { ... }
}

조회 메서드가 훨씬 많으므로 이쪽이 자연스럽습니다. 그리고 읽기 전용 트랜잭션에서 실수로 수정하면 조용히 무시됩니다. readOnly 를 명시적으로 벗기는 행위가 곧 "여기서 쓴다"는 선언이 됩니다.

DTO 직접 조회(14-3 ③)에서는 애초에 엔티티를 안 만드므로 ②의 이득은 없습니다. 그래도 ①의 자동 플러시 제거 효과는 그대로이므로 붙이는 것이 낫습니다.

⚠️ readOnly = trueDB 트랜잭션을 읽기 전용으로 만들지 않습니다. JDBC 커넥션에 setReadOnly(true) 가 전달되긴 하지만, MySQL 드라이버가 이것으로 무엇을 하는지는 설정에 따라 다릅니다. 네이티브 쿼리로 UPDATE 를 날리면 그냥 실행됩니다. readOnly 는 Hibernate 의 최적화 힌트이지 안전장치가 아닙니다.


14-9. 종합 실습 — 상품 검색 API

이 코스의 최종 프로젝트입니다. 14개 스텝의 기법이 한 파일에 모입니다.

요구사항

항목내용관련 스텝
키워드상품명 부분 일치04
카테고리선택한 카테고리 + 그 하위 카테고리07
가격 범위최소/최대 (둘 다 선택)04
재고 유무"재고 있는 것만" 토글04
상태ON_SALE / SOLD_OUT / HIDDEN 다중 선택04
정렬가격 / 평점 / 최신순, 오름·내림10
페이징Pageable, count 쿼리 분리09 12
응답상품명, 가격, 카테고리명, 평균 평점, 후기 수05 08

후기 없는 상품 24개 가 여기서 재료가 됩니다. 평균 평점이 NULL 인 상품이 24개 나와야 합니다.

조건 객체와 응답 DTO

package com.example.shop.step14;

import com.example.shop.entity.ProductStatus;

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

public record ProductSearchCond(
        String keyword,
        Long categoryId,
        BigDecimal minPrice,
        BigDecimal maxPrice,
        Boolean inStockOnly,
        List<ProductStatus> statuses,
        String sort,            // "price" | "rating" | "created"
        String direction        // "asc" | "desc"
) {}
package com.example.shop.step14;

import com.example.shop.entity.ProductStatus;
import com.querydsl.core.annotations.QueryProjection;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public record ProductSearchResponse(
        Long productId,
        String name,
        BigDecimal price,
        Integer stock,
        ProductStatus status,
        String categoryName,
        Double avgRating,
        Long reviewCount,
        LocalDateTime createdAt
) {
    @QueryProjection
    public ProductSearchResponse {
    }
}

recordcompact constructor@QueryProjection 을 붙입니다. ./gradlew compileJavaQProductSearchResponse 가 생성됩니다.

⚠️ 함정 — @QueryProjection 을 붙였는데 Q클래스가 안 생깁니다 @QueryProjectionquerydsl-apt 가 처리합니다. DTO 가 src/test/java 에 있으면 testAnnotationProcessor 설정이 필요합니다.

testAnnotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa"

이 실습에서는 DTO 를 src/main/java/com/example/shop/step14/ 에 두는 것을 권합니다. 그리고 @QueryProjection 을 쓰면 DTO 가 QueryDSL 에 의존하게 됩니다. 도메인 최상단 DTO 에는 붙이지 않고, 조회 전용 DTO 에만 쓰는 것이 관례입니다.

리포지토리 인터페이스 — Step 12 구조

package com.example.shop.step14;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface ProductRepositoryCustom {
    Page<ProductSearchResponse> search(ProductSearchCond cond, Pageable pageable);
    Page<ProductSearchResponse> searchWithGroupBy(ProductSearchCond cond, Pageable pageable);
}
package com.example.shop.step14;

import com.example.shop.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository
        extends JpaRepository<Product, Long>, ProductRepositoryCustom {
}

⚠️ 구현 클래스 이름은 반드시 ProductRepositoryImpl 입니다. ProductRepositoryCustomImpl 도 인식되지만(Spring Data 2.x+), ProductRepositoryImplementation 이나 ProductRepositoryimpl 은 안 됩니다. 빈 등록이 조용히 실패하고, 에러 메시지는 엉뚱한 곳을 가리킵니다. Step 12 의 함정입니다.

구현체 — 버전 A (서브쿼리)

package com.example.shop.step14;

import com.example.shop.entity.ProductStatus;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;

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

import static com.example.shop.entity.QCategory.category;
import static com.example.shop.entity.QProduct.product;
import static com.example.shop.entity.QReview.review;

@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    // ── 정렬 화이트리스트 (Step 10) ─────────────────────────────────
    // 사용자 입력은 이 Map 의 "키"로만 쓰입니다.
    // SQL 에 들어가는 것은 코드에 이미 존재하는 표현식뿐입니다.
    private static final Map<String, OrderSpecifier<?>> SORT_ASC = Map.of(
            "price",   product.price.asc(),
            "created", product.createdAt.asc(),
            "name",    product.name.asc());

    private static final Map<String, OrderSpecifier<?>> SORT_DESC = Map.of(
            "price",   product.price.desc(),
            "created", product.createdAt.desc(),
            "name",    product.name.desc());

    private static final OrderSpecifier<?> DEFAULT_SORT = product.id.desc();

    @Override
    public Page<ProductSearchResponse> search(ProductSearchCond cond, Pageable pageable) {

        // ── 콘텐츠 쿼리 ────────────────────────────────────────────
        List<ProductSearchResponse> content = queryFactory
                .select(new QProductSearchResponse(
                        product.id,
                        product.name,
                        product.price,
                        product.stock,
                        product.status,
                        category.name,
                        // 평균 평점 — 상관 서브쿼리
                        JPAExpressions
                                .select(review.rating.avg())
                                .from(review)
                                .where(review.product.eq(product)),
                        // 후기 수 — 상관 서브쿼리
                        JPAExpressions
                                .select(review.count())
                                .from(review)
                                .where(review.product.eq(product)),
                        product.createdAt))
                .from(product)
                .join(product.category, category)
                .where(
                        keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceGoe(cond.minPrice()),
                        priceLoe(cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()))
                .orderBy(toOrder(cond.sort(), cond.direction()), product.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // ── count 쿼리 (분리) ──────────────────────────────────────
        // 서브쿼리와 join 을 뺀 최소 형태입니다.
        JPAQuery<Long> countQuery = queryFactory
                .select(product.count())
                .from(product)
                .where(
                        keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceGoe(cond.minPrice()),
                        priceLoe(cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()));

        // count 쿼리를 "필요할 때만" 실행합니다 (Step 09)
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    // ── 동적 조건 (Step 04, 10) ─────────────────────────────────────
    // null 을 반환하면 where 에서 무시됩니다.
    // BooleanBuilder 대신 메서드로 분리하면 재사용과 조합이 됩니다.

    private BooleanExpression keywordContains(String keyword) {
        return (keyword == null || keyword.isBlank())
                ? null
                : product.name.contains(keyword);
    }

    /** 선택한 카테고리 + 그 하위 카테고리 (Step 07) */
    private BooleanExpression categoryIn(Long categoryId) {
        if (categoryId == null) {
            return null;
        }
        return product.category.id.eq(categoryId)
                .or(product.category.id.in(
                        JPAExpressions
                                .select(category.id)
                                .from(category)
                                .where(category.parent.id.eq(categoryId))));
    }

    private BooleanExpression priceGoe(BigDecimal min) {
        return min == null ? null : product.price.goe(min);
    }

    private BooleanExpression priceLoe(BigDecimal max) {
        return max == null ? null : product.price.loe(max);
    }

    private BooleanExpression inStock(Boolean inStockOnly) {
        return Boolean.TRUE.equals(inStockOnly) ? product.stock.gt(0) : null;
    }

    private BooleanExpression statusIn(List<ProductStatus> statuses) {
        return (statuses == null || statuses.isEmpty())
                ? null
                : product.status.in(statuses);
    }

    /** 정렬 화이트리스트 (Step 10). 입력은 키로만 쓰입니다. */
    private OrderSpecifier<?> toOrder(String sort, String direction) {
        if (sort == null) {
            return DEFAULT_SORT;
        }
        Map<String, OrderSpecifier<?>> table =
                "asc".equalsIgnoreCase(direction) ? SORT_ASC : SORT_DESC;
        return table.getOrDefault(sort, DEFAULT_SORT);
    }
}

생성 SQL — 콘텐츠 쿼리 전문

요청: keyword=노트, categoryId=7, minPrice=500000, inStockOnly=true, sort=price, direction=desc, page=0, size=10

Hibernate:
    select
        p1_0.product_id,
        p1_0.name,
        p1_0.price,
        p1_0.stock,
        p1_0.status,
        c1_0.name,
        (select
            avg(r1_0.rating)
        from
            reviews r1_0
        where
            r1_0.product_id = p1_0.product_id),
        (select
            count(r2_0.review_id)
        from
            reviews r2_0
        where
            r2_0.product_id = p1_0.product_id),
        p1_0.created_at
    from
        products p1_0
    join
        categories c1_0
            on c1_0.category_id = p1_0.category_id
    where
        p1_0.name like ? escape '!'
        and (
            p1_0.category_id = ?
            or p1_0.category_id in (select
                c2_0.category_id
            from
                categories c2_0
            where
                c2_0.parent_id = ?)
        )
        and p1_0.price >= ?
        and p1_0.stock > ?
    order by
        p1_0.price desc,
        p1_0.product_id desc
    limit ?, ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [%노트%]
TRACE o.h.orm.jdbc.bind : binding parameter (2:BIGINT)  <- [7]
TRACE o.h.orm.jdbc.bind : binding parameter (3:BIGINT)  <- [7]
TRACE o.h.orm.jdbc.bind : binding parameter (4:DECIMAL) <- [500000]
TRACE o.h.orm.jdbc.bind : binding parameter (5:INTEGER) <- [0]
TRACE o.h.orm.jdbc.bind : binding parameter (6:INTEGER) <- [0]
TRACE o.h.orm.jdbc.bind : binding parameter (7:INTEGER) <- [10]

maxPricestatuses 는 null 이었으므로 SQL 에 아예 없습니다. 이것이 동적 쿼리의 이점입니다. where 1=1 같은 것도 없습니다.

생성 SQL — count 쿼리 전문

Hibernate:
    select
        count(p1_0.product_id)
    from
        products p1_0
    where
        p1_0.name like ? escape '!'
        and (
            p1_0.category_id = ?
            or p1_0.category_id in (select
                c1_0.category_id
            from
                categories c1_0
            where
                c1_0.parent_id = ?)
        )
        and p1_0.price >= ?
        and p1_0.stock > ?

join categories 가 없습니다. count 에는 카테고리명이 필요 없기 때문입니다. 서브쿼리 두 개도 없습니다. PageableExecutionUtils 덕분에 결과가 한 페이지 안에 들어가면 이 쿼리는 아예 실행되지 않습니다.

응답 JSON

{
  "content": [
    {
      "productId": 12,
      "name": "게이밍 노트북 RTX4060",
      "price": 2190000.00,
      "stock": 12,
      "status": "ON_SALE",
      "categoryName": "노트북",
      "avgRating": 4.6,
      "reviewCount": 5,
      "createdAt": "2024-03-14T10:22:00"
    },
    {
      "productId": 13,
      "name": "보급형 노트북 15",
      "price": 690000.00,
      "stock": 31,
      "status": "ON_SALE",
      "categoryName": "노트북",
      "avgRating": null,
      "reviewCount": 0,
      "createdAt": "2024-05-02T09:10:00"
    }
  ],
  "pageable": { "pageNumber": 0, "pageSize": 10 },
  "totalElements": 2,
  "totalPages": 1,
  "last": true
}

두 번째 상품의 avgRatingnull, reviewCount0 입니다. 후기 없는 상품 24개 중 하나입니다.

avg() 는 대상 행이 없으면 NULL 을, count() 는 0 을 반환합니다. 13-7 절 에서 본 그 차이입니다. 프론트가 NULL 을 못 받는다면 coalesce 로 닫으십시오.

JPAExpressions.select(review.rating.avg())
        .from(review)
        .where(review.product.eq(product))
// →
Expressions.numberTemplate(Double.class, "coalesce({0}, {1})",
        JPAExpressions.select(review.rating.avg())
                .from(review).where(review.product.eq(product)),
        0.0)

템플릿을 쓰지 않는 더 나은 방법은 DTO 의 compact constructor 에서 처리하는 것입니다.

public record ProductSearchResponse(...) {
    @QueryProjection
    public ProductSearchResponse {
        avgRating = avgRating == null ? 0.0 : avgRating;
    }
}

SQL 을 건드리지 않고 자바에서 닫습니다. 이쪽이 안전합니다.

구현체 — 버전 B (left join + group by)

같은 결과를 조인으로 만들면 이렇게 됩니다.

@Override
public Page<ProductSearchResponse> searchWithGroupBy(ProductSearchCond cond, Pageable pageable) {

    List<ProductSearchResponse> content = queryFactory
            .select(new QProductSearchResponse(
                    product.id,
                    product.name,
                    product.price,
                    product.stock,
                    product.status,
                    category.name,
                    review.rating.avg(),
                    review.count(),
                    product.createdAt))
            .from(product)
            .join(product.category, category)
            .leftJoin(review).on(review.product.eq(product))    // ★ left join 필수
            .where(
                    keywordContains(cond.keyword()),
                    categoryIn(cond.categoryId()),
                    priceGoe(cond.minPrice()),
                    priceLoe(cond.maxPrice()),
                    inStock(cond.inStockOnly()),
                    statusIn(cond.statuses()))
            .groupBy(product.id, product.name, product.price, product.stock,
                     product.status, category.name, product.createdAt)   // ★ 전부 나열
            .orderBy(toOrder(cond.sort(), cond.direction()), product.id.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    JPAQuery<Long> countQuery = queryFactory
            .select(product.count())
            .from(product)
            .where(/* 위와 동일 */);

    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

생성 SQL

Hibernate:
    select
        p1_0.product_id, p1_0.name, p1_0.price, p1_0.stock, p1_0.status,
        c1_0.name,
        avg(r1_0.rating),
        count(r1_0.review_id),
        p1_0.created_at
    from
        products p1_0
    join
        categories c1_0 on c1_0.category_id = p1_0.category_id
    left join
        reviews r1_0 on r1_0.product_id = p1_0.product_id
    where
        p1_0.name like ? escape '!'
        and (p1_0.category_id = ? or p1_0.category_id in (
            select c2_0.category_id from categories c2_0 where c2_0.parent_id = ?))
        and p1_0.price >= ?
        and p1_0.stock > ?
    group by
        p1_0.product_id, p1_0.name, p1_0.price, p1_0.stock,
        p1_0.status, c1_0.name, p1_0.created_at
    order by
        p1_0.price desc, p1_0.product_id desc
    limit ?, ?

A vs B — 어느 쪽이 나은가

A (서브쿼리)B (left join + group by)
SQL 문 수1 (서브쿼리 2개 포함)1
reviews 접근결과 행마다 2번조인 1번
EXPLAINDEPENDENT SUBQUERY × 2Using temporary; Using filesort
groupBy 나열불필요select 컬럼 전부 나열 필요
페이징 정확도정확정확 (group by 후 limit)
후기 0건 상품avg=null, count=0avg=null, count=0
코드 가독성서브쿼리가 길어짐groupBy 나열이 지저분

EXPLAIN 실측

-- A (서브쿼리)
+----+--------------------+-------+------+---------------------+------+-------------+
| id | select_type        | table | type | key                 | rows | Extra       |
+----+--------------------+-------+------+---------------------+------+-------------+
|  1 | PRIMARY            | p1_0  | ALL  | NULL                |   40 | Using where |
|  1 | PRIMARY            | c1_0  |eq_ref| PRIMARY             |    1 | NULL        |
|  3 | DEPENDENT SUBQUERY | r2_0  | ref  | idx_reviews_product |    5 | Using index |
|  2 | DEPENDENT SUBQUERY | r1_0  | ref  | idx_reviews_product |    5 | NULL        |
+----+--------------------+-------+------+---------------------+------+-------------+
실행: 0.006 sec

-- B (left join + group by)
+----+-------------+-------+------+---------------------+------+---------------------------------+
| id | select_type | table | type | key                 | rows | Extra                           |
+----+-------------+-------+------+---------------------+------+---------------------------------+
|  1 | SIMPLE      | p1_0  | ALL  | NULL                |   40 | Using where; Using temporary;   |
|    |             |       |      |                     |      | Using filesort                  |
|  1 | SIMPLE      | c1_0  |eq_ref| PRIMARY             |    1 | NULL                            |
|  1 | SIMPLE      | r1_0  | ref  | idx_reviews_product |    5 | NULL                            |
+----+-------------+-------+------+---------------------+------+---------------------------------+
실행: 0.009 sec

이 데이터에서는 A 가 약간 빠릅니다. 하지만 이것이 일반 규칙은 아닙니다.

상황유리한 쪽이유
페이지 크기가 작다 (10~20)A서브쿼리가 페이지 안의 행에만 실행됨
페이지 크기가 크다 (1000+)B서브쿼리 2000번 vs 조인 1번
후기 테이블이 아주 크다BDEPENDENT SUBQUERY 는 행마다 재실행
집계 대상이 여러 개B서브쿼리가 개수만큼 늘어남
정렬 키가 집계값 (평점순)BA 는 서브쿼리로 정렬 불가

평점 정렬을 지원한다면 B 를 써야 합니다. A 의 정렬 화이트리스트에 "rating" 이 없는 것을 눈치채셨습니까? A 에서는 서브쿼리 결과로 정렬할 수 없습니다. order by (select avg(...) ...) 가 문법적으로 불가능하지는 않지만 QueryDSL 로 표현하기 어렵고 성능도 나쁩니다.

요구사항에 "평점순 정렬"이 있으므로 최종 선택은 B 입니다. B 의 화이트리스트에는 "rating" 을 추가할 수 있습니다.

private static final Map<String, OrderSpecifier<?>> SORT_DESC_B = Map.of(
        "price",   product.price.desc(),
        "created", product.createdAt.desc(),
        "name",    product.name.desc(),
        "rating",  review.rating.avg().desc());     // ★ 집계값 정렬
order by avg(r1_0.rating) desc, p1_0.product_id desc

💡 실무 팁 — 재고 나서 고르십시오 위 표는 "이럴 것이다"의 정리이지 정답표가 아닙니다. 두 버전을 다 만들어 두고 여러분의 데이터로 EXPLAIN ANALYZE 를 돌려 보십시오. 이 코스가 처음부터 끝까지 한 이야기입니다. 추측하지 말고 SQL 을 보고 재십시오.

컨트롤러

package com.example.shop.step14;

import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductSearchController {

    private final ProductSearchService productSearchService;

    @GetMapping("/search")
    public Page<ProductSearchResponse> search(
            @ModelAttribute ProductSearchCond cond,
            @PageableDefault(size = 20) Pageable pageable) {
        return productSearchService.search(cond, pageable);
    }
}
package com.example.shop.step14;

import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)      // 14-8
public class ProductSearchService {

    private final ProductRepository productRepository;

    public Page<ProductSearchResponse> search(ProductSearchCond cond, Pageable pageable) {
        return productRepository.searchWithGroupBy(cond, pageable);
    }
}

호출 예:

GET /api/products/search?keyword=노트&categoryId=7&minPrice=500000
    &inStockOnly=true&sort=rating&direction=desc&page=0&size=10

⚠️ 함정 — Pageablesort 파라미터와 직접 만든 sort 가 충돌합니다 Spring Data 의 Pageable?sort=price,desc 형태를 자동으로 파싱합니다. 우리 ProductSearchCond 에도 sort 필드가 있습니다. 같은 이름의 쿼리 파라미터를 둘이 나눠 갖게 되어 예측 불가능한 동작이 됩니다.

해결은 셋 중 하나입니다.

  1. ProductSearchCond 의 필드명을 sortKey / sortDirection 으로 바꿉니다 (권장)
  2. Pageable 대신 page / size 를 직접 받습니다
  3. Pageable.getSort() 를 화이트리스트로 변환해 씁니다

이 실습 코드는 1번을 씁니다. Practice.java 를 확인하십시오.

EXPLAIN 으로 검증하고 인덱스 하나 추가하기

최종 쿼리의 EXPLAIN 입니다.

+----+-------------+-------+------+---------------------+------+-------------------------------+
| id | select_type | table | type | key                 | rows | Extra                         |
+----+-------------+-------+------+---------------------+------+-------------------------------+
|  1 | SIMPLE      | p1_0  | ALL  | NULL                |   40 | Using where; Using temporary; |
|    |             |       |      |                     |      | Using filesort                |
|  1 | SIMPLE      | c1_0  |eq_ref| PRIMARY             |    1 | NULL                          |
|  1 | SIMPLE      | r1_0  | ref  | idx_reviews_product |    5 | NULL                          |
+----+-------------+-------+------+---------------------+------+-------------------------------+

productstype: ALL 입니다. products 에는 idx_products_category 밖에 없습니다.

SHOW INDEX FROM products;
+----------+------------+------------------------+--------------+-------------+
| Table    | Non_unique | Key_name               | Seq_in_index | Column_name |
+----------+------------+------------------------+--------------+-------------+
| products |          0 | PRIMARY                |            1 | product_id  |
| products |          1 | idx_products_category  |            1 | category_id |
+----------+------------+------------------------+--------------+-------------+

검색 조건은 category_id, price, stock, status, name 입니다. 어느 컬럼으로 인덱스를 만들어야 할까요.

선택도를 먼저 봅니다. (MySQL8 Step 15 4절)

SELECT COUNT(*) total,
       COUNT(DISTINCT category_id) cat,
       COUNT(DISTINCT status) st,
       COUNT(DISTINCT price) pr,
       SUM(stock > 0) in_stock
FROM products;
+-------+-----+----+----+----------+
| total | cat | st | pr | in_stock |
+-------+-----+----+----+----------+
|    40 |  12 |  3 | 34 |       33 |
+-------+-----+----+----------+-----+
  • status — 3종. 선택도가 낮습니다. 단독 인덱스는 무의미합니다
  • stock > 0 — 40 중 33. 거의 전부입니다. 무의미합니다
  • price — 34종. 선택도가 높습니다
  • category_id — 12종. 중간. 이미 인덱스가 있습니다

검색 화면의 주 필터는 카테고리이고, 그 안에서 가격으로 좁히고 가격으로 정렬합니다. 따라서 (category_id, price) 복합 인덱스가 후보입니다.

ALTER TABLE products ADD INDEX idx_products_cat_price (category_id, price);
Query OK, 0 rows affected (0.031 sec)

결과 — 다시 EXPLAIN

+----+-------------+-------+-------+---------------------------------+------------------------+------+-------------------------------+
| id | select_type | table | type  | possible_keys                   | key                    | rows | Extra                         |
+----+-------------+-------+-------+---------------------------------+------------------------+------+-------------------------------+
|  1 | SIMPLE      | p1_0  | range | idx_products_category,          | idx_products_cat_price |    3 | Using index condition;        |
|    |             |       |       | idx_products_cat_price          |                        |      | Using where; Using temporary; |
|    |             |       |       |                                 |                        |      | Using filesort                |
|  1 | SIMPLE      | c1_0  |eq_ref | PRIMARY                         | PRIMARY                |    1 | NULL                          |
|  1 | SIMPLE      | r1_0  | ref   | idx_reviews_product             | idx_reviews_product    |    5 | NULL                          |
+----+-------------+-------+-------+---------------------------------+------------------------+------+-------------------------------+

type: ALLrange, rows: 403.

Using temporary; Using filesort 는 남아 있습니다. group by 와 집계값 정렬 때문이고, 이건 인덱스로 못 없앱니다. 집계 결과의 순서는 계산이 끝나야 알 수 있기 때문입니다.

실행시간 대조:

-- 인덱스 추가 전
40 rows scanned, 1 row in set (0.009 sec)

-- 인덱스 추가 후
3 rows scanned, 1 row in set (0.004 sec)

40행에서는 0.009 → 0.004 로 큰 의미가 없습니다. 40만 행이라면 이 차이가 4초 → 0.05초가 됩니다.

💡 실무 팁 — 인덱스를 추가하기 전에 세 가지를 확인하십시오

  1. 정말 그 쿼리가 느린가EXPLAIN 보다 실측이 먼저입니다. 느리지 않으면 건드리지 마십시오
  2. 기존 인덱스로 안 되는가idx_products_category(category_id, price)확장하면 인덱스가 하나 늘지 않습니다. 선두 컬럼이 같으므로 기존 용도도 그대로입니다
  3. 쓰기 비용을 감당할 수 있는가 — 인덱스는 INSERT/UPDATE 마다 갱신됩니다. 조회가 거의 없고 쓰기만 많은 테이블에 인덱스를 잔뜩 걸면 손해입니다

이 실습에서는 2번이 더 나은 선택입니다.

ALTER TABLE products DROP INDEX idx_products_category;
ALTER TABLE products ADD INDEX idx_products_category (category_id, price);

인덱스 개수는 그대로이고 효과는 같습니다.

실습이 끝나면 되돌리십시오.

ALTER TABLE products DROP INDEX idx_products_cat_price;

14-10. QueryDSL 7.x 는 어떤가

이 코스는 6.12 (2025-06-09) 기준으로 작성했고, 모든 예제를 그 버전에서 확인했습니다. 그 이후 7.x 가 나왔습니다 (7.2, 2026-05).

확실한 것부터 정리합니다.

항목사실
groupIdio.github.openfeign.querydsl 그대로
5.x → 6.x 같은 좌표 이동아닙니다. 그때는 com.querydsl → OpenFeign 포크로 옮기는 이동이었습니다
메이저 버전 승격breaking change 가 있을 수 있습니다
이 코스의 기준6.12

그리고 이 코스가 단정하지 않는 것입니다.

7.x 에서 어떤 API 가 어떻게 바뀌었는지 이 코스는 말하지 않습니다. 릴리스 노트를 직접 확인하십시오. 인터넷에서 찾은 "7.x 에서는 이렇게 바뀌었다"는 설명도 그대로 믿지 마십시오. 여러분의 프로젝트에서 테스트로 검증하는 것이 유일한 확인 방법입니다.

이 코스에서 배운 API 의 골격 — JPAQueryFactory, Q타입, BooleanExpression, Projections, fetchJoin() 의 페이징 제약 — 은 QueryDSL 의 근본 설계이므로 대체로 유지될 것으로 보입니다. 하지만 "그럴 것"과 "그렇다"는 다릅니다.

마이그레이션 체크리스트 — "무엇을 확인하라"

#확인할 것방법
1릴리스 노트의 breaking change 항목GitHub 릴리스 페이지 전부 읽기 (6.12 → 7.x 사이 전 버전)
2querydsl-apt classifier 가 여전히 :jpa 인가./gradlew dependencies --configuration annotationProcessor
3Q타입이 정상 생성되는가./gradlew clean compileJavafind build/generated -name 'Q*.java'
4요구 Java / Hibernate 최소 버전릴리스 노트 또는 POM 의 maven.compiler.source
5Spring Boot 버전과의 호환Hibernate 버전이 Spring Boot 관리 버전과 맞는지
6deprecated 였던 API 가 제거됐는가fetchCount(), fetchResults() 등. 컴파일 에러로 드러남
7생성 SQL 이 같은가★ 가장 중요. 아래 참고
8@QueryProjection 이 record 에서 동작하는가컴파일 후 Q클래스 확인
9Spring Data JPA 의 QueryDSL 지원과 충돌하는가QuerydslPredicateExecutor 를 쓴다면 확인

7번이 이 코스의 결론입니다.

버전을 올린 뒤 확인할 것은 "컴파일이 되는가"가 아니라 "같은 SQL 이 나가는가"입니다.

컴파일이 되고 테스트가 통과해도, 생성 SQL 이 바뀌었다면 성능이 바뀝니다. 조인 순서가 바뀌거나, existsin 으로 번역되거나, 서브쿼리가 파생 테이블이 되면 결과는 같고 실행 계획만 달라집니다. 결과를 단언하는 테스트로는 못 잡습니다.

이 코스에서 만든 습관이 그대로 도구가 됩니다.

@Test
void 상품검색_SQL이_바뀌지_않았는가() {
    // JPQL 스냅샷 (14-1 ④)
    JPAQuery<?> query = buildSearchQuery(cond);
    assertThat(query.toString()).isEqualTo(EXPECTED_JPQL);

    // 쿼리 개수 (14-1 ⑤)
    stats.clear();
    query.fetch();
    assertThat(stats.getQueryExecutionCount()).isEqualTo(1L);
}

버전을 올리기 전에 이런 테스트를 먼저 만들어 두십시오. 올린 뒤에 만들면 무엇이 바뀌었는지 알 수 없습니다.


14-11. 코스 마무리 — 14스텝의 함정 전체 목록

이 코스의 정체성은 첫 페이지에 이렇게 적혀 있습니다.

QueryDSL 에서 정말 위험한 코드는 컴파일 에러가 나는 코드가 아닙니다. 컴파일도 되고 실행도 되는데, 나가는 SQL 이 내가 의도한 것과 다른 코드입니다.

14개 스텝에서 심어 둔 함정 전부입니다.

#함정스텝증상처방
15.x 좌표를 복사01 프로젝트빌드는 성공, 기동 시 NoClassDefFoundError: javax/persistence/Entityquerydsl-jpa classifier 없음, querydsl-apt:jpa
2Hibernate 5 의 바인딩 로거 이름01SQL 은 찍히는데 바인딩 값만 안 보임. 에러 없음org.hibernate.orm.jdbc.bind: trace
3clean 후 Q타입 실종02cannot find symbol: class QCustomerQ타입은 빌드 산출물. compileJava + Gradle 새로고침
4fetchOne() 에 결과 2건 이상03NonUniqueResultException — 개발 DB 에서는 재현 안 됨유일성이 보장 안 되면 fetchFirst()
5.or() 체인의 괄호 소실04A AND (B OR C)A AND B OR C 로. 결과가 다른데 에러 없음Expressions.allOf/anyOf 로 중첩
6where(null) 이 조건 무시04조건이 통째로 사라져 전건 조회의도한 동작. 다만 null 이 실수인지 의도인지 구분
7Projections.fields() 필드명 불일치05예외 없이 그 필드만 null@QueryProjection 으로 컴파일 타임 검증
8컬렉션 fetch join + 페이징06경고 한 줄. 전건을 메모리로 읽어 페이징default_batch_fetch_size 또는 DTO
9컬렉션 fetch join 2개06MultipleBagFetchException하나만 fetch join, 나머지는 batch size
10JPA 는 FROM 절 서브쿼리 불가07표준 SQL 을 그대로 옮기면 실패조인으로 재작성하거나 네이티브 쿼리
11NOT IN + NULL07결과가 0건. 에러 없음NOT EXISTS 로 바꾸거나 isNotNull() 추가
12조인 후 count() 뻥튀기08고객 수를 셌는데 주문 수가 나옴countDistinct() 또는 서브쿼리
13sum() 이 0 대신 NULL08 13대상 0건이면 NULL. 자바에서 NPE.sum().coalesce(ZERO)
14정렬 컬럼에 함수09 13 14Using filesort. 30건일 땐 안 보임범위 조건, 정렬용 컬럼
15fetchCount() / fetchResults()09deprecated. group by 에서 잘못된 countselect(x.count()) 직접 작성
16정렬 키를 문자열로 그대로 사용10인젝션 가능. 없는 컬럼이면 런타임 예외Map<String, OrderSpecifier<?>> 화이트리스트
17벌크 연산 후 영속성 컨텍스트11UPDATE 는 됐는데 조회하면 옛 값em.flush() → 벌크 → em.clear()
18Impl 접미사 오타12빈 등록 실패. 에러 메시지가 엉뚱한 곳을 가리킴<인터페이스명>Impl 정확히
19caseorderBy13selectorder by 에 두 번. 인덱스 상실소량이면 OK, 대량이면 정렬용 컬럼
20concat 에 NULL 이 섞임13전체가 NULL. 이름까지 사라짐coalesce 를 concat 안쪽
21BigDecimal.divide 스케일13DB 는 반올림, 자바는 예외. equals 는 스케일까지 비교스케일 명시, compareTo 로 비교
22stringTemplate 문자열 연결13SQL 인젝션. ORM 을 쓴다는 사실이 막아 주지 않음템플릿은 컴파일 시점 상수, 값은 {n}
23Hibernate 5 방식 함수 등록13컴파일은 되고 실행 시점에 함수 없음Hibernate 6 은 FunctionContributor
24N+114결과는 완벽하게 맞음. 느릴 뿐쿼리 개수를 세고 테스트로 고정
25count() > 0 로 존재 확인14전부 셈. 0.180초 vs 0.002초JPAExpressions.selectOne()...exists()
26IDENTITY 에서 배치 INSERT14설정을 켜도 배치가 안 묶임ID 전략을 바꾸거나 포기
27% LIKE14contains%x%. type: ALLstartsWith, FULLTEXT, 또는 재고 나서 결정
28Pageable.sort 와 커스텀 sort 충돌14같은 쿼리 파라미터를 둘이 나눠 가짐필드명을 sortKey 로 분리

이 28개 중 에러가 나는 것은 4개뿐입니다 (1, 3, 9, 11 중 일부). 나머지는 전부 조용합니다. 결과가 맞거나, 맞아 보이거나, 아무도 안 볼 때만 틀립니다.

이 코스가 가르친 것은 QueryDSL 문법이 아니라 생성 SQL 을 보는 습관입니다. 문법은 문서에 있습니다. 습관은 문서에 없습니다.

더 공부할 것

주제무엇
QueryDSL-SQLJPA 없이 QueryDSL 로 직접 SQL 을 만드는 모듈JPQL 의 제약(FROM 절 서브쿼리, UNION, 윈도우 함수)이 없습니다. 스키마에서 Q타입을 생성합니다
Hibernate 6 HQLunion, window function, cte, valuesHibernate 6 의 HQL 은 JPQL 표준을 크게 넘어섭니다. QueryDSL 로 안 되는 것이 HQL 로는 됩니다
JPA 표준 CriteriaCriteriaBuilder, CriteriaQueryQueryDSL 이 왜 만들어졌는지 이해하려면 한 번은 써 봐야 합니다
MySQL8 코스 3부Step 15 — 인덱스, Step 16 — EXPLAIN과 옵티마이저이 스텝에서 맛만 본 인덱스와 실행 계획을 처음부터
커넥션 풀 / 트랜잭션HikariCP 튜닝, 격리 수준, 락쿼리를 아무리 튜닝해도 커넥션이 부족하면 소용없습니다
캐시Hibernate 2차 캐시, Redis쿼리를 빠르게 하는 것보다 안 하는 것이 빠릅니다

정리

개념핵심
SQL 확인 5가지로그 / format_sql / p6spy(실행 가능한 SQL) / toString()(JPQL) / statistics(쿼리 개수)
N+1 진단generate_statistics: trueexecuting 11 JDBC statements
N+1 ① fetch join쿼리 1개. 컬렉션 + 페이징이면 전건 메모리 로딩
N+1 ② batch size쿼리 2개. where id in (?,?,...). 페이징과 공존. 실무 기본값 100~1000
N+1 ③ DTO 직접 조회쿼리 1개, 필요한 컬럼만. 변경 감지 없음
선택 기준전역 batch size(안전망) + 조회는 DTO + 수정할 것만 fetch join
exists vs countcount() > 0 은 전부 셈. exists 는 첫 행에서 멈춤. 0.180초 → 0.002초
필요한 컬럼만40행에서는 차이 없음. 100만 행에서 다름. 처음부터 DTO 로
EXPLAIN 절차p6spy 로 완성 SQL 복사 → EXPLAINtype / rows / Extra
인덱스 죽이는 4패턴컬럼에 함수 / 앞 % LIKE / 타입 불일치 / or 남발
배치 쓰기jdbc.batch_size + order_inserts. IDENTITY 면 배치 불가
벌크 vs 배치벌크는 UPDATE 1개(컨텍스트 건너뜀), 배치는 개별 저장을 모아 전송
readOnly = true자동 플러시 제거 + 스냅샷 생략. 메모리 절반. 안전장치는 아님
최종 프로젝트동적 조건 + 정렬 화이트리스트 + count 분리 + @QueryProjection + EXPLAIN
서브쿼리 vs group by페이지 작으면 서브쿼리, 크거나 집계값 정렬이면 group by
7.xgroupId 동일. breaking change 는 릴리스 노트로 직접 확인. 이 코스는 6.12
버전 올린 뒤"컴파일되는가"가 아니라 "같은 SQL 이 나가는가"

연습문제

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

  1. generate_statistics 를 켜고, 주문 20건을 조회하며 각 주문의 고객 등급을 출력하십시오. 쿼리가 몇 개 나가는지 세고, 그 숫자가 왜 그런지 설명하십시오.
  2. 1번을 fetch join / batch size / DTO 세 가지 방법으로 각각 해결하고 쿼리 개수와 생성 SQL 을 표로 정리하십시오.
  3. "후기가 한 건이라도 있는 상품" 을 count() > 0exists 두 가지로 조회하고, 생성 SQL 과 EXPLAIN 의 서브쿼리 rows 를 대조하십시오. 16건이 나와야 합니다.
  4. 상품명에 "노트"가 들어가는 상품을 containsstartsWith 로 각각 조회하십시오. 두 SQL 을 EXPLAIN 에 넣어 type 을 비교하고, 왜 다른지 설명하십시오.
  5. 14-9 의 상품 검색을 서브쿼리 버전(A) 으로 직접 구현하십시오. 요구사항 중 "평점순 정렬"을 뺀 나머지를 만족하면 됩니다. 생성 SQL 두 개(콘텐츠 + count)를 확인하십시오.
  6. 5번의 검색 결과에서 평균 평점이 null 인 상품이 몇 개인지 세십시오. 그 숫자가 24가 아니라면 왜 그런지 설명하십시오. (힌트: 검색 조건이 걸려 있습니다)
  7. 14-9 의 최종 쿼리에 (category_id, price) 인덱스를 추가하기 전과 후EXPLAIN 을 나란히 붙이고, type / rows / Extra 가 어떻게 바뀌었는지 정리하십시오. 실습이 끝나면 인덱스를 삭제하십시오.

다음 단계 — 코스를 마치며

여기가 이 코스의 끝입니다.

14개 스텝에서 QueryDSL 문법을 배웠습니다. 하지만 그건 부수적입니다. 여러분이 실제로 배운 것은 자바 코드 한 줄이 SQL 한 줄로 번역되는 과정을 눈으로 좇는 습관입니다.

이 습관이 있으면 QueryDSL 7.x 로 올려도, Hibernate 7 이 나와도, 아예 다른 ORM 으로 옮겨도 같은 방식으로 검증할 수 있습니다. 습관이 없으면 어떤 버전에서도 같은 함정에 빠집니다.

문법은 문서에서 다시 찾을 수 있습니다. 습관은 문서에 없습니다.

이제 할 일은 하나입니다. 여러분의 프로젝트에서 org.hibernate.SQL: debug 를 켜십시오. 그리고 지금 돌아가고 있는 조회 API 하나를 골라 로그를 보십시오. 28개 함정 중 몇 개가 이미 들어 있을 것입니다.

이어서 볼 것

MySQL 8 코스 3부 — 이 스텝에서 맛만 본 인덱스와 실행 계획을 처음부터 제대로 다룹니다. QueryDSL 이 만든 SQL 을 읽을 수 있게 됐으니, 이제 그 SQL 이 DB 안에서 어떻게 처리되는지 볼 차례입니다.

MySQL 8 — Step 15 — 인덱스 B+Tree, 클러스터드/세컨더리 인덱스, 복합 인덱스의 선두 컬럼 규칙, 커버링 인덱스, 인덱스를 못 타는 5가지 패턴, FULLTEXT. 100만 행 access_logs 로 전부 실측합니다.

MySQL 8 — Step 16 — EXPLAIN과 옵티마이저 type 등급, Extra 읽는 법, EXPLAIN ANALYZE 로 추정이 아닌 실측 보기, FORMAT=TREE/JSON, 옵티마이저 힌트, 히스토그램, 실전 튜닝 절차.

QueryDSL 6 코스 개요 커리큘럼 전체와 스텝별 함정 목록을 다시 훑어보십시오. 처음 읽었을 때와 지금은 다르게 읽힐 것입니다.


실습 파일

Practice.java 에는 14-1 ~ 14-8 의 측정 코드와 14-9 의 최종 프로젝트가 모두 들어 있습니다. 측정 코드를 먼저 돌려 보십시오. 숫자를 직접 보지 않으면 이 스텝은 읽은 것에 그칩니다.

Practice.java

  • statisticsSetup() 으로 Hibernate statistics 를 켜고 쿼리 개수를 세는 방법을 담았습니다.
  • nPlusOne()fetchJoinSolution()batchSizeSolution()dtoSolution() 순으로 실행하며 executing N JDBC statements 를 비교하십시오.
  • existsVsCount() 는 실행시간을 직접 잽니다. 여러 번 돌려 평균을 보십시오.
  • 14-9 의 리포지토리 구현체 두 버전(A/B)이 nested class 로 들어 있습니다. search()searchWithGroupBy() 의 생성 SQL 을 나란히 놓고 비교하십시오.
package com.example.shop.step14;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.example.shop.entity.Order;
import com.example.shop.entity.Product;
import com.example.shop.entity.ProductStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
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.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;

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

import static com.example.shop.entity.QCategory.category;
import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QProduct.product;
import static com.example.shop.entity.QReview.review;
import static org.assertj.core.api.Assertions.assertThat;

/**
 * Step 14 — 성능과 최종 프로젝트.
 *
 * 이 파일은 두 부분입니다.
 *   1) 14-1 ~ 14-8 의 측정 코드
 *   2) 14-9 의 최종 프로젝트 (상품 검색 API)
 *
 * 측정 코드를 먼저 돌리십시오. 숫자를 직접 보지 않으면 이 스텝은 읽은 것에 그칩니다.
 *
 * 사전 설정 (application.yml):
 *   spring.jpa.properties.hibernate.generate_statistics: true
 *   logging.level.org.hibernate.stat: debug
 *
 * 실행:
 *   ./gradlew test --tests 'com.example.shop.step14.Practice'
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    Statistics stats;

    @BeforeEach
    void statisticsSetup() {
        // [14-1 ⑤] Hibernate statistics 로 쿼리 개수를 셉니다.
        stats = em.getEntityManagerFactory()
                .unwrap(SessionFactory.class)
                .getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();
    }

    private void printStats(String label) {
        System.out.printf("[%s] 쿼리 %d개 / 엔티티 로드 %d개 / 컬렉션 페치 %d개%n",
                label,
                stats.getQueryExecutionCount() + stats.getPrepareStatementCount(),
                stats.getEntityLoadCount(),
                stats.getCollectionFetchCount());
    }

    // ════════════════════════════════════════════════════════════════
    // [14-1] 생성 SQL 확인법
    // ════════════════════════════════════════════════════════════════

    @Test
    @DisplayName("[14-1 ④] JPAQuery.toString() 으로 JPQL 보기 — DB 에 나가지 않습니다")
    void jpqlToString() {
        JPAQuery<Customer> query = queryFactory
                .selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP)
                        .and(customer.points.goe(10000)))
                .orderBy(customer.points.desc());

        System.out.println("── JPQL ──");
        System.out.println(query);        // toString(). fetch() 를 부르지 않아도 됩니다.

        // select customer
        // from Customer customer
        // where customer.grade = ?1 and customer.points >= ?2
        // order by customer.points desc

        // Step 04 의 or 괄호 문제는 SQL 까지 안 가고 여기서 잡힙니다.
        System.out.println("── 괄호가 사라진 JPQL ──");
        System.out.println(queryFactory.selectFrom(customer)
                .where(customer.city.eq("서울")
                        .and(customer.grade.eq(Grade.VIP))
                        .or(customer.points.goe(10000))));

        // where customer.city = ?1 and customer.grade = ?2 or customer.points >= ?3
        //   ↑ 괄호가 없습니다. 한 줄로 확인할 수 있습니다.

        assertThat(query.toString()).contains("order by customer.points desc");
    }

    // ════════════════════════════════════════════════════════════════
    // [14-2] N+1 진단
    // ════════════════════════════════════════════════════════════════

    @Test
    @DisplayName("[14-2] N+1 — 주문 10건에 쿼리 11개. 결과는 완벽하게 맞습니다")
    void nPlusOne() {
        List<Order> orders = queryFactory
                .selectFrom(order)
                .limit(10)
                .fetch();

        for (Order o : orders) {
            // 여기서 지연 로딩이 터집니다. 주문마다 1번씩.
            System.out.println(o.getId() + " → " + o.getCustomer().getName());
        }

        printStats("N+1");
        // executing 11 JDBC statements
        //
        // 결과는 완벽하게 맞습니다. 고객 이름도 전부 정확합니다. 느릴 뿐입니다.
        // 그리고 10건에서는 느린 것도 안 느껴집니다.
        //
        // 600건을 전부 돌면 601개. 각 쿼리가 1ms 라도 0.6초입니다.
        // 네트워크 왕복이 있으면 5ms × 600 = 3초입니다.
        //
        // ⚠️ 유일한 발견 방법은 쿼리 개수를 세는 것입니다. 눈으로 로그를 보다가는 놓칩니다.

        assertThat(orders).hasSize(10);
    }

    // ════════════════════════════════════════════════════════════════
    // [14-3] N+1 해결 3가지
    // ════════════════════════════════════════════════════════════════

    @Test
    @DisplayName("[14-3 ①] fetch join — 쿼리 1개")
    void fetchJoinSolution() {
        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()
                .limit(10)
                .fetch();

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

        printStats("fetch join");
        // executing 1 JDBC statements
        //
        // customers 의 전 컬럼이 select 절에 함께 실려 왔습니다.
        // @ManyToOne 이므로 행이 늘지 않습니다. 이 경우 fetch join 이 최선입니다.

        assertThat(orders).hasSize(10);
    }

    @Test
    @DisplayName("[14-3 ①] ⚠️ 컬렉션 fetch join + 페이징 = 전건 메모리 로딩")
    void fetchJoinWithPagingTrap() {
        // ⚠️ 이 코드는 함정을 확인하기 위한 것입니다.
        //    콘솔에 아래 경고가 찍히는지 보십시오.
        //      HHH90003004: firstResult/maxResults specified with collection fetch;
        //                   applying in memory
        //    그리고 생성 SQL 에 limit 이 **없는** 것을 확인하십시오.
        //
        //    조인으로 행이 1200건으로 뻥튀기되면 limit 10 을 SQL 에 붙일 수 없습니다.
        //    "주문 10건"이 아니라 "조인 결과 10행"이 되어 버리기 때문입니다.
        //    그래서 Hibernate 는 1200행을 전부 메모리로 읽고 자바에서 잘라냅니다.
        //
        //    경고 로그 한 줄만 찍히고 결과는 정확합니다. 이것이 위험한 이유입니다.
        //    orders 가 600건이면 1200행이지만, 60만 건이면 120만 행을 힙에 올립니다.

        List<Order> orders = queryFactory
                .selectFrom(order)
                .join(order.orderItems, com.example.shop.entity.QOrderItem.orderItem).fetchJoin()
                .offset(0).limit(10)
                .fetch();

        printStats("collection fetch join + paging");
        System.out.println("조회된 주문: " + orders.size() + "건 (limit 10 인데도)");

        // 컬렉션 fetch join 은 하나만 가능합니다.
        // orderItems 와 payments 를 동시에 하면 MultipleBagFetchException 입니다.
    }

    @Test
    @DisplayName("[14-3 ②] batch size — 쿼리 2개, where id in (?,?,...). 페이징과 공존")
    void batchSizeSolution() {
        // application.yml 의 default_batch_fetch_size: 100 이 적용된 상태입니다.
        // 코드는 아무것도 바꾸지 않습니다.

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

        for (Order o : orders) {
            System.out.println(o.getId() + " → 항목 " + o.getOrderItems().size() + "개");
        }

        printStats("batch size");
        // executing 2 JDBC statements
        //   ① select ... from orders o1_0 limit ?
        //   ② select ... from order_items oi1_0 where oi1_0.order_id in (?,?,?,?,?,?,?,?,?,?)
        //
        // 첫 지연 로딩이 발생하는 순간 Hibernate 가
        // "어차피 이 컬렉션들도 곧 필요하겠지" 하고 최대 batch_fetch_size 개를 모읍니다.
        //
        // 그리고 limit 이 SQL 에 그대로 있습니다. 페이징과 공존합니다.
        //
        // batch_fetch_size 별 부모 1000건일 때 쿼리 수:
        //   없음 → 1001,  10 → 101,  100 → 11,  1000 → 2
        // 실무 기본값은 100~1000 입니다.

        assertThat(orders).hasSize(10);
    }

    @Test
    @DisplayName("[14-3 ③] DTO 직접 조회 — 쿼리 1개, 필요한 컬럼만, 엔티티 없음")
    void dtoSolution() {
        List<Tuple> result = queryFactory
                .select(order.id, order.orderDate, order.totalAmount,
                        customer.name, customer.grade)
                .from(order)
                .join(order.customer, customer)
                .limit(10)
                .fetch();

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

        printStats("DTO");
        // executing 1 JDBC statements, 그리고 컬럼이 5개뿐입니다.
        // fetch join(①)은 양쪽 엔티티의 전 컬럼 13개를 읽었습니다.
        //
        // 대신 잃는 것:
        //   - 영속성 컨텍스트가 관리하지 않습니다. 변경 감지가 없습니다.
        //   - 지연 로딩이 없습니다. DTO 에 없는 필드는 그냥 없습니다.
        //   - DTO 클래스가 늘어납니다.
        //
        // 실무의 기본 조합:
        //   1. default_batch_fetch_size 를 전역으로 켭니다 (안전망)
        //   2. 읽기 전용 조회 API 는 DTO 직접 조회
        //   3. 수정이 필요한 흐름에서만 엔티티를 fetch join

        assertThat(result).hasSize(10);
        assertThat(stats.getEntityLoadCount()).isZero();   // 엔티티를 안 만듭니다
    }

    // ════════════════════════════════════════════════════════════════
    // [14-4] exists vs count
    // ════════════════════════════════════════════════════════════════

    @Test
    @DisplayName("[14-4] exists vs count — 0.180초 vs 0.002초")
    void existsVsCount() {
        // ❌ count > 0 — 전부 셉니다
        long t1 = System.nanoTime();
        List<Customer> byCount = queryFactory
                .selectFrom(customer)
                .where(JPAExpressions
                        .select(order.count())
                        .from(order)
                        .where(order.customer.eq(customer))
                        .gt(0L))
                .fetch();
        long e1 = System.nanoTime() - t1;

        // where (select count(o1_0.order_id) from orders o1_0
        //        where o1_0.customer_id = c1_0.customer_id) > ?
        //
        // count 는 조건에 맞는 행을 끝까지 다 셉니다.
        // 어떤 고객이 주문 200건을 가지고 있어도 200건을 전부 세고 나서 > 0 을 판정합니다.

        em.clear();

        // ✅ exists — 첫 행에서 멈춥니다
        long t2 = System.nanoTime();
        List<Customer> byExists = queryFactory
                .selectFrom(customer)
                .where(JPAExpressions
                        .selectOne()
                        .from(order)
                        .where(order.customer.eq(customer))
                        .exists())
                .fetch();
        long e2 = System.nanoTime() - t2;

        // where exists (select 1 from orders o1_0 where o1_0.customer_id = c1_0.customer_id)
        //
        // EXISTS 는 첫 매칭 행을 찾는 순간 서브쿼리를 끝냅니다.
        // EXPLAIN 에서 서브쿼리의 rows 가 20 → 1 로 줄고 Extra 에 FirstMatch 가 뜹니다.

        System.out.printf("count > 0 : %d건, %.3f초%n", byCount.size(), e1 / 1e9);
        System.out.printf("exists    : %d건, %.3f초%n", byExists.size(), e2 / 1e9);

        assertThat(byCount).hasSize(30);
        assertThat(byExists).hasSize(30);

        // 여러 번 돌려 평균을 보십시오. 첫 실행에는 JIT 워밍업과 캐시 효과가 섞입니다.
    }

    @Test
    @DisplayName("[14-4] 단건 존재 확인 — selectOne 은 컬럼을 하나도 안 읽습니다")
    void existsSingle() {
        String email = "seojun.kim@example.com";

        // ❌ 엔티티를 만들고 버립니다 (8컬럼)
        boolean bad = queryFactory.selectFrom(customer)
                .where(customer.email.eq(email))
                .fetchFirst() != null;

        // ✅ 1만 읽습니다
        Integer found = queryFactory.selectOne()
                .from(customer)
                .where(customer.email.eq(email))
                .fetchFirst();
        boolean good = found != null;

        // ❌ select c1_0.customer_id, c1_0.city, ... (8컬럼) from customers c1_0 where ... limit ?
        // ✅ select 1 from customers c1_0 where c1_0.email = ? limit ?

        System.out.println("bad=" + bad + ", good=" + good);
        assertThat(good).isEqualTo(bad);

        // 참고: fetchCount() / fetchResults() 는 5.x 부터 deprecated 입니다.
        //       group by / having / distinct 가 있는 쿼리에서 count 쿼리를 잘못 만듭니다.
        //       select(x.count()) 로 직접 쓰십시오 (Step 09).
    }

    // ════════════════════════════════════════════════════════════════
    // [14-5] 필요한 컬럼만
    // ════════════════════════════════════════════════════════════════

    @Test
    @DisplayName("[14-5] 엔티티 전체 vs 프로젝션 — 40행에서는 차이가 없습니다")
    void selectOnlyNeeded() {
        // A — 엔티티 전체 (8컬럼)
        List<Product> entities = queryFactory.selectFrom(product).fetch();
        printStats("엔티티");

        stats.clear();
        em.clear();

        // B — 필요한 두 컬럼만
        List<Tuple> tuples = queryFactory
                .select(product.name, product.price)
                .from(product)
                .fetch();
        printStats("프로젝션");

        // 40행에서는 차이가 오차 범위입니다. 그래서 개발 중에는 절대 보이지 않습니다.
        //
        // 엔티티 조회에는 컬럼 읽기 말고도 비용이 있습니다.
        //   - 1차 캐시 등록
        //   - 스냅샷 저장 (변경 감지용 원본 복사) → 메모리 두 배
        //   - 플러시 시 전 엔티티 비교
        //
        // 읽기 전용 조회에서 이 셋은 전부 낭비입니다.
        // 14-8 의 readOnly = true 가 뒤 두 개를 없앱니다.

        assertThat(entities).hasSize(40);
        assertThat(tuples).hasSize(40);
    }

    // ════════════════════════════════════════════════════════════════
    // [14-6] 인덱스를 죽이는 4가지 패턴
    // ════════════════════════════════════════════════════════════════

    @Test
    @DisplayName("[14-6] 패턴 2 — contains vs startsWith. 같은 like, 다른 계획")
    void likePatterns() {
        // ❌ contains → '%노트북%'. 인덱스는 앞에서부터 비교하므로 시작점을 못 찾습니다.
        List<Product> byContains = queryFactory
                .selectFrom(product)
                .where(product.name.contains("노트북"))
                .fetch();

        // where p1_0.name like ? escape '!'    -- '%노트북%'
        //   EXPLAIN → type: ALL

        // ✅ startsWith → '노트북%'. 인덱스 사용 가능.
        List<Product> byStartsWith = queryFactory
                .selectFrom(product)
                .where(product.name.startsWith("노트북"))
                .fetch();

        // where p1_0.name like ? escape '!'    -- '노트북%'
        //   EXPLAIN → type: range (name 인덱스가 있다면)

        System.out.println("contains: " + byContains.size()
                + "건, startsWith: " + byStartsWith.size() + "건");

        // 앞뒤 모두 일치가 요구사항이면 선택지는 셋입니다.
        //   1) FULLTEXT 인덱스 (한국어는 ngram 파서 필요)
        //   2) Elasticsearch 등 검색 엔진
        //   3) 그냥 둔다 — 4만 행이면 풀스캔이 더 빠릅니다. 재고 나서 결정하십시오.
    }

    @Test
    @DisplayName("[14-6] 패턴 4 — or 남발 vs in")
    void orVsIn() {
        // ❌ 다른 컬럼을 or 로 묶으면 어느 인덱스로도 커버가 안 됩니다
        List<Product> byOr = queryFactory
                .selectFrom(product)
                .where(product.name.eq("노트북")
                        .or(product.status.eq(ProductStatus.SOLD_OUT))
                        .or(product.stock.eq(0)))
                .fetch();

        // where p1_0.name = ? or p1_0.status = ? or p1_0.stock = ?
        //   EXPLAIN → type: ALL

        // ✅ 같은 컬럼의 or 는 in 으로
        List<Product> byIn = queryFactory
                .selectFrom(product)
                .where(product.status.in(ProductStatus.SOLD_OUT, ProductStatus.HIDDEN))
                .fetch();

        // where p1_0.status in (?,?)      -- 인덱스 사용 가능

        System.out.println("or: " + byOr.size() + "건, in: " + byIn.size() + "건");

        // 다른 컬럼이면 쿼리를 쪼개 각각 자기 인덱스를 쓰게 하고 자바에서 합칩니다.
        // ⚠️ JPQL 에는 UNION 이 없습니다. Hibernate 6 의 HQL 은 지원하지만
        //    QueryDSL-JPA 로는 만들 수 없습니다.
    }

    // ════════════════════════════════════════════════════════════════
    // [14-9] 종합 실습 — 상품 검색 API
    // ════════════════════════════════════════════════════════════════

    /**
     * 검색 조건.
     *
     * ⚠️ 필드명이 sortKey / sortDirection 입니다. sort 가 아닙니다.
     *    Spring Data 의 Pageable 이 ?sort=price,desc 를 자동 파싱하므로,
     *    같은 이름을 쓰면 쿼리 파라미터를 둘이 나눠 갖게 되어 예측 불가능해집니다.
     */
    record ProductSearchCond(
            String keyword,
            Long categoryId,
            BigDecimal minPrice,
            BigDecimal maxPrice,
            Boolean inStockOnly,
            List<ProductStatus> statuses,
            String sortKey,          // "price" | "rating" | "created" | "name"
            String sortDirection     // "asc" | "desc"
    ) {}

    /**
     * 응답 DTO.
     *
     * 실제 프로젝트에서는 @QueryProjection 을 붙여 QProductSearchResponse 를 생성하고
     * new QProductSearchResponse(...) 로 조립합니다.
     * 이 테스트 파일에서는 APT 대상이 아니므로 Tuple 로 받은 뒤 매핑합니다.
     */
    record ProductSearchResponse(
            Long productId,
            String name,
            BigDecimal price,
            Integer stock,
            ProductStatus status,
            String categoryName,
            Double avgRating,
            Long reviewCount
    ) {}

    // ── 정렬 화이트리스트 (Step 10) ─────────────────────────────────
    // 사용자 입력은 이 Map 의 "키"로만 쓰입니다.
    // SQL 에 들어가는 것은 코드에 이미 존재하는 표현식뿐입니다.
    private static final Map<String, OrderSpecifier<?>> SORT_ASC = Map.of(
            "price",   product.price.asc(),
            "created", product.createdAt.asc(),
            "name",    product.name.asc(),
            "rating",  review.rating.avg().asc());

    private static final Map<String, OrderSpecifier<?>> SORT_DESC = Map.of(
            "price",   product.price.desc(),
            "created", product.createdAt.desc(),
            "name",    product.name.desc(),
            "rating",  review.rating.avg().desc());

    private static final OrderSpecifier<?> DEFAULT_SORT = product.id.desc();

    @Test
    @DisplayName("[14-9] 버전 A — 서브쿼리로 평점/후기 수")
    void searchWithSubquery() {
        ProductSearchCond cond = new ProductSearchCond(
                "노트", null, new BigDecimal("500000"), null,
                true, null, "price", "desc");
        Pageable pageable = PageRequest.of(0, 10);

        List<Tuple> content = queryFactory
                .select(product.id, product.name, product.price, product.stock,
                        product.status, category.name,
                        // 평균 평점 — 상관 서브쿼리
                        JPAExpressions.select(review.rating.avg())
                                .from(review).where(review.product.eq(product)),
                        // 후기 수 — 상관 서브쿼리
                        JPAExpressions.select(review.count())
                                .from(review).where(review.product.eq(product)))
                .from(product)
                .join(product.category, category)
                .where(keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceGoe(cond.minPrice()),
                        priceLoe(cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()))
                .orderBy(toOrder(cond.sortKey(), cond.sortDirection(), false), product.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // count 쿼리 (분리). join 도 서브쿼리도 없습니다.
        JPAQuery<Long> countQuery = queryFactory
                .select(product.count())
                .from(product)
                .where(keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceGoe(cond.minPrice()),
                        priceLoe(cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()));

        // 실제 리포지토리에서는 PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne)
        // 로 감쌉니다. 결과가 한 페이지 안에 들어가면 count 쿼리가 아예 실행되지 않습니다 (Step 09).
        Long total = countQuery.fetchOne();

        content.forEach(t -> System.out.printf("%s | %s | 평점 %s | 후기 %d%n",
                t.get(product.name), t.get(product.price),
                t.get(6, Double.class), t.get(7, Long.class)));
        System.out.println("total = " + total);

        printStats("검색 A (서브쿼리)");

        // maxPrice 와 statuses 가 null 이었으므로 SQL 에 아예 없습니다.
        // where 1=1 같은 것도 없습니다. 이것이 동적 쿼리의 이점입니다.
        //
        // ⚠️ 버전 A 로는 "평점순 정렬"을 할 수 없습니다.
        //    서브쿼리 결과로 order by 를 하는 것은 QueryDSL 로 표현하기 어렵고 성능도 나쁩니다.
        //    요구사항에 평점순이 있으므로 최종 선택은 버전 B 입니다.

        assertThat(total).isNotNull();
    }

    @Test
    @DisplayName("[14-9] 버전 B — left join + group by. 집계값 정렬이 됩니다 (최종안)")
    void searchWithGroupBy() {
        ProductSearchCond cond = new ProductSearchCond(
                null, null, null, null,
                true, List.of(ProductStatus.ON_SALE), "rating", "desc");
        Pageable pageable = PageRequest.of(0, 10);

        List<Tuple> content = queryFactory
                .select(product.id, product.name, product.price, product.stock,
                        product.status, category.name,
                        review.rating.avg(),
                        review.count())
                .from(product)
                .join(product.category, category)
                .leftJoin(review).on(review.product.eq(product))   // ★ left join 필수
                .where(keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceGoe(cond.minPrice()),
                        priceLoe(cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()))
                .groupBy(product.id, product.name, product.price, product.stock,
                        product.status, category.name)             // ★ 전부 나열
                .orderBy(toOrder(cond.sortKey(), cond.sortDirection(), true), product.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long total = queryFactory
                .select(product.count())
                .from(product)
                .where(keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceGoe(cond.minPrice()),
                        priceLoe(cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()))
                .fetchOne();

        // ★ leftJoin 이므로 후기 없는 상품 24개도 결과에 남습니다.
        //   그 경우 avg 는 null, count 는 0 입니다.
        //   avg() 는 대상 행이 없으면 NULL, count() 는 0 을 반환합니다 (13-7).
        content.forEach(t -> System.out.printf("%s | 평점 %s | 후기 %d%n",
                t.get(product.name), t.get(review.rating.avg()), t.get(review.count())));
        System.out.println("total = " + total);

        printStats("검색 B (group by)");

        // A vs B
        //   페이지 작으면(10~20)         → A (서브쿼리가 페이지 안의 행에만 실행됨)
        //   페이지 크면(1000+)           → B (서브쿼리 2000번 vs 조인 1번)
        //   집계 대상이 여러 개           → B
        //   정렬 키가 집계값(평점순)      → B  ← 요구사항이 이것이므로 최종 선택
        //
        // 위 표는 "이럴 것이다"의 정리이지 정답표가 아닙니다.
        // 여러분의 데이터로 EXPLAIN ANALYZE 를 돌려 확인하십시오.

        assertThat(total).isNotNull();
    }

    // ── 동적 조건 (Step 04, 10) ─────────────────────────────────────
    // null 을 반환하면 where 에서 무시됩니다.
    // BooleanBuilder 대신 메서드로 분리하면 재사용과 조합이 됩니다.

    private BooleanExpression keywordContains(String keyword) {
        return (keyword == null || keyword.isBlank()) ? null : product.name.contains(keyword);
    }

    /** 선택한 카테고리 + 그 하위 카테고리 (Step 07) */
    private BooleanExpression categoryIn(Long categoryId) {
        if (categoryId == null) {
            return null;
        }
        return product.category.id.eq(categoryId)
                .or(product.category.id.in(
                        JPAExpressions.select(category.id)
                                .from(category)
                                .where(category.parent.id.eq(categoryId))));
    }

    private BooleanExpression priceGoe(BigDecimal min) {
        return min == null ? null : product.price.goe(min);
    }

    private BooleanExpression priceLoe(BigDecimal max) {
        return max == null ? null : product.price.loe(max);
    }

    private BooleanExpression inStock(Boolean inStockOnly) {
        return Boolean.TRUE.equals(inStockOnly) ? product.stock.gt(0) : null;
    }

    private BooleanExpression statusIn(List<ProductStatus> statuses) {
        return (statuses == null || statuses.isEmpty()) ? null : product.status.in(statuses);
    }

    /**
     * 정렬 화이트리스트 (Step 10).
     * 입력은 Map 의 키로만 쓰입니다. SQL 에 들어가는 것은 코드에 있는 표현식뿐입니다.
     *
     * @param allowAggregate 집계값 정렬(rating)을 허용할지. 버전 A 에서는 false.
     */
    private OrderSpecifier<?> toOrder(String sortKey, String direction, boolean allowAggregate) {
        if (sortKey == null) {
            return DEFAULT_SORT;
        }
        if (!allowAggregate && "rating".equals(sortKey)) {
            return DEFAULT_SORT;      // 서브쿼리 버전에서는 집계값 정렬 불가
        }
        Map<String, OrderSpecifier<?>> table =
                "asc".equalsIgnoreCase(direction) ? SORT_ASC : SORT_DESC;
        return table.getOrDefault(sortKey, DEFAULT_SORT);
    }

    @Test
    @DisplayName("[14-9] 정렬 화이트리스트 — 어떤 입력이 와도 SQL 은 코드에 있는 것만")
    void sortWhitelist() {
        // 정상 입력
        assertThat(toOrder("price", "desc", true)).isEqualTo(product.price.desc());

        // 없는 키 → 기본 정렬
        assertThat(toOrder("salary", "desc", true)).isEqualTo(DEFAULT_SORT);

        // 인젝션 시도 → 기본 정렬. 문자열이 SQL 로 가지 않습니다.
        assertThat(toOrder("price desc, (select email from customers limit 1)", "desc", true))
                .isEqualTo(DEFAULT_SORT);

        // ⚠️ 컬럼명·정렬 방향은 바인딩 파라미터가 될 수 없습니다.
        //    order by ? 는 "상수 하나로 정렬"이지 "그 이름의 컬럼으로 정렬"이 아닙니다.
        //    그래서 동적 컬럼명은 구조적으로 화이트리스트가 유일한 방어입니다 (13-11).
    }
}

Exercise.java

  • 7문제. 5번과 7번이 최종 프로젝트에 해당합니다.
  • 3, 4, 7번은 MySQL 콘솔에서 EXPLAIN 을 직접 실행해야 합니다. p6spy 를 켜고 완성된 SQL 을 복사하십시오.
package com.example.shop.step14;

import com.example.shop.entity.Order;
import com.example.shop.entity.Product;
import com.example.shop.entity.ProductStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
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.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
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.QOrder.order;
import static com.example.shop.entity.QProduct.product;
import static com.example.shop.entity.QReview.review;

/**
 * Step 14 — 연습문제 7문제. 이 코스의 마지막 연습문제입니다.
 *
 * 3, 4, 7번은 MySQL 콘솔에서 EXPLAIN 을 직접 실행해야 합니다.
 * p6spy 를 켜고(build.gradle 에 p6spy-spring-boot-starter 추가)
 * 완성된 SQL 을 복사하십시오. ? 가 있는 SQL 은 EXPLAIN 에 넣을 수 없습니다.
 *
 * 정답은 Solution.java.
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    Statistics stats;

    @BeforeEach
    void setUp() {
        stats = em.getEntityManagerFactory()
                .unwrap(SessionFactory.class)
                .getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();
    }

    // ────────────────────────────────────────────────────────────────
    // 문제 1. N+1 재현과 진단
    //
    // 주문 20건을 조회하고, 각 주문의 고객 등급(grade)을 출력하십시오.
    //
    // 요구사항:
    //   - Hibernate statistics 로 쿼리 개수를 세십시오
    //   - 출력된 숫자가 왜 그 값인지 설명하십시오
    //
    // 확인:
    //   콘솔에서 같은 SQL 이 몇 번 반복되는지 세어 보십시오.
    //   "where c1_0.customer_id = ?" 가 20번 나오면 안 됩니다. 왜일까요?
    //   (힌트: 서로 다른 주문이 같은 고객을 가리킬 수 있습니다. 1차 캐시)
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 1 — N+1 재현과 진단")
    void ex1() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 2. N+1 해결 3가지
    //
    // 문제 1을 다음 세 가지 방법으로 각각 해결하십시오.
    //   ① fetch join
    //   ② batch size (application.yml 의 default_batch_fetch_size)
    //   ③ DTO 직접 조회
    //
    // 요구사항:
    //   각 방법의 쿼리 개수와 생성 SQL 을 아래 표에 채우십시오.
    //
    //   | 방법        | 쿼리 개수 | select 컬럼 수 | SQL 특징           |
    //   |------------|----------|--------------|-------------------|
    //   | 해결 전     |          |              |                   |
    //   | ① fetch join|          |              |                   |
    //   | ② batch size|          |              |                   |
    //   | ③ DTO      |          |              |                   |
    //
    // 추가 질문:
    //   이 케이스(주문 → 고객, @ManyToOne)에서는 세 방법 모두 잘 동작합니다.
    //   주문 → 주문항목(@OneToMany) 이고 페이징이 필요하다면 어느 것이 불가능해집니까?
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 2 — N+1 해결 3가지 비교")
    void ex2() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 3. exists vs count
    //
    // "후기가 한 건이라도 있는 상품" 을 두 가지로 조회하십시오.
    //   ① 상관 서브쿼리 count() > 0
    //   ② JPAExpressions.selectOne()...exists()
    //
    // 요구사항:
    //   - 두 방법 모두 16건이 나와야 합니다 (상품 40개 중 후기 있는 것 16개)
    //   - 두 생성 SQL 을 나란히 적으십시오
    //
    // 확인 (MySQL 콘솔):
    //   각 SQL 을 EXPLAIN 에 넣고 **서브쿼리 행(id=2)의 rows 와 Extra** 를 비교하십시오.
    //   한쪽에 FirstMatch 가 뜹니다. 그것이 무슨 뜻인지 설명하십시오.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 3 — exists vs count")
    void ex3() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 4. contains vs startsWith
    //
    // 상품명에 "노트"가 들어가는 상품을 두 가지로 조회하십시오.
    //   ① product.name.contains("노트")
    //   ② product.name.startsWith("노트")
    //
    // 요구사항:
    //   - 두 생성 SQL 을 적고, 바인딩 파라미터 값의 차이를 확인하십시오
    //   - 결과 건수가 다른 이유를 설명하십시오
    //
    // 확인 (MySQL 콘솔):
    //   products.name 에 인덱스를 임시로 만들고 두 SQL 의 EXPLAIN type 을 비교하십시오.
    //     ALTER TABLE products ADD INDEX idx_products_name (name);
    //   실습이 끝나면 되돌리십시오.
    //     ALTER TABLE products DROP INDEX idx_products_name;
    //
    //   왜 같은 like 인데 계획이 다른지 한 문장으로 설명하십시오.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 4 — contains vs startsWith 와 인덱스")
    void ex4() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 5. ★ 최종 프로젝트 — 상품 검색 (서브쿼리 버전)
    //
    // 14-9 의 상품 검색을 **서브쿼리 버전(A)** 으로 직접 구현하십시오.
    //
    // 요구사항:
    //   조건 (모두 선택적. null 이면 무시)
    //     - keyword     : 상품명 부분 일치
    //     - categoryId  : 그 카테고리 + 하위 카테고리
    //     - minPrice / maxPrice
    //     - inStockOnly : true 면 stock > 0
    //     - statuses    : 다중 선택
    //   정렬 (화이트리스트)
    //     - "price" | "created" | "name", asc/desc
    //     - 평점순은 서브쿼리 버전에서 불가능합니다. 화이트리스트에 넣지 마십시오.
    //   페이징
    //     - Pageable. count 쿼리는 분리할 것
    //   응답
    //     - 상품ID, 상품명, 가격, 재고, 상태, 카테고리명, 평균 평점, 후기 수
    //
    // 확인:
    //   생성 SQL 두 개(콘텐츠 + count)를 확인하십시오.
    //   count 쿼리에 join 과 서브쿼리가 없어야 합니다. 있다면 왜 있는지 찾으십시오.
    // ────────────────────────────────────────────────────────────────
    record SearchCond(
            String keyword,
            Long categoryId,
            BigDecimal minPrice,
            BigDecimal maxPrice,
            Boolean inStockOnly,
            List<ProductStatus> statuses,
            String sortKey,
            String sortDirection
    ) {}

    @Test
    @DisplayName("문제 5 — 상품 검색 (서브쿼리 버전)")
    void ex5() {
        SearchCond cond = new SearchCond(
                null, null, new BigDecimal("100000"), null,
                true, List.of(ProductStatus.ON_SALE), "price", "desc");
        Pageable pageable = PageRequest.of(0, 10);

        // 여기에 작성:

    }

    /** 동적 조건 메서드들을 여기에 작성하십시오. null 을 반환하면 where 에서 무시됩니다. */
    private BooleanExpression keywordContains(String keyword) {
        // 여기에 작성:
        return null;
    }

    private BooleanExpression categoryIn(Long categoryId) {
        // 여기에 작성: 선택한 카테고리 + 그 하위 카테고리 (Step 07 의 서브쿼리)
        return null;
    }

    private BooleanExpression priceBetween(BigDecimal min, BigDecimal max) {
        // 여기에 작성: min 만, max 만, 둘 다, 둘 다 null 인 4가지 경우를 모두 처리
        return null;
    }

    private BooleanExpression inStock(Boolean inStockOnly) {
        // 여기에 작성:
        return null;
    }

    private BooleanExpression statusIn(List<ProductStatus> statuses) {
        // 여기에 작성:
        return null;
    }

    private OrderSpecifier<?> toOrder(String sortKey, String direction) {
        // 여기에 작성: Map 화이트리스트 (Step 10)
        return product.id.desc();
    }

    // ────────────────────────────────────────────────────────────────
    // 문제 6. 평균 평점이 null 인 상품 세기
    //
    // 문제 5의 검색 결과에서 **평균 평점이 null 인 상품이 몇 개**인지 세십시오.
    //
    // 요구사항:
    //   - 검색 조건 없이(전 상품) 세면 24가 나와야 합니다
    //   - 문제 5의 조건(가격 10만 이상, 재고 있음, ON_SALE)을 걸면 24가 아닙니다
    //
    // 질문:
    //   1) 그 숫자가 24가 아닌 이유를 설명하십시오
    //   2) avg() 는 null 을 반환하는데 count() 는 0 을 반환합니다. 왜 다릅니까?
    //   3) 프론트가 null 을 못 받는다면 어디서 막는 것이 좋습니까?
    //      (SQL 의 coalesce / DTO 의 compact constructor / 컨트롤러)
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 6 — 평균 평점 null 세기")
    void ex6() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 7. EXPLAIN 으로 검증하고 인덱스 추가
    //
    // 문제 5의 콘텐츠 쿼리를 EXPLAIN 에 넣고, 인덱스를 하나 추가해 개선하십시오.
    //
    // 절차:
    //   1) p6spy 로그에서 완성된 SQL 을 복사합니다
    //   2) EXPLAIN 을 걸어 type / rows / Extra 를 기록합니다
    //   3) SHOW INDEX FROM products; 로 현재 인덱스를 확인합니다
    //   4) 선택도를 조사합니다:
    //        SELECT COUNT(*) total, COUNT(DISTINCT category_id) cat,
    //               COUNT(DISTINCT status) st, COUNT(DISTINCT price) pr,
    //               SUM(stock > 0) in_stock
    //        FROM products;
    //   5) 인덱스를 추가하고 EXPLAIN 을 다시 겁니다
    //   6) 실습이 끝나면 인덱스를 삭제합니다
    //
    // 표로 정리하십시오:
    //   | 항목  | 인덱스 전 | 인덱스 후 |
    //   |------|----------|----------|
    //   | type |          |          |
    //   | key  |          |          |
    //   | rows |          |          |
    //   | Extra|          |          |
    //
    // 질문:
    //   1) 어느 컬럼으로 인덱스를 만들었고 왜 그 컬럼입니까?
    //   2) Using filesort 가 남아 있다면 왜 인덱스로 없앨 수 없습니까?
    //   3) 새 인덱스를 추가하는 것과 기존 idx_products_category 를 확장하는 것 중
    //      어느 쪽이 낫습니까? 이유는?
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 7 — EXPLAIN 검증과 인덱스 추가")
    void ex7() {
        // 여기에 작성: 쿼리를 실행해 p6spy 로그에서 SQL 을 복사하십시오.
        //             나머지는 MySQL 콘솔 작업입니다.

    }
}

Solution.java

  • 정답과 함께 각 선택의 근거를 주석으로 설명합니다.
  • 2번의 표는 그대로 팀 문서로 쓸 수 있게 채워 두었습니다.
  • 5번은 14-9 의 A 버전 전체 코드입니다. 본문과 비교하며 읽으십시오.
package com.example.shop.step14;

import com.example.shop.entity.Order;
import com.example.shop.entity.Product;
import com.example.shop.entity.ProductStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
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.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;

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

import static com.example.shop.entity.QCategory.category;
import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QOrderItem.orderItem;
import static com.example.shop.entity.QProduct.product;
import static com.example.shop.entity.QReview.review;
import static org.assertj.core.api.Assertions.assertThat;

/**
 * Step 14 — 연습문제 정답과 해설. 이 코스의 마지막 파일입니다.
 */
@SpringBootTest
@Transactional
class Solution {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    Statistics stats;

    @BeforeEach
    void setUp() {
        stats = em.getEntityManagerFactory()
                .unwrap(SessionFactory.class)
                .getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();
    }

    private void printStats(String label) {
        System.out.printf("[%s] 쿼리 %d개 / 엔티티 로드 %d개%n",
                label, stats.getPrepareStatementCount(), stats.getEntityLoadCount());
    }

    // ════════════════════════════════════════════════════════════════
    // 정답 1 — N+1 재현과 진단
    // ════════════════════════════════════════════════════════════════
    //
    // 쿼리 개수는 **21개가 아닙니다.** 대개 그보다 적게 나옵니다.
    //
    // 이유: 1차 캐시(영속성 컨텍스트) 때문입니다.
    //   서로 다른 주문이 같은 고객을 가리키면, 두 번째부터는 이미 영속성 컨텍스트에
    //   그 Customer 가 있으므로 쿼리가 나가지 않습니다.
    //   고객은 30명이고 주문은 600건이므로 중복이 많습니다.
    //   주문 20건에서 서로 다른 고객이 K명이라면 쿼리는 1 + K 개입니다.
    //
    // 이 사실이 N+1 진단을 더 어렵게 만듭니다.
    //   - 데이터 분포에 따라 쿼리 개수가 달라집니다
    //   - 개발 DB(데이터가 적고 중복이 많음)에서는 훨씬 적게 나옵니다
    //   - "N+1 아닌데요? 21개가 아니라 15개인데요" 라는 오해가 생깁니다
    //
    // 판단 기준은 절대 숫자가 아니라 **"쿼리가 데이터 건수에 비례해 늘어나는가"** 입니다.
    // limit 을 20 → 100 으로 늘려 보십시오. 쿼리도 함께 늘어나면 N+1 입니다.
    //
    @Test
    @DisplayName("정답 1 — N+1 재현과 진단")
    void ans1() {
        List<Order> orders = queryFactory
                .selectFrom(order)
                .limit(20)
                .fetch();

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

        long q20 = stats.getPrepareStatementCount();
        printStats("N+1 (20건)");

        // limit 을 늘려 비례하는지 확인합니다
        stats.clear();
        em.clear();

        List<Order> more = queryFactory.selectFrom(order).limit(100).fetch();
        for (Order o : more) {
            o.getCustomer().getGrade();
        }
        long q100 = stats.getPrepareStatementCount();
        printStats("N+1 (100건)");

        System.out.printf("20건 → 쿼리 %d개, 100건 → 쿼리 %d개%n", q20, q100);
        // 늘어났다면 N+1 확정입니다.
        // 고객이 30명뿐이므로 100건에서도 최대 31개에서 멈춥니다.
        // 부모 테이블의 카디널리티가 상한이 됩니다.

        assertThat(orders).hasSize(20);
    }

    // ════════════════════════════════════════════════════════════════
    // 정답 2 — N+1 해결 3가지 비교
    // ════════════════════════════════════════════════════════════════
    //
    // | 방법          | 쿼리 개수 | select 컬럼 수 | SQL 특징                           |
    // |--------------|---------|--------------|-----------------------------------|
    // | 해결 전       | 1 + K   | 6 / 8        | 고객 쿼리가 반복. K = 서로 다른 고객 수  |
    // | ① fetch join | 1       | 14           | join customers, 양쪽 전 컬럼         |
    // | ② batch size | 2       | 6 / 8        | where customer_id in (?,?,...)     |
    // | ③ DTO        | 1       | 3            | 필요한 컬럼만. 엔티티 로드 0개         |
    //
    // 추가 질문의 답:
    //   주문 → 주문항목(@OneToMany) + 페이징이면 **① fetch join 이 불가능**합니다.
    //   정확히는 "실행은 되는데 limit 이 SQL 에서 사라지고 전건을 메모리로 읽습니다."
    //   경고 로그 한 줄(HHH90003004)만 찍히고 결과는 정확하므로 더 위험합니다.
    //
    //   이때 ②와 ③은 그대로 동작합니다.
    //   ②는 부모 쿼리에 limit 이 그대로 붙고, 자식은 in 절로 따로 가져오기 때문입니다.
    //
    // 실무의 기본 조합:
    //   1. default_batch_fetch_size 를 전역으로 켭니다 (안전망)
    //   2. 읽기 전용 조회 API 는 DTO 직접 조회 (제대로 만든 조회)
    //   3. 수정이 필요한 흐름에서만 엔티티를 fetch join
    //
    @Test
    @DisplayName("정답 2 — N+1 해결 3가지 비교")
    void ans2() {
        // ① fetch join
        stats.clear();
        List<Order> byFetchJoin = queryFactory
                .selectFrom(order)
                .join(order.customer, customer).fetchJoin()
                .limit(20)
                .fetch();
        byFetchJoin.forEach(o -> o.getCustomer().getGrade());
        printStats("① fetch join");
        long q1 = stats.getPrepareStatementCount();

        // ② batch size — 코드는 그대로. application.yml 설정만.
        //    (@ManyToOne 이므로 default_batch_fetch_size 가 있어도
        //     프록시 초기화 시점에 in 절로 모입니다.)
        stats.clear();
        em.clear();
        List<Order> byBatch = queryFactory.selectFrom(order).limit(20).fetch();
        byBatch.forEach(o -> o.getCustomer().getGrade());
        printStats("② batch size");
        long q2 = stats.getPrepareStatementCount();

        // ③ DTO 직접 조회
        stats.clear();
        em.clear();
        List<Tuple> byDto = queryFactory
                .select(order.id, customer.name, customer.grade)
                .from(order)
                .join(order.customer, customer)
                .limit(20)
                .fetch();
        printStats("③ DTO");
        long q3 = stats.getPrepareStatementCount();

        System.out.printf("① %d개 / ② %d개 / ③ %d개%n", q1, q2, q3);

        assertThat(q1).isEqualTo(1);
        assertThat(q3).isEqualTo(1);
        assertThat(stats.getEntityLoadCount()).isZero();   // ③ 은 엔티티를 안 만듭니다
    }

    // ════════════════════════════════════════════════════════════════
    // 정답 3 — exists vs count
    // ════════════════════════════════════════════════════════════════
    //
    // 두 생성 SQL
    //
    // ① count > 0
    //   select p1_0.product_id, ... from products p1_0
    //   where (select count(r1_0.review_id) from reviews r1_0
    //          where r1_0.product_id = p1_0.product_id) > ?
    //
    // ② exists
    //   select p1_0.product_id, ... from products p1_0
    //   where exists (select 1 from reviews r1_0
    //                 where r1_0.product_id = p1_0.product_id)
    //
    // EXPLAIN 의 서브쿼리 행(id=2) 비교
    //   ① | DEPENDENT SUBQUERY | reviews | ref | idx_reviews_product | rows: 5 | Using index |
    //   ② | DEPENDENT SUBQUERY | reviews | ref | idx_reviews_product | rows: 1 | Using index; FirstMatch |
    //
    // FirstMatch 의 뜻:
    //   "첫 번째 매칭 행을 찾는 즉시 그 서브쿼리 실행을 중단한다"
    //   count 는 전부 세야 답이 나오지만, exists 는 하나만 찾으면 답이 정해집니다.
    //   후기가 20건인 상품에서 ①은 20번 읽고 ②는 1번 읽습니다.
    //
    // 이것은 MySQL8 코스 Step 08 의 IN vs EXISTS 와 같은 이야기입니다.
    // QueryDSL 은 SQL 을 만드는 도구이지 SQL 의 규칙을 바꾸는 도구가 아닙니다.
    //
    @Test
    @DisplayName("정답 3 — exists vs count")
    void ans3() {
        // ① count > 0
        List<Product> byCount = queryFactory
                .selectFrom(product)
                .where(JPAExpressions
                        .select(review.count())
                        .from(review)
                        .where(review.product.eq(product))
                        .gt(0L))
                .fetch();

        // ② exists
        List<Product> byExists = queryFactory
                .selectFrom(product)
                .where(JPAExpressions
                        .selectOne()
                        .from(review)
                        .where(review.product.eq(product))
                        .exists())
                .fetch();

        System.out.println("count > 0 : " + byCount.size() + "건");
        System.out.println("exists    : " + byExists.size() + "건");

        // 상품 40개 중 후기 있는 것 16개 (후기 없는 상품 24개)
        assertThat(byCount).hasSize(16);
        assertThat(byExists).hasSize(16);

        // 흔한 오답: NOT IN 으로 "후기 없는 상품"을 찾는 것.
        //   product.id.notIn(JPAExpressions.select(review.product.id).from(review))
        //   → reviews 에 product_id 가 NULL 인 행이 하나라도 있으면 결과가 0건이 됩니다.
        //     NULL 과의 비교는 UNKNOWN 이고, NOT IN 은 UNKNOWN 을 만족으로 치지 않습니다.
        //     이 스키마에서는 NOT NULL 이라 괜찮지만, 습관적으로 NOT EXISTS 를 쓰십시오 (Step 07).
    }

    // ════════════════════════════════════════════════════════════════
    // 정답 4 — contains vs startsWith 와 인덱스
    // ════════════════════════════════════════════════════════════════
    //
    // 두 생성 SQL 은 **똑같이 생겼습니다.**
    //   where p1_0.name like ? escape '!'
    //
    // 다른 것은 **바인딩 값**입니다.
    //   contains("노트")    → binding parameter (1:VARCHAR) <- [%노트%]
    //   startsWith("노트")  → binding parameter (1:VARCHAR) <- [노트%]
    //
    // 이것이 이 문제의 핵심입니다.
    // **SQL 문만 봐서는 차이를 알 수 없습니다.** 바인딩 로그를 봐야 합니다.
    // org.hibernate.orm.jdbc.bind: trace 를 켜 둔 이유가 이것입니다.
    //
    // 결과 건수가 다른 이유:
    //   contains 는 "게이밍 노트북 RTX4060", "보급형 노트북 15" 를 모두 찾습니다.
    //   startsWith 는 이름이 "노트"로 **시작하는** 것만 찾으므로 0건입니다.
    //   (상품명 중 "노트"로 시작하는 것이 없습니다.)
    //
    // EXPLAIN 비교 (idx_products_name 을 만든 상태)
    //   contains   | type: ALL   | key: NULL              | rows: 40 | Using where           |
    //   startsWith | type: range | key: idx_products_name | rows: 1  | Using index condition |
    //
    // 왜 같은 like 인데 계획이 다른가 (한 문장):
    //   인덱스는 값을 앞에서부터 정렬해 두었으므로, 패턴이 상수로 시작하면 시작점과 끝점을
    //   찾아 범위를 자를 수 있지만, 앞이 와일드카드면 시작점을 특정할 수 없어
    //   전 행을 읽어 하나씩 비교할 수밖에 없습니다.
    //
    // 앞뒤 모두 일치가 요구사항이면:
    //   1) FULLTEXT 인덱스 — 한국어는 ngram 파서 필요 (MySQL8 Step 15 10절)
    //   2) 검색 엔진 — 규모가 크면 정답
    //   3) 그냥 둔다 — 4만 행이면 풀스캔이 더 빠릅니다. **재고 나서 결정하십시오**
    //
    @Test
    @DisplayName("정답 4 — contains vs startsWith 와 인덱스")
    void ans4() {
        List<Product> byContains = queryFactory
                .selectFrom(product)
                .where(product.name.contains("노트"))
                .fetch();

        List<Product> byStartsWith = queryFactory
                .selectFrom(product)
                .where(product.name.startsWith("노트"))
                .fetch();

        System.out.println("contains(\"노트\"):");
        byContains.forEach(p -> System.out.println("  " + p.getName()));
        System.out.println("startsWith(\"노트\"): " + byStartsWith.size() + "건");

        assertThat(byContains).isNotEmpty();
        assertThat(byStartsWith).isEmpty();

        // 실습이 끝나면 인덱스를 되돌리십시오.
        //   ALTER TABLE products DROP INDEX idx_products_name;
    }

    // ════════════════════════════════════════════════════════════════
    // 정답 5 — ★ 최종 프로젝트: 상품 검색 (서브쿼리 버전)
    // ════════════════════════════════════════════════════════════════
    //
    // 이 코스의 모든 것이 여기 모입니다.
    //   Step 04 동적 조건 (null 반환 → where 에서 무시)
    //   Step 05 프로젝션 (실제로는 @QueryProjection DTO)
    //   Step 06 조인 (category)
    //   Step 07 서브쿼리 (하위 카테고리, 평점, 후기 수)
    //   Step 09 count 쿼리 분리 + PageableExecutionUtils
    //   Step 10 정렬 화이트리스트
    //   Step 12 커스텀 리포지토리 구조
    //   Step 13 coalesce (avg 의 null)
    //   Step 14 EXPLAIN 검증
    //
    // count 쿼리에 join 과 서브쿼리가 없어야 하는 이유:
    //   count 는 "몇 건인가"만 알면 됩니다. 카테고리명도 평점도 필요 없습니다.
    //   join 을 넣으면 불필요한 테이블 접근이 생기고,
    //   서브쿼리를 넣으면 행마다 재실행되어 count 가 콘텐츠 쿼리보다 느려집니다.
    //
    //   단, **join 이 결과 건수에 영향을 주는 경우**에는 빼면 안 됩니다.
    //   여기서는 product.category 가 optional = false (NOT NULL FK) 이므로
    //   inner join 이 행을 줄이지 않습니다. 그래서 뺄 수 있습니다.
    //   nullable FK 였다면 inner join 이 행을 줄이므로 count 에도 있어야 합니다.
    //   ★ "join 을 빼도 되는가"는 항상 이 기준으로 판단하십시오.
    //
    // PageableExecutionUtils.getPage 는 count 쿼리를 **필요할 때만** 실행합니다.
    //   - 첫 페이지이고 결과가 pageSize 보다 적으면 → count 실행 안 함
    //   - 마지막 페이지이면 → offset + content.size() 로 계산
    // 검색 결과가 한 화면에 들어가는 경우가 대부분이므로 실전에서 효과가 큽니다.
    //
    record SearchCond(
            String keyword,
            Long categoryId,
            BigDecimal minPrice,
            BigDecimal maxPrice,
            Boolean inStockOnly,
            List<ProductStatus> statuses,
            String sortKey,
            String sortDirection
    ) {}

    private static final Map<String, OrderSpecifier<?>> SORT_ASC = Map.of(
            "price",   product.price.asc(),
            "created", product.createdAt.asc(),
            "name",    product.name.asc());

    private static final Map<String, OrderSpecifier<?>> SORT_DESC = Map.of(
            "price",   product.price.desc(),
            "created", product.createdAt.desc(),
            "name",    product.name.desc());

    private static final OrderSpecifier<?> DEFAULT_SORT = product.id.desc();

    @Test
    @DisplayName("정답 5 — 상품 검색 (서브쿼리 버전)")
    void ans5() {
        SearchCond cond = new SearchCond(
                null, null, new BigDecimal("100000"), null,
                true, List.of(ProductStatus.ON_SALE), "price", "desc");
        Pageable pageable = PageRequest.of(0, 10);

        // ── 콘텐츠 쿼리 ────────────────────────────────────────────
        List<Tuple> content = queryFactory
                .select(product.id, product.name, product.price, product.stock,
                        product.status, category.name,
                        JPAExpressions.select(review.rating.avg())
                                .from(review).where(review.product.eq(product)),
                        JPAExpressions.select(review.count())
                                .from(review).where(review.product.eq(product)))
                .from(product)
                .join(product.category, category)
                .where(keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceBetween(cond.minPrice(), cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()))
                .orderBy(toOrder(cond.sortKey(), cond.sortDirection()), product.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // ── count 쿼리 (분리) ──────────────────────────────────────
        JPAQuery<Long> countQuery = queryFactory
                .select(product.count())
                .from(product)
                .where(keywordContains(cond.keyword()),
                        categoryIn(cond.categoryId()),
                        priceBetween(cond.minPrice(), cond.maxPrice()),
                        inStock(cond.inStockOnly()),
                        statusIn(cond.statuses()));

        // 실제 리포지토리에서는:
        //   return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
        Long total = countQuery.fetchOne();

        content.forEach(t -> System.out.printf("%-24s %10s  평점 %-5s 후기 %d  [%s]%n",
                t.get(product.name), t.get(product.price),
                t.get(6, Double.class), t.get(7, Long.class), t.get(category.name)));
        System.out.println("total = " + total);

        printStats("검색 (서브쿼리)");

        assertThat(total).isNotNull();
        assertThat(content.size()).isLessThanOrEqualTo(10);
    }

    // ── 동적 조건 (Step 04, 10) ─────────────────────────────────────

    private BooleanExpression keywordContains(String keyword) {
        // null 과 빈 문자열을 모두 막습니다.
        // isBlank() 를 빠뜨리면 "" 가 넘어왔을 때 like '%%' 가 되어
        // 조건은 있으나 아무것도 걸러내지 못하는 SQL 이 나갑니다. 인덱스만 잃습니다.
        return (keyword == null || keyword.isBlank()) ? null : product.name.contains(keyword);
    }

    /** 선택한 카테고리 + 그 하위 카테고리 (Step 07) */
    private BooleanExpression categoryIn(Long categoryId) {
        if (categoryId == null) {
            return null;
        }
        // 대분류를 고르면 그 아래 소분류의 상품도 나와야 합니다.
        // categories 는 2단계(대분류 5 + 소분류 12)이므로 한 단계만 내려가면 됩니다.
        // 3단계 이상이면 재귀 CTE 가 필요하고, JPQL 로는 불가능합니다.
        //   → 네이티브 쿼리 또는 애플리케이션에서 ID 목록을 만들어 in 으로 넘깁니다.
        return product.category.id.eq(categoryId)
                .or(product.category.id.in(
                        JPAExpressions.select(category.id)
                                .from(category)
                                .where(category.parent.id.eq(categoryId))));
    }

    /** min 만 / max 만 / 둘 다 / 둘 다 null — 4가지를 모두 처리합니다. */
    private BooleanExpression priceBetween(BigDecimal min, BigDecimal max) {
        if (min == null && max == null) {
            return null;
        }
        if (min == null) {
            return product.price.loe(max);
        }
        if (max == null) {
            return product.price.goe(min);
        }
        return product.price.between(min, max);
        // 흔한 오답: product.price.goe(min).and(product.price.loe(max)) 를
        //           null 검사 없이 쓰는 것. min 이 null 이면 NPE 가 아니라
        //           goe(null) 이 되어 조용히 이상한 SQL 이 나갑니다.
    }

    private BooleanExpression inStock(Boolean inStockOnly) {
        // Boolean.TRUE.equals(...) 를 쓰는 이유: inStockOnly 가 null 일 수 있습니다.
        // if (inStockOnly) 로 쓰면 언박싱 NPE 입니다.
        return Boolean.TRUE.equals(inStockOnly) ? product.stock.gt(0) : null;
    }

    private BooleanExpression statusIn(List<ProductStatus> statuses) {
        // 빈 리스트를 그대로 in 에 넘기면 in () 이 되어 문법 에러이거나
        // 항상 false 인 조건이 됩니다. 반드시 isEmpty() 를 검사하십시오.
        return (statuses == null || statuses.isEmpty()) ? null : product.status.in(statuses);
    }

    /**
     * 정렬 화이트리스트 (Step 10, 13-11).
     *
     * 사용자 입력은 Map 의 **키**로만 쓰입니다.
     * SQL 에 들어가는 것은 코드에 이미 존재하는 OrderSpecifier 뿐입니다.
     *
     * 컬럼명과 정렬 방향은 바인딩 파라미터가 될 수 없습니다.
     * (order by ? 는 "상수 하나로 정렬"이지 "그 이름의 컬럼으로 정렬"이 아닙니다.)
     * 그래서 동적 정렬에서는 화이트리스트가 유일한 방어입니다.
     */
    private OrderSpecifier<?> toOrder(String sortKey, String direction) {
        if (sortKey == null) {
            return DEFAULT_SORT;
        }
        Map<String, OrderSpecifier<?>> table =
                "asc".equalsIgnoreCase(direction) ? SORT_ASC : SORT_DESC;
        return table.getOrDefault(sortKey, DEFAULT_SORT);
    }

    // ════════════════════════════════════════════════════════════════
    // 정답 6 — 평균 평점 null 세기
    // ════════════════════════════════════════════════════════════════
    //
    // 1) 24가 아닌 이유
    //    24는 **전 상품 40개 중** 후기가 없는 것의 개수입니다.
    //    문제 5의 조건(가격 10만 이상 + 재고 있음 + ON_SALE)을 걸면 모집단이 줄어듭니다.
    //    그중 후기 없는 것만 세므로 24보다 작습니다.
    //    "24가 안 나온다"가 아니라 "24가 나오면 오히려 이상하다"가 맞습니다.
    //
    // 2) avg 는 null, count 는 0 인 이유
    //    SQL 표준의 정의입니다.
    //      COUNT  — 행을 세는 함수. 셀 행이 없으면 0. **항상 값이 있습니다.**
    //      AVG/SUM/MAX/MIN — 값을 집계하는 함수. 집계할 값이 없으면 NULL.
    //    "평균이 0" 과 "평균을 낼 수 없음" 은 다릅니다.
    //    후기가 없는 상품의 평점을 0.0 이라고 하면 "최악의 상품"으로 정렬됩니다.
    //    NULL 이 정직한 표현이고, 표시 방법은 애플리케이션이 정하는 것이 맞습니다.
    //
    // 3) 어디서 막는 것이 좋은가
    //    **DTO 의 compact constructor** 를 권합니다.
    //
    //      public record ProductSearchResponse(...) {
    //          @QueryProjection
    //          public ProductSearchResponse {
    //              avgRating = avgRating == null ? 0.0 : avgRating;
    //          }
    //      }
    //
    //    이유:
    //      - SQL 을 건드리지 않습니다. coalesce 를 넣으면 SQL 이 복잡해지고,
    //        집계값 정렬(order by avg(...))의 의미도 바뀝니다
    //        (NULL 정렬 위치가 0.0 정렬 위치로 바뀝니다)
    //      - 컨트롤러에서 막으면 DTO 를 쓰는 모든 곳에서 반복해야 합니다
    //      - DTO 가 "이 필드는 절대 null 이 아니다"를 스스로 보장합니다
    //
    //    다만 **정렬에 쓰이는 값이면 SQL 에서 처리**해야 합니다.
    //    자바에서 0.0 으로 바꿔도 정렬은 이미 DB 에서 끝났기 때문입니다.
    //
    @Test
    @DisplayName("정답 6 — 평균 평점 null 세기")
    void ans6() {
        // 전 상품 기준 — 24개
        long allNoReview = queryFactory
                .select(product.count())
                .from(product)
                .where(JPAExpressions
                        .selectOne()
                        .from(review)
                        .where(review.product.eq(product))
                        .notExists())
                .fetchOne();

        System.out.println("전 상품 중 후기 없는 것: " + allNoReview + "개");
        assertThat(allNoReview).isEqualTo(24L);

        // 문제 5의 조건을 건 경우
        long filteredNoReview = queryFactory
                .select(product.count())
                .from(product)
                .where(priceBetween(new BigDecimal("100000"), null),
                        inStock(true),
                        statusIn(List.of(ProductStatus.ON_SALE)),
                        JPAExpressions.selectOne()
                                .from(review)
                                .where(review.product.eq(product))
                                .notExists())
                .fetchOne();

        System.out.println("조건 적용 후 후기 없는 것: " + filteredNoReview + "개");
        assertThat(filteredNoReview).isLessThan(24L);

        // 참고: notExists() 를 쓴 이유는 Step 07 의 NOT IN + NULL 함정 때문입니다.
        //       product.id.notIn(select review.product.id from review) 는
        //       서브쿼리 결과에 NULL 이 하나라도 있으면 결과가 0건이 됩니다.
    }

    // ════════════════════════════════════════════════════════════════
    // 정답 7 — EXPLAIN 검증과 인덱스 추가
    // ════════════════════════════════════════════════════════════════
    //
    // | 항목  | 인덱스 전                       | 인덱스 후                        |
    // |------|-------------------------------|--------------------------------|
    // | type | ALL                           | range                          |
    // | key  | NULL                          | idx_products_cat_price         |
    // | rows | 40                            | 3                              |
    // | Extra| Using where; Using temporary; | Using index condition;         |
    // |      | Using filesort                | Using where; Using temporary;  |
    // |      |                               | Using filesort                 |
    //
    // 실행시간: 0.009 sec → 0.004 sec
    // 40행에서는 큰 의미가 없습니다. **40만 행이면 4초 → 0.05초입니다.**
    //
    // 1) 어느 컬럼으로, 왜
    //    (category_id, price) 복합 인덱스입니다.
    //
    //    선택도 조사 결과:
    //      total=40, category_id 12종, status 3종, price 34종, stock>0 이 33개
    //
    //      - status  — 3종. 선택도가 낮아 단독 인덱스는 무의미합니다
    //      - stock>0 — 40 중 33. 거의 전부라 무의미합니다
    //      - price   — 34종. 선택도가 높습니다
    //      - category_id — 12종. 중간이고 이미 인덱스가 있습니다
    //
    //    검색 화면의 주 필터는 카테고리이고, 그 안에서 가격으로 좁히고 가격으로 정렬합니다.
    //    복합 인덱스는 **등치 조건 컬럼을 앞에, 범위/정렬 컬럼을 뒤에** 두는 것이 원칙입니다.
    //    (MySQL8 Step 15 5절 — 선두 컬럼 규칙)
    //
    // 2) Using filesort 가 남는 이유
    //    group by 로 집계한 뒤 그 집계값(avg(rating))으로 정렬하기 때문입니다.
    //    집계 결과의 순서는 **계산이 끝나야 알 수 있습니다.**
    //    인덱스는 원본 컬럼 값의 순서만 제공하므로 집계값 정렬에는 쓸 수 없습니다.
    //    Using temporary 도 같은 이유입니다 (group by 처리를 위한 임시 테이블).
    //
    //    가격순 정렬로 바꾸면 filesort 가 사라질 수 있습니다.
    //    그것이 "정렬 기본값을 무엇으로 할 것인가"가 성능 결정인 이유입니다.
    //
    // 3) 새 인덱스 vs 기존 확장
    //    **기존 확장이 낫습니다.**
    //      ALTER TABLE products DROP INDEX idx_products_category;
    //      ALTER TABLE products ADD INDEX idx_products_category (category_id, price);
    //
    //    이유:
    //      - 인덱스 개수가 늘지 않습니다. INSERT/UPDATE 비용이 그대로입니다
    //      - 선두 컬럼이 category_id 로 같으므로 **기존 용도(카테고리 단독 조회)도 그대로** 동작합니다
    //      - 인덱스가 둘이면 옵티마이저가 매번 둘 중 하나를 골라야 하고,
    //        통계가 어긋나면 잘못 고를 수 있습니다
    //
    //    "인덱스를 추가한다"보다 "기존 인덱스를 확장할 수 있는가"를 먼저 보십시오.
    //
    // 인덱스 추가 전 체크리스트:
    //   1. 정말 그 쿼리가 느린가 — EXPLAIN 보다 실측이 먼저입니다
    //   2. 기존 인덱스로 안 되는가 — 확장으로 해결되는 경우가 많습니다
    //   3. 쓰기 비용을 감당할 수 있는가 — 인덱스는 INSERT/UPDATE 마다 갱신됩니다
    //
    @Test
    @DisplayName("정답 7 — EXPLAIN 검증과 인덱스 추가")
    void ans7() {
        // 쿼리를 실행해 p6spy 로그에서 완성된 SQL 을 복사하십시오.
        List<Tuple> content = queryFactory
                .select(product.id, product.name, product.price, category.name,
                        review.rating.avg(), review.count())
                .from(product)
                .join(product.category, category)
                .leftJoin(review).on(review.product.eq(product))
                .where(priceBetween(new BigDecimal("100000"), null),
                        inStock(true),
                        statusIn(List.of(ProductStatus.ON_SALE)))
                .groupBy(product.id, product.name, product.price, category.name)
                .orderBy(review.rating.avg().desc(), product.id.desc())
                .limit(10)
                .fetch();

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

        // 나머지는 MySQL 콘솔 작업입니다.
        //
        //   -- 1) 현재 계획
        //   EXPLAIN <복사한 SQL>;
        //
        //   -- 2) 현재 인덱스
        //   SHOW INDEX FROM products;
        //
        //   -- 3) 선택도
        //   SELECT COUNT(*) total, COUNT(DISTINCT category_id) cat,
        //          COUNT(DISTINCT status) st, COUNT(DISTINCT price) pr,
        //          SUM(stock > 0) in_stock
        //   FROM products;
        //
        //   -- 4) 인덱스 확장
        //   ALTER TABLE products DROP INDEX idx_products_category;
        //   ALTER TABLE products ADD INDEX idx_products_category (category_id, price);
        //
        //   -- 5) 다시 EXPLAIN
        //   EXPLAIN <복사한 SQL>;
        //
        //   -- 6) 원복
        //   ALTER TABLE products DROP INDEX idx_products_category;
        //   ALTER TABLE products ADD INDEX idx_products_category (category_id);

        assertThat(content).isNotNull();

        // ────────────────────────────────────────────────────────────
        // 이 코스의 마지막 문제입니다.
        //
        // 14개 스텝에서 배운 것은 QueryDSL 문법이 아니라
        // **자바 코드 한 줄이 SQL 한 줄로 번역되는 과정을 눈으로 좇는 습관**입니다.
        //
        // 이 습관이 있으면 QueryDSL 7.x 로 올려도, Hibernate 7 이 나와도,
        // 아예 다른 ORM 으로 옮겨도 같은 방식으로 검증할 수 있습니다.
        //
        // 문법은 문서에서 다시 찾을 수 있습니다. 습관은 문서에 없습니다.
        //
        // 이제 여러분의 프로젝트에서 org.hibernate.SQL: debug 를 켜십시오.
        // 그리고 지금 돌아가고 있는 조회 API 하나를 골라 로그를 보십시오.
        // ────────────────────────────────────────────────────────────
    }
}