실습 프로젝트 셋업

학습 목표

  • QueryDSL 6.12 (io.github.openfeign.querydsl) 좌표를 5.x 와 구분해 정확히 쓴다
  • Gradle annotationProcessor 로 Q타입이 생성되는 위치를 확인한다
  • shop 스키마에 대응하는 JPA 엔티티 8개를 ddl-auto: validate 로 검증한다
  • 생성 SQL 과 바인딩 파라미터가 콘솔에 찍히도록 로깅을 설정한다
  • JPAQueryFactory 로 첫 쿼리를 날려 30건이 조회되는 것을 확인한다

선행 조건: Java 21, Docker, MySQL 8 코스docker/sql/ 디렉터리 예상 소요: 40분

이 문서를 끝내면 Step 01 부터 Step 14 까지의 모든 실습 코드를 그대로 복사해 실행할 수 있는 프로젝트가 만들어집니다. 이 디렉터리에는 실습 Java 파일을 두지 않습니다. 각 스텝 디렉터리의 Practice.java 를 씁니다.


0-1. 프로젝트 구조

프로젝트 루트는 어디든 상관없습니다. 이 문서는 ~/querydsl6-shop 을 기준으로 씁니다.

querydsl6-shop/
├── build.gradle
├── settings.gradle
├── gradlew / gradlew.bat / gradle/
├── docker/                                   ← (선택) 이 프로젝트 안에 MySQL 을 둘 경우
│   └── docker-compose.yml
└── src
    ├── main
    │   ├── java/com/example/shop
    │   │   ├── ShopApplication.java
    │   │   ├── config/
    │   │   │   └── QuerydslConfig.java        ← JPAQueryFactory 빈
    │   │   └── entity/
    │   │       ├── Customer.java
    │   │       ├── Category.java
    │   │       ├── Product.java
    │   │       ├── Order.java
    │   │       ├── OrderItem.java
    │   │       ├── Payment.java
    │   │       ├── Review.java
    │   │       ├── Employee.java
    │   │       ├── Grade.java
    │   │       ├── OrderStatus.java
    │   │       ├── ProductStatus.java
    │   │       ├── PaymentMethod.java
    │   │       └── PaymentStatus.java
    │   └── resources
    │       └── application.yml
    └── test
        └── java/com/example/shop
            ├── SetupVerifyTest.java           ← 0-8 절에서 만듭니다
            ├── step01/                        ← 각 스텝의 Practice/Exercise/Solution
            ├── step02/
            └── ...

Q타입은 소스 디렉터리에 만들지 않습니다. 빌드 산출물입니다.

build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/
├── QCategory.java
├── QCustomer.java
├── QEmployee.java
├── QOrder.java
├── QOrderItem.java
├── QPayment.java
├── QProduct.java
└── QReview.java

💡 실무 팁 — Q타입을 절대 커밋하지 마십시오 build/ 는 이미 .gitignore 대상입니다. Q타입을 src/main/generated 로 빼서 커밋하는 설정을 종종 보는데, 엔티티와 Q타입이 어긋난 채로 리뷰를 통과하는 사고가 납니다. 생성물은 생성물 자리에 둡니다.


0-2. build.gradle 전문

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.5'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

ext {
    querydslVersion = '6.12'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // ── QueryDSL 6.x (OpenFeign 포크) ───────────────────────────────
    implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}"
    implementation "io.github.openfeign.querydsl:querydsl-core:${querydslVersion}"
    annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa"
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    // ───────────────────────────────────────────────────────────────

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'

    runtimeOnly 'com.mysql:mysql-connector-j'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

// ── Q타입 생성 경로 설정 ─────────────────────────────────────────────
def generatedDir = "$buildDir/generated/sources/annotationProcessor/java/main"

sourceSets {
    main {
        java {
            srcDirs += generatedDir
        }
    }
}

tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
    options.generatedSourceOutputDirectory = file(generatedDir)
}

// clean 시 생성된 Q타입도 함께 삭제
clean {
    delete file(generatedDir)
}

tasks.named('test') {
    useJUnitPlatform()
}

settings.gradle 은 한 줄입니다.

rootProject.name = 'querydsl6-shop'

5.x 좌표와의 대조

가장 많이 틀리는 지점입니다. 5.x 는 두 아티팩트 모두 :jakarta classifier 를 붙였고, 6.x 는 querydsl-apt 에만 :jpa classifier 를 붙입니다.

항목5.x (com.querydsl)6.x (io.github.openfeign.querydsl)
jpacom.querydsl:querydsl-jpa:5.0.0:jakartaio.github.openfeign.querydsl:querydsl-jpa:6.12
corecom.querydsl:querydsl-core:5.0.0io.github.openfeign.querydsl:querydsl-core:6.12
aptcom.querydsl:querydsl-apt:5.0.0:jakartaio.github.openfeign.querydsl:querydsl-apt:6.12:jpa
classifier 규칙jpa 있음, apt 있음jpa 없음, apt :jpa
persistence APIjavax 기본 (jakarta 는 classifier)jakarta 네이티브
Hibernate 6미지원6.4 통합

⚠️ 함정 — 5.x 좌표를 그대로 옮겼을 때 io.github.openfeign.querydsl:querydsl-jpa:6.12:jakarta 처럼 존재하지 않는 classifier 를 쓰면 의존성 해석 단계에서 바로 실패하므로 오히려 안전합니다. 진짜 위험한 건 groupId 만 바꾸지 않고 com.querydsl:querydsl-jpa:5.0.0 (classifier 없음) 을 쓰는 경우입니다. 이건 javax 를 참조하는 아티팩트라 빌드가 성공합니다. 그리고 애플리케이션 기동 중에 이렇게 죽습니다.

Caused by: java.lang.NoClassDefFoundError: javax/persistence/Entity
    at com.querydsl.jpa.JPQLTemplates.<clinit>(JPQLTemplates.java:47)
    ...
Caused by: java.lang.ClassNotFoundException: javax.persistence.Entity
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)

스택트레이스에 javax. 가 보이면 100% 좌표 문제입니다. ./gradlew dependencies --configuration runtimeClasspath | grep querydsl 로 확인하십시오.

결과 — 좌표가 올바를 때의 출력

$ ./gradlew dependencies --configuration runtimeClasspath | grep querydsl
+--- io.github.openfeign.querydsl:querydsl-jpa:6.12
|    +--- io.github.openfeign.querydsl:querydsl-core:6.12
+--- io.github.openfeign.querydsl:querydsl-core:6.12 (*)

com.querydsl 이 한 줄이라도 섞여 있으면 잘못된 것입니다.


0-3. docker compose

MySQL 8 을 127.0.0.1:3307 에 띄웁니다. 방법은 두 가지입니다.

방법 A — MySQL8 코스의 docker/ 를 재사용 (권장)

이 코스는 MySQL8 코스와 완전히 같은 shop 스키마와 데이터를 씁니다. 이미 그 컨테이너를 띄워 본 적이 있다면 새로 만들 이유가 없습니다.

cd docs/reference/mysql8/docker
docker compose up -d
docker compose ps

결과

NAME           IMAGE       STATUS                   PORTS
learn-mysql8   mysql:8.0   Up 12 seconds (healthy)  33060/tcp, 0.0.0.0:3307->3306/tcp

(healthy) 가 뜰 때까지 기다립니다. 보통 10~20초입니다. (health: starting) 상태에서 접속하면 커넥션이 거부됩니다.

방법 B — 이 프로젝트 안에 새로 두기

MySQL8 코스 파일을 건드리고 싶지 않다면 querydsl6-shop/docker/docker-compose.yml 을 만듭니다.

name: querydsl6-shop

services:
  mysql:
    image: mysql:8.0
    container_name: querydsl6-mysql8
    restart: unless-stopped
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root1234
      MYSQL_DATABASE: shop
      MYSQL_USER: learner
      MYSQL_PASSWORD: learn1234
      TZ: Asia/Seoul
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_0900_ai_ci
    volumes:
      - querydsl6-mysql8-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot1234"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  querydsl6-mysql8-data:
cd ~/querydsl6-shop/docker
docker compose up -d

⚠️ 함정 — 두 컨테이너가 같은 3307 을 잡는 경우 방법 A 와 B 를 둘 다 실행하면 두 번째가 port is already allocated 로 실패합니다. 하나만 쓰십시오. 이미 떠 있는 것을 확인하려면 docker ps --filter publish=3307.

컨테이너 상태가 꼬이면 언제든 초기화할 수 있습니다.

docker compose down -v && docker compose up -d

볼륨까지 지우므로 데이터가 사라집니다. 0-4 절을 다시 실행하면 됩니다.


0-4. shop 스키마 적재

MySQL8 코스의 sql/install.sh 를 그대로 씁니다. 이 코스용 별도 스크립트는 없습니다.

cd docs/reference/mysql8/sql
./install.sh

결과

▶ MySQL 접속 확인 (127.0.0.1:3307)
+---------------+
| mysql_version |
+---------------+
| 8.0.46        |
+---------------+
▶ 01_schema.sql
▶ 02_seed_master.sql
▶ 03_seed_orders.sql
✅ 완료. 접속:  mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop

--big 옵션은 100만 행짜리 access_logs 를 추가로 만듭니다. 이 코스에서는 Step 14 에서만 쓰므로 지금은 생략해도 됩니다.

적재가 제대로 됐는지 행 수로 검증합니다. 이 숫자는 교재 전체에서 고정입니다.

SELECT 'categories'  AS t, COUNT(*) AS rows_ FROM categories
UNION ALL SELECT 'customers',  COUNT(*) FROM customers
UNION ALL SELECT 'products',   COUNT(*) FROM products
UNION ALL SELECT 'orders',     COUNT(*) FROM orders
UNION ALL SELECT 'order_items',COUNT(*) FROM order_items
UNION ALL SELECT 'payments',   COUNT(*) FROM payments
UNION ALL SELECT 'reviews',    COUNT(*) FROM reviews
UNION ALL SELECT 'employees',  COUNT(*) FROM employees;

결과

+-------------+-------+
| t           | rows_ |
+-------------+-------+
| categories  |    17 |
| customers   |    30 |
| products    |    40 |
| orders      |   600 |
| order_items |  1200 |
| payments    |   540 |
| reviews     |    80 |
| employees   |    18 |
+-------------+-------+
8 rows in set (0.01 sec)

하나라도 다르면 ./install.sh 를 다시 실행하십시오. 스크립트는 재실행 가능합니다.

교재의 함정 재료가 제대로 심어졌는지도 확인합니다.

SELECT
  (SELECT COUNT(*) FROM customers WHERE phone IS NULL)          AS phone_null,
  (SELECT COUNT(*) FROM customers WHERE grade = 'VIP')          AS vip,
  (SELECT COUNT(*) FROM orders    WHERE status = 'PENDING')     AS pending,
  (SELECT COUNT(*) FROM products p
    WHERE NOT EXISTS (SELECT 1 FROM reviews r
                      WHERE r.product_id = p.product_id))       AS no_review;

결과

+------------+-----+---------+-----------+
| phone_null | vip | pending | no_review |
+------------+-----+---------+-----------+
|          3 |   4 |      60 |        24 |
+------------+-----+---------+-----------+

전화번호 NULL 3명, VIP 4명, 결제 없는 PENDING 주문 60건, 후기 없는 상품 24개. 이 네 숫자는 코스 내내 반복해서 등장합니다.


0-5. application.yml

spring:
  application:
    name: querydsl6-shop

  datasource:
    url: jdbc:mysql://127.0.0.1:3307/shop?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: learner
    password: learn1234
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        use_sql_comments: false
        default_batch_fetch_size: 100

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

각 설정이 왜 필요한지는 이 코스의 학습 방식과 직결됩니다.

설정왜 필요한가
ddl-autovalidate스키마를 절대 건드리지 않고, 엔티티 매핑이 기존 테이블과 맞는지만 검증합니다. update 로 두면 매핑 실수가 스키마를 조용히 바꿔 버립니다
open-in-viewfalse뷰 렌더링 시점까지 영속성 컨텍스트가 열려 있으면 지연 로딩이 어디서 터지는지 흐려집니다. 끄면 N+1 이 정직하게 드러납니다
format_sqltrue한 줄짜리 SQL 은 읽을 수 없습니다. 이 코스는 SQL 을 읽는 코스입니다
highlight_sqltrue콘솔에서 SQL 키워드에 색이 들어갑니다. IDE 콘솔에서 예제 SQL 을 찾기 쉬워집니다
default_batch_fetch_size100지연 로딩 N+1 을 IN (...) 배치로 줄입니다. Step 14 에서 이 값을 켜고 끄며 실측합니다
org.hibernate.SQLdebug생성 SQL 이 콘솔에 찍힙니다. 이게 없으면 이 코스는 성립하지 않습니다
org.hibernate.orm.jdbc.bindtrace? 자리에 실제로 무슨 값이 들어갔는지 보여줍니다. Hibernate 6 에서 바뀐 로거 이름입니다

⚠️ 함정 — Hibernate 5 시절의 바인딩 로거 이름 Hibernate 5 는 org.hibernate.type.descriptor.sql.BasicBinder 였습니다. Hibernate 6 은 org.hibernate.orm.jdbc.bind 입니다. 옛 이름을 쓰면 설정은 정상 적용된 것처럼 보이는데 바인딩 값이 안 찍힙니다. 아무 에러도 나지 않으므로 "원래 안 나오나 보다" 하고 넘어가기 쉽습니다.

결과 — 로깅이 제대로 켜졌을 때의 콘솔

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]

binding parameter 줄이 안 보이면 로거 이름을 다시 확인하십시오.


0-6. 엔티티 전체

8개 엔티티와 5개 enum 을 모두 com.example.shop.entity 패키지에 만듭니다. 규칙은 전부 동일합니다.

  • Lombok @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) (@Setter 는 붙이지 않습니다. 이 코스는 조회 중심이고, 벌크 연산은 Step 11 에서 따로 다룹니다)
  • 모든 연관은 FetchType.LAZY
  • enum 은 @Enumerated(EnumType.STRING)ORDINAL 은 절대 쓰지 않습니다
  • 컬럼명을 @Column(name = "...")명시합니다
  • 금액은 전부 BigDecimal

enum 5개

package com.example.shop.entity;

public enum Grade {
    BRONZE, SILVER, GOLD, VIP
}
package com.example.shop.entity;

public enum OrderStatus {
    PENDING, PAID, SHIPPED, DELIVERED, CANCELLED
}
package com.example.shop.entity;

public enum ProductStatus {
    ON_SALE, SOLD_OUT, HIDDEN
}
package com.example.shop.entity;

public enum PaymentMethod {
    CARD, BANK, POINT, MOBILE
}
package com.example.shop.entity;

public enum PaymentStatus {
    DONE, REFUNDED
}

DB 의 ENUM 컬럼 순서와 자바 enum 상수 순서를 일부러 맞춰 두었습니다. 하지만 @Enumerated(EnumType.STRING) 을 쓰므로 순서는 매핑에 영향을 주지 않습니다. 순서에 의존하지 않는 것이 핵심입니다.

Customer

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "customers")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "customer_id")
    private Long id;

    @Column(name = "email", nullable = false, length = 120, unique = true)
    private String email;

    @Column(name = "name", nullable = false, length = 50)
    private String name;

    /** NULL 인 고객이 3명 있습니다. isNull() 실습의 재료입니다. */
    @Column(name = "phone", length = 20)
    private String phone;

    @Enumerated(EnumType.STRING)
    @Column(name = "grade", nullable = false, length = 10)
    private Grade grade;

    @Column(name = "birth_date")
    private LocalDate birthDate;

    @Column(name = "city", nullable = false, length = 30)
    private String city;

    @Column(name = "points", nullable = false)
    private Integer points;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @OneToMany(mappedBy = "customer", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}

Category

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "categories")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "category_id")
    private Long id;

    /** 자기참조. NULL 이면 최상위(대분류 5개). */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
    private List<Category> children = new ArrayList<>();

    @Column(name = "name", nullable = false, length = 50)
    private String name;

    @Column(name = "sort_order", nullable = false)
    private Short sortOrder;
}

Product

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;

    @Column(name = "name", nullable = false, length = 100)
    private String name;

    @Column(name = "price", nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(name = "cost", nullable = false, precision = 10, scale = 2)
    private BigDecimal cost;

    @Column(name = "stock", nullable = false)
    private Integer stock;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 10)
    private ProductStatus status;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
    private List<Review> reviews = new ArrayList<>();
}

products 테이블에는 attrs JSON 컬럼이 있지만 엔티티에 매핑하지 않습니다. QueryDSL 로 JSON 을 다루는 것은 이 코스의 범위 밖입니다. 매핑하지 않아도 ddl-auto: validate 는 통과합니다 — 그 이유는 아래 함정 블록에서 설명합니다.

Order

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    @Column(name = "order_date", nullable = false)
    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 10)
    private OrderStatus status;

    @Column(name = "total_amount", nullable = false, precision = 12, scale = 2)
    private BigDecimal totalAmount;

    @Column(name = "shipping_city", nullable = false, length = 30)
    private String shippingCity;

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

    /** PENDING 주문 60건은 이 리스트가 비어 있습니다. */
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<Payment> payments = new ArrayList<>();
}

💡 Order 는 SQL 예약어입니다. @Table(name = "orders") 로 실제 테이블명을 명시했기 때문에 문제가 없습니다. 클래스명만 보고 테이블을 추론하게 두면 order 로 생성돼 터집니다.

OrderItem

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;

@Entity
@Table(name = "order_items")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @Column(name = "quantity", nullable = false)
    private Integer quantity;

    /** 주문 시점의 가격 스냅샷. product.price 와 다를 수 있습니다. */
    @Column(name = "unit_price", nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
}

Payment

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

@Entity
@Table(name = "payments")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "payment_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @Enumerated(EnumType.STRING)
    @Column(name = "method", nullable = false, length = 10)
    private PaymentMethod method;

    @Column(name = "amount", nullable = false, precision = 12, scale = 2)
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 10)
    private PaymentStatus status;

    @Column(name = "paid_at", nullable = false)
    private LocalDateTime paidAt;
}

Review

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Table(name = "reviews")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "review_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    /** DB 는 TINYINT UNSIGNED, CHECK (rating BETWEEN 1 AND 5). */
    @Column(name = "rating", nullable = false)
    private Integer rating;

    @Column(name = "title", length = 100)
    private String title;

    @Lob
    @Column(name = "body")
    private String body;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;
}

Employee

package com.example.shop.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "employees")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "employee_id")
    private Long id;

    @Column(name = "name", nullable = false, length = 50)
    private String name;

    /** 자기참조. NULL 이면 최상위(대표). 4단계 조직도입니다. */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "manager_id")
    private Employee manager;

    @OneToMany(mappedBy = "manager", fetch = FetchType.LAZY)
    private List<Employee> subordinates = new ArrayList<>();

    @Column(name = "dept", nullable = false, length = 30)
    private String dept;

    @Column(name = "position", nullable = false, length = 30)
    private String position;

    @Column(name = "salary", nullable = false, precision = 10, scale = 2)
    private BigDecimal salary;

    @Column(name = "hire_date", nullable = false)
    private LocalDate hireDate;
}

QuerydslConfig

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);
    }
}

📌 패키지가 com.querydsl.jpa.impl 인 것에 주목하십시오. groupId 는 io.github.openfeign.querydsl 로 바뀌었지만 자바 패키지명은 com.querydsl 그대로입니다. import 문은 5.x 와 동일합니다. 이 비대칭이 좌표 혼동을 더 키웁니다.

⚠️ 함정 — ddl-auto: validate 가 잡아주는 것과 못 잡는 것

Order.totalAmount 에서 @Column(name = "total_amount") 를 지우면 어떻게 될까요. 아무 일도 일어나지 않습니다. 그대로 통과합니다. Spring Boot 의 기본 physical naming strategy 인 CamelCaseToUnderscoresNamingStrategytotalAmounttotal_amount 로 변환해 주기 때문입니다.

문제는 이 자동 변환이 언제 통하고 언제 안 통하는지가 눈에 안 보인다는 점입니다.

자바 필드자동 변환 결과DB 컬럼validate
totalAmounttotal_amounttotal_amount통과
shippingCityshipping_cityshipping_city통과
ididorder_id실패
orderDatedate 로 개명dateorder_date실패

PK 는 거의 항상 실패합니다. customers.customer_id 처럼 테이블명 접두사가 붙어 있기 때문입니다. 그래서 이 코스는 모든 컬럼에 @Column(name) 을 명시합니다. 반쯤 명시하고 반쯤 규칙에 맡기면, 어느 쪽 규칙이 적용됐는지 매번 추론해야 합니다.

반대로 validate못 잡는 것도 알아 두십시오. products.attrs 처럼 DB 에만 있고 엔티티에 없는 컬럼은 검사하지 않습니다. validate 는 "엔티티가 요구하는 컬럼이 DB 에 있는가"만 봅니다. 반대 방향은 보지 않습니다.

결과 — 매핑이 틀렸을 때의 실패 메시지

Caused by: org.hibernate.tool.schema.spi.SchemaManagementException:
    Schema-validation: missing column [id] in table [orders]
    at org.hibernate.tool.schema.internal.AbstractSchemaValidator
       .validateTable(AbstractSchemaValidator.java:135)

missing column [X] in table [Y] 는 "DB 에 없다"가 아니라 "엔티티가 X 를 요구하는데 DB 에는 없다" 는 뜻입니다. 고칠 곳은 대개 엔티티입니다.


0-7. Q타입 생성 확인

엔티티를 다 만들었으면 컴파일합니다.

cd ~/querydsl6-shop
./gradlew clean compileJava

결과

> Task :clean
> Task :compileJava

BUILD SUCCESSFUL in 6s
2 actionable tasks: 2 executed

Q타입이 생겼는지 확인합니다.

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

결과

build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QCategory.java
build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QCustomer.java
build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QEmployee.java
build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QOrder.java
build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QOrderItem.java
build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QPayment.java
build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QProduct.java
build/generated/sources/annotationProcessor/java/main/com/example/shop/entity/QReview.java

8개입니다. enum 은 @Entity 가 아니므로 Q타입이 생기지 않습니다.

생성된 QCustomer.java 를 열어 봅니다. Q타입이 마법이 아니라 그냥 자바 클래스라는 것을 확인하는 것이 중요합니다.

package com.example.shop.entity;

import static com.querydsl.core.types.PathMetadataFactory.*;

import com.querydsl.core.types.dsl.*;
import com.querydsl.core.types.PathMetadata;
import javax.annotation.processing.Generated;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.PathInits;

/**
 * QCustomer is a Querydsl query type for Customer
 */
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QCustomer extends EntityPathBase<Customer> {

    private static final long serialVersionUID = -1234567890L;

    public static final QCustomer customer = new QCustomer("customer");

    public final StringPath city = createString("city");

    public final DateTimePath<java.time.LocalDateTime> createdAt = createDateTime("createdAt", java.time.LocalDateTime.class);

    public final StringPath email = createString("email");

    public final EnumPath<Grade> grade = createEnum("grade", Grade.class);

    public final NumberPath<Long> id = createNumber("id", Long.class);

    public final StringPath name = createString("name");

    public final ListPath<Order, QOrder> orders = this.<Order, QOrder>createList("orders", Order.class, QOrder.class, PathInits.DIRECT2);

    public final StringPath phone = createString("phone");

    public final NumberPath<Integer> points = createNumber("points", Integer.class);

    public QCustomer(String variable) {
        super(Customer.class, forVariable(variable));
    }

    // ... 생성자 오버로드 생략
}

읽어 둘 지점이 셋 있습니다.

  • public static final QCustomer customer — 이것이 우리가 import static com.example.shop.entity.QCustomer.customer; 로 가져다 쓰는 기본 인스턴스입니다.
  • 필드 타입이 StringPath, NumberPath<Integer>, EnumPath<Grade>갈라져 있습니다. customer.name.gt(...) 가 컴파일 에러인 이유가 여기 있습니다. StringPath 에는 gt 가 없습니다.
  • 필드명은 자바 필드명(createdAt)이지 컬럼명(created_at)이 아닙니다. QueryDSL 은 JPQL 을 만들고, 컬럼명 변환은 Hibernate 가 합니다.

Q타입이 안 생기는 경우의 대응은 0-9 절에 정리했습니다.


0-8. 첫 실행 확인

src/test/java/com/example/shop/SetupVerifyTest.java 를 만듭니다.

package com.example.shop;

import com.example.shop.entity.Grade;
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;

@SpringBootTest
@Transactional
class SetupVerifyTest {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    @Test
    @DisplayName("고객은 30명이다")
    void customerCount() {
        List<com.example.shop.entity.Customer> all = queryFactory
                .selectFrom(customer)
                .fetch();

        assertThat(all).hasSize(30);
    }

    @Test
    @DisplayName("VIP 고객은 4명이다")
    void vipCount() {
        List<String> names = queryFactory
                .select(customer.name)
                .from(customer)
                .where(customer.grade.eq(Grade.VIP))
                .fetch();

        assertThat(names).hasSize(4);
    }
}

import static com.example.shop.entity.QCustomer.customer; — 이 코스는 Q타입 기본 인스턴스를 항상 static import 로 가져다 씁니다. 본문 예제도 전부 이 관례를 따릅니다.

./gradlew test --tests 'com.example.shop.SetupVerifyTest'

결과 — 콘솔에 찍힌 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
BUILD SUCCESSFUL in 9s

두 번째 테스트의 SQL 입니다.

Hibernate:
    select
        c1_0.name
    from
        customers c1_0
    where
        c1_0.grade = ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [VIP]
조회 4건 — 김서준, 류하나, 정  훈, 배채영

여기서 확인할 것.

  • 별칭이 c1_0 입니다. Hibernate 6 은 <엔티티첫글자><인덱스>_<서브인덱스> 로 별칭을 만들고, as 키워드 없이 붙입니다. 이 코스의 모든 생성 SQL 이 이 형태입니다.
  • selectFrom(customer) 는 전 컬럼을, select(customer.name)필요한 컬럼만 읽습니다. QueryDSL 코드 한 글자 차이가 SQL 을 바꿉니다.
  • orders 연관은 LAZY 이므로 SQL 에 조인이 전혀 없습니다.

두 테스트가 통과하고 위 SQL 이 그대로 보이면 셋업 완료입니다.


0-9. 문제 해결

에러 메시지원인해결
cannot find symbol: class QCustomerannotationProcessor 가 안 돌았거나 clean 직후 상태./gradlew clean compileJava 후 IDE 에서 Gradle 프로젝트 새로고침. IntelliJ 는 Settings > Build Tools > Gradle > Build and run using: Gradle 로 두는 편이 안전합니다
NoClassDefFoundError: javax/persistence/Entity5.x 좌표(com.querydsl) 를 쓰고 있음build.gradle 을 0-2 절 표대로 교정. ./gradlew dependencies --configuration runtimeClasspath | grep querydslcom.querydsl 이 없는지 확인
Unable to load class ...QCustomer / Q타입은 있는데 IDE 만 빨간 줄생성 디렉터리가 소스 루트로 인식되지 않음build.gradlesourceSets.main.java.srcDirs += generatedDir 확인 후 Gradle 새로고침
SchemaManagementException: missing column [id] in table [orders]엔티티 필드에 @Column(name) 누락 (PK 에서 가장 흔함)해당 필드에 @Column(name = "order_id") 명시. 0-6 절 함정 블록 참고
SchemaManagementException: missing table [shop.customers]스키마가 적재되지 않았거나 다른 DB 에 붙음0-4 절 ./install.sh 재실행. datasource.url 의 DB 이름이 shop 인지 확인
Communications link failure / Connection refused: 3307컨테이너 미기동 또는 아직 health: startingdocker compose ps(healthy) 확인 후 재시도. 포트 충돌은 docker ps --filter publish=3307
Access denied for user 'learner'@'...'계정/비밀번호 불일치, 또는 볼륨은 남고 환경변수만 바뀐 상태docker compose down -v && docker compose up -d./install.sh. MySQL 은 최초 기동 시에만 MYSQL_USER 를 만듭니다
NoSuchBeanDefinitionException: ... JPAQueryFactoryQuerydslConfig 가 컴포넌트 스캔 범위 밖com.example.shop 하위(예: com.example.shop.config)에 두었는지 확인
LazyInitializationException트랜잭션 밖에서 지연 로딩 접근테스트에 @Transactional 이 붙어 있는지 확인. open-in-view: false 라 웹 계층에서도 동일하게 터집니다
SQL 은 찍히는데 binding parameter 가 안 보임Hibernate 5 시절 로거 이름 사용org.hibernate.orm.jdbc.bind: trace 로 교정 (0-5 절)

💡 실무 팁 — 막히면 초기화가 가장 빠릅니다 이 코스의 DB 는 언제든 버려도 되는 학습용입니다. 원인 추적에 10분 이상 쓰지 말고 docker compose down -v && docker compose up -d && ./install.sh 를 돌리십시오. 데이터는 결정론적으로 생성되므로 항상 똑같은 상태로 돌아옵니다.


정리

항목핵심
좌표io.github.openfeign.querydsl 6.12. querydsl-jpa 는 classifier 없음, querydsl-apt:jpa
자바 패키지groupId 는 바뀌었지만 import 는 여전히 com.querydsl.*
5.x 를 쓰면빌드는 통과하고 기동 시 NoClassDefFoundError: javax/persistence/Entity
Q타입 위치build/generated/sources/annotationProcessor/java/main/... — 커밋하지 않음
clean생성 디렉터리를 함께 지우도록 clean { delete ... } 설정
ddl-autovalidate. 스키마를 바꾸지 않고 매핑만 검증
네이밍모든 컬럼에 @Column(name) 명시. 자동 변환에 반쯤 의존하지 않음
validate 의 한계엔티티에 없는 DB 컬럼(products.attrs)은 검사하지 않음
연관전부 FetchType.LAZY. 예외 없음
enum@Enumerated(EnumType.STRING). ORDINAL 금지
금액전부 BigDecimal. double 은 정산을 어긋나게 합니다
로깅org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace (Hibernate 6 기준 이름)
검증 기준customers 30, orders 600, VIP 4명, phone NULL 3명, PENDING 60건, 후기 없는 상품 24개
별칭Hibernate 6 은 c1_0 형태로, as 없이 붙입니다

다음 단계

프로젝트가 준비됐습니다. 이제 JPAQueryFactory 하나로 쿼리를 짜기 시작합니다. Step 01 에서는 SQL 로그를 더 정교하게 읽는 법과, 같은 결과를 내는 여러 QueryDSL 표현이 서로 다른 SQL 을 만드는 첫 사례를 봅니다.

Step 01 — 환경 구축과 첫 쿼리