Step 01 — 환경 구축과 첫 쿼리

학습 목표

  • 문자열 JPQL 의 오타가 컴파일을 통과해 런타임에 터지는 과정을 직접 재현하고, QueryDSL 이 그것을 컴파일 에러로 바꾸는 것을 확인한다
  • QueryDSL 5.x(com.querydsl)와 6.x(io.github.openfeign.querydsl)의 의존성 좌표 차이를 표로 구별하고, 잘못 쓴 3가지 케이스의 에러 메시지를 각각 읽어낸다
  • 원본 QueryDSL 이 중단되고 OpenFeign 포크가 6.x 를 낸 히스토리와, Hibernate 6 에서 5.x 가 깨진 실제 원인을 설명한다
  • JPAQueryFactory 를 스프링 빈으로 등록하고, 그것이 왜 싱글턴으로 안전한지 EntityManager 프록시의 동작으로 설명한다
  • selectFrom 으로 첫 쿼리를 실행하고, hibernate.SQL 로그로 생성 SQL 과 바인딩 파라미터를 눈으로 확인한다
  • QueryDSL 이 만드는 것이 SQL 이 아니라 JPQL 임을 .toString() 으로 실측하고, 2단계 변환 구조를 그린다

선행 스텝: 실습 프로젝트 셋업 예상 소요: 90분


1-1. QueryDSL 이란 — 오타가 런타임에야 터진다

JPA 로 조회를 짜는 방법은 크게 셋입니다.

방법타입 안전동적 쿼리가독성
문자열 JPQL (em.createQuery("select ..."))✗ (문자열 이어붙이기)보통
Criteria API (CriteriaBuilder)최악
QueryDSL좋음

문자열 JPQL 의 진짜 문제는 "장황하다"가 아닙니다. 틀린 코드가 빌드를 통과한다는 것입니다.

오타를 하나 내 봅니다

// selectFrom 이 아니라 selct — 알파벳 하나가 빠졌습니다
String jpql = "selct c from Customer c where c.grade = :grade";

List<Customer> result = em.createQuery(jpql, Customer.class)
        .setParameter("grade", Grade.VIP)
        .getResultList();
./gradlew compileJava

결과

> Task :compileJava
BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed

컴파일이 성공합니다. 자바 컴파일러 입장에서 jpql 은 그냥 String 이고, String 에 오타라는 개념은 없습니다. 이 코드는 그대로 커밋되고, 리뷰를 통과하고, 배포됩니다. 그리고 그 코드 경로를 처음 밟는 사용자가 이걸 만납니다.

// 테스트를 실행하면
@Test
void 오타난_JPQL() {
    String jpql = "selct c from Customer c where c.grade = :grade";
    em.createQuery(jpql, Customer.class).setParameter("grade", Grade.VIP).getResultList();
}

결과

jakarta.persistence.PersistenceException: org.hibernate.query.SyntaxException:
  At 1:0 and token 'selct', mismatched input 'selct' expecting {'delete', 'insert', 'select', 'update', 'with'}
  [selct c from Customer c where c.grade = :grade]

	at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:158)
	at org.hibernate.internal.AbstractSharedSessionContract.createQuery(AbstractSharedSessionContract.java:829)
	at com.example.shop.step01.Practice.오타난_JPQL(Practice.java:57)
Caused by: org.hibernate.query.SyntaxException: At 1:0 and token 'selct', mismatched input 'selct'
	at org.hibernate.query.hql.internal.StandardHqlTranslator.translate(StandardHqlTranslator.java:78)

💡 Hibernate 5 에서는 이 예외가 org.hibernate.hql.internal.ast.QuerySyntaxException 이었습니다. Hibernate 6 에서 HQL 파서가 ANTLR 기반으로 새로 작성되면서 org.hibernate.query.SyntaxException 으로 바뀌었습니다. 인터넷의 오래된 글에서 QuerySyntaxException 을 검색하고 있다면 이 차이 때문입니다.

오타뿐이 아닙니다. 문자열 JPQL 에서 런타임에야 터지는 것들:

실수언제 발견되나
키워드 오타 (selct, wher)쿼리 실행 시
엔티티 이름 오타 (Custmer)쿼리 실행 시
필드 이름 오타 (c.grde)쿼리 실행 시
필드 타입 불일치 (c.points = 'VIP')쿼리 실행 시
파라미터 이름 불일치 (:grade vs setParameter("grad", ...))쿼리 실행 시
엔티티 필드명을 리팩터링으로 바꿈아무도 모름 — 문자열은 IDE 리네임에 안 따라옵니다

마지막 줄이 가장 무섭습니다. Customer.gradeCustomer.memberGrade 로 바꾸면 IDE 가 자바 코드는 전부 고쳐 주지만, 문자열 안의 c.grade 는 그대로 남습니다.

같은 실수를 QueryDSL 로

List<Customer> result = queryFactory
        .selctFrom(customer)                       // 오타
        .where(customer.grade.eq(Grade.VIP))
        .fetch();
./gradlew compileJava

결과

> Task :compileJava FAILED

/src/test/java/com/example/shop/step01/Practice.java:64: error: cannot find symbol
                .selctFrom(customer)
                ^
  symbol:   method selctFrom(QCustomer)
  location: variable queryFactory of type JPAQueryFactory

1 error
BUILD FAILED in 2s

필드명 오타도 마찬가지입니다.

.where(customer.grde.eq(Grade.VIP))   // grade 가 아니라 grde
error: cannot find symbol
                .where(customer.grde.eq(Grade.VIP))
                               ^
  symbol:   variable grde
  location: variable customer of type QCustomer

타입 불일치도 잡힙니다.

.where(customer.points.eq("VIP"))     // points 는 Integer 인데 String 을 넣음
error: incompatible types: String cannot be converted to Integer
                .where(customer.points.eq("VIP"))
                                          ^

이것이 QueryDSL 의 전부입니다. 쿼리를 문자열이 아니라 자바 객체 그래프로 조립하기 때문에, 쿼리에 대한 검증이 자바 컴파일러의 일이 됩니다.

💡 실무 팁 — "컴파일 타임에 잡힌다"가 주는 진짜 이득은 리팩터링이다 오타는 사실 테스트 한 번이면 잡힙니다. QueryDSL 의 실질적 가치는 엔티티 스키마가 바뀌었을 때 영향받는 모든 쿼리가 빌드에서 터진다는 것입니다. 컬럼 하나를 지웠는데 어느 쿼리가 깨지는지 ./gradlew compileJava 가 목록을 뽑아 줍니다. 문자열 JPQL 은 이 목록을 절대 주지 않습니다.


1-2. 의존성 함정 — 이 코스에서 가장 중요한 절

이 절에서 시간을 아끼면 나머지 13개 스텝에서 시간을 잃습니다. QueryDSL 도입 실패의 압도적 다수가 의존성 좌표 문제입니다.

좌표 3종 비교

항목com.querydsl 5.0.0 (기본)com.querydsl 5.0.0 :jakartaio.github.openfeign.querydsl 6.12
배포 주체원본 (중단)원본 (중단)OpenFeign 포크 (유지보수 중)
최종 릴리스2021-072021-072025-06-09
영속성 APIjavax.persistencejakarta.persistencejakarta.persistence (네이티브)
querydsl-jpa classifier없음:jakarta없음
querydsl-apt classifier없음:jakarta:jpa
Hibernate 6✗ 깨짐✗ 깨짐○ 완전 통합 (6.4)
Spring Boot 3.x△ (Hibernate 6 이슈)

핵심은 classifier 규칙이 5.x 와 6.x 에서 서로 다르다는 것입니다.

5.x → jpa 도 :jakarta,  apt 도 :jakarta    (둘 다 붙는다)
6.x → jpa 는 classifier 없음,  apt 만 :jpa  (한쪽만, 그것도 다른 이름)

이 비대칭이 함정의 근원입니다. 5.x 설정을 아는 사람일수록 ":jakarta 를 양쪽에 붙이면 되겠지" 하고 틀립니다.

정답 — 이 코스의 build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly    'com.mysql:mysql-connector-j'

    // ── QueryDSL 6.12 (io.github.openfeign.querydsl) ──────────────────
    implementation      'io.github.openfeign.querydsl:querydsl-jpa:6.12'      // classifier 없음
    implementation      'io.github.openfeign.querydsl:querydsl-core:6.12'
    annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12:jpa'  // :jpa classifier
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}
./gradlew dependencies --configuration compileClasspath | grep querydsl

결과

+--- io.github.openfeign.querydsl:querydsl-jpa:6.12
|    \--- io.github.openfeign.querydsl:querydsl-core:6.12
|         +--- com.google.code.findbugs:jsr305:3.0.2
|         \--- com.querydsl:querydsl-codegen-utils:6.12
\--- io.github.openfeign.querydsl:querydsl-core:6.12 (*)

케이스 (a) — 6.12 에 :jakarta classifier 를 붙였다

5.x 습관 그대로 옮긴 가장 흔한 실수입니다.

implementation 'io.github.openfeign.querydsl:querydsl-jpa:6.12:jakarta'   // ✗
./gradlew compileJava

결과

> Task :compileJava FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':compileJava'.
> Could not resolve all files for configuration ':compileClasspath'.
   > Could not find querydsl-jpa-6.12-jakarta.jar (io.github.openfeign.querydsl:querydsl-jpa:6.12).
     Searched in the following locations:
         https://repo.maven.apache.org/maven2/io/github/openfeign/querydsl/querydsl-jpa/6.12/querydsl-jpa-6.12-jakarta.jar

증상이 명확한 편이라 오히려 다행인 케이스입니다. "그런 아티팩트가 없다"고 말해 주니까요. 6.x 는 애초에 Jakarta 가 본체라서 jakarta classifier 아티팩트를 발행하지 않습니다. 붙일 게 없습니다.

케이스 (b) — apt 에 :jakarta 를 붙였다 (또는 classifier 를 안 붙였다)

annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12:jakarta'   // ✗ 존재하지 않음
// 또는
annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12'           // ✗ 프로세서가 안 붙음

두 번째가 훨씬 고약합니다. querydsl-apt:6.12실제로 존재하는 아티팩트라 의존성 해결이 성공합니다. 그런데 이 기본 아티팩트에는 JPA 용 프로세서를 등록하는 META-INF/services/javax.annotation.processing.Processor 항목이 없습니다. 즉 APT 가 조용히 아무것도 하지 않습니다.

./gradlew clean compileJava

결과

> Task :compileJava
BUILD SUCCESSFUL in 3s

빌드가 성공합니다. 그런데:

find build/generated -name 'Q*.java'

결과

(출력 없음)

Q타입이 하나도 없습니다. 그 상태로 QCustomer.customer 를 쓰는 코드를 컴파일하면:

error: package com.example.shop.entity does not exist
import static com.example.shop.entity.QCustomer.customer;
                                     ^
error: cannot find symbol
                .selectFrom(customer)
                            ^
  symbol:   variable customer

⚠️ 함정 — "Q타입을 못 찾는다"는 에러를 IDE 문제로 오해한다 cannot find symbol: QCustomer 를 보면 대부분 IDE 를 재시작하거나 Invalidate Caches 를 누릅니다. 그래도 안 되면 Gradle refresh 를 합니다. 원인이 IDE 가 아니라 annotationProcessor 좌표인 경우가 절반 이상입니다. 순서를 뒤집으세요: ① ./gradlew clean compileJava → ② find build/generated -name 'Q*.java' → ③ 파일이 없으면 IDE 를 만지지 말고 build.gradle 을 보세요. 이 진단 절차는 Step 02 — Q타입의 정체 에서 5가지 원인으로 세분해 다룹니다.

또 하나의 변종은 6.x jpa 라이브러리에 5.x apt 를 섞는 경우입니다.

implementation      'io.github.openfeign.querydsl:querydsl-jpa:6.12'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jpa'      // ✗ 5.x apt

이러면 Q타입은 생성되지만 javax.persistence 를 기준으로 엔티티를 스캔합니다. 우리 엔티티는 jakarta.persistence.@Entity 를 쓰므로 프로세서 눈에는 엔티티가 하나도 없고, 결과는 (b) 와 같이 Q타입 0개입니다. 또는 부분 생성된 Q타입이 javax.persistence 를 참조해 이런 에러가 납니다.

error: cannot access javax.persistence.Entity
  class file for javax.persistence.Entity not found

케이스 (c) — 5.0.0 을 classifier 없이 썼다

Spring Boot 3 프로젝트에 오래된 블로그 설정을 그대로 붙였을 때입니다.

implementation      'com.querydsl:querydsl-jpa:5.0.0'      // ✗ javax 버전
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0'      // ✗ javax 버전

컴파일은 통과할 수도 있습니다. 터지는 것은 런타임입니다.

결과

java.lang.NoClassDefFoundError: javax/persistence/Entity
	at com.querydsl.jpa.impl.JPAQuery.<init>(JPAQuery.java:57)
	at com.querydsl.jpa.impl.JPAQueryFactory.selectFrom(JPAQueryFactory.java:135)
	at com.example.shop.step01.Practice.첫쿼리(Practice.java:71)
Caused by: java.lang.ClassNotFoundException: javax.persistence.Entity
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
	... 42 more

Spring Boot 3 / Jakarta EE 9+ 환경에는 javax.persistence 패키지 자체가 없습니다. Java EE 가 Eclipse 재단으로 이관되면서 패키지 네임스페이스가 javax.*jakarta.* 로 통째로 바뀌었기 때문입니다. 이름만 바뀐 게 아니라 다른 패키지이므로, javax.persistence.Entity 를 참조하는 5.0.0 기본 아티팩트는 클래스로더가 찾을 수 없습니다.

세 케이스 요약

케이스잘못된 좌표증상발견 시점
(a)querydsl-jpa:6.12:jakartaCould not find querydsl-jpa-6.12-jakarta.jar빌드 (의존성 해결)
(b)querydsl-apt:6.12 (classifier 누락)빌드 성공 + Q타입 0개cannot find symbol빌드 (뒤늦게)
(b')querydsl-apt:5.0.0:jpa 혼용cannot access javax.persistence.Entity 또는 Q타입 0개빌드
(c)querydsl-jpa:5.0.0 (classifier 없음)NoClassDefFoundError: javax/persistence/Entity런타임

(c) 가 가장 위험합니다. 빌드가 초록불인데 서버가 첫 쿼리에서 죽습니다.

왜 이렇게 갈렸는가

원본 QueryDSL 5.0.0 은 javax 가 본체이고 jakarta 가 부록인 구조였습니다. 기존 사용자를 깨지 않으면서 Jakarta 전환기를 버티려고, 소스를 기계적으로 변환한 -jakarta classifier 아티팩트를 곁들여 냈습니다.

OpenFeign 포크 6.x 는 그 전환기가 끝난 뒤에 출발했습니다. Jakarta 를 유일한 본체로 삼았고, javax 지원을 아예 버렸습니다. 그래서 querydsl-jpa 에는 붙일 classifier 가 없습니다 — 변종이 하나뿐이니까요.

그럼 querydsl-apt:jpa 는 무엇인가? 이건 Jakarta 여부와 무관한 축입니다. querydsl-apt 는 원래부터 여러 프로세서를 담고 있고(JPA용, JDO용, 순수 @QueryEntity용), classifier 로 "어느 프로세서를 서비스 등록할 것인가"를 고릅니다. JPA 엔티티를 스캔하려면 :jpa 를 골라야 합니다.

querydsl-jpa   의 classifier 축 = "javax냐 jakarta냐"  → 6.x 는 jakarta 하나뿐 → classifier 소멸
querydsl-apt   의 classifier 축 = "어느 프로세서냐"     → 6.x 도 여전히 필요  → :jpa 유지

두 축이 다르다는 것을 알면 비대칭이 자연스럽게 읽힙니다.

💡 실무 팁 — 좌표를 의심할 때 가장 빠른 확인법

./gradlew dependencies --configuration annotationProcessor | grep querydsl

querydsl-apt:6.12 뒤에 아무 표시가 없으면 classifier 가 빠진 것입니다. 정상이라면 아래처럼 나옵니다.

\--- io.github.openfeign.querydsl:querydsl-apt:6.12

Gradle 은 dependencies 출력에 classifier 를 표시하지 않으므로, 확실히 하려면 실제 jar 파일명을 봅니다.

./gradlew -q dependencies --configuration annotationProcessor > /dev/null
find ~/.gradle/caches/modules-2 -name 'querydsl-apt-6.12*.jar'
.../querydsl-apt-6.12-jpa.jar     ← -jpa 가 붙어 있어야 정상

1-3. 왜 포크인가 — 원본 중단과 Hibernate 6

QueryDSL 6 을 쓰려면 "왜 그룹 ID 가 io.github.openfeign 인가"에 답할 수 있어야 합니다. 팀에 도입할 때 반드시 받는 질문이기도 합니다.

타임라인

시점사건
2021-07원본 com.querydsl 5.0.0 릴리스 — 결과적으로 마지막 정식 릴리스
2022-06Hibernate 6.0 GA. ByteType 등 구 타입 API 대거 제거
2022-10원본 저장소의 사실상 마지막 커밋
2022-11Spring Boot 3.0 GA — Jakarta EE 9+ 전면 전환
2023OpenFeign 팀이 포크를 이어받아 io.github.openfeign.querydsl 로 배포 시작
20246.x 계열 — Hibernate 6.4 완전 통합
2024-11Spring Data JPA 가 포크 전환을 논의 (spring-projects/spring-data-jpa#3335)
2025-06-096.12 릴리스 (이 코스의 기준 버전)
2026-057.x 계열 (7.2)

5.x 가 Hibernate 6 에서 실제로 어떻게 깨졌는가

Spring Boot 3.x 는 Hibernate 6 을 씁니다. 그런데 QueryDSL 5.0.0 의 querydsl-jpa 는 Hibernate 5 의 내부 타입 API 를 직접 참조합니다. 대표적으로 HibernateHandler / HibernateQuery 계열이 org.hibernate.type.ByteType 같은 Type 계층을 임포트합니다.

Hibernate 6.0 은 타입 시스템을 BasicType/JavaType/JdbcType 조합으로 재설계하면서 ByteType, StringType, LongType 같은 싱글턴 상수 클래스들을 삭제했습니다. 결과는 이렇습니다.

결과

java.lang.NoClassDefFoundError: org/hibernate/type/ByteType
	at com.querydsl.jpa.HibernateHandler.<clinit>(HibernateHandler.java:41)
	at com.querydsl.jpa.impl.AbstractJPAQuery.createQuery(AbstractJPAQuery.java:180)
	at com.querydsl.jpa.impl.AbstractJPAQuery.fetch(AbstractJPAQuery.java:216)
Caused by: java.lang.ClassNotFoundException: org.hibernate.type.ByteType

관련 이슈:

  • querydsl/querydsl#3436 — Hibernate 6 호환성 요청
  • querydsl/querydsl#3439 — 같은 맥락의 후속 보고

두 이슈 모두 원본 저장소에서 해결되지 않은 채 남았습니다. 유지보수가 멈춰 있었기 때문입니다.

⚠️ 함정 — 5.0.0:jakarta 면 Spring Boot 3 에서 괜찮다는 오해 :jakarta classifier 는 javaxjakarta 패키지 치환만 해결합니다. Hibernate 6 의 내부 API 변경은 전혀 다른 문제입니다. 즉 com.querydsl:querydsl-jpa:5.0.0:jakarta + Spring Boot 3.2 조합은 NoClassDefFoundError: javax/persistence/Entity 는 피하지만, 쿼리를 실행하는 순간 NoClassDefFoundError: org/hibernate/type/ByteType 을 만날 수 있습니다. 단순 조회에서는 이 코드 경로를 안 밟아 우연히 동작하기도 해서, "되는 줄 알았는데 특정 쿼리에서만 죽는" 최악의 형태로 나타납니다. Spring Boot 3.x 를 쓴다면 6.x 포크가 사실상 유일한 선택입니다.

포크를 써도 되는가

실무 판단에 필요한 근거는 이 정도입니다.

  • 패키지 이름은 그대로 com.querydsl.* 입니다. 그룹 ID 만 다릅니다. 즉 코드는 한 줄도 안 고쳐도 됩니다.
  • 5.x → 6.x 마이그레이션은 대부분 build.gradle 만 수정하면 끝납니다.
  • Spring Data JPA 도 이 포크로의 전환을 공식 이슈로 논의했습니다 (#3335). 커뮤니티가 사실상의 후계로 인정하고 있다는 신호입니다.
  • 릴리스가 계속 나오고 있습니다 (6.12 → 7.x).

💡 실무 팁 — 7.x 가 있는데 왜 6.12 인가 이 코스가 6.12 를 쓰는 이유는 Spring Boot 3.2 / Hibernate 6.4 조합에서 가장 검증된 버전이기 때문입니다. 7.x 는 더 최신 Hibernate 를 겨냥합니다. 버전을 올릴 때는 반드시 Hibernate 버전과의 짝을 먼저 확인하세요. QueryDSL 은 Hibernate 내부 API 에 의존하는 부분이 있어, "최신이니까 좋겠지"로 올리면 1-3 절의 사고를 그대로 재현하게 됩니다.


1-4. JPAQueryFactory 빈 등록

QueryDSL 로 쿼리를 만드는 진입점은 JPAQueryFactory 입니다. 이것을 스프링 빈으로 한 번 등록해 두고 어디서나 주입받아 씁니다.

package com.example.shop.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QuerydslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}

이게 전부입니다. 그런데 여기서 대부분의 사람이 멈칫합니다.

JPAQueryFactory 는 싱글턴 빈인데, 그 안에 EntityManager 를 필드로 붙들고 있다. EntityManager 는 스레드 안전하지 않다고 배웠는데, 이래도 되나?

됩니다. 이유를 정확히 알아야 합니다.

주입되는 것은 진짜 EntityManager 가 아니다

스프링이 jpaQueryFactory(EntityManager em) 의 파라미터로 넣어 주는 것은 SharedEntityManagerCreator 가 만든 프록시입니다. 실제 EntityManager 구현체(SessionImpl)가 아닙니다.

@Test
void 주입된_EntityManager의_정체() {
    System.out.println(em.getClass().getName());
    System.out.println(em instanceof jakarta.persistence.EntityManager);
}

결과

jdk.proxy2.$Proxy214
true

이 프록시의 동작은 이렇습니다.

queryFactory.selectFrom(...)  호출


 JPAQueryFactory 의 필드 em (프록시)
        │  메서드 호출을 가로챔

 SharedEntityManagerInvocationHandler
        │  "지금 이 스레드의 트랜잭션에 바인딩된 EntityManager 를 찾아라"

 TransactionSynchronizationManager (ThreadLocal)

        ├── 스레드 A 의 트랜잭션 → SessionImpl@1a2b   ← A 는 여기로 위임
        └── 스레드 B 의 트랜잭션 → SessionImpl@3c4d   ← B 는 여기로 위임

싱글턴이 붙들고 있는 것은 "지금 스레드의 EntityManager 를 찾아 주는 함수" 이지, 특정 영속성 컨텍스트가 아닙니다. 스레드마다 다른 실제 세션으로 위임되므로 안전합니다.

@PersistenceContext 로 필드 주입해도 동일합니다.

@PersistenceContext
private EntityManager em;      // 이것도 SharedEntityManagerCreator 프록시

@PersistenceContextPersistenceAnnotationBeanPostProcessor 가 처리하며, 그 결과 주입되는 것 역시 같은 종류의 공유 프록시입니다. 생성자 파라미터로 받든 @PersistenceContext 로 받든 결과물은 같습니다.

⚠️ 함정 — EntityManagerFactory.createEntityManager() 로 직접 만든 것을 넘기면 안 된다

@Bean
public JPAQueryFactory jpaQueryFactory(EntityManagerFactory emf) {
    return new JPAQueryFactory(emf.createEntityManager());   // ✗ 절대 금지
}

이건 진짜 EntityManager하나 만들어서 싱글턴에 영구 고정하는 것입니다. 프록시가 아니므로 스레드 분리가 없습니다. 모든 요청이 같은 영속성 컨텍스트를 공유하고, 1차 캐시가 무한히 커지고, 트랜잭션 경계와 무관하게 동작하며, 동시 요청에서 ConcurrentModificationException 이나 정체불명의 LazyInitializationException 이 튑니다. 개발 중에는 요청이 한 번에 하나씩 오니까 잘 돌아갑니다. 부하가 걸리는 운영에서 처음 터집니다.

트랜잭션이 없으면

JPAQueryFactory 로 조회만 한다면 트랜잭션 없이도 동작합니다. 공유 프록시가 트랜잭션 밖에서는 요청마다 임시 EntityManager 를 만들고 바로 닫기 때문입니다. 다만 그 경우 영속성 컨텍스트가 조회 직후 닫히므로 지연 로딩이 즉시 깨집니다.

org.hibernate.LazyInitializationException: could not initialize proxy
  [com.example.shop.entity.Customer#7] - no Session

이 코스의 실습 클래스가 전부 @Transactional 인 이유입니다.


1-5. 첫 쿼리 — selectFrom

VIP 등급 고객을 조회합니다. MySQL8 코스의 SELECT * FROM customers WHERE grade = 'VIP' 와 같은 쿼리입니다.

📌 MySQL8 코스 Step 05 — 연산자와 조건 에서 이렇게 썼던 조건입니다.

package com.example.shop.step01;

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static com.example.shop.entity.QCustomer.customer;   // ★ static import

@SpringBootTest
@Transactional
class FirstQueryTest {

    @Autowired
    JPAQueryFactory queryFactory;

    @Test
    void VIP_고객_조회() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        result.forEach(c -> System.out.println(c.getName() + " / " + c.getCity()));
    }
}

import static com.example.shop.entity.QCustomer.customer;이 코스의 모든 예제는 Q타입을 static import 해서 씁니다. QCustomer.customer.grade 대신 customer.grade 로 쓰면 쿼리가 SQL 처럼 읽힙니다. 이 관례는 Step 02 에서 이름 충돌 대처까지 다룹니다.

결과hibernate.SQL 로그

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 = ?
바인딩: [1] Grade.VIP
조회 4건 — 김서준, 류하나, 정  훈, 배채영

김서준 / 서울
류하나 / 부산
정  훈 / 서울
배채영 / 대구

읽어야 할 것 세 가지

selectFrom(customer)select(customer).from(customer) 의 축약입니다. 조회 대상과 from 절이 같을 때만 씁니다. 다르면 나눠 씁니다.

// 이름만 뽑을 때는 축약할 수 없습니다
List<String> names = queryFactory
        .select(customer.name)
        .from(customer)
        .where(customer.grade.eq(Grade.VIP))
        .fetch();

결과

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

② 별칭이 c1_0 입니다. Hibernate 6 은 <엔티티 첫 글자><인덱스>_<서브인덱스> 규칙으로 별칭을 붙이고, as 키워드를 생략합니다. Hibernate 5 는 customer0_ 형태였습니다. 인터넷 예제의 별칭 모양만 봐도 Hibernate 세대를 구별할 수 있습니다.

③ 값이 ? 로 나갔습니다. QueryDSL 은 상수를 자동으로 바인딩 파라미터로 만듭니다. 문자열을 이어붙이지 않으므로 SQL 인젝션이 구조적으로 불가능하고, DB 가 실행계획을 재사용할 수 있습니다. 이 동작을 바꾸는 방법은 Step 03 — 기본 조회 에서 다룹니다.

결과 반환 메서드

메서드반환결과가 없을 때결과가 2건 이상일 때
fetch()List<T>빈 리스트정상
fetchOne()TnullNonUniqueResultException
fetchFirst()Tnull첫 건 (limit(1) 을 붙임)
fetchCount()long0— (deprecated)
Customer one = queryFactory.selectFrom(customer)
        .where(customer.email.eq("seojun.kim@example.com"))
        .fetchOne();

결과

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.email = ?
바인딩: [1] seojun.kim@example.com
조회 1건 — 김서준

⚠️ 함정 — fetchOne() 은 결과가 2건이면 예외를 던진다

Customer c = queryFactory.selectFrom(customer)
        .where(customer.city.eq("서울"))     // 서울 고객은 여럿입니다
        .fetchOne();
com.querydsl.core.NonUniqueResultException: Only one result is allowed for fetchOne calls
	at com.querydsl.jpa.impl.AbstractJPAQuery.fetchOne(AbstractJPAQuery.java:242)

유니크 제약이 있는 컬럼(email 등)으로 조회할 때만 fetchOne() 을 쓰세요. 조건상 1건일 것 같지만 확신이 없다면 fetchFirst() 를 쓰거나, fetch() 로 받아 크기를 확인하는 편이 안전합니다. "지금은 1건인데 데이터가 늘면 2건이 될 수 있는" 조건이 가장 위험합니다. 개발 DB 에서는 절대 안 터집니다.

💡 fetchCount()fetchResults() 는 6.x 에서 deprecated 입니다. 카운트 쿼리를 직접 조립하는 방식으로 대체되었으며, Step 09 — 정렬과 페이징 에서 정확한 대체 코드를 다룹니다.


1-6. SQL 로그 보는 법

이 코스는 "생성된 SQL 을 눈으로 보는 것"을 전제로 합니다. 로그 설정이 안 되어 있으면 나머지 스텝의 절반이 무의미해집니다. 지금 설정하고 갑니다.

아무 설정도 안 한 상태

# application.yml — 비어 있음
queryFactory.selectFrom(customer).where(customer.grade.eq(Grade.VIP)).fetch();

결과

(아무 로그도 나오지 않음)

쿼리가 나갔는지조차 알 수 없습니다.

① SQL 문 출력

logging:
  level:
    org.hibernate.SQL: debug

결과

2026-07-22 10:11:23.441 DEBUG 41822 --- [    Test worker] org.hibernate.SQL : 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=?

SQL 은 보이는데 한 줄로 길고, ? 에 무엇이 들어갔는지 모릅니다.

② 파라미터 바인딩 값 출력

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

결과

2026-07-22 10:11:23.441 DEBUG --- org.hibernate.SQL : select c1_0.customer_id,c1_0.city,... where c1_0.grade=?
2026-07-22 10:11:23.443 TRACE --- org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [VIP]

이제 ? 의 정체가 보입니다. (1:VARCHAR) 는 "1번 파라미터, JDBC 타입 VARCHAR", <- [VIP] 가 실제 값입니다.

⚠️ 함정 — org.hibernate.type.descriptor.sql.BasicBinder 는 Hibernate 6 에서 안 먹는다 검색하면 대부분 이 카테고리를 알려 줍니다.

logging.level.org.hibernate.type.descriptor.sql.BasicBinder: trace   # Hibernate 5 전용

Hibernate 6 에서는 아무 효과가 없습니다. 바인딩 로거가 org.hibernate.orm.jdbc.bind 로 이동했기 때문입니다. "설정했는데 파라미터가 안 보인다"면 십중팔구 이것입니다. Spring Boot 3.x 를 쓴다면 org.hibernate.orm.jdbc.bind 를 쓰세요. 추출된 값(SELECT 결과)까지 보려면 org.hibernate.orm.jdbc.extract: trace 를 추가합니다.

③ 줄바꿈 정렬

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

결과

2026-07-22 10:11:23.441 DEBUG --- org.hibernate.SQL :
    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=?
2026-07-22 10:11:23.443 TRACE --- org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [VIP]

조인이 여러 개 들어가는 Step 06 부터는 format_sql 없이는 읽기 어렵습니다. 켜 두는 것을 권합니다.

④ JPQL 까지 보기

QueryDSL 이 만든 JPQL 을 보려면 별도 카테고리가 필요합니다.

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

결과

2026-07-22 10:11:23.401 TRACE --- org.hibernate.orm.query : HQL : select customer from Customer customer where customer.grade = ?1
2026-07-22 10:11:23.441 DEBUG --- org.hibernate.SQL :
    select ...

JPQL 한 줄, SQL 한 줄이 나란히 찍힙니다. 이 두 줄의 관계가 1-8 절의 주제입니다.

⑤ p6spy — 완성된 SQL 을 한 줄로

? 자리에 값이 채워진 바로 복붙해서 실행 가능한 SQL 을 보고 싶다면 p6spy 를 씁니다.

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

결과

2026-07-22 10:11:23.445  INFO --- p6spy : #1753146683445 | took 3ms | statement | connection 4| url jdbc:mysql://127.0.0.1:3307/shop
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=?
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'

원본 SQL 과 값이 채워진 SQL 을 둘 다 주고, 실행 시간(took 3ms) 까지 붙여 줍니다.

방식장점단점
hibernate.SQL + orm.jdbc.bind의존성 추가 없음, Hibernate 가 실제로 만든 그대로값이 따로 놀아 조합이 번거로움
p6spy실행 가능한 완성 SQL, 실행 시간 측정의존성 추가, JDBC 프록시 오버헤드

⚠️ 함정 — 운영에 bind: trace 나 p6spy 를 켜 둔다 바인딩 로그는 파라미터 값을 전부 평문으로 남깁니다. 이메일, 전화번호, 주민등록번호가 로그 파일에 그대로 쌓입니다. 개인정보 유출 사고의 흔한 경로입니다. p6spy 는 여기에 더해 모든 JDBC 호출을 프록시로 감싸므로 성능 오버헤드도 있습니다. 로컬/개발 프로파일에만 켜세요.

# application-local.yml 에만
logging.level.org.hibernate.orm.jdbc.bind: trace

💡 실무 팁 — show-sql: true 는 쓰지 마세요

spring.jpa.show-sql: true    # 권장하지 않음

이건 System.out 으로 직접 출력합니다. 로그 레벨로 끌 수 없고, 로그 파일에 안 남고, 타임스탬프도 스레드 정보도 없습니다. 같은 결과를 logging.level.org.hibernate.SQL=debug 로 얻을 수 있고 그쪽이 모든 면에서 낫습니다.


1-7. JPQL vs QueryDSL 대조표

같은 쿼리 5개를 문자열 JPQL 과 QueryDSL 로 나란히 놓습니다.

① VIP 고객 전체

// JPQL
em.createQuery("select c from Customer c where c.grade = :grade", Customer.class)
  .setParameter("grade", Grade.VIP)
  .getResultList();

// QueryDSL
queryFactory.selectFrom(customer)
        .where(customer.grade.eq(Grade.VIP))
        .fetch();

결과 — 양쪽 모두 동일한 SQL

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 = ?
바인딩: [1] Grade.VIP
조회 4건 — 김서준, 류하나, 정  훈, 배채영

② 포인트 5000 이상, 이름순

// JPQL
em.createQuery("""
        select c from Customer c
        where c.points >= :min
        order by c.name asc
        """, Customer.class)
  .setParameter("min", 5000)
  .getResultList();

// QueryDSL
queryFactory.selectFrom(customer)
        .where(customer.points.goe(5000))
        .orderBy(customer.name.asc())
        .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 c1_0.points >= ?
order by c1_0.name asc
바인딩: [1] 5000
조회 11건 — 김서준, 류하나, 문시우, 배채영, 안지수, ...

③ 이름에 "지" 가 들어가는 고객

// JPQL — % 를 직접 붙여야 합니다
em.createQuery("select c from Customer c where c.name like :kw", Customer.class)
  .setParameter("kw", "%지%")
  .getResultList();

// QueryDSL — contains 가 % 를 붙여 줍니다
queryFactory.selectFrom(customer)
        .where(customer.name.contains("지"))
        .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 c1_0.name like ? escape '!'
바인딩: [1] %지%
조회 2건 — 안지수, 한지호

escape '!' 가 붙는 것이 QueryDSL 의 특징입니다. 검색어에 %_ 가 들어가도 리터럴로 취급되도록 이스케이프 문자를 지정합니다. 문자열 JPQL 로 직접 %키워드% 를 만들면 이 보호가 없습니다.

④ 전화번호가 없는 고객

// JPQL
em.createQuery("select c from Customer c where c.phone is null", Customer.class)
  .getResultList();

// QueryDSL
queryFactory.selectFrom(customer)
        .where(customer.phone.isNull())
        .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 c1_0.phone is null
조회 3건 — 오하윤, 문시우, 정  훈

⑤ 도시별 고객 수

// JPQL
em.createQuery("""
        select c.city, count(c)
        from Customer c
        group by c.city
        order by count(c) desc
        """, Object[].class)
  .getResultList();

// QueryDSL
queryFactory
        .select(customer.city, customer.count())
        .from(customer)
        .groupBy(customer.city)
        .orderBy(customer.count().desc())
        .fetch();

결과

select c1_0.city, count(c1_0.customer_id)
from customers c1_0
group by c1_0.city
order by count(c1_0.customer_id) desc
서울   9
부산   6
대구   5
인천   4
광주   3
대전   3

JPQL 은 Object[] 를 돌려주므로 row[0], row[1] 로 꺼내며 캐스팅해야 합니다. QueryDSL 은 Tuple 을 돌려주고 t.get(customer.city) 처럼 타입이 유지된 채로 꺼냅니다. 자세한 것은 Step 05 — 프로젝션과 DTO 에서 다룹니다.

종합 비교

항목문자열 JPQLQueryDSL
오타 검출런타임컴파일
IDE 자동완성
필드 리네임 추적
동적 조건문자열 조립 (지옥)BooleanBuilder / null 무시
% 이스케이프직접자동
결과 타입Object[]Tuple
학습 비용낮음중간
빌드 복잡도없음APT 필요 (1-2 절의 함정)

1-8. QueryDSL 이 만드는 것은 JPQL 이지 SQL 이 아니다

이 절이 이 스텝에서 개념적으로 가장 중요합니다. 여기를 대충 넘기면 Step 07 — 서브쿼리 에서 반드시 막힙니다.

2단계 변환

  QueryDSL 자바 코드
        │  ① JPAQueryFactory / JPAQuery 가 조립

      JPQL 문자열                     ← QueryDSL 의 출력물은 여기까지입니다
        │  ② Hibernate 의 HQL 파서 + Dialect 가 번역

      네이티브 SQL (MySQL 방언)
        │  ③ JDBC 드라이버가 전송

       MySQL 8

QueryDSL 은 SQL 을 모릅니다. 테이블 이름도, 컬럼 이름도, MySQL 문법도 모릅니다. QueryDSL 이 아는 것은 엔티티와 필드뿐이고, 만들어 내는 것은 JPQL 입니다. SQL 로 바꾸는 일은 전적으로 Hibernate 의 몫입니다.

직접 확인하기 — .toString()

JPAQuerytoString()JPQL 을 담아 줍니다. 실행하지 않고 찍어 볼 수 있습니다.

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

System.out.println(query.toString());     // 아직 실행 안 함
List<Customer> result = query.fetch();    // 여기서 실행

결과toString() 출력 (JPQL)

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

결과hibernate.SQL (SQL)

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 = ? and c1_0.points >= ?
바인딩: [1] Grade.VIP, [2] 5000
조회 3건 — 김서준, 류하나, 배채영

두 줄을 나란히 놓고 대조합니다.

요소JPQLSQL
조회 대상customer (엔티티)c1_0.customer_id, c1_0.city, ... (컬럼 목록)
fromCustomer customer (클래스명)customers c1_0 (테이블명)
별칭customer (Q타입 변수명)c1_0 (Hibernate 생성)
필드customer.gradec1_0.grade (컬럼명)
파라미터?1, ?2 (번호 있음)?, ? (JDBC 자리표시자)

from Customer customerCustomer클래스 이름입니다. customers 테이블이 아닙니다. QueryDSL 은 customers 라는 문자열을 한 번도 만들지 않습니다.

왜 이 구분이 중요한가

JPQL 이 못 하는 것은 QueryDSL 도 못 합니다. QueryDSL 은 JPQL 의 상위 집합이 아니라, JPQL 을 타입 안전하게 조립하는 도구일 뿐입니다.

하고 싶은 것SQLJPQLQueryDSL(JPA)
where 절 서브쿼리
select 절 서브쿼리
from 절 서브쿼리 (인라인 뷰)
윈도우 함수 (ROW_NUMBER())✗ (JPA 모듈에서는)
UNION
DB 고유 함수△ (등록 필요)

from 절 서브쿼리를 못 쓰는 것은 QueryDSL 의 한계가 아니라 JPQL 명세의 한계입니다. JPQL 은 "엔티티를 조회하는 언어"라서 from 절에는 엔티티만 올 수 있고, "임시 결과 집합"이라는 개념이 없습니다.

// ✗ 이런 API 자체가 없습니다
queryFactory.selectFrom( <서브쿼리> )

Step 07 에서 이 벽에 부딪히고, 세 가지 우회로(쿼리 분리 / 조인 변환 / 네이티브 SQL)를 배웁니다. 그때 "왜 안 되는가"의 답이 바로 이 절입니다.

💡 실무 팁 — toString() 을 디버깅 1순위로 쓰세요 "의도와 다른 SQL 이 나간다" 싶을 때, SQL 로그부터 보면 Hibernate 의 변환까지 섞여 있어 원인을 좁히기 어렵습니다. 먼저 query.toString() 으로 JPQL 을 봅니다.

  • JPQL 이 이미 이상하다 → 내 QueryDSL 코드가 틀렸다
  • JPQL 은 맞는데 SQL 이 이상하다 → 엔티티 매핑이나 페치 전략 문제다 이 두 갈래로 나누는 것만으로 디버깅 시간이 절반이 됩니다. .toString() 은 쿼리를 실행하지 않으므로 테스트에서 마음껏 찍어도 됩니다.

⚠️ 함정 — toString()?1 을 SQL 파라미터 번호로 착각한다 JPQL 의 ?1, ?2JPQL 레벨의 위치 파라미터입니다. Hibernate 가 SQL 로 번역하는 과정에서 파라미터 개수와 순서가 바뀔 수 있습니다. 예를 들어 in 절이 확장되거나, 상속 매핑의 판별 컬럼 조건이 추가되면 SQL 쪽 ? 가 더 많아집니다. JPQL 의 ?1 과 SQL 의 첫 번째 ? 가 항상 같다고 가정하지 마세요. 실제 바인딩 값은 orm.jdbc.bind 로그로 확인합니다.


1-9. 함정 모음

함정 ① — JPAQueryFactory 를 매번 new 로 만든다

@Service
public class CustomerService {

    @PersistenceContext
    private EntityManager em;

    public List<Customer> findVips() {
        JPAQueryFactory queryFactory = new JPAQueryFactory(em);   // 호출마다 생성
        return queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();
    }
}

동작은 합니다. SQL 도 정상이고, 결과도 맞고, 스레드 안전성 문제도 없습니다. 그래서 리뷰에서 잘 안 걸립니다.

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 = ?
바인딩: [1] Grade.VIP
조회 4건 — 정상

문제는 다른 데 있습니다.

  • JPAQueryFactory 는 상태가 거의 없는 얇은 팩토리입니다. 매번 만드는 것은 낭비이며, 무엇보다 불필요합니다.
  • 진짜 손해는 설정 주입 지점을 잃는 것입니다. 예를 들어 SQL 템플릿이나 JPQLTemplates 를 커스터마이즈해야 할 때(네이티브 함수 등록 등), 빈이 하나면 한 곳만 고치면 됩니다. new 가 프로젝트 곳곳에 흩어져 있으면 전부 찾아 고쳐야 합니다.
  • 테스트에서 JPAQueryFactory 를 갈아 끼울 수 없습니다.
// 고친 코드
@Service
@RequiredArgsConstructor
public class CustomerService {

    private final JPAQueryFactory queryFactory;   // 빈 주입

    public List<Customer> findVips() {
        return queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();
    }
}

SQL 은 완전히 동일합니다. 바뀌는 것은 유지보수성뿐입니다.

함정 ② — 진짜 EntityManager 를 필드로 붙들어 둔다

1-4 절에서 다뤘지만 스택트레이스를 보고 갑니다.

@Component
public class BadQuerySupport {

    private final JPAQueryFactory queryFactory;

    public BadQuerySupport(EntityManagerFactory emf) {
        // ✗ 프록시가 아니라 진짜 EntityManager 를 만들어 싱글턴에 고정
        this.queryFactory = new JPAQueryFactory(emf.createEntityManager());
    }
}

개발 중에는 완벽하게 동작합니다. 부하 테스트를 돌리면:

결과

java.util.ConcurrentModificationException
	at java.base/java.util.HashMap$KeyIterator.next(HashMap.java:1584)
	at org.hibernate.engine.internal.StatefulPersistenceContext.clear(StatefulPersistenceContext.java:222)
	at org.hibernate.internal.SessionImpl.internalClear(SessionImpl.java:394)

org.hibernate.HibernateException: Session is closed
	at org.hibernate.internal.AbstractSharedSessionContract.checkOpen(AbstractSharedSessionContract.java:429)
	at org.hibernate.internal.SessionImpl.checkOpenOrWaitingForAutoClose(SessionImpl.java:388)

한 스레드가 세션을 닫으면 다른 스레드가 닫힌 세션을 씁니다. 증상이 산발적이고 재현이 안 되므로 원인을 찾는 데 며칠이 걸립니다.

⚠️ 함정 — 이 버그는 "테스트에서 통과"한다 단위 테스트는 스레드 하나로 순차 실행됩니다. @SpringBootTest 도 마찬가지입니다. 동시성 버그를 테스트로 잡을 수 없다는 것이 이 함정의 본질입니다. 규칙으로 막아야 합니다: emf.createEntityManager() 를 애플리케이션 코드에서 직접 호출하지 마세요. 스프링이 주는 프록시만 쓰세요.

함정 ③ — 조회 전용 메서드에 @Transactional 을 안 붙인다

public List<Order> findOrdersWithCustomerName() {
    List<Order> orders = queryFactory.selectFrom(order).limit(5).fetch();
    // 트랜잭션 없음 → 조회 직후 영속성 컨텍스트가 닫힘
    return orders;
}

// 호출한 쪽에서
orders.get(0).getCustomer().getName();

결과

org.hibernate.LazyInitializationException: could not initialize proxy
  [com.example.shop.entity.Customer#12] - no Session
	at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:165)
	at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:314)
	at com.example.shop.entity.Customer$HibernateProxy.getName(Unknown Source)

모든 연관이 LAZY 이므로 (스펙상 그렇게 매핑했습니다) 프록시를 건드리는 순간 세션이 필요합니다.

해결: 조회 메서드에도 @Transactional(readOnly = true) 를 붙입니다. 읽기 전용 플래그는 플러시를 끄고 스냅샷을 만들지 않아 성능 이득도 있습니다. 애초에 필요한 데이터를 조인으로 가져오는 것이 더 나은 해법이며, Step 06 — 조인 에서 다룹니다.

함정 ④ — Q 클래스를 커밋한다

build/generated/sources/annotationProcessor/java/main/
  └── com/example/shop/entity/QCustomer.java

이 파일들은 빌드 산출물입니다. .gitignore 에 넣어야 합니다. 커밋해 두면 엔티티를 바꾼 뒤 옛 Q타입이 살아남아 "분명 필드를 추가했는데 Q타입에 없다"는 유령 버그가 생깁니다. Step 02 — Q타입의 정체 에서 이 사고를 자세히 재현합니다.


정리

개념핵심
문자열 JPQL 의 문제오타·필드명·타입 불일치가 전부 런타임에 터진다. 리팩터링을 못 따라온다
QueryDSL 의 본질쿼리를 자바 객체 그래프로 조립 → 검증이 자바 컴파일러의 일이 된다
6.x 그룹 IDio.github.openfeign.querydsl (OpenFeign 포크). 패키지는 여전히 com.querydsl.*
classifier 규칙6.x: jpa 는 없음, apt 만 :jpa. 5.x: 양쪽 다 :jakarta
케이스 (a)jpa:6.12:jakartaCould not find ...-jakarta.jar (의존성 해결 실패)
케이스 (b)apt:6.12 (classifier 누락) → 빌드 성공 + Q타입 0개
케이스 (c)5.0.0 classifier 없음 → 런타임 NoClassDefFoundError: javax/persistence/Entity
왜 비대칭인가jpa 의 classifier 축 = javax/jakarta (6.x 는 하나뿐), apt 의 축 = 프로세서 종류 (여전히 필요)
포크 배경원본 2021-07 5.0.0 이후 중단. Hibernate 6 의 ByteType 제거로 5.x 가 깨짐 (#3436, #3439)
JPAQueryFactory싱글턴으로 안전. 주입되는 EntityManager스레드별로 위임하는 공유 프록시
금지 패턴emf.createEntityManager() 를 싱글턴에 고정 → 운영에서만 터지는 동시성 버그
selectFromselect(x).from(x) 의 축약. 조회 대상과 from 이 같을 때만
fetchOne()2건 이상이면 NonUniqueResultException. 유니크 컬럼에만
SQL 로그org.hibernate.SQL=debug + org.hibernate.orm.jdbc.bind=trace + format_sql
Hibernate 6 로거 변경BasicBinder(5) → org.hibernate.orm.jdbc.bind(6). 안 보이면 이것
Hibernate 6 별칭c1_0 형태, as 생략. Hibernate 5 는 customer0_
2단계 변환QueryDSL → JPQL → SQL. QueryDSL 의 출력물은 JPQL 까지다
query.toString()실행하지 않고 JPQL 을 확인. 디버깅 1순위
JPQL 의 한계 = QueryDSL 의 한계from 절 서브쿼리·UNION·윈도우 함수 불가 → Step 07 의 벽

연습문제

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

  1. 문자열 JPQL 에 필드명 오타(c.grde)를 내고 실행해 예외 클래스명과 메시지를 기록한 뒤, 같은 쿼리를 QueryDSL 로 옮겨 컴파일 에러가 나는 것을 확인하기
  2. 주어진 4개의 잘못된 build.gradle 조각을 보고, 각각 어떤 에러가 언제(빌드/런타임) 나는지 판정하기
  3. JPAQueryFactory 를 빈으로 등록하고, 주입된 EntityManager 가 프록시임을 클래스명 출력으로 증명하기
  4. GOLD 등급이면서 포인트 3000 이상인 고객을 selectFrom 으로 조회하고 생성 SQL 을 로그에서 확인하기
  5. 같은 쿼리의 JPQL(toString())과 SQL(로그)을 나란히 출력하고, 별칭·테이블명·파라미터 표기의 차이 3가지를 서술하기
  6. fetchOne()NonUniqueResultException 을 던지는 조건을 실제로 재현하고, 안전한 대안 두 가지로 고치기

다음 단계

selectFrom(customer)customer 는 도대체 어디서 왔을까요. 우리가 만든 적 없는 QCustomer 클래스가 빌드 디렉터리에 생겨 있고, 그 안에 NumberPath<Long> id 같은 필드가 들어 있습니다. 이것을 만드는 것이 애노테이션 프로세서(APT)이며, QueryDSL 도입 실패의 나머지 절반이 "Q타입이 안 생긴다" 입니다.

다음 스텝에서는 생성된 QCustomer.java 를 한 줄씩 뜯어보고, Q타입이 안 생기는 5가지 원인을 각각 재현·진단·해결합니다. 1-2 절에서 좌표를 맞췄으니, 이제 그 좌표가 실제로 무슨 일을 하는지 봅니다.

Step 02 — Q타입의 정체


실습 파일

이 스텝은 자바 파일 세 개로 진행합니다. 셋 다 @SpringBootTest @Transactional 테스트 클래스이며, 패키지는 com.example.shop.step01 입니다. 먼저 Practice.java 를 IDE 에서 열어 테스트를 하나씩 실행하며 1-1 ~ 1-9 의 모든 로그 출력을 재현하고, 그다음 Exercise.java 의 6문제를 직접 채운 뒤, Solution.java 로 정답과 해설을 대조합니다.

세 파일 모두 SQL 로그 설정이 되어 있다는 전제로 쓰여 있습니다. 1-6 절의 application.yml 설정을 먼저 적용하지 않으면 콘솔에 아무것도 안 나와 실습의 의미가 없습니다. 실행 전에 반드시 확인하세요.

./gradlew test --tests 'com.example.shop.step01.*'

Practice.java

본문 1-1 ~ 1-9 의 모든 예제를 절 번호 주석(// [1-5] 첫 쿼리 — selectFrom)과 함께 담은 실행 파일입니다.

  • [1-1]오타난_JPQL_은_런타임에_터진다()의도적으로 예외를 던지는 테스트입니다. assertThatThrownBy 로 예외를 잡아 클래스명과 메시지를 콘솔에 찍습니다. 실패하는 테스트가 아니라 "예외가 나는 것을 검증하는" 테스트이므로 초록불이 정상입니다.
  • [1-4]주입된_EntityManager의_정체()em.getClass().getName() 을 출력합니다. jdk.proxy2.$Proxy214 처럼 숫자가 붙은 프록시 클래스명이 나오면 정상이며, 숫자는 실행할 때마다 다릅니다. org.hibernate.internal.SessionImpl 이 나온다면 프록시가 아닌 실제 세션이 주입된 것이니 설정을 의심하세요.
  • [1-6]로그_설정_확인() 은 쿼리 하나를 날려 놓고 아무것도 단언하지 않습니다. 콘솔을 눈으로 보라는 테스트입니다. org.hibernate.SQL 줄과 binding parameter 줄이 둘 다 보여야 이후 스텝을 정상적으로 진행할 수 있습니다.
  • [1-8]JPQL과_SQL을_나란히()query.toString() 을 먼저 출력한 뒤 query.fetch() 를 호출합니다. 출력 순서가 곧 변환 순서입니다 — 콘솔에서 JPQL 한 줄이 먼저 찍히고 그 아래로 Hibernate 의 SQL 이 이어집니다.
  • [1-9]fetchOne_은_2건이면_터진다() 역시 예외 검증 테스트입니다. customer.city.eq("서울") 로 9건이 나오는 조건을 일부러 씁니다.
  • @Transactional 이 붙어 있어 모든 테스트가 끝나면 롤백됩니다. 이 스텝은 조회만 하므로 롤백할 것도 없지만, 코스 전체의 규칙으로 통일했습니다.
package com.example.shop.step01;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.querydsl.core.NonUniqueResultException;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static com.example.shop.entity.QCustomer.customer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * Step 01 — 환경 구축과 첫 쿼리
 *
 * 본문 1-1 ~ 1-9 의 모든 예제를 절 번호 주석과 함께 담았습니다.
 *
 * 실행:
 *   ./gradlew test --tests 'com.example.shop.step01.Practice'
 *
 * ★ 전제 — application.yml 에 SQL 로그 설정이 되어 있어야 합니다.
 *   logging:
 *     level:
 *       org.hibernate.SQL: debug
 *       org.hibernate.orm.jdbc.bind: trace
 *       org.hibernate.orm.query: trace
 *   spring:
 *     jpa:
 *       properties:
 *         hibernate:
 *           format_sql: true
 *
 * 이 설정이 없으면 콘솔에 아무것도 안 나오고, 이 스텝의 실습은 의미가 없어집니다.
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =====================================================================
    // [1-1] QueryDSL 이란 — 오타가 런타임에야 터진다
    // =====================================================================

    /**
     * 키워드 오타(selct)는 컴파일을 통과하고 실행 시점에 SyntaxException 을 던집니다.
     * 이 테스트는 "예외가 나는 것"을 검증하므로 초록불이 정상입니다.
     */
    @Test
    @DisplayName("[1-1] 문자열 JPQL 의 키워드 오타는 런타임에 터진다")
    void 오타난_JPQL_은_런타임에_터진다() {
        String jpql = "selct c from Customer c where c.grade = :grade";   // select 오타

        assertThatThrownBy(() ->
                em.createQuery(jpql, Customer.class)
                        .setParameter("grade", Grade.VIP)
                        .getResultList()
        ).satisfies(e -> {
            System.out.println("예외 클래스 : " + e.getClass().getName());
            System.out.println("메시지     : " + e.getMessage());
        });
    }

    /**
     * 필드명 오타는 파싱은 통과하고 "의미 해석" 단계에서 실패합니다.
     * 그래서 SyntaxException 이 아니라 SemanticException 이 납니다.
     */
    @Test
    @DisplayName("[1-1] 필드명 오타는 SemanticException")
    void 필드명_오타는_SemanticException() {
        String jpql = "select c from Customer c where c.grde = :grade";   // grade 오타

        assertThatThrownBy(() ->
                em.createQuery(jpql, Customer.class)
                        .setParameter("grade", Grade.VIP)
                        .getResultList()
        ).satisfies(e -> System.out.println("예외 클래스 : " + e.getClass().getName()));
    }

    /**
     * 같은 실수를 QueryDSL 로 하면 아래 코드는 애초에 컴파일되지 않습니다.
     * 주석을 풀고 ./gradlew compileTestJava 를 돌려 직접 확인해 보세요.
     *
     *   queryFactory.selctFrom(customer)                   // cannot find symbol: selctFrom
     *   queryFactory.selectFrom(customer)
     *           .where(customer.grde.eq(Grade.VIP))        // cannot find symbol: grde
     *   queryFactory.selectFrom(customer)
     *           .where(customer.points.eq("VIP"))          // incompatible types
     */

    // =====================================================================
    // [1-4] JPAQueryFactory 빈 등록 — EntityManager 는 프록시다
    // =====================================================================

    /**
     * 스프링이 주입하는 EntityManager 는 SharedEntityManagerCreator 가 만든 프록시입니다.
     * 실제 세션(SessionImpl)이 아니라, "지금 스레드의 세션을 찾아 위임하는" 객체입니다.
     * 그래서 싱글턴 JPAQueryFactory 가 이 프록시를 필드로 붙들어도 안전합니다.
     */
    @Test
    @DisplayName("[1-4] 주입된 EntityManager 는 프록시다")
    void 주입된_EntityManager의_정체() {
        System.out.println("클래스명 : " + em.getClass().getName());
        System.out.println("인터페이스 만족 : " + (em instanceof EntityManager));

        // jdk.proxy2.$Proxy214 처럼 $Proxy 가 들어가면 정상 (숫자는 매 실행 다릅니다)
        assertThat(em.getClass().getName()).contains("Proxy");
    }

    /**
     * unwrap 으로 프록시 뒤의 실제 세션을 꺼내 볼 수 있습니다.
     * 이건 확인용이며, 애플리케이션 코드에서 이렇게 꺼내 쓰면 안 됩니다.
     */
    @Test
    @DisplayName("[1-4] 프록시 뒤의 실제 세션")
    void 프록시_뒤의_실제_세션() {
        Object session = em.unwrap(org.hibernate.Session.class);
        System.out.println("실제 세션 : " + session.getClass().getName());
        // org.hibernate.internal.SessionImpl
    }

    // =====================================================================
    // [1-5] 첫 쿼리 — selectFrom
    // =====================================================================

    /**
     * 생성 SQL:
     *   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 = ?
     * 바인딩: [1] VIP  → 4건 (김서준, 류하나, 정  훈, 배채영)
     */
    @Test
    @DisplayName("[1-5] VIP 고객 조회 — selectFrom")
    void VIP_고객_조회() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        result.forEach(c -> System.out.println(c.getName() + " / " + c.getCity()));
        assertThat(result).hasSize(4);
    }

    /**
     * 조회 대상과 from 이 다르면 selectFrom 축약을 쓸 수 없습니다.
     * 생성 SQL: select c1_0.name from customers c1_0 where c1_0.grade = ?
     */
    @Test
    @DisplayName("[1-5] 이름만 조회 — select + from 은 나눠 쓴다")
    void 이름만_조회() {
        List<String> names = queryFactory
                .select(customer.name)
                .from(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        System.out.println(names);
        assertThat(names).hasSize(4);
    }

    /**
     * fetchOne() 은 유니크 제약이 있는 컬럼으로 조회할 때만 안전합니다.
     */
    @Test
    @DisplayName("[1-5] fetchOne — 유니크 컬럼(email)으로 단건 조회")
    void 단건_조회() {
        Customer one = queryFactory
                .selectFrom(customer)
                .where(customer.email.eq("seojun.kim@example.com"))
                .fetchOne();

        System.out.println("조회 결과 : " + (one == null ? "null" : one.getName()));
    }

    // =====================================================================
    // [1-6] SQL 로그 보는 법
    // =====================================================================

    /**
     * 이 테스트는 아무것도 단언하지 않습니다. 콘솔을 눈으로 보라는 테스트입니다.
     *
     * 아래 두 줄이 모두 보여야 이후 스텝을 정상적으로 진행할 수 있습니다.
     *   org.hibernate.SQL              : select ... from customers c1_0 where ...
     *   org.hibernate.orm.jdbc.bind    : binding parameter (1:VARCHAR) <- [VIP]
     *
     * 파라미터 줄이 안 보인다면 Hibernate 5 용 카테고리
     * (org.hibernate.type.descriptor.sql.BasicBinder) 를 설정한 것입니다.
     * Hibernate 6 에서는 org.hibernate.orm.jdbc.bind 를 써야 합니다.
     */
    @Test
    @DisplayName("[1-6] 로그 설정 확인 — 콘솔을 눈으로 볼 것")
    void 로그_설정_확인() {
        queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        System.out.println("↑ 위 콘솔에 hibernate.SQL 과 binding parameter 가 둘 다 보이는지 확인하세요.");
    }

    // =====================================================================
    // [1-7] JPQL vs QueryDSL 대조
    // =====================================================================

    @Test
    @DisplayName("[1-7] ① VIP 고객 — JPQL / QueryDSL 결과 동일")
    void 대조_VIP_고객() {
        List<Customer> byJpql = em.createQuery(
                        "select c from Customer c where c.grade = :grade", Customer.class)
                .setParameter("grade", Grade.VIP)
                .getResultList();

        List<Customer> byQuerydsl = queryFactory
                .selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        assertThat(byQuerydsl).hasSameSizeAs(byJpql);
    }

    /**
     * 생성 SQL: ... where c1_0.points >= ? order by c1_0.name asc
     */
    @Test
    @DisplayName("[1-7] ② 포인트 5000 이상, 이름순")
    void 대조_포인트_정렬() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(customer.points.goe(5000))
                .orderBy(customer.name.asc())
                .fetch();

        result.forEach(c -> System.out.println(c.getName() + " " + c.getPoints()));
    }

    /**
     * contains 는 %키워드% 를 자동으로 만들고 escape '!' 까지 붙여 줍니다.
     * 생성 SQL: ... where c1_0.name like ? escape '!'   /  바인딩 [1] %지%
     */
    @Test
    @DisplayName("[1-7] ③ 이름 부분 일치 — contains 는 escape 를 붙인다")
    void 대조_이름_부분일치() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(customer.name.contains("지"))
                .fetch();

        result.forEach(c -> System.out.println(c.getName()));
        assertThat(result).hasSize(2);      // 안지수, 한지호
    }

    /**
     * 생성 SQL: ... where c1_0.phone is null   → 3건
     */
    @Test
    @DisplayName("[1-7] ④ 전화번호가 없는 고객")
    void 대조_전화번호_null() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(customer.phone.isNull())
                .fetch();

        result.forEach(c -> System.out.println(c.getName()));
        assertThat(result).hasSize(3);
    }

    /**
     * 생성 SQL:
     *   select c1_0.city, count(c1_0.customer_id)
     *   from customers c1_0 group by c1_0.city order by count(c1_0.customer_id) desc
     */
    @Test
    @DisplayName("[1-7] ⑤ 도시별 고객 수 — Tuple 로 받는다")
    void 대조_도시별_고객수() {
        queryFactory
                .select(customer.city, customer.count())
                .from(customer)
                .groupBy(customer.city)
                .orderBy(customer.count().desc())
                .fetch()
                .forEach(t -> System.out.println(
                        t.get(customer.city) + "  " + t.get(customer.count())));
    }

    // =====================================================================
    // [1-8] QueryDSL 이 만드는 것은 JPQL 이지 SQL 이 아니다
    // =====================================================================

    /**
     * 출력 순서가 곧 변환 순서입니다.
     *   ① toString()  → JPQL   : select customer from Customer customer where ...
     *   ② fetch()     → SQL    : select c1_0.customer_id, ... from customers c1_0 where ...
     *
     * 차이 3가지:
     *   - 별칭     : customer (Q타입 변수명)  vs  c1_0 (Hibernate 생성)
     *   - from 대상 : Customer (클래스명)      vs  customers (테이블명)
     *   - 파라미터 : ?1, ?2 (번호 있음)        vs  ?, ? (JDBC 자리표시자)
     */
    @Test
    @DisplayName("[1-8] JPQL 과 SQL 을 나란히 본다")
    void JPQL과_SQL을_나란히() {
        JPAQuery<Customer> query = queryFactory
                .selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP)
                        .and(customer.points.goe(5000)));

        System.out.println("===== JPQL (toString, 아직 실행 안 함) =====");
        System.out.println(query.toString());

        System.out.println("===== SQL (아래 hibernate.SQL 로그) =====");
        List<Customer> result = query.fetch();

        result.forEach(c -> System.out.println(c.getName() + " " + c.getPoints()));
    }

    /**
     * JPQL 이 못 하는 것은 QueryDSL 도 못 합니다.
     * from 절 서브쿼리(인라인 뷰)는 JPQL 명세에 없으므로 API 자체가 존재하지 않습니다.
     * Step 07 에서 이 벽과 우회로를 다룹니다.
     */
    @Test
    @DisplayName("[1-8] from 절 서브쿼리는 API 자체가 없다")
    void from절_서브쿼리는_불가능() {
        // queryFactory.selectFrom( <서브쿼리> )   ← 이런 오버로드가 없습니다
        System.out.println("JPQL 명세에 인라인 뷰가 없으므로 QueryDSL(JPA) 에도 없습니다.");
    }

    // =====================================================================
    // [1-9] 함정
    // =====================================================================

    /**
     * fetchOne() 은 결과가 2건 이상이면 NonUniqueResultException 을 던집니다.
     * 서울 고객은 9명이므로 반드시 터집니다.
     */
    @Test
    @DisplayName("[1-9] fetchOne 은 2건 이상이면 터진다")
    void fetchOne_은_2건이면_터진다() {
        assertThatThrownBy(() ->
                queryFactory.selectFrom(customer)
                        .where(customer.city.eq("서울"))
                        .fetchOne()
        ).isInstanceOf(NonUniqueResultException.class)
         .satisfies(e -> System.out.println("메시지 : " + e.getMessage()));
    }

    /**
     * 안전한 대안 ① — fetchFirst() 는 limit(1) 을 붙여 첫 건만 가져옵니다.
     * 생성 SQL 끝에 limit ? 가 붙습니다.
     */
    @Test
    @DisplayName("[1-9] 대안 ① fetchFirst")
    void 대안_fetchFirst() {
        Customer first = queryFactory.selectFrom(customer)
                .where(customer.city.eq("서울"))
                .orderBy(customer.id.asc())
                .fetchFirst();

        System.out.println("첫 건 : " + (first == null ? "null" : first.getName()));
    }

    /**
     * 안전한 대안 ② — fetch() 로 받아 크기를 직접 검사합니다.
     * "몇 건 나왔는지"를 코드가 알고 있으므로 예외 대신 분기로 처리할 수 있습니다.
     */
    @Test
    @DisplayName("[1-9] 대안 ② fetch 후 size 검사")
    void 대안_fetch_후_size검사() {
        List<Customer> found = queryFactory.selectFrom(customer)
                .where(customer.city.eq("서울"))
                .fetch();

        System.out.println("건수 : " + found.size());
        if (found.size() == 1) {
            System.out.println("단건 : " + found.get(0).getName());
        } else {
            System.out.println("단건이 아니므로 별도 처리 (예외 아님)");
        }
    }

    /**
     * 함정 ③ — 조회 전용 메서드에 트랜잭션이 없으면 LazyInitializationException.
     * 이 클래스는 @Transactional 이라 여기서는 정상 동작합니다.
     * 트랜잭션을 떼고 같은 코드를 호출하면 프록시 초기화에서 터집니다.
     */
    @Test
    @DisplayName("[1-9] 지연 로딩은 트랜잭션 안에서만 안전하다")
    void 지연로딩은_트랜잭션_안에서만() {
        Customer c = queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetchFirst();

        // @Transactional 덕분에 세션이 살아 있어 orders 프록시를 초기화할 수 있습니다.
        System.out.println(c.getName() + " 의 주문 수 : " + c.getOrders().size());
    }
}

Exercise.java

6문제의 문제지입니다. 각 문제는 요구사항 주석과 // 여기에 작성: 자리로 구성돼 있고, 그대로 실행하면 대부분 컴파일은 되지만 아무 일도 하지 않거나 단언이 실패합니다.

  • 문제 1·6 은 예외를 재현하는 문제입니다. "무엇이 어디서 터지는가"를 직접 보는 것이 목적이므로, 예외를 삼키지 말고 메시지를 콘솔에 출력하세요.
  • 문제 2 는 코드를 쓰는 문제가 아니라 판정 문제입니다. 4개의 build.gradle 조각이 주석 블록으로 들어 있고, 각각에 대해 (빌드 실패 / Q타입 미생성 / 런타임 실패 / 정상) 중 하나를 고르고 근거를 적습니다. 답을 주석으로 적는 문제이므로 실행 결과는 없습니다.
  • 문제 3em.getClass().getName()em instanceof EntityManager 를 둘 다 출력해 "프록시인데 인터페이스는 만족한다"를 확인하는 문제입니다.
  • 문제 5 는 이 문제지에서 가장 중요합니다. JPQL 과 SQL 의 차이를 3가지 서술해야 하며, 답은 별칭 표기(customer vs c1_0), from 대상(클래스명 vs 테이블명), 파라미터 표기(?1 vs ?) 입니다.
  • 문제 4 의 조건(GOLD + 포인트 3000 이상)은 결과가 6건 나오도록 데이터가 준비돼 있습니다. 건수가 다르면 시드 데이터를 다시 넣으세요.
package com.example.shop.step01;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static com.example.shop.entity.QCustomer.customer;

/**
 * Step 01 — 연습문제 (6문제)
 *
 * 각 문제의 "여기에 작성:" 자리를 채우세요.
 * 정답과 해설은 Solution.java 에 있습니다. 먼저 직접 풀어 보세요.
 *
 * 실행:
 *   ./gradlew test --tests 'com.example.shop.step01.Exercise'
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =====================================================================
    // 문제 1. 오타의 발견 시점 비교
    //
    // (1) 아래 JPQL 은 필드명이 c.grade 가 아니라 c.grde 로 잘못돼 있습니다.
    //     이 쿼리를 실행해서 어떤 예외가 나는지 확인하고,
    //     예외 클래스명과 메시지를 콘솔에 출력하세요.
    //     (예외를 삼키지 말고 반드시 출력할 것. 무엇이 어디서 터지는지 보는 게 목적입니다.)
    //
    // (2) 그다음 같은 쿼리를 QueryDSL 로 옮겨 쓰되,
    //     customer.grde 라고 오타를 내 보고 컴파일이 실패하는 것을 확인하세요.
    //     확인한 뒤에는 주석 처리해 두세요(그래야 나머지 테스트가 돌아갑니다).
    //
    // (3) 두 오타가 발견되는 "시점"이 어떻게 다른지 아래 주석에 한 줄로 적으세요.
    //     답:
    // =====================================================================
    @Test
    @DisplayName("문제 1. 오타의 발견 시점 비교")
    void 문제1_오타의_발견시점() {
        String jpql = "select c from Customer c where c.grde = :grade";

        // 여기에 작성:

    }

    // =====================================================================
    // 문제 2. 잘못된 build.gradle 판정
    //
    // 아래 4개의 의존성 조각에 대해, 각각 어떤 결과가 나오는지 판정하고
    // 근거를 주석으로 적으세요. 코드를 실행하는 문제가 아니라 판정 문제입니다.
    //
    // 보기:
    //   (A) 빌드 실패 — 의존성 해결 단계에서
    //   (B) 빌드 성공하지만 Q타입이 생성되지 않음
    //   (C) 빌드 성공하지만 런타임에 실패
    //   (D) 정상
    //
    // ---------------------------------------------------------------------
    // (a)
    //   implementation      'io.github.openfeign.querydsl:querydsl-jpa:6.12:jakarta'
    //   annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12:jpa'
    //   판정:        근거:
    //
    // (b)
    //   implementation      'io.github.openfeign.querydsl:querydsl-jpa:6.12'
    //   annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12'
    //   판정:        근거:
    //
    // (c)
    //   implementation      'com.querydsl:querydsl-jpa:5.0.0'
    //   annotationProcessor 'com.querydsl:querydsl-apt:5.0.0'
    //   판정:        근거:
    //
    // (d)
    //   implementation      'io.github.openfeign.querydsl:querydsl-jpa:6.12'
    //   implementation      'io.github.openfeign.querydsl:querydsl-core:6.12'
    //   annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.12:jpa'
    //   annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    //   annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    //   판정:        근거:
    // =====================================================================
    @Test
    @DisplayName("문제 2. build.gradle 판정 (주석으로 답을 적는 문제)")
    void 문제2_의존성_판정() {
        System.out.println("위 주석에 판정과 근거를 적었는지 확인하세요.");
    }

    // =====================================================================
    // 문제 3. 주입된 EntityManager 가 프록시임을 증명하기
    //
    // 요구사항:
    //   (1) em.getClass().getName() 을 출력하세요.
    //   (2) (em instanceof EntityManager) 를 출력하세요.
    //   (3) 두 출력이 동시에 성립한다는 것이 무슨 뜻인지 한 줄로 적으세요.
    //       답:
    //   (4) 이 사실이 왜 싱글턴 JPAQueryFactory 를 안전하게 만드는지 한 줄로 적으세요.
    //       답:
    // =====================================================================
    @Test
    @DisplayName("문제 3. EntityManager 프록시 증명")
    void 문제3_EntityManager_프록시() {
        // 여기에 작성:

    }

    // =====================================================================
    // 문제 4. GOLD 등급 + 포인트 3000 이상 고객 조회
    //
    // 요구사항:
    //   - selectFrom 을 사용할 것
    //   - 조건 두 개를 and 로 묶을 것
    //   - 결과를 이름과 포인트로 출력할 것
    //   - 콘솔의 hibernate.SQL 로그에서 생성 SQL 을 확인할 것
    //
    // 기대 결과: 6건
    // 기대 SQL:
    //   select c1_0.customer_id, c1_0.city, ... from customers c1_0
    //   where c1_0.grade = ? and c1_0.points >= ?
    // =====================================================================
    @Test
    @DisplayName("문제 4. GOLD 등급 + 포인트 3000 이상")
    void 문제4_GOLD_포인트조회() {
        // 여기에 작성:
        List<Customer> result = null;

        // result.forEach(c -> System.out.println(c.getName() + " " + c.getPoints()));
    }

    // =====================================================================
    // 문제 5. JPQL 과 SQL 을 나란히 출력하고 차이를 서술하기  ★ 이 문제지에서 가장 중요
    //
    // 요구사항:
    //   (1) VIP 이면서 도시가 "서울"인 고객을 조회하는 JPAQuery 를 만들되,
    //       바로 fetch() 하지 말고 변수에 담으세요.
    //   (2) query.toString() 으로 JPQL 을 먼저 출력하세요.
    //   (3) 그다음 fetch() 를 호출해 hibernate.SQL 로그를 띄우세요.
    //   (4) 두 출력의 차이를 3가지 적으세요.
    //       차이 1:
    //       차이 2:
    //       차이 3:
    // =====================================================================
    @Test
    @DisplayName("문제 5. JPQL vs SQL 차이 3가지")
    void 문제5_JPQL과_SQL의_차이() {
        // 여기에 작성:
        JPAQuery<Customer> query = null;

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

    // =====================================================================
    // 문제 6. fetchOne 의 NonUniqueResultException 재현과 대안
    //
    // 요구사항:
    //   (1) fetchOne() 이 NonUniqueResultException 을 던지는 조건을 만들어
    //       실제로 예외가 나는 것을 확인하고 메시지를 출력하세요.
    //       (힌트: 결과가 2건 이상 나오는 조건이면 됩니다)
    //   (2) 같은 의도를 안전하게 처리하는 방법 두 가지로 고쳐 쓰세요.
    //       대안 A:
    //       대안 B:
    //   (3) 두 대안 중 어느 쪽도 해결해 주지 못하는 것이 무엇인지 한 줄로 적으세요.
    //       답:
    // =====================================================================
    @Test
    @DisplayName("문제 6. fetchOne 예외 재현과 대안")
    void 문제6_fetchOne_예외와_대안() {
        // (1) 예외 재현 — 여기에 작성:

        // (2) 대안 A — 여기에 작성:

        // (2) 대안 B — 여기에 작성:

    }
}

Solution.java

6문제의 정답과, "왜 그 답인가"를 설명하는 긴 주석이 함께 들어 있습니다. 문제를 풀어 본 뒤에 여세요.

  • 정답 1 은 예외 클래스가 org.hibernate.query.SemanticException (필드명 오타의 경우) 임을 짚습니다. 1-1 절의 키워드 오타(SyntaxException)와 다른 예외라는 점이 포인트입니다. 파싱은 성공했지만 의미 해석에서 실패했기 때문입니다.
  • 정답 2 의 채점 기준은 이렇습니다: (a) jpa:6.12:jakarta → 빌드 실패, (b) apt:6.12 → Q타입 미생성, (c) jpa:5.0.0 → 런타임 실패, (d) 정답 조합 → 정상. (b) 가 "빌드 성공"으로 분류된다는 점이 가장 자주 틀리는 부분입니다.
  • 정답 3 은 프록시 클래스명이 jdk.proxy2.$Proxy 로 시작하는 이유를 SharedEntityManagerCreator 까지 거슬러 설명하고, 왜 그것이 싱글턴 JPAQueryFactory 를 안전하게 만드는지 스레드별 위임 구조로 정리합니다.
  • 정답 5 는 차이 3가지에 더해 네 번째 차이를 보너스로 언급합니다: JPQL 은 select customer 로 엔티티 하나를 지정하지만 SQL 은 8개 컬럼을 전부 나열한다는 것 — 이것이 Step 05 의 프로젝션으로 이어집니다.
  • 정답 6 은 대안 두 가지를 모두 제시합니다: fetchFirst() 로 바꾸거나, fetch() 로 받아 size() 를 검사하는 것. 그리고 어느 쪽도 "조건이 유니크한지"를 대신 판단해 주지 않는다는 경고로 마무리합니다.
package com.example.shop.step01;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.querydsl.core.NonUniqueResultException;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static com.example.shop.entity.QCustomer.customer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * Step 01 — 연습문제 정답과 해설
 *
 * 문제를 직접 풀어 본 뒤에 여세요.
 *
 * 실행:
 *   ./gradlew test --tests 'com.example.shop.step01.Solution'
 */
@SpringBootTest
@Transactional
class Solution {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =====================================================================
    // 정답 1. 오타의 발견 시점 비교
    //
    // 【핵심】 키워드 오타와 필드명 오타는 "다른 예외"가 납니다.
    //
    //   selct  (키워드 오타)  → org.hibernate.query.SyntaxException
    //   c.grde (필드명 오타)  → org.hibernate.query.SemanticException
    //
    // 왜 다른가?
    //   Hibernate 6 의 HQL 처리는 두 단계입니다.
    //     ① 파싱(syntax)   : 토큰이 문법에 맞는가. 'selct' 는 아예 키워드가 아니므로 여기서 실패.
    //     ② 의미 해석(semantic) : 파싱은 통과했지만 Customer 에 grde 라는 속성이 없으므로 여기서 실패.
    //   즉 필드명 오타는 "문법적으로는 올바른 문장"입니다. 그래서 파서를 통과합니다.
    //
    // Hibernate 5 를 쓰던 자료에서는 둘 다 QuerySyntaxException 이었습니다.
    // Hibernate 6 에서 ANTLR 기반으로 파서를 새로 쓰면서 예외 계층이 분화됐습니다.
    // 인터넷 검색으로 QuerySyntaxException 이 안 잡힌다면 이 때문입니다.
    //
    // (3) 발견 시점의 차이 — 이 문제의 진짜 답:
    //   문자열 JPQL 의 오타는 "그 코드 경로를 실행할 때" 발견됩니다.
    //   즉 테스트가 그 경로를 안 밟으면 배포까지 살아남고, 최초 사용자가 대신 발견합니다.
    //   QueryDSL 은 ./gradlew compileJava 단계에서 발견됩니다.
    //   실행 여부와 무관하게, 커버리지와 무관하게, 100% 발견됩니다.
    //   이 "100%" 가 QueryDSL 을 쓰는 이유의 전부입니다.
    // =====================================================================
    @Test
    @DisplayName("정답 1. 오타의 발견 시점 비교")
    void 정답1_오타의_발견시점() {
        String jpql = "select c from Customer c where c.grde = :grade";

        assertThatThrownBy(() ->
                em.createQuery(jpql, Customer.class)
                        .setParameter("grade", Grade.VIP)
                        .getResultList()
        ).satisfies(e -> {
            System.out.println("예외 클래스 : " + e.getClass().getName());
            System.out.println("메시지     : " + e.getMessage());
        });

        // QueryDSL 판 — 아래 주석을 풀면 컴파일이 실패합니다.
        //   queryFactory.selectFrom(customer).where(customer.grde.eq(Grade.VIP)).fetch();
        //   error: cannot find symbol
        //     symbol: variable grde, location: variable customer of type QCustomer
    }

    // =====================================================================
    // 정답 2. build.gradle 판정
    //
    // (a) querydsl-jpa:6.12:jakarta  →  (A) 빌드 실패
    //     6.x 는 Jakarta 를 네이티브로 삼았기 때문에 jakarta classifier 아티팩트를
    //     아예 발행하지 않습니다. Maven Central 에 그런 파일이 없습니다.
    //       Could not find querydsl-jpa-6.12-jakarta.jar
    //     5.x 습관을 그대로 옮겼을 때 나오는 가장 흔한 케이스이고,
    //     그나마 증상이 명확해서 다행인 케이스입니다.
    //
    // (b) querydsl-apt:6.12 (classifier 누락)  →  (B) 빌드 성공하지만 Q타입 미생성
    //     ★ 이 문항이 가장 많이 틀립니다. "빌드 실패"로 답하기 쉽습니다.
    //     querydsl-apt:6.12 는 실제로 존재하는 아티팩트라 의존성 해결이 성공합니다.
    //     하지만 그 기본 아티팩트에는 JPA 프로세서를 등록하는
    //     META-INF/services/javax.annotation.processing.Processor 항목이 없습니다.
    //     → APT 가 조용히 아무 일도 하지 않고, BUILD SUCCESSFUL 이 뜹니다.
    //     → 그다음 Q타입을 참조하는 코드에서 cannot find symbol 이 납니다.
    //     확인 명령: find build/generated -name 'Q*.java'  (출력이 없으면 이 케이스)
    //
    // (c) querydsl-jpa:5.0.0 (classifier 없음)  →  (C) 런타임 실패
    //     ★ 네 개 중 가장 위험합니다. 빌드가 초록불이기 때문입니다.
    //     5.0.0 기본 아티팩트는 javax.persistence 를 참조하는데,
    //     Spring Boot 3 / Jakarta EE 9+ 환경에는 그 패키지 자체가 없습니다.
    //       java.lang.NoClassDefFoundError: javax/persistence/Entity
    //     첫 쿼리를 실행하는 순간, 즉 서버가 뜬 뒤에 터집니다.
    //
    // (d) 정답 조합  →  (D) 정상
    //     jpa 는 classifier 없음, apt 만 :jpa.
    //     jakarta.persistence-api / jakarta.annotation-api 를 annotationProcessor 에
    //     추가한 것도 필수입니다. 프로세서가 엔티티를 읽을 때 이 API 가 프로세서
    //     클래스패스에 있어야 하기 때문입니다. 빠지면 Q타입이 부분적으로만 생성되거나
    //     cannot access jakarta.persistence.Entity 가 납니다.
    //
    // 【외울 것 한 줄】
    //   6.x → jpa 는 classifier 없음, apt 만 :jpa
    //   5.x → 양쪽 다 :jakarta
    //   두 classifier 의 "축"이 다르기 때문입니다.
    //     jpa 의 축 = javax냐 jakarta냐  (6.x 는 jakarta 하나뿐 → 축이 소멸)
    //     apt 의 축 = 어느 프로세서냐    (6.x 도 여전히 여러 개 → 축이 유지)
    // =====================================================================
    @Test
    @DisplayName("정답 2. build.gradle 판정")
    void 정답2_의존성_판정() {
        System.out.println("(a) A 빌드실패 / (b) B Q타입미생성 / (c) C 런타임실패 / (d) D 정상");
    }

    // =====================================================================
    // 정답 3. EntityManager 프록시 증명
    //
    // 출력 예:
    //   클래스명       : jdk.proxy2.$Proxy214
    //   인터페이스 만족 : true
    //
    // (3) 두 출력이 동시에 성립한다는 뜻:
    //   "EntityManager 인터페이스를 구현한 동적 프록시"라는 뜻입니다.
    //   타입은 EntityManager 가 맞지만, 실제 구현체(SessionImpl)는 아닙니다.
    //   호출을 받아 다른 객체에게 넘기는 중간자입니다.
    //
    //   이 프록시를 만드는 것은 스프링의 SharedEntityManagerCreator 이고,
    //   호출을 가로채는 것은 SharedEntityManagerInvocationHandler 입니다.
    //   핸들러가 하는 일은 매 호출마다 이것뿐입니다:
    //     "TransactionSynchronizationManager 에서 지금 이 스레드에 바인딩된
    //      EntityManager 를 꺼내서, 거기로 위임한다."
    //
    // (4) 왜 싱글턴 JPAQueryFactory 가 안전한가:
    //   JPAQueryFactory 가 붙들고 있는 것은 "특정 영속성 컨텍스트"가 아니라
    //   "지금 스레드의 영속성 컨텍스트를 찾아 주는 함수"이기 때문입니다.
    //   스레드 A 의 호출은 A 의 세션으로, 스레드 B 의 호출은 B 의 세션으로 갑니다.
    //   공유되는 상태가 없으므로 싱글턴으로 두어도 됩니다.
    //
    //   ★ 반대로 emf.createEntityManager() 로 만든 진짜 EntityManager 를
    //     싱글턴에 고정하면 이 분리가 사라집니다. 모든 스레드가 하나의 세션을 공유하고,
    //     ConcurrentModificationException / "Session is closed" 가 산발적으로 납니다.
    //     그리고 이 버그는 단일 스레드로 도는 테스트로는 절대 잡히지 않습니다.
    //     테스트로 못 잡는 버그이므로 "규칙"으로 막아야 합니다:
    //     애플리케이션 코드에서 emf.createEntityManager() 를 직접 호출하지 말 것.
    // =====================================================================
    @Test
    @DisplayName("정답 3. EntityManager 프록시 증명")
    void 정답3_EntityManager_프록시() {
        System.out.println("클래스명       : " + em.getClass().getName());
        System.out.println("인터페이스 만족 : " + (em instanceof EntityManager));

        assertThat(em.getClass().getName()).contains("Proxy");
        assertThat(em).isInstanceOf(EntityManager.class);

        // 프록시 뒤의 실제 세션 (확인용. 애플리케이션 코드에서 이러지 마세요)
        System.out.println("실제 세션      : "
                + em.unwrap(org.hibernate.Session.class).getClass().getName());
    }

    // =====================================================================
    // 정답 4. GOLD 등급 + 포인트 3000 이상
    //
    // 조건을 and 로 묶는 방법은 두 가지이고 결과 SQL 은 동일합니다.
    //   ① .where(A.and(B))
    //   ② .where(A, B)          ← 쉼표로 나열하면 QueryDSL 이 and 로 묶습니다
    //
    // ②를 권장합니다. 이유는 Step 04 에서 본격적으로 다루지만 미리 말하면,
    // 쉼표 나열은 인자 중 null 을 "조건 없음"으로 무시하기 때문에
    // 동적 쿼리를 조립할 때 그대로 재사용할 수 있습니다.
    //   .where(gradeEq(cond), pointsGoe(cond))   // null 이면 그 조건만 빠짐
    // .and() 체이닝은 앞이 null 이면 NullPointerException 이 납니다.
    //
    // 생성 SQL:
    //   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 = ? and c1_0.points >= ?
    //   바인딩: [1] GOLD, [2] 3000  → 6건
    //
    // 여기서 확인할 것: 값이 SQL 문자열에 박히지 않고 ? 로 나갔다는 것입니다.
    // QueryDSL 은 상수를 자동으로 바인딩 파라미터로 만듭니다.
    // 문자열을 이어붙이는 코드가 없으므로 SQL 인젝션이 구조적으로 불가능하고,
    // DB 가 실행계획을 재사용할 수 있습니다.
    // =====================================================================
    @Test
    @DisplayName("정답 4. GOLD 등급 + 포인트 3000 이상")
    void 정답4_GOLD_포인트조회() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.GOLD),
                        customer.points.goe(3000)
                )
                .fetch();

        result.forEach(c -> System.out.println(c.getName() + " " + c.getPoints()));
        assertThat(result).hasSize(6);
    }

    // =====================================================================
    // 정답 5. JPQL vs SQL 차이 3가지   ★ 이 스텝의 핵심
    //
    // toString() 출력 (JPQL):
    //   select customer
    //   from Customer customer
    //   where customer.grade = ?1 and customer.city = ?2
    //
    // hibernate.SQL 출력 (SQL):
    //   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 = ? and c1_0.city = ?
    //
    // 차이 1 — 별칭
    //   JPQL: customer  (우리가 static import 한 Q타입의 변수명이 그대로 별칭이 됩니다)
    //   SQL : c1_0      (Hibernate 6 이 <엔티티 첫 글자><인덱스>_<서브인덱스> 로 만듭니다)
    //   Hibernate 5 는 customer0_ 형태였습니다. 별칭 모양만 봐도 세대를 구별할 수 있습니다.
    //
    // 차이 2 — from 대상
    //   JPQL: Customer   (자바 클래스 이름)
    //   SQL : customers  (DB 테이블 이름)
    //   ★ QueryDSL 은 "customers" 라는 문자열을 단 한 번도 만들지 않습니다.
    //     테이블 이름을 아는 것은 Hibernate 이고, 매핑(@Table)에서 가져옵니다.
    //
    // 차이 3 — 파라미터 표기
    //   JPQL: ?1, ?2  (번호가 붙은 위치 파라미터)
    //   SQL : ?, ?    (JDBC 자리표시자, 번호 없음)
    //   주의: JPQL 의 ?1 이 SQL 의 첫 번째 ? 와 항상 일치한다고 가정하면 안 됩니다.
    //   in 절 확장이나 상속 판별 조건이 추가되면 SQL 쪽 ? 가 더 많아집니다.
    //   실제 바인딩 값은 org.hibernate.orm.jdbc.bind 로그로 확인하세요.
    //
    // 차이 4 (보너스) — 조회 대상의 세밀도
    //   JPQL: select customer   → "이 엔티티를 통째로"
    //   SQL : 8개 컬럼을 전부 나열
    //   엔티티를 조회하면 항상 모든 컬럼이 나갑니다. 필요한 두 컬럼만 읽고 싶다면
    //   프로젝션을 써야 하며, 그것이 Step 05 의 주제입니다.
    //
    // 【이 절이 왜 중요한가】
    //   QueryDSL 의 출력물은 JPQL 까지입니다. SQL 은 Hibernate 의 결과물입니다.
    //   따라서 JPQL 이 못 하는 것은 QueryDSL 도 못 합니다.
    //     - from 절 서브쿼리(인라인 뷰)  → 불가
    //     - UNION                        → 불가
    //     - 윈도우 함수                  → 불가 (JPA 모듈에서는)
    //   Step 07 에서 이 벽에 부딪히는데, 그때 "왜 안 되는가"의 답이 여기입니다.
    //
    // 【디버깅 요령】
    //   의도와 다른 SQL 이 나갈 때는 SQL 로그부터 보지 말고 toString() 부터 보세요.
    //     JPQL 이 이미 이상하다  → 내 QueryDSL 코드가 틀렸다
    //     JPQL 은 맞는데 SQL 이 이상하다 → 엔티티 매핑/페치 전략 문제다
    //   이 두 갈래로 나누는 것만으로 디버깅 시간이 절반이 됩니다.
    //   toString() 은 쿼리를 실행하지 않으므로 마음껏 찍어도 됩니다.
    // =====================================================================
    @Test
    @DisplayName("정답 5. JPQL vs SQL 차이 3가지")
    void 정답5_JPQL과_SQL의_차이() {
        JPAQuery<Customer> query = queryFactory
                .selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.VIP),
                        customer.city.eq("서울")
                );

        System.out.println("===== JPQL (실행 전) =====");
        System.out.println(query.toString());

        System.out.println("===== SQL (아래 hibernate.SQL 로그) =====");
        List<Customer> result = query.fetch();

        result.forEach(c -> System.out.println(c.getName()));
        assertThat(result).hasSize(2);      // 김서준, 정  훈
    }

    // =====================================================================
    // 정답 6. fetchOne 예외 재현과 대안
    //
    // (1) 재현
    //   city = "서울" 인 고객은 9명이므로 fetchOne() 은 반드시 터집니다.
    //     com.querydsl.core.NonUniqueResultException:
    //       Only one result is allowed for fetchOne calls
    //
    //   ★ 주의: QueryDSL 은 이 예외를 "결과를 다 가져온 뒤"에 던집니다.
    //     즉 9건을 DB 에서 전부 읽어 온 다음에 예외가 납니다.
    //     조건이 잘못돼서 100만 건이 매칭되면 100만 건을 읽고 나서 터집니다.
    //     예외가 났다고 해서 쿼리가 가벼웠던 게 아닙니다.
    //
    // (2) 대안 A — fetchFirst()
    //   내부적으로 limit(1) 을 붙입니다. 생성 SQL 끝에 limit ? 가 붙으므로
    //   DB 가 1건만 반환합니다. 위의 "다 읽고 터진다" 문제도 없습니다.
    //   단, 정렬이 없으면 "어느 1건"인지 DB 마음입니다.
    //   반드시 orderBy 를 함께 주세요. 이게 안 되어 있으면
    //   개발 DB 와 운영 DB 에서 다른 행이 나오는 현상이 생깁니다.
    //
    // (2) 대안 B — fetch() 후 size() 검사
    //   건수를 코드가 알고 있으므로 예외 대신 분기로 처리할 수 있습니다.
    //   "0건이면 기본값, 1건이면 그것, 2건 이상이면 로그를 남기고 첫 건" 같은
    //   비즈니스 규칙을 표현할 수 있습니다.
    //   단점은 전부 읽어 온다는 것이라, 결과가 많을 수 있으면 limit 을 함께 거세요.
    //
    // (3) 어느 쪽도 해결해 주지 못하는 것:
    //   ★ "이 조건이 정말 유일한가"는 코드가 판단해 주지 않습니다.
    //   fetchFirst() 로 바꾸면 예외는 사라지지만, 사실 2건이 나와야 정상인
    //   상황에서 조용히 1건만 쓰게 됩니다 — 에러 없이 틀린 결과를 반환하는 것이라
    //   예외가 나는 것보다 더 나쁠 수 있습니다.
    //   유일성이 보장되는 것은 DB 의 UNIQUE 제약뿐입니다.
    //   customers 테이블에는 uk_customers_email 이 있으므로 email 조회는 안전하고,
    //   city 나 name 은 제약이 없으므로 언제든 2건이 될 수 있습니다.
    //   fetchOne() 을 쓸지 말지의 기준은 "지금 데이터가 1건인가"가 아니라
    //   "스키마가 1건을 보장하는가" 입니다.
    // =====================================================================
    @Test
    @DisplayName("정답 6. fetchOne 예외 재현과 대안")
    void 정답6_fetchOne_예외와_대안() {
        // (1) 재현
        assertThatThrownBy(() ->
                queryFactory.selectFrom(customer)
                        .where(customer.city.eq("서울"))
                        .fetchOne()
        ).isInstanceOf(NonUniqueResultException.class)
         .satisfies(e -> System.out.println("예외 : " + e.getMessage()));

        // (2) 대안 A — fetchFirst + orderBy (정렬 없이 쓰지 말 것)
        Customer first = queryFactory.selectFrom(customer)
                .where(customer.city.eq("서울"))
                .orderBy(customer.id.asc())
                .fetchFirst();
        System.out.println("대안 A 첫 건 : " + first.getName());

        // (2) 대안 B — fetch 후 건수로 분기
        List<Customer> found = queryFactory.selectFrom(customer)
                .where(customer.city.eq("서울"))
                .fetch();
        System.out.println("대안 B 건수 : " + found.size());
        if (found.size() == 1) {
            System.out.println("단건 : " + found.get(0).getName());
        } else {
            System.out.println("단건이 아님 → 비즈니스 규칙으로 처리");
        }

        // (참고) 스키마가 유일성을 보장하는 조회 — email 은 UNIQUE 제약이 있습니다
        Customer byEmail = queryFactory.selectFrom(customer)
                .where(customer.email.eq("seojun.kim@example.com"))
                .fetchOne();
        System.out.println("email 단건 : " + (byEmail == null ? "null" : byEmail.getName()));
    }
}