Step 13 — 고급 표현식

학습 목표

  • CaseBuilder 로 단순/복합 CASE WHEN 을 만들고 생성 SQL 을 확인한다
  • caseorderBy 에 넣어 커스텀 정렬 순서를 만들고, 그 대가로 인덱스를 잃는 것을 확인한다
  • coalesce / nullif 로 NULL 과 0 을 SQL 단계에서 처리한다
  • Expressions 팩토리 메서드를 용도별로 구분해 쓴다
  • stringTemplate 에 사용자 입력을 이어 붙이면 SQL 인젝션이 그대로 열린다는 것을 실제 공격 문자열로 재현한다
  • 템플릿 문자열은 컴파일 시점 상수만 쓰고 변하는 값은 {n} 바인딩으로 넘기는 규칙을 몸에 익힌다

선행 스텝: Step 12 — Spring Data JPA 통합 예상 소요: 100분


지금까지 12개 스텝에서 다룬 것은 대부분 JPQL 이 문법으로 지원하는 것들이었습니다. where, join, group by, order by, limit. QueryDSL 은 그것을 타입 안전하게 감쌌을 뿐입니다.

이 스텝은 그 경계 밖으로 나갑니다. CASE WHEN, 문자열 함수, DB 고유 함수, 그리고 직접 쓴 템플릿 문자열. 표현력이 커지는 만큼 안전장치는 줄어듭니다.

이 스텝의 함정은 지금까지와 성격이 다릅니다. Step 04 의 괄호 소실이나 Step 05 의 조용한 null 은 틀린 결과를 냅니다. 13-11 의 함정은 공격자가 원하는 결과를 냅니다.

📌 MySQL8 코스 Step 12 — 내장 함수 에서 SQL 로 썼던 CASE, CONCAT, COALESCE, DATE_FORMAT 을 이 스텝에서 QueryDSL 로 다시 씁니다. 같은 데이터, 같은 결과, 다른 표기입니다.

이 스텝의 모든 예제는 Q타입 기본 인스턴스를 static import 로 씁니다.

import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QProduct.product;

13-1. CaseBuilder — 단순 case 와 복합 case

단순 case — 한 컬럼의 값을 매핑

등급 enum 을 한국어 라벨로 바꿉니다. 대상 경로에서 바로 .when(...).then(...) 을 체인하는 형태입니다.

List<Tuple> result = queryFactory
        .select(customer.name,
                customer.grade
                        .when(Grade.VIP).then("최우수")
                        .when(Grade.GOLD).then("우수")
                        .otherwise("일반"))
        .from(customer)
        .orderBy(customer.id.asc())
        .limit(5)
        .fetch();

결과hibernate.SQL 로그

select
    c1_0.name,
    case
        when c1_0.grade = ? then ?
        when c1_0.grade = ? then ?
        else ?
    end
from
    customers c1_0
order by
    c1_0.customer_id
limit ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [VIP]
TRACE o.h.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [최우수]
TRACE o.h.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [GOLD]
TRACE o.h.orm.jdbc.bind : binding parameter (4:VARCHAR) <- [우수]
TRACE o.h.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [일반]
TRACE o.h.orm.jdbc.bind : binding parameter (6:INTEGER) <- [5]

김서준   | 최우수
류하나   | 최우수
안지수   | 우수
한지호   | 일반
배채영   | 최우수

여기서 확인할 것이 두 가지입니다.

첫째, when 의 비교 대상과 then 의 결과값이 모두 ? 바인딩으로 나갑니다. case when c1_0.grade = 'VIP' then '최우수' 처럼 리터럴이 SQL 에 박히지 않습니다. 이것이 13-11 에서 깨지는 안전장치의 정체입니다. 기억해 두십시오.

둘째, case ... endselect 절 안에 그대로 들어갑니다. JPQL 을 거쳐 SQL 로 번역되는 것이지, QueryDSL 이 자바에서 후처리하는 것이 아닙니다.

복합 case — 조건식으로 분기

new CaseBuilder() 로 시작하면 아무 조건식이나 when 에 넣을 수 있습니다. 단순 case 처럼 한 컬럼의 등치 비교에 묶이지 않습니다.

StringExpression pointTier = new CaseBuilder()
        .when(customer.points.goe(10000)).then("골드")
        .when(customer.points.goe(5000)).then("실버")
        .when(customer.points.goe(1000)).then("브론즈")
        .otherwise("신규");

List<Tuple> result = queryFactory
        .select(customer.name, customer.points, pointTier)
        .from(customer)
        .orderBy(customer.points.desc())
        .limit(6)
        .fetch();

결과

select
    c1_0.name,
    c1_0.points,
    case
        when c1_0.points >= ? then ?
        when c1_0.points >= ? then ?
        when c1_0.points >= ? then ?
        else ?
    end
from
    customers c1_0
order by
    c1_0.points desc
limit ?
류하나   | 14200 | 골드
김서준   | 12800 | 골드
배채영   | 11500 | 골드
정  훈   |  9300 | 실버
오하윤   |  6100 | 실버
문시우   |  4800 | 브론즈

when 절의 순서가 곧 평가 순서입니다. goe(1000) 을 맨 위로 올리면 14,200 포인트 고객도 "브론즈"가 됩니다. SQL 의 CASE 와 완전히 동일한 의미론이고, 에러는 나지 않습니다.

💡 실무 팁 — case 는 표현으로 뽑아 재사용하십시오 위처럼 StringExpression pointTier = ... 로 변수에 담으면 selectorderBygroupBy 에 같은 표현을 넣을 수 있습니다. 인라인으로 세 번 쓰면 셋 중 하나만 고치는 사고가 납니다.

타입별 반환 표현

then 에 무엇을 넣느냐에 따라 반환 타입이 달라집니다.

then 인자결과 타입쓸 수 있는 연산
StringStringExpressionconcat, like, lower
Integer / LongNumberExpression<T>sum, avg, add, desc
BigDecimalNumberExpression<BigDecimal>sum, multiply
경로 (예: order.totalAmount)그 경로의 타입경로와 동일
BooleanBooleanExpressionand, or, not

then 들의 타입이 서로 다르면 컴파일 에러입니다. 이건 QueryDSL 이 잡아 줍니다.


13-2. caseorderBy 에 — 커스텀 정렬 순서

등급을 VIP → GOLD → SILVER → BRONZE 순으로 정렬하고 싶습니다. 그런데 grade@Enumerated(EnumType.STRING) 이므로 DB 에는 문자열로 들어 있습니다. 알파벳 순으로 정렬하면 BRONZE, GOLD, SILVER, VIP 가 됩니다. 원하는 순서가 아닙니다.

case 로 정렬용 숫자를 만들어 그것으로 정렬합니다.

NumberExpression<Integer> gradeRank = new CaseBuilder()
        .when(customer.grade.eq(Grade.VIP)).then(4)
        .when(customer.grade.eq(Grade.GOLD)).then(3)
        .when(customer.grade.eq(Grade.SILVER)).then(2)
        .otherwise(1);

List<Tuple> result = queryFactory
        .select(customer.name, customer.grade, gradeRank)
        .from(customer)
        .orderBy(gradeRank.desc(), customer.name.asc())
        .fetch();

결과

select
    c1_0.name,
    c1_0.grade,
    case
        when c1_0.grade = ? then ?
        when c1_0.grade = ? then ?
        when c1_0.grade = ? then ?
        else ?
    end
from
    customers c1_0
order by
    case
        when c1_0.grade = ? then ?
        when c1_0.grade = ? then ?
        when c1_0.grade = ? then ?
        else ?
    end desc,
    c1_0.name
김서준   | VIP    | 4
류하나   | VIP    | 4
배채영   | VIP    | 4
정  훈   | VIP    | 4
안지수   | GOLD   | 3
...
조회 30건 (VIP 4 / GOLD 9 / SILVER 8 / BRONZE 9)

의도한 순서가 나왔습니다. 그런데 생성 SQL 을 다시 보십시오.

case 식이 select 에 한 번, order by 에 한 번, 두 번 통째로 들어갔습니다. QueryDSL 은 같은 자바 객체를 두 자리에 쓴다고 해서 별칭으로 묶어 주지 않습니다. 바인딩 파라미터도 8개가 나갑니다 (4 + 4).

⚠️ 함정 — orderBycase 는 인덱스를 쓸 수 없습니다

Step 09 의 9-7 절 에서 정렬 컬럼에 lower(), substring() 을 씌우면 인덱스를 못 탄다고 했습니다. case 도 정확히 같은 문제입니다.

인덱스는 컬럼 값 자체로 정렬돼 있습니다. case when grade = 'VIP' then 4 ... end 는 컬럼 값이 아니라 계산 결과입니다. 옵티마이저는 이 계산 결과의 순서를 인덱스에서 읽어낼 방법이 없으므로, 전 행을 읽어 계산하고 Using filesort 로 정렬합니다.

EXPLAIN SELECT name, grade FROM customers
ORDER BY CASE WHEN grade='VIP' THEN 4 WHEN grade='GOLD' THEN 3
              WHEN grade='SILVER' THEN 2 ELSE 1 END DESC, name;
+------+---------------+------+------+----------------+
| type | possible_keys | key  | rows | Extra          |
+------+---------------+------+------+----------------+
| ALL  | NULL          | NULL |   30 | Using filesort |
+------+---------------+------+------+----------------+

customers 는 30행이므로 지금은 아무 문제가 없습니다. 30만 행이 되면 문제가 됩니다. 그때는 아래 두 대안 중 하나를 씁니다.

  1. 정렬용 숫자 컬럼을 테이블에 둡니다. grade_rank TINYINT 를 만들고 인덱스를 겁니다. 등급 체계가 바뀌는 일은 거의 없으므로 비정규화 비용이 낮습니다.
  2. enum 순서를 DB ENUM 순서와 맞추고 그 순서로 정렬합니다. MySQL 의 ENUM 은 내부적으로 정수이므로 ORDER BY grade 가 정의 순서대로 정렬됩니다. 다만 JPA @Enumerated(EnumType.STRING) 로는 이 성질을 쓸 수 없습니다. (ORDINAL 로 바꾸는 것은 이 코스에서 금지입니다. 순서가 바뀌면 데이터가 전부 어긋납니다.)

💡 소량 데이터에서 정렬 순서를 맞추는 것이 목적이라면 case 정렬로 충분합니다. 문제가 되는 건 행 수이지 case 그 자체가 아닙니다. 재는 습관이 판단을 대신합니다.


13-3. 조건부 집계 — case 로 만드는 피벗

Step 08 의 8-11 절 에서 "상태별 매출을 한 행에 펼치고 싶다"고 했다가 groupBy 로는 세로로만 나온다는 데서 멈췄습니다. 그 완성판이 이것입니다.

sum() 안에 case 를 넣으면 조건에 맞는 행만 더합니다.

Tuple result = queryFactory
        .select(
                new CaseBuilder()
                        .when(order.status.eq(OrderStatus.PAID)).then(order.totalAmount)
                        .otherwise(BigDecimal.ZERO).sum(),
                new CaseBuilder()
                        .when(order.status.eq(OrderStatus.DELIVERED)).then(order.totalAmount)
                        .otherwise(BigDecimal.ZERO).sum(),
                new CaseBuilder()
                        .when(order.status.eq(OrderStatus.CANCELLED)).then(order.totalAmount)
                        .otherwise(BigDecimal.ZERO).sum(),
                order.count())
        .from(order)
        .fetchOne();

결과

select
    sum(case when o1_0.status = ? then o1_0.total_amount else ? end),
    sum(case when o1_0.status = ? then o1_0.total_amount else ? end),
    sum(case when o1_0.status = ? then o1_0.total_amount else ? end),
    count(o1_0.order_id)
from
    orders o1_0
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [PAID]
TRACE o.h.orm.jdbc.bind : binding parameter (2:DECIMAL) <- [0]
TRACE o.h.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [DELIVERED]
...

PAID: 41,382,000  DELIVERED: 78,914,500  CANCELLED: 9,220,000  총 600건

테이블을 한 번만 읽고 세 개의 합계를 만듭니다. 상태별로 쿼리를 세 번 날리는 것과 결과는 같지만 I/O 는 1/3 입니다.

도시별로 쪼개면 진짜 피벗이 됩니다.

List<Tuple> byCity = queryFactory
        .select(order.shippingCity,
                new CaseBuilder().when(order.status.eq(OrderStatus.PAID))
                        .then(order.totalAmount).otherwise(BigDecimal.ZERO).sum(),
                new CaseBuilder().when(order.status.eq(OrderStatus.CANCELLED))
                        .then(1).otherwise(0).sum())
        .from(order)
        .groupBy(order.shippingCity)
        .orderBy(order.shippingCity.asc())
        .fetch();

결과

select
    o1_0.shipping_city,
    sum(case when o1_0.status = ? then o1_0.total_amount else ? end),
    sum(case when o1_0.status = ? then ? else ? end)
from
    orders o1_0
group by
    o1_0.shipping_city
order by
    o1_0.shipping_city
광주 | 5,102,000  | 8
대구 | 6,730,500  | 11
대전 | 4,988,000  | 7
부산 | 8,455,000  | 14
서울 | 12,904,500 | 21
인천 | 3,202,000  | 5

then(1).otherwise(0).sum()조건부 카운트입니다. count(case when ... then 1 end) 로도 되지만 sum 쪽이 otherwise 를 명시해 읽기 쉽습니다.

💡 실무 팁 — 조건부 카운트에서 otherwise(0)otherwise(null) 의 차이 count() 는 NULL 을 세지 않으므로 count(case when ... then 1 else null end) 도 같은 결과입니다. 하지만 sum(case ... else 0 end)행이 하나도 없을 때 NULL 을 반환하고, count(...)0 을 반환합니다. 이 차이가 13-7 의 coalesce 로 이어집니다.


13-4. 상수 — Expressions.constant

결과에 고정값을 끼워 넣고 싶을 때가 있습니다. 구분자, 버전 태그, 소스 표시 같은 것들입니다.

List<Tuple> result = queryFactory
        .select(customer.name, Expressions.constant("A"))
        .from(customer)
        .limit(3)
        .fetch();

결과

select
    c1_0.name
from
    customers c1_0
limit ?
김서준 | A
류하나 | A
안지수 | A

SQL 에 'A' 가 없습니다.

값은 정상적으로 붙어 나오는데 SQL 에는 흔적이 없습니다. QueryDSL 이 "이건 행마다 달라지지 않는 값이니 DB 에 보낼 이유가 없다"고 판단해 JPQL 에서 빼고 결과 조립 단계에서 붙입니다.

이 최적화는 상황에 따라 적용될 수도 있고 아닐 수도 있습니다. Expressions.constantwhere 조건이나 다른 표현식의 피연산자로 쓰면 DB 로 내려가야 하므로 SQL 에 파라미터로 나타납니다.

// concat 의 인자로 쓰이면 SQL 에 나갑니다
queryFactory
        .select(customer.name.concat(Expressions.constant("-님")))
        .from(customer)
        .limit(3)
        .fetch();

결과

select
    concat(c1_0.name, ?)
from
    customers c1_0
limit ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [-님]

김서준-님
류하나-님
안지수-님

⚠️ 함정 — "SQL 에 안 보이니 안 나간 것"이 아닙니다 반대 방향의 오해도 흔합니다. Expressions.constant 가 SQL 에서 사라지는 것을 보고 "QueryDSL 은 상수를 DB 로 안 보내는구나" 라고 일반화하면 안 됩니다. 어디에 쓰였느냐에 따라 달라집니다. 그리고 이 동작은 최적화이므로 버전에 따라 달라질 수 있습니다.

값이 반드시 DB 단계에서 평가돼야 한다면 (예: DB 함수의 인자) 상수인지 아닌지 추측하지 말고 생성 SQL 을 직접 확인하십시오. 이 코스가 매 절마다 SQL 을 붙이는 이유입니다.


13-5. 문자열 연산 — concatstringValue()

concat

List<String> result = queryFactory
        .select(customer.city.concat("/").concat(customer.name))
        .from(customer)
        .limit(4)
        .fetch();

결과

select
    concat(concat(c1_0.city, ?), c1_0.name)
from
    customers c1_0
limit ?
서울/김서준
부산/류하나
서울/안지수
대구/한지호

concat 을 두 번 체인하면 중첩된 concat 두 개가 됩니다. MySQL 의 CONCAT 은 가변 인자를 받지만 JPQL 의 CONCAT 은 2항이므로 이렇게 번역됩니다. 결과는 같습니다.

⚠️ MySQL 의 CONCAT 은 인자 중 하나라도 NULL 이면 전체가 NULL 입니다. customer.phone.concat("!") 는 전화번호가 NULL 인 3명에 대해 NULL 을 반환합니다. 빈 문자열이 아닙니다. 13-7 의 coalesce 와 조합하십시오.

stringValue() — enum 과 숫자를 문자열로

이름과 등급을 이어 붙이려 하면 컴파일 에러가 납니다.

// 컴파일 에러
customer.name.concat("_").concat(customer.grade);
//                              ^^^^^^^^^^^^^^^
// StringExpression.concat(Expression<String>) 인데 EnumPath<Grade> 를 넘김

concatExpression<String> 만 받습니다. EnumPath<Grade>String 이 아닙니다. 타입 시스템이 정확하게 막은 것입니다. 우회하지 말고 명시적으로 변환합니다.

List<String> result = queryFactory
        .select(customer.name.concat("_").concat(customer.grade.stringValue()))
        .from(customer)
        .limit(4)
        .fetch();

결과

select
    concat(concat(c1_0.name, ?), cast(c1_0.grade as char))
from
    customers c1_0
limit ?
김서준_VIP
류하나_VIP
안지수_GOLD
한지호_BRONZE

stringValue() 는 SQL 의 cast(... as char) 로 번역됩니다. 숫자에도 똑같이 씁니다.

queryFactory
        .select(customer.name.concat("(").concat(customer.points.stringValue()).concat("P)"))
        .from(customer)
        .limit(3)
        .fetch();

결과

select
    concat(concat(concat(c1_0.name, ?), cast(c1_0.points as char)), ?)
from
    customers c1_0
limit ?
김서준(12800P)
류하나(14200P)
안지수(3400P)

💡 실무 팁 — 문자열 조립은 애플리케이션에서 하는 편이 낫습니다 castconcat 이 중첩될수록 SQL 은 읽기 어려워지고, 그 결과 컬럼에는 인덱스도 못 탑니다. DTO 로 namepoints 를 따로 받아 자바에서 조립하는 편이 SQL 도 단순하고 포맷 변경도 쉽습니다. DB 에서 조립해야 하는 경우는 그 결과로 정렬하거나 필터링할 때뿐입니다.

그 밖의 문자열 메서드

QueryDSL생성 SQL비고
.lower() / .upper()lower(...) / upper(...)인덱스 무력화
.trim()trim(...)
.length()length(...)NumberExpression<Integer>
.substring(1, 3)substring(..., 2, 2)0-based → 1-based 변환됨
.startsWith("서")like ? escape '!' ('서%')인덱스 사용 가능
.contains("노트")like ? escape '!' ('%노트%')인덱스 사용 불가
.like("%북%")like ?이스케이프 없음
.indexOf("_")locate(?, ...) - 1

substring 의 인덱스 기준이 자바(0-based)이고 SQL(1-based)로 자동 변환된다는 점을 기억하십시오. substring(0, 2) 는 SQL 에서 substring(x, 1, 2) 입니다.


13-6. 숫자 연산 — add / subtract / multiply / divide

List<Tuple> result = queryFactory
        .select(product.name,
                product.price,
                product.cost,
                product.price.subtract(product.cost),
                product.price.subtract(product.cost)
                        .multiply(100)
                        .divide(product.price))
        .from(product)
        .where(product.status.eq(ProductStatus.ON_SALE))
        .orderBy(product.price.desc())
        .limit(4)
        .fetch();

결과

select
    p1_0.name,
    p1_0.price,
    p1_0.cost,
    p1_0.price - p1_0.cost,
    (p1_0.price - p1_0.cost) * ? / p1_0.price
from
    products p1_0
where
    p1_0.status = ?
order by
    p1_0.price desc
limit ?
게이밍 노트북 RTX4060 | 2190000.00 | 1533000.00 | 657000.00 | 30.000000
보급형 노트북 15      |  690000.00 |  483000.00 | 207000.00 | 30.000000
27인치 4K 모니터      |  459000.00 |  321300.00 | 137700.00 | 30.000000
원목 4인 식탁         |  459000.00 |  344250.00 | 114750.00 | 25.000000

연산자 우선순위가 SQL 에 그대로 반영됩니다. .subtract(...).multiply(100).divide(...)체인 순서대로 왼쪽부터 묶입니다. 괄호가 필요한 곳에는 QueryDSL 이 알아서 넣습니다 — Step 04 의 or 와 달리 산술 연산은 안전합니다.

⚠️ 함정 — BigDecimaldivide 와 스케일

위 결과의 30.000000 을 보십시오. 소수점 여섯 자리입니다. 이 스케일은 MySQL 이 정한 것입니다. MySQL 은 DECIMAL 나눗셈의 결과 스케일을 div_precision_increment 시스템 변수(기본 4)에 따라 결정합니다. QueryDSL 도 JPA 도 여기에 관여하지 않습니다.

문제는 이것을 자바에서 BigDecimal.divide 한 결과와 비교할 때 드러납니다.

// DB 에서 계산: 30.000000
// 자바에서 계산:
BigDecimal margin = price.subtract(cost)
        .multiply(BigDecimal.valueOf(100))
        .divide(price);            // ArithmeticException: Non-terminating decimal expansion

자바의 BigDecimal.divide스케일을 지정하지 않으면 나누어떨어지지 않을 때 예외를 던집니다. DB 는 조용히 반올림하고, 자바는 예외를 던집니다. 같은 계산이 아닙니다.

처방

  • 자바에서 나눌 때는 항상 divide(divisor, 2, RoundingMode.HALF_UP) 처럼 스케일과 반올림을 명시합니다.
  • DB 에서 계산한 비율을 자바 값과 equals 로 비교하지 마십시오. BigDecimal.equals스케일까지 비교합니다. 30.00000030.00equalsfalse 입니다. 비교는 compareTo(...) == 0 으로 하십시오.
  • 금액 정산처럼 정확성이 요구되는 계산은 한쪽에서만 하십시오. DB 와 자바에 나눠 두면 반올림 지점이 두 곳이 되어 1원씩 어긋나기 시작합니다.

divide 로 0 을 나누면 MySQL 은 기본 설정에서 NULL 을 반환합니다 (ERROR_FOR_DIVISION_BY_ZEROsql_mode 에 있으면 경고 또는 에러). 이 처리를 명시적으로 하는 것이 13-8 의 nullif 입니다.


13-7. coalesce — NULL 을 기본값으로

customers 에는 phone 이 NULL 인 고객이 3명 있습니다. Step 04 에서 isNull() 로 찾아냈던 그 3명입니다.

List<Tuple> result = queryFactory
        .select(customer.name, customer.phone, customer.phone.coalesce("번호없음"))
        .from(customer)
        .where(customer.phone.isNull())
        .fetch();

결과

select
    c1_0.name,
    c1_0.phone,
    coalesce(c1_0.phone, ?)
from
    customers c1_0
where
    c1_0.phone is null
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [번호없음]

정  훈   | null | 번호없음
오하윤   | null | 번호없음
문시우   | null | 번호없음
조회 3건

coalesce(...) 뒤에 .asString() 을 붙이면 StringExpression 으로 되돌아와 concat 같은 문자열 연산을 이어 갈 수 있습니다. 붙이지 않으면 CoalesceBuilder 계열 타입입니다.

queryFactory
        .select(customer.name.concat(" / ")
                .concat(customer.phone.coalesce("번호없음").asString()))
        .from(customer)
        .where(customer.phone.isNull())
        .fetch();

결과

select
    concat(concat(c1_0.name, ?), coalesce(c1_0.phone, ?))
from
    customers c1_0
where
    c1_0.phone is null
정  훈 / 번호없음
오하윤 / 번호없음
문시우 / 번호없음

sum() 의 NULL — Step 08 의 미완성 처방

Step 08 의 8-7 절 에서 이 문제를 예고했습니다. 조건에 맞는 행이 하나도 없으면 sum() 은 0 이 아니라 NULL 을 반환합니다.

BigDecimal total = queryFactory
        .select(order.totalAmount.sum())
        .from(order)
        .where(order.shippingCity.eq("제주"))     // 제주 주문은 0건
        .fetchOne();

System.out.println(total);

결과

select
    sum(o1_0.total_amount)
from
    orders o1_0
where
    o1_0.shipping_city = ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [제주]

null

fetchOne()행 하나를 반환했고, 그 행의 값이 NULL 입니다. "결과가 없다"가 아닙니다. total.compareTo(BigDecimal.ZERO) 를 부르면 NullPointerException 입니다.

SQL 단계에서 막습니다.

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

결과

select
    coalesce(sum(o1_0.total_amount), ?)
from
    orders o1_0
where
    o1_0.shipping_city = ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:DECIMAL) <- [0]
TRACE o.h.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [제주]

0

coalescesum 을 감싼다는 데 주의하십시오. order.totalAmount.coalesce(BigDecimal.ZERO).sum() 은 의미가 완전히 다릅니다. 그건 "각 행의 금액이 NULL 이면 0 으로 보고 더하라"이고, total_amountNOT NULL 컬럼이므로 아무 효과도 없습니다.

코드SQL의미
.sum().coalesce(ZERO)coalesce(sum(x), 0)합계가 NULL 이면 0 ← 원하는 것
.coalesce(ZERO).sum()sum(coalesce(x, 0))각 행의 NULL 을 0 으로 보고 합산

💡 실무 팁 — 집계 결과를 반환하는 리포지토리는 coalesce 를 기본값으로 "합계 조회" 메서드가 NULL 을 반환할 수 있다는 사실은 호출부에서 잊히기 쉽습니다. 리포지토리 안에서 coalesce 로 닫아 두면 호출부가 null 검사를 하지 않아도 됩니다. avg(), max(), min() 도 모두 같습니다.

coalesce 는 인자를 여러 개 받을 수 있습니다.

Expressions.coalesce(customer.phone, customer.email, Expressions.constant("연락처없음"))
coalesce(c1_0.phone, c1_0.email, ?)

13-8. nullif — 0 을 NULL 로 바꿔 나눗셈을 막기

nullif(a, b)ab 가 같으면 NULL, 다르면 a 를 반환합니다. 가장 흔한 용도는 0 나누기 방지입니다.

재고 대비 후기 수 비율을 계산한다고 합시다. 재고가 0인 상품(SOLD_OUT)이 있습니다.

// 위험한 코드
queryFactory
        .select(product.name,
                product.stock,
                product.price.divide(product.stock))
        .from(product)
        .fetch();

MySQL 기본 sql_mode (ERROR_FOR_DIVISION_BY_ZERO 포함)에서는 이렇게 됩니다.

결과

select
    p1_0.name,
    p1_0.stock,
    p1_0.price / p1_0.stock
from
    products p1_0
게이밍 노트북 RTX4060 | 12 | 182500.000000
콜드브루 원액 1L      |  0 | null              ← 0 나누기. 경고 + NULL
인체공학 사무용 의자  | 25 |  13160.000000

에러가 아니라 NULL 이 나왔습니다. 조용합니다. 계산 결과를 자바에서 BigDecimal 로 받아 쓰면 그 자리에서 NPE 가 납니다.

의도를 명시적으로 표현합니다.

NumberExpression<BigDecimal> pricePerStock = product.price
        .divide(Expressions.nullif(product.stock, 0))
        .coalesce(BigDecimal.ZERO);

List<Tuple> result = queryFactory
        .select(product.name, product.stock, pricePerStock)
        .from(product)
        .orderBy(product.id.asc())
        .limit(4)
        .fetch();

결과

select
    p1_0.name,
    p1_0.stock,
    coalesce(p1_0.price / nullif(p1_0.stock, ?), ?)
from
    products p1_0
order by
    p1_0.product_id
limit ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:INTEGER) <- [0]
TRACE o.h.orm.jdbc.bind : binding parameter (2:DECIMAL) <- [0]

게이밍 노트북 RTX4060 | 12 | 182500.000000
콜드브루 원액 1L      |  0 |      0.000000     ← NULL 대신 0
인체공학 사무용 의자  | 25 |  13160.000000
27인치 4K 모니터      | 18 |  25500.000000

nullif + coalesce 조합은 "0으로 나누면 0으로 친다" 는 비즈니스 규칙을 SQL 에 명시한 것입니다. NULL 이 우연히 흘러가는 것과, 규칙에 따라 0 이 되는 것은 다릅니다. 코드를 읽는 사람이 그 차이를 볼 수 있어야 합니다.

경로에서 직접 부르는 형태도 있습니다.

product.stock.nullif(0)          // nullif(p1_0.stock, ?)
customer.city.nullif("미상")      // nullif(c1_0.city, ?)

13-9. Expressions 팩토리 총정리

ExpressionsQ타입 경로로 표현할 수 없는 것을 만들 때 쓰는 정적 팩토리입니다. Q타입에 없는 것을 억지로 만들어 내는 도구이므로, 쓸수록 타입 안전성은 떨어집니다.

메서드반환용도안전도
Expressions.constant(v)Expression<T>고정값 (13-4)안전
Expressions.asNumber(10)NumberExpression<Integer>숫자 리터럴을 표현식으로 승격안전
Expressions.asString("x")StringExpression문자열 리터럴 승격안전
Expressions.asBoolean(true)BooleanExpression항상 참/거짓 조건안전
Expressions.nullif(a, b)인자 타입NULL 치환 (13-8)안전
Expressions.allOf(a, b, c)BooleanExpressionAND 결합, null 인자 무시안전
Expressions.anyOf(a, b, c)BooleanExpressionOR 결합, null 인자 무시안전
Expressions.numberPath(...)NumberPath<T>별칭 등 동적 경로주의
Expressions.stringTemplate(...)StringExpression임의 SQL/JPQL 조각위험 (13-11)
Expressions.numberTemplate(...)NumberExpression<T>위와 동일, 숫자 반환위험
Expressions.booleanTemplate(...)BooleanExpression위와 동일, 조건 반환위험
Expressions.dateTemplate(...)DateExpression<T>위와 동일, 날짜 반환위험

allOf / anyOf — null 을 무시하는 결합

Step 04 의 동적 조건 조립에서 유용합니다.

BooleanExpression cond = Expressions.allOf(
        cityEq(req.city()),          // null 일 수 있음
        gradeEq(req.grade()),        // null 일 수 있음
        pointsGoe(req.minPoints()));  // null 일 수 있음

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(cond)
        .fetch();

세 조건이 모두 null 이면 cond 자체가 null 이 되고, where(null) 은 조건 없음이 됩니다. where(a, b, c) 의 가변 인자 형태와 동일한 동작이지만, 조건 묶음을 변수에 담아 재사용하거나 or 로 다시 묶어야 할 때allOf 가 필요합니다.

// (도시 조건 AND 등급 조건) OR (VIP)
BooleanExpression cond = Expressions.anyOf(
        Expressions.allOf(cityEq(city), gradeEq(grade)),
        customer.grade.eq(Grade.VIP));

결과

where
    (c1_0.city = ? and c1_0.grade = ?)
    or c1_0.grade = ?

Step 04 에서 .and().or() 체인이 괄호를 잃었던 문제가 여기서는 발생하지 않습니다. 결합 구조를 함수 호출의 중첩으로 표현했기 때문입니다. 괄호를 잃을 수 없는 형태로 쓰는 것이 괄호를 잘 넣는 것보다 안전합니다.

asNumber — 숫자 리터럴이 왼쪽에 와야 할 때

// 100 - stock 을 표현하고 싶은데, 100 에는 subtract 가 없습니다
Expressions.asNumber(100).subtract(product.stock)
? - p1_0.stock

13-10. DB 함수 호출

JPQL 이 표준으로 지원하지 않는 DB 고유 함수를 부르는 방법입니다.

function(...) 문법

JPA 2.1 표준입니다. function('함수명', 인자...) 형태로 씁니다.

StringExpression yearMonth = Expressions.stringTemplate(
        "function('date_format', {0}, {1})", order.orderDate, "%Y-%m");

List<Tuple> result = queryFactory
        .select(yearMonth, order.count(), order.totalAmount.sum())
        .from(order)
        .groupBy(yearMonth)
        .orderBy(yearMonth.asc())
        .limit(4)
        .fetch();

결과

select
    date_format(o1_0.order_date, ?),
    count(o1_0.order_id),
    sum(o1_0.total_amount)
from
    orders o1_0
group by
    date_format(o1_0.order_date, ?)
order by
    date_format(o1_0.order_date, ?)
limit ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [%Y-%m]
TRACE o.h.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [%Y-%m]
TRACE o.h.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [%Y-%m]

2024-01 | 22 | 4,102,000
2024-02 | 19 | 3,554,500
2024-03 | 25 | 5,880,000
2024-04 | 21 | 4,331,000

{0}, {1} 자리에 넘긴 인자가 ? 바인딩 파라미터로 나갔습니다. 이것이 안전한 형태입니다. 13-11 에서 이 문장으로 다시 돌아옵니다.

Hibernate 6 에서는 function(...) 없이 되는 경우가 많습니다

Hibernate 6 는 HQL 이 인식하는 함수 목록을 크게 늘렸습니다. Dialect 에 등록된 함수라면 function('...') 래퍼 없이 이름으로 직접 쓸 수 있는 경우가 많습니다.

Expressions.stringTemplate("date_format({0}, {1})", order.orderDate, "%Y-%m")

다만 어떤 함수가 등록돼 있는지는 전적으로 Dialect 에 달려 있습니다. MySQLDialect 가 등록한 함수와 PostgreSQLDialect 가 등록한 함수가 다르고, 같은 MySQL 이어도 Hibernate 버전에 따라 목록이 달라집니다.

따라서 이 코스는 다음 원칙을 씁니다.

  • function('name', ...) 형태를 기본으로 씁니다. 등록 여부와 무관하게 통과하는 표준 문법입니다.
  • 이름 직접 호출은 그 프로젝트의 Dialect 와 Hibernate 버전에서 실제로 동작하는지 확인한 뒤 씁니다. 안 되면 SemanticException: Could not interpret path expression 같은 형태로 기동 시점이 아니라 쿼리 실행 시점에 터집니다.

커스텀 함수 등록 — Hibernate 5 와 6 이 다릅니다

Dialect 에 없는 함수(예: 회사 DB 에 만든 UDF)를 쓰려면 등록이 필요합니다. 등록 방법이 Hibernate 5 와 6 에서 바뀌었습니다.

Hibernate 5Hibernate 6
방식 ADialect 상속 후 생성자에서 registerFunction(...)Dialect 상속 후 함수 기여 지점 오버라이드
방식 BMetadataBuilder.applySqlFunction(...)FunctionContributor 구현 + META-INF/services 등록

Hibernate 5 에서 널리 쓰이던 metadataBuilder.applySqlFunction("group_concat", ...) 패턴을 Hibernate 6 에 그대로 옮기면 동작하지 않습니다. Hibernate 6 은 FunctionContributor 라는 SPI 를 통해 함수를 기여받는 구조로 바뀌었습니다.

⚠️ 정확한 시그니처는 Hibernate 6 문서를 확인하십시오 FunctionContributor 의 메서드 시그니처, FunctionContributions 에서 함수를 등록하는 정확한 호출 형태, 그리고 META-INF/services 에 등록할 인터페이스 전체 이름은 Hibernate 6 의 마이너 버전에 따라 세부가 달라질 수 있습니다. 이 코스는 그 세부를 단정하지 않습니다. "Hibernate 5 의 applySqlFunction 방식은 더 이상 쓰이지 않고, Hibernate 6 은 FunctionContributor 로 간다" 는 방향만 기억하고, 구현 시점에 사용 중인 Hibernate 버전의 공식 문서에서 FunctionContributor 항목을 확인하십시오. 잘못된 시그니처를 복사해 넣으면 컴파일은 되는데 함수가 등록되지 않아 쿼리 실행 시점에 알 수 없는 함수 에러가 납니다.

💡 실무 팁 — 커스텀 함수를 등록하기 전에 한 번 더 생각하십시오 DB 고유 함수를 쿼리에 넣는 순간 그 쿼리는 그 DB 에 묶입니다. 테스트를 H2 로 돌리고 있었다면 그 테스트부터 깨집니다. date_format 이 필요한 이유가 "월별 집계"라면, orderDate.year()orderDate.month() 로 그룹핑하는 표준 방법이 있습니다 (13-13). 표준으로 되는 것은 표준으로 하십시오.


13-11. ⚠️ stringTemplate 으로 SQL 인젝션이 열립니다

이 스텝에서 가장 중요한 절입니다.

Step 10 에서 이렇게 썼습니다.

QueryDSL 은 사용자 입력을 JPQL 의 바인딩 파라미터로 넘기므로, 문자열 조립 방식의 SQL 인젝션은 구조적으로 발생하기 어렵습니다.

이 문장은 Expressions.*Template 앞에서 무너집니다. 템플릿 문자열은 QueryDSL 이 해석하지 않고 그대로 JPQL 에 삽입하기 때문입니다.

안전한 형태

String pattern = "%Y-%m";      // 사용자가 정할 수 있는 값이라고 가정

StringExpression expr = Expressions.stringTemplate(
        "function('date_format', {0}, {1})",
        order.orderDate,
        pattern);                       // ← 값을 인자로 넘김

queryFactory.select(expr).from(order).limit(3).fetch();

결과

select
    date_format(o1_0.order_date, ?)
from
    orders o1_0
limit ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [%Y-%m]

? 가 보입니다. pattern 이 무엇이든 그것은 값으로만 취급됩니다. pattern'); DROP TABLE orders; -- 를 넣어도 그냥 그 문자열로 포맷을 시도하고 끝납니다.

위험한 형태

같은 결과를 내는 것처럼 보이는 코드입니다.

String pattern = req.getPattern();     // ← 사용자 입력

StringExpression expr = Expressions.stringTemplate(
        "function('date_format', {0}, '" + pattern + "')",   // ★ 템플릿에 이어 붙임
        order.orderDate);

queryFactory.select(expr).from(order).limit(3).fetch();

pattern = "%Y-%m" 이면 정상 동작합니다. 테스트도 통과합니다. 코드 리뷰도 통과합니다.

select
    date_format(o1_0.order_date, '%Y-%m')
from
    orders o1_0
limit ?

? 가 없습니다. 값이 SQL 에 그대로 박혔습니다. 여기까지는 "리터럴이 박혔네" 정도로 보이고 결과도 맞습니다.

실제 공격

공격자가 이 값을 보냅니다.

pattern = %Y-%m') , (select email from customers where grade='VIP' limit 1

이 문자열이 템플릿에 이어 붙으면 템플릿은 이렇게 됩니다.

function('date_format', {0}, '%Y-%m') , (select email from customers where grade='VIP' limit 1')

QueryDSL 은 이 문자열을 검사하지 않습니다. {0} 만 치환하고 JPQL 로 넘깁니다. Hibernate 는 이것을 문법적으로 유효한 JPQL 로 파싱하고, 다음 SQL 을 만듭니다.

결과

select
    date_format(o1_0.order_date, '%Y-%m'),
    (select c1_0.email from customers c1_0 where c1_0.grade = 'VIP' limit 1)
from
    orders o1_0
limit ?
2024-01 | seojun.kim@example.com
2024-01 | seojun.kim@example.com
2024-02 | seojun.kim@example.com

월별 집계 API 가 고객 이메일을 반환했습니다. select 절이 하나 늘었으므로 Tuple 의 크기가 달라져 애플리케이션이 예외로 죽을 수도 있지만, DTO 를 쓰지 않고 Tuple 을 그대로 JSON 으로 직렬화하는 코드였다면 그대로 응답에 실려 나갑니다.

더 나쁜 형태도 가능합니다.

pattern = %Y-%m') , (select count(*) from customers where email like 'a%

응답의 숫자를 보고 블라인드로 데이터를 한 글자씩 추출할 수 있습니다. a%, b%, ... 를 반복하면 됩니다. 몇 분이면 전체 고객 이메일이 빠져나갑니다.

⚠️ 함정 — "QueryDSL 을 쓰니까 인젝션은 없다"

이 믿음이 이 함정을 위험하게 만듭니다. 보안 리뷰에서 "ORM 을 쓰고 있으니 인젝션 항목은 통과"로 처리되는 경우가 많습니다. 정적 분석 도구도 PreparedStatement 와 문자열 연결을 찾도록 튜닝돼 있어 Expressions.stringTemplate("..." + x + "...") 를 놓치기 쉽습니다.

Expressions.stringTemplate, numberTemplate, booleanTemplate, dateTemplate 의 첫 인자는 QueryDSL 이 검사하지 않고 그대로 JPQL 에 넣는 원시 문자열입니다. PreparedStatement"... where id = " + id 를 쓰는 것과 위험도가 같습니다.

처방

규칙 하나면 됩니다.

템플릿 문자열은 컴파일 시점 상수여야 합니다. 변하는 값은 예외 없이 {n} 자리의 인자로 넘깁니다.

// ✅ 상수 리터럴
Expressions.stringTemplate("function('date_format', {0}, {1})", order.orderDate, pattern)

// ✅ static final 상수
private static final String DATE_FMT = "function('date_format', {0}, {1})";
Expressions.stringTemplate(DATE_FMT, order.orderDate, pattern)

// ❌ 연결 연산자
Expressions.stringTemplate("function('date_format', {0}, '" + pattern + "')", order.orderDate)

// ❌ String.format
Expressions.stringTemplate(String.format("function('date_format', {0}, '%s')", pattern), order.orderDate)

// ❌ 텍스트 블록 + 보간 흉내
Expressions.stringTemplate("""
        function('date_format', {0}, '%s')
        """.formatted(pattern), order.orderDate)

아래 세 개는 전부 같은 사고입니다. 형태만 다릅니다.

리뷰 / 정적 분석 체크리스트

확인 항목방법
템플릿 첫 인자에 + 가 있는가grep -rn 'Template("' --include=*.java+ 검색
템플릿 첫 인자에 format( / formatted( 가 있는가동일 검색
템플릿 첫 인자가 지역 변수/필드인가변수라면 그 변수의 출처를 끝까지 추적
{n} 개수와 넘긴 인자 개수가 맞는가불일치는 대개 이어 붙였다는 신호
템플릿 안에 작은따옴표 ' 가 있는가리터럴을 직접 쓰고 있다는 뜻. 대부분 {n} 으로 바꿀 수 있음
정렬 키 / 컬럼명을 템플릿으로 만드는가화이트리스트 필수 (Step 10)

마지막 항목이 특히 중요합니다. 컬럼명이나 정렬 방향은 바인딩 파라미터가 될 수 없습니다. order by ? 는 SQL 에서 상수 하나로 정렬하라는 뜻이지 그 이름의 컬럼으로 정렬하라는 뜻이 아닙니다. 그래서 동적 컬럼명은 구조적으로 템플릿에 넣을 수밖에 없고, 그렇기 때문에 화이트리스트가 유일한 방어입니다. Step 10 에서 정렬 키를 Map<String, OrderSpecifier<?>> 로 고정한 이유가 이것입니다.

// 화이트리스트 — 입력값이 키로만 쓰이고, 값은 코드에 있는 표현식
private static final Map<String, OrderSpecifier<?>> SORT_KEYS = Map.of(
        "price",   product.price.desc(),
        "created", product.createdAt.desc(),
        "name",    product.name.asc());

OrderSpecifier<?> spec = SORT_KEYS.getOrDefault(req.sort(), product.id.desc());

입력이 무엇이든 SQL 에 들어가는 것은 코드에 이미 존재하는 세 표현식 중 하나입니다.


13-12. booleanTemplate 으로 조건 만들기

where 절에 표준 문법으로 표현할 수 없는 조건을 넣어야 할 때 씁니다.

private static final String REGEXP = "function('regexp_like', {0}, {1})";

List<Product> result = queryFactory
        .selectFrom(product)
        .where(Expressions.booleanTemplate(REGEXP, product.name, "노트북|모니터"))
        .fetch();

결과

select
    p1_0.product_id, p1_0.category_id, p1_0.cost, p1_0.created_at,
    p1_0.name, p1_0.price, p1_0.status, p1_0.stock
from
    products p1_0
where
    regexp_like(p1_0.name, ?)
TRACE o.h.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [노트북|모니터]

게이밍 노트북 RTX4060
보급형 노트북 15
27인치 4K 모니터
조회 3건

동작합니다. 그리고 13-11 과 완전히 같은 위험을 가집니다. where 절에 열리는 인젝션은 select 절에 열리는 것보다 나쁩니다. or 1=1 하나로 전체 행이 노출되기 때문입니다.

// ❌ 절대 하지 마십시오
Expressions.booleanTemplate("function('regexp_like', {0}, '" + userInput + "')", product.name)

userInput = "x') or 1=1 and function('regexp_like', {0}, 'x" 같은 입력이면 where 조건이 통째로 무력화됩니다.

💡 실무 팁 — booleanTemplate 이 정말 필요한지 먼저 확인하십시오 booleanTemplate 을 쓰려는 상황의 대부분은 표준 방법이 있습니다.

하려는 것표준 방법
정규식 매칭like 로 충분한 경우가 많음. 아니면 애플리케이션 필터링
대소문자 무시 비교lower() (단, 인덱스 포기) 또는 콜레이션 설정
날짜 부분 비교orderDate.year(), .month() (13-13)
JSON 필드 조건이 코스 범위 밖. 하려면 화이트리스트된 경로만
항상 참 조건Expressions.asBoolean(true).isTrue()

표준으로 안 되는 것이 확인된 뒤에 템플릿을 쓰고, 쓸 때는 상수 문자열 + {n} 인자를 예외 없이 지키십시오.


13-13. 날짜 표현식

DateTimePath 는 날짜 부분을 뽑는 메서드를 제공합니다.

List<Tuple> result = queryFactory
        .select(order.orderDate.year(),
                order.orderDate.month(),
                order.count(),
                order.totalAmount.sum())
        .from(order)
        .groupBy(order.orderDate.year(), order.orderDate.month())
        .orderBy(order.orderDate.year().asc(), order.orderDate.month().asc())
        .limit(4)
        .fetch();

결과

select
    extract(year from o1_0.order_date),
    extract(month from o1_0.order_date),
    count(o1_0.order_id),
    sum(o1_0.total_amount)
from
    orders o1_0
group by
    extract(year from o1_0.order_date),
    extract(month from o1_0.order_date)
order by
    extract(year from o1_0.order_date),
    extract(month from o1_0.order_date)
limit ?
2024 | 1 | 22 | 4,102,000
2024 | 2 | 19 | 3,554,500
2024 | 3 | 25 | 5,880,000
2024 | 4 | 21 | 4,331,000

13-10 의 date_format 과 결과가 같은데 DB 고유 함수를 쓰지 않았습니다. extract 는 표준 SQL 이므로 다른 DB 로 옮겨도 그대로 동작합니다. 가능하면 이쪽을 쓰십시오.

메서드생성 SQL
.year()extract(year from ...)
.month()extract(month from ...)
.dayOfMonth()extract(day from ...)
.hour() / .minute() / .second()extract(hour from ...)
.dayOfWeek()extract(day_of_week from ...)
.week()extract(week from ...)
.yearMonth()extract(year from ...) * 100 + extract(month from ...)

yearMonth()202401 같은 정수를 만듭니다. 정렬과 그룹핑을 한 컬럼으로 처리할 때 편합니다.

between — 인덱스를 살리는 형태

List<Order> result = queryFactory
        .selectFrom(order)
        .where(order.orderDate.between(
                LocalDateTime.of(2025, 1, 1, 0, 0),
                LocalDateTime.of(2025, 1, 31, 23, 59, 59)))
        .fetch();

결과

select
    o1_0.order_id, o1_0.customer_id, o1_0.order_date,
    o1_0.shipping_city, o1_0.status, o1_0.total_amount
from
    orders o1_0
where
    o1_0.order_date between ? and ?
TRACE o.h.orm.jdbc.bind : binding parameter (1:TIMESTAMP) <- [2025-01-01T00:00]
TRACE o.h.orm.jdbc.bind : binding parameter (2:TIMESTAMP) <- [2025-01-31T23:59:59]

조회 24건

⚠️ 함정 — 같은 조건, 다른 실행 계획

"2025년 1월 주문" 을 표현하는 두 가지 방법입니다. 결과는 같습니다.

// A — 컬럼에 함수
.where(order.orderDate.year().eq(2025).and(order.orderDate.month().eq(1)))

// B — 범위
.where(order.orderDate.between(
        LocalDateTime.of(2025, 1, 1, 0, 0),
        LocalDateTime.of(2025, 2, 1, 0, 0).minusNanos(1)))
-- A
where extract(year from o1_0.order_date) = ? and extract(month from o1_0.order_date) = ?
-- B
where o1_0.order_date between ? and ?

order_date 에 인덱스가 있다면 B 만 그 인덱스를 씁니다. A 는 컬럼을 함수로 감쌌으므로 인덱스의 정렬 순서를 쓸 수 없습니다. 함수 기반 인덱스(MySQL 8.0.13+)를 별도로 만들지 않는 한 풀스캔입니다.

-- A 의 EXPLAIN
| type | key  | rows | Extra       |
| ALL  | NULL |  600 | Using where |

-- B 의 EXPLAIN (idx_orders_date 가 있다고 가정)
| type  | key            | rows | Extra                 |
| range | idx_orders_date|   24 | Using index condition |

집계·그룹핑에는 year()/month() 를, 필터링에는 between 을 쓰십시오. group by 는 어차피 전 행을 훑으므로 함수를 써도 손해가 없지만, where 는 함수 하나로 인덱스 전체를 버리게 됩니다. Step 09 의 정렬 컬럼 함수 문제와 정확히 같은 원리입니다. Step 14 의 14-6 절 에서 이 네 가지 패턴을 다시 정리합니다.

between 의 상한을 2025-01-31 23:59:59 로 잡으면 23:59:59.5 같은 값을 놓칩니다. orders.order_dateDATETIME (소수 초 없음) 이므로 이 예제에서는 문제가 없지만, DATETIME(6) 컬럼이라면 goe(시작) and lt(다음달 1일) 형태가 안전합니다.

.where(order.orderDate.goe(LocalDateTime.of(2025, 1, 1, 0, 0))
        .and(order.orderDate.lt(LocalDateTime.of(2025, 2, 1, 0, 0))))
where o1_0.order_date >= ? and o1_0.order_date < ?

13-14. 정리 — 표현식을 고르는 순서

이 스텝에서 다룬 도구들을 안전한 순서로 나열하면 이렇게 됩니다. 위에서부터 시도하고, 안 될 때만 아래로 내려가십시오.

순위도구타입 안전이식성인젝션 위험
1Q타입 경로 메서드 (.eq, .concat, .year())완전높음없음
2CaseBuilder, coalesce, nullif완전높음없음
3Expressions.constant / asNumber / allOf / anyOf완전높음없음
4Expressions.*Template + 상수 문자열 + {n} 인자부분낮음없음
5Expressions.*Template + 조립된 문자열없음낮음높음

5번은 선택지가 아닙니다. 표에 넣은 것은 그것을 알아보기 위해서입니다.

4번까지 내려왔다면 다음을 함께 남기십시오.

  • 왜 표준 방법으로 안 되는지 주석 한 줄
  • 템플릿 상수를 private static final 로 분리
  • 그 쿼리를 검증하는 테스트 (DB 를 바꿀 때 여기서 깨져야 합니다)

정리

개념핵심
단순 casepath.when(v).then(r).otherwise(d) — 비교값과 결과값 모두 ? 바인딩
복합 casenew CaseBuilder().when(조건식)... — 임의 조건 분기
case 정렬orderBy 에 넣으면 selectorder by두 번 들어가고 인덱스를 못 씀
조건부 집계new CaseBuilder().when(...).then(금액).otherwise(ZERO).sum() — 한 번 읽고 피벗
constant최적화로 JPQL 에서 빠질 수 있음. 쓰인 위치에 따라 다르니 SQL 로 확인
concat2항. 체인하면 중첩 concat. NULL 이 섞이면 전체 NULL
stringValue()cast(x as char). enum/숫자를 문자열 연산에 넣을 때 필수
산술체인 순서대로 좌결합. 괄호는 QueryDSL 이 넣어 줌
BigDecimal.divideDB 는 반올림, 자바는 예외. compareTo 로 비교하고 스케일을 명시
coalesce.sum().coalesce(ZERO) 가 맞고 .coalesce(ZERO).sum() 은 다른 뜻
nullif0 을 NULL 로 → coalesce 로 다시 0. "0으로 나누면 0" 규칙을 SQL 에 명시
allOf/anyOfnull 인자를 무시하며 결합. 괄호를 잃을 수 없는 구조
DB 함수function('name', {0}, {1}) 이 표준. 이름 직접 호출은 Dialect 의존
커스텀 함수Hibernate 5 applySqlFunction → Hibernate 6 FunctionContributor. 시그니처는 문서 확인
템플릿 인젝션템플릿 문자열은 컴파일 시점 상수만. 값은 예외 없이 {n} 으로
인젝션 신호템플릿 첫 인자의 +, format(, 작은따옴표 리터럴
날짜집계는 year()/month(), 필터는 between / goe+lt

연습문제

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

  1. CaseBuilder 로 주문 금액을 10만원 미만 → "소액", 10만~50만 → "중액", 50만 이상 → "고액" 으로 분류하고, 분류별 주문 건수를 세십시오. 생성 SQL 에 case 가 몇 번 나오는지 확인하십시오.
  2. 고객을 등급 순(VIP → GOLD → SILVER → BRONZE)으로 정렬해 이름과 등급을 출력하십시오. 그리고 그 SQL 을 EXPLAIN 에 넣었을 때 Extra 에 무엇이 뜨는지 예측한 뒤 확인하십시오.
  3. 도시별로 PAID 매출 합계와 CANCELLED 건수를 한 행에 뽑는 쿼리를 조건부 집계로 작성하십시오. 단, 매출 합계가 NULL 이 되지 않도록 처리하십시오.
  4. 전화번호가 NULL 인 고객 3명을 포함해 전 고객"이름(전화번호)" 문자열을 만드십시오. NULL 인 경우 "이름(미등록)" 이 나와야 합니다. concat 만 쓰면 왜 NULL 이 나오는지 SQL 로 설명하십시오.
  5. 상품의 마진율((price - cost) / price * 100)을 소수점 둘째 자리까지 계산하되, price 가 0 인 경우에도 예외 없이 0 이 나오도록 작성하십시오.
  6. Expressions.stringTemplate 으로 주문 날짜를 yyyy년 MM월 형태로 포맷하는 코드를 작성하십시오. 포맷 문자열을 메서드 파라미터로 받되 인젝션이 불가능한 형태여야 합니다. 그리고 인젝션이 가능한 잘못된 버전도 함께 작성해 두 SQL 을 비교하십시오.
  7. 2025년 상반기(1~6월) 주문을 조회하는 쿼리를 ① year()+month() 방식과 ② goe+lt 방식 두 가지로 작성하고, 생성 SQL 과 EXPLAIN 결과를 대조하십시오.

다음 단계

표현식은 여기까지입니다. 이제 남은 것은 그 표현식들이 만든 SQL 이 실제로 얼마나 빠른가입니다.

Step 14 에서는 지금까지 14개 스텝에서 만든 모든 쿼리를 성능 관점에서 다시 봅니다. N+1 을 실측으로 진단하고 세 가지 방법으로 해결하며, 생성된 SQL 을 그대로 EXPLAIN 에 넣어 읽습니다. 그리고 이 코스의 모든 것을 쓰는 상품 검색 API 를 처음부터 끝까지 만듭니다.

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


실습 파일

Practice.java 를 먼저 실행해 본문의 SQL 이 그대로 나오는지 확인하십시오. 특히 13-4 의 constant 가 SQL 에서 사라지는 것과, 13-11 의 두 SQL 차이는 직접 콘솔에서 봐야 의미가 전달됩니다.

Practice.java

  • 13-1 ~ 13-13 의 모든 예제를 절 번호 주석과 함께 담았습니다.
  • injectionSafe()injectionVulnerable() 을 나란히 두었습니다. 두 메서드가 만드는 SQL 을 콘솔에서 비교하십시오. injectionVulnerable() 은 학습용입니다. 어떤 형태로도 운영 코드에 복사하지 마십시오.
  • caseInOrderBy() 를 실행한 뒤 그 SQL 을 MySQL 콘솔에 붙여 EXPLAIN 을 걸어 보십시오.
package com.example.shop.step13;

import com.example.shop.entity.Grade;
import com.example.shop.entity.OrderStatus;
import com.example.shop.entity.Product;
import com.example.shop.entity.ProductStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.core.types.dsl.StringExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

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

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

/**
 * Step 13 — 고급 표현식.
 *
 * 본문의 모든 예제를 절 번호 주석과 함께 담았습니다.
 * 각 메서드를 실행하면서 콘솔의 hibernate.SQL 로그를 교재의 SQL 과 한 글자씩 비교하십시오.
 *
 * 실행:
 *   ./gradlew test --tests 'com.example.shop.step13.Practice'
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // ────────────────────────────────────────────────────────────────
    // [13-1] CaseBuilder — 단순 case 와 복합 case
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-1] 단순 case — 등급을 한국어 라벨로")
    void simpleCase() {
        List<Tuple> result = queryFactory
                .select(customer.name,
                        customer.grade
                                .when(Grade.VIP).then("최우수")
                                .when(Grade.GOLD).then("우수")
                                .otherwise("일반"))
                .from(customer)
                .orderBy(customer.id.asc())
                .limit(5)
                .fetch();

        // 확인 포인트: when 의 비교값과 then 의 결과값이 모두 ? 바인딩으로 나갑니다.
        //             case when c1_0.grade = ? then ? ... else ? end
        result.forEach(t -> System.out.println(t.get(0, String.class) + " | " + t.get(1, String.class)));

        assertThat(result).hasSize(5);
    }

    @Test
    @DisplayName("[13-1] 복합 case — 포인트 구간별 등급")
    void complexCase() {
        StringExpression pointTier = new CaseBuilder()
                .when(customer.points.goe(10000)).then("골드")
                .when(customer.points.goe(5000)).then("실버")
                .when(customer.points.goe(1000)).then("브론즈")
                .otherwise("신규");

        List<Tuple> result = queryFactory
                .select(customer.name, customer.points, pointTier)
                .from(customer)
                .orderBy(customer.points.desc())
                .limit(6)
                .fetch();

        // when 절의 순서가 곧 평가 순서입니다.
        // goe(1000) 을 맨 위로 올리면 14200 포인트 고객도 "브론즈"가 됩니다. 에러는 안 납니다.
        result.forEach(t -> System.out.printf("%s | %d | %s%n",
                t.get(customer.name), t.get(customer.points), t.get(pointTier)));

        assertThat(result).hasSize(6);
    }

    // ────────────────────────────────────────────────────────────────
    // [13-2] case 를 orderBy 에 — 커스텀 정렬 순서
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-2] case 정렬 — VIP → GOLD → SILVER → BRONZE")
    void caseInOrderBy() {
        NumberExpression<Integer> gradeRank = new CaseBuilder()
                .when(customer.grade.eq(Grade.VIP)).then(4)
                .when(customer.grade.eq(Grade.GOLD)).then(3)
                .when(customer.grade.eq(Grade.SILVER)).then(2)
                .otherwise(1);

        List<Tuple> result = queryFactory
                .select(customer.name, customer.grade, gradeRank)
                .from(customer)
                .orderBy(gradeRank.desc(), customer.name.asc())
                .fetch();

        // 생성 SQL 을 보십시오. case 식이 select 에 한 번, order by 에 한 번,
        // 통째로 두 번 들어갑니다. 바인딩 파라미터도 8개입니다.
        //
        // 그리고 이 SQL 을 MySQL 콘솔에 그대로 붙여 EXPLAIN 을 걸어 보십시오.
        //   Extra: Using filesort
        // 정렬 대상이 컬럼 값이 아니라 계산 결과이므로 인덱스를 쓸 수 없습니다.
        // customers 는 30행이라 지금은 문제가 없습니다. 30만 행이면 문제가 됩니다.
        result.stream().limit(6).forEach(t -> System.out.printf("%s | %s | %d%n",
                t.get(customer.name), t.get(customer.grade), t.get(gradeRank)));

        assertThat(result).hasSize(30);
    }

    // ────────────────────────────────────────────────────────────────
    // [13-3] 조건부 집계 — case 로 만드는 피벗
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-3] 상태별 매출을 한 행에 — 테이블 한 번만 읽음")
    void conditionalAggregation() {
        Tuple result = queryFactory
                .select(
                        new CaseBuilder()
                                .when(order.status.eq(OrderStatus.PAID)).then(order.totalAmount)
                                .otherwise(BigDecimal.ZERO).sum(),
                        new CaseBuilder()
                                .when(order.status.eq(OrderStatus.DELIVERED)).then(order.totalAmount)
                                .otherwise(BigDecimal.ZERO).sum(),
                        new CaseBuilder()
                                .when(order.status.eq(OrderStatus.CANCELLED)).then(order.totalAmount)
                                .otherwise(BigDecimal.ZERO).sum(),
                        order.count())
                .from(order)
                .fetchOne();

        // 상태별로 쿼리를 세 번 날리는 것과 결과는 같지만 I/O 는 1/3 입니다.
        System.out.println(result);

        assertThat(result).isNotNull();
        assertThat(result.get(3, Long.class)).isEqualTo(600L);
    }

    @Test
    @DisplayName("[13-3] 도시별 피벗 — 조건부 합계 + 조건부 카운트")
    void pivotByCity() {
        List<Tuple> byCity = queryFactory
                .select(order.shippingCity,
                        new CaseBuilder().when(order.status.eq(OrderStatus.PAID))
                                .then(order.totalAmount).otherwise(BigDecimal.ZERO).sum(),
                        new CaseBuilder().when(order.status.eq(OrderStatus.CANCELLED))
                                .then(1).otherwise(0).sum())
                .from(order)
                .groupBy(order.shippingCity)
                .orderBy(order.shippingCity.asc())
                .fetch();

        // then(1).otherwise(0).sum() 은 조건부 카운트입니다.
        // count(case when ... then 1 end) 로도 되지만,
        // 행이 하나도 없을 때 sum 은 NULL 을, count 는 0 을 반환한다는 차이가 있습니다.
        byCity.forEach(System.out::println);

        assertThat(byCity).hasSize(6);   // 서울/부산/대구/인천/광주/대전
    }

    // ────────────────────────────────────────────────────────────────
    // [13-4] 상수 — Expressions.constant
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-4] constant 는 JPQL 에서 빠질 수 있습니다")
    void constantDisappears() {
        List<Tuple> result = queryFactory
                .select(customer.name, Expressions.constant("A"))
                .from(customer)
                .limit(3)
                .fetch();

        // 생성 SQL 에 'A' 도 ? 도 없습니다. select c1_0.name from customers c1_0 limit ?
        // 값은 결과 조립 단계에서 붙습니다.
        // 이것은 최적화이므로 버전과 사용 위치에 따라 달라질 수 있습니다.
        result.forEach(t -> System.out.println(t.get(0, String.class) + " | " + t.get(1, String.class)));

        assertThat(result).hasSize(3);
    }

    @Test
    @DisplayName("[13-4] 다른 표현식의 인자로 쓰이면 SQL 에 나갑니다")
    void constantAsArgument() {
        List<String> result = queryFactory
                .select(customer.name.concat(Expressions.constant("-님")))
                .from(customer)
                .limit(3)
                .fetch();

        // concat(c1_0.name, ?) — 이번에는 ? 로 나갑니다.
        // "SQL 에 안 보이니 안 나간 것" 이라고 일반화하면 안 됩니다.
        result.forEach(System.out::println);

        assertThat(result).allMatch(s -> s.endsWith("-님"));
    }

    // ────────────────────────────────────────────────────────────────
    // [13-5] 문자열 연산 — concat 과 stringValue()
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-5] concat 은 2항 — 체인하면 중첩됩니다")
    void concatChain() {
        List<String> result = queryFactory
                .select(customer.city.concat("/").concat(customer.name))
                .from(customer)
                .limit(4)
                .fetch();

        // concat(concat(c1_0.city, ?), c1_0.name)
        // JPQL 의 CONCAT 은 2항이므로 이렇게 번역됩니다. 결과는 같습니다.
        result.forEach(System.out::println);

        assertThat(result).hasSize(4);
    }

    @Test
    @DisplayName("[13-5] stringValue() — enum 과 숫자를 문자열 연산에 넣기")
    void stringValue() {
        // 아래는 컴파일 에러입니다. concat 은 Expression<String> 만 받습니다.
        //   customer.name.concat("_").concat(customer.grade);
        // 타입 시스템이 정확하게 막은 것이므로 우회하지 말고 명시적으로 변환합니다.

        List<String> withGrade = queryFactory
                .select(customer.name.concat("_").concat(customer.grade.stringValue()))
                .from(customer)
                .limit(4)
                .fetch();

        // concat(concat(c1_0.name, ?), cast(c1_0.grade as char))
        withGrade.forEach(System.out::println);

        List<String> withPoints = queryFactory
                .select(customer.name.concat("(").concat(customer.points.stringValue()).concat("P)"))
                .from(customer)
                .limit(3)
                .fetch();

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

        assertThat(withGrade).hasSize(4);
        assertThat(withPoints).hasSize(3);
    }

    // ────────────────────────────────────────────────────────────────
    // [13-6] 숫자 연산
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-6] 마진과 마진율 — 체인 순서대로 좌결합")
    void arithmetic() {
        List<Tuple> result = queryFactory
                .select(product.name,
                        product.price,
                        product.cost,
                        product.price.subtract(product.cost),
                        product.price.subtract(product.cost)
                                .multiply(100)
                                .divide(product.price))
                .from(product)
                .where(product.status.eq(ProductStatus.ON_SALE))
                .orderBy(product.price.desc())
                .limit(4)
                .fetch();

        // (p1_0.price - p1_0.cost) * ? / p1_0.price
        // 괄호는 QueryDSL 이 알아서 넣습니다. Step 04 의 or 와 달리 산술 연산은 안전합니다.
        //
        // 결과 스케일이 30.000000 (소수 6자리) 인 것에 주목하십시오.
        // 이 스케일은 MySQL 의 div_precision_increment(기본 4)가 정합니다.
        // 자바의 BigDecimal.divide 는 스케일을 안 주면 예외를 던집니다. 같은 계산이 아닙니다.
        result.forEach(System.out::println);

        assertThat(result).hasSize(4);
    }

    @Test
    @DisplayName("[13-6] BigDecimal 비교는 compareTo 로 — equals 는 스케일까지 봅니다")
    void bigDecimalComparison() {
        BigDecimal fromDb = new BigDecimal("30.000000");
        BigDecimal literal = new BigDecimal("30.00");

        assertThat(fromDb.equals(literal)).isFalse();          // 스케일이 다르므로 false
        assertThat(fromDb.compareTo(literal)).isZero();        // 값은 같으므로 0

        // 이 차이가 "테스트는 통과하는데 운영에서 assert 가 깨지는" 사고의 원인입니다.
    }

    // ────────────────────────────────────────────────────────────────
    // [13-7] coalesce
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-7] phone NULL 3명에 기본값 붙이기")
    void coalescePhone() {
        List<Tuple> result = queryFactory
                .select(customer.name, customer.phone, customer.phone.coalesce("번호없음"))
                .from(customer)
                .where(customer.phone.isNull())
                .fetch();

        // coalesce(c1_0.phone, ?)
        result.forEach(System.out::println);

        assertThat(result).hasSize(3);
    }

    @Test
    @DisplayName("[13-7] coalesce 뒤에 asString() 을 붙여야 문자열 연산을 이어갈 수 있습니다")
    void coalesceAsString() {
        List<String> result = queryFactory
                .select(customer.name.concat(" / ")
                        .concat(customer.phone.coalesce("번호없음").asString()))
                .from(customer)
                .where(customer.phone.isNull())
                .fetch();

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

        assertThat(result).hasSize(3);
        assertThat(result).allMatch(s -> s.endsWith("번호없음"));
    }

    @Test
    @DisplayName("[13-7] sum() 은 대상이 없으면 0 이 아니라 NULL — Step 08 8-7 의 완성판")
    void sumReturnsNull() {
        // 제주로 배송된 주문은 0건입니다.
        BigDecimal total = queryFactory
                .select(order.totalAmount.sum())
                .from(order)
                .where(order.shippingCity.eq("제주"))
                .fetchOne();

        // fetchOne() 은 "행 하나"를 반환했고 그 값이 NULL 입니다. "결과 없음"이 아닙니다.
        System.out.println("coalesce 없이: " + total);
        assertThat(total).isNull();

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

        // coalesce(sum(o1_0.total_amount), ?)
        // coalesce 가 sum 을 "감싼다"는 것이 핵심입니다.
        //   .sum().coalesce(ZERO)  → coalesce(sum(x), 0)   ← 원하는 것
        //   .coalesce(ZERO).sum()  → sum(coalesce(x, 0))   ← 전혀 다른 뜻
        System.out.println("coalesce 적용: " + safe);
        assertThat(safe).isNotNull();
        assertThat(safe.compareTo(BigDecimal.ZERO)).isZero();
    }

    // ────────────────────────────────────────────────────────────────
    // [13-8] nullif
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-8] 0 나누기를 nullif + coalesce 로 명시적으로 처리")
    void nullifDivision() {
        NumberExpression<BigDecimal> pricePerStock = product.price
                .divide(Expressions.nullif(product.stock, 0))
                .coalesce(BigDecimal.ZERO);

        List<Tuple> result = queryFactory
                .select(product.name, product.stock, pricePerStock)
                .from(product)
                .orderBy(product.id.asc())
                .limit(6)
                .fetch();

        // coalesce(p1_0.price / nullif(p1_0.stock, ?), ?)
        //
        // nullif 없이 그냥 나누면 재고 0 인 상품에서 NULL 이 나옵니다.
        // 에러가 아니라 조용한 NULL 입니다. 자바에서 받아 쓰는 순간 NPE 입니다.
        //
        // "0으로 나누면 0으로 친다"는 비즈니스 규칙을 SQL 에 명시한 것입니다.
        // NULL 이 우연히 흘러가는 것과 규칙에 따라 0 이 되는 것은 다릅니다.
        result.forEach(System.out::println);

        assertThat(result).hasSize(6);
    }

    // ────────────────────────────────────────────────────────────────
    // [13-9] Expressions 팩토리
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-9] allOf / anyOf — null 인자를 무시하며 결합, 괄호를 잃지 않음")
    void allOfAnyOf() {
        String city = "서울";
        Grade grade = Grade.GOLD;

        // (도시 조건 AND 등급 조건) OR (VIP)
        BooleanExpression cond = Expressions.anyOf(
                Expressions.allOf(cityEq(city), gradeEq(grade)),
                customer.grade.eq(Grade.VIP));

        List<String> result = queryFactory
                .select(customer.name)
                .from(customer)
                .where(cond)
                .fetch();

        // where (c1_0.city = ? and c1_0.grade = ?) or c1_0.grade = ?
        //
        // Step 04 에서 .and().or() 체인이 괄호를 잃었던 문제가 여기서는 발생하지 않습니다.
        // 결합 구조를 함수 호출의 중첩으로 표현했기 때문입니다.
        // 괄호를 잃을 수 없는 형태로 쓰는 것이 괄호를 잘 넣는 것보다 안전합니다.
        result.forEach(System.out::println);

        assertThat(result).isNotEmpty();
    }

    @Test
    @DisplayName("[13-9] 모든 조건이 null 이면 결합 결과도 null → 조건 없음")
    void allOfWithAllNull() {
        BooleanExpression cond = Expressions.allOf(cityEq(null), gradeEq(null));

        List<String> result = queryFactory
                .select(customer.name)
                .from(customer)
                .where(cond)               // where(null) 은 조건 없음
                .fetch();

        assertThat(cond).isNull();
        assertThat(result).hasSize(30);
    }

    @Test
    @DisplayName("[13-9] asNumber — 숫자 리터럴이 왼쪽에 와야 할 때")
    void asNumber() {
        List<Tuple> result = queryFactory
                .select(product.name,
                        product.stock,
                        Expressions.asNumber(100).subtract(product.stock))
                .from(product)
                .limit(3)
                .fetch();

        // ? - p1_0.stock
        result.forEach(System.out::println);

        assertThat(result).hasSize(3);
    }

    private BooleanExpression cityEq(String city) {
        return city == null ? null : customer.city.eq(city);
    }

    private BooleanExpression gradeEq(Grade grade) {
        return grade == null ? null : customer.grade.eq(grade);
    }

    // ────────────────────────────────────────────────────────────────
    // [13-10] DB 함수 호출
    // ────────────────────────────────────────────────────────────────

    /** 템플릿 문자열은 반드시 컴파일 시점 상수로 분리합니다. 13-11 의 규칙입니다. */
    private static final String DATE_FORMAT_TPL = "function('date_format', {0}, {1})";

    @Test
    @DisplayName("[13-10] function('date_format', ...) 으로 월별 집계")
    void dbFunction() {
        StringExpression yearMonth = Expressions.stringTemplate(
                DATE_FORMAT_TPL, order.orderDate, "%Y-%m");

        List<Tuple> result = queryFactory
                .select(yearMonth, order.count(), order.totalAmount.sum())
                .from(order)
                .groupBy(yearMonth)
                .orderBy(yearMonth.asc())
                .limit(4)
                .fetch();

        // date_format(o1_0.order_date, ?)
        // {0}, {1} 자리에 넘긴 인자가 ? 바인딩 파라미터로 나갔습니다. 이것이 안전한 형태입니다.
        //
        // Hibernate 6 는 Dialect 에 등록된 함수라면 function(...) 래퍼 없이도 되는 경우가 많지만,
        // 어떤 함수가 등록돼 있는지는 전적으로 Dialect 에 달려 있습니다.
        // 이 코스는 표준 문법인 function('name', ...) 을 기본으로 씁니다.
        result.forEach(System.out::println);

        assertThat(result).hasSize(4);
    }

    // ────────────────────────────────────────────────────────────────
    // [13-11] ⚠️ stringTemplate 으로 SQL 인젝션이 열립니다
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-11] ✅ 안전 — 값을 {n} 인자로 넘김. SQL 에 ? 가 보입니다")
    void injectionSafe() {
        String pattern = "%Y-%m";        // 사용자가 정할 수 있는 값이라고 가정

        StringExpression expr = Expressions.stringTemplate(
                DATE_FORMAT_TPL,          // ← 컴파일 시점 상수
                order.orderDate,
                pattern);                 // ← 값은 인자로

        List<String> result = queryFactory
                .select(expr)
                .from(order)
                .limit(3)
                .fetch();

        // select date_format(o1_0.order_date, ?) from orders o1_0 limit ?
        //
        // pattern 이 무엇이든 그것은 "값"으로만 취급됩니다.
        // "'); DROP TABLE orders; --" 를 넣어도 그냥 그 문자열로 포맷을 시도하고 끝납니다.
        result.forEach(System.out::println);

        assertThat(result).hasSize(3);
    }

    @Test
    @DisplayName("[13-11] ❌ 위험 — 학습용. 어떤 형태로도 운영 코드에 복사하지 마십시오")
    void injectionVulnerable() {
        // ⚠️ 이 메서드는 함정을 눈으로 확인하기 위한 것입니다.
        //    Expressions.*Template 의 첫 인자는 QueryDSL 이 검사하지 않고
        //    그대로 JPQL 에 삽입하는 원시 문자열입니다.
        //    PreparedStatement 에 "... where id = " + id 를 쓰는 것과 위험도가 같습니다.

        String benign = "%Y-%m";

        StringExpression ok = Expressions.stringTemplate(
                "function('date_format', {0}, '" + benign + "')",   // ★ 이어 붙임
                order.orderDate);

        List<String> okResult = queryFactory.select(ok).from(order).limit(3).fetch();

        // select date_format(o1_0.order_date, '%Y-%m') from orders o1_0 limit ?
        //   ↑ ? 가 없습니다. 값이 SQL 에 그대로 박혔습니다.
        //     결과는 맞으므로 테스트도 리뷰도 통과합니다.
        okResult.forEach(System.out::println);

        // 이제 공격자가 보낸 값입니다.
        String attack = "%Y-%m') , (select email from customers where grade='VIP' limit 1";

        StringExpression evil = Expressions.stringTemplate(
                "function('date_format', {0}, '" + attack + "')",
                order.orderDate);

        // 생성되는 SQL:
        //   select date_format(o1_0.order_date, '%Y-%m'),
        //          (select c1_0.email from customers c1_0 where c1_0.grade = 'VIP' limit 1)
        //   from orders o1_0
        //
        // 월별 집계 API 가 고객 이메일을 반환합니다.
        // select 절이 하나 늘어 Tuple 크기가 달라지므로 아래는 예외로 끝날 수 있지만,
        // Tuple 을 그대로 JSON 직렬화하는 코드였다면 응답에 실려 나갑니다.
        try {
            List<Tuple> leaked = queryFactory
                    .select(evil, order.orderDate)
                    .from(order)
                    .limit(3)
                    .fetch();
            leaked.forEach(t -> System.out.println("[LEAK] " + t));
        } catch (Exception e) {
            System.out.println("[LEAK] 실행 중 예외: " + e.getClass().getSimpleName()
                    + " — 예외가 나도 SQL 은 이미 만들어졌습니다: " + e.getMessage());
        }

        // 처방은 규칙 하나입니다.
        //   템플릿 문자열은 컴파일 시점 상수여야 하고,
        //   변하는 값은 예외 없이 {n} 자리의 인자로 넘깁니다.
    }

    // ────────────────────────────────────────────────────────────────
    // [13-12] booleanTemplate
    // ────────────────────────────────────────────────────────────────

    private static final String REGEXP_TPL = "function('regexp_like', {0}, {1})";

    @Test
    @DisplayName("[13-12] booleanTemplate — where 절에 열리는 인젝션은 더 나쁩니다")
    void booleanTemplate() {
        List<Product> result = queryFactory
                .selectFrom(product)
                .where(Expressions.booleanTemplate(REGEXP_TPL, product.name, "노트북|모니터"))
                .fetch();

        // where regexp_like(p1_0.name, ?)
        //
        // 동작합니다. 그리고 13-11 과 완전히 같은 위험을 가집니다.
        // where 절에 열리는 인젝션은 select 절보다 나쁩니다. or 1=1 하나로 전체가 노출됩니다.
        //
        // booleanTemplate 을 쓰기 전에 표준 방법이 없는지 먼저 확인하십시오.
        //   정규식     → like 로 충분한 경우가 많음
        //   대소문자   → lower() (인덱스 포기) 또는 콜레이션
        //   날짜 부분  → orderDate.year(), .month()  ← 13-13
        result.forEach(p -> System.out.println(p.getName()));

        assertThat(result).hasSize(3);
    }

    // ────────────────────────────────────────────────────────────────
    // [13-13] 날짜 표현식
    // ────────────────────────────────────────────────────────────────

    @Test
    @DisplayName("[13-13] year()/month() 로 월별 집계 — 표준 SQL 이라 이식 가능")
    void dateParts() {
        List<Tuple> result = queryFactory
                .select(order.orderDate.year(),
                        order.orderDate.month(),
                        order.count(),
                        order.totalAmount.sum())
                .from(order)
                .groupBy(order.orderDate.year(), order.orderDate.month())
                .orderBy(order.orderDate.year().asc(), order.orderDate.month().asc())
                .limit(4)
                .fetch();

        // extract(year from o1_0.order_date), extract(month from o1_0.order_date)
        //
        // 13-10 의 date_format 과 결과가 같은데 DB 고유 함수를 쓰지 않았습니다.
        // 표준으로 되는 것은 표준으로 하십시오.
        result.forEach(System.out::println);

        assertThat(result).hasSize(4);
    }

    @Test
    @DisplayName("[13-13] between vs year()+month() — 같은 결과, 다른 실행 계획")
    void dateRangeVsFunction() {
        // A — 컬럼에 함수. 인덱스를 못 씁니다.
        long byFunction = queryFactory
                .selectFrom(order)
                .where(order.orderDate.year().eq(2025)
                        .and(order.orderDate.month().eq(1)))
                .fetch().size();

        // where extract(year from o1_0.order_date) = ? and extract(month from ...) = ?
        //   EXPLAIN → type: ALL, Extra: Using where

        // B — 범위. order_date 에 인덱스가 있다면 그 인덱스를 씁니다.
        long byRange = queryFactory
                .selectFrom(order)
                .where(order.orderDate.goe(LocalDateTime.of(2025, 1, 1, 0, 0))
                        .and(order.orderDate.lt(LocalDateTime.of(2025, 2, 1, 0, 0))))
                .fetch().size();

        // where o1_0.order_date >= ? and o1_0.order_date < ?
        //   EXPLAIN → type: range, Extra: Using index condition

        System.out.println("함수 방식: " + byFunction + "건, 범위 방식: " + byRange + "건");

        // 결과는 같습니다. 실행 계획만 다릅니다.
        // 집계·그룹핑에는 year()/month() 를, 필터링에는 between / goe+lt 를 쓰십시오.
        assertThat(byFunction).isEqualTo(byRange);

        // 상한을 23:59:59 로 잡는 대신 goe + lt 를 쓰는 이유:
        // DATETIME(6) 컬럼이라면 23:59:59.5 같은 값을 놓칩니다.
    }
}

Exercise.java

  • 7문제. 각 문제에 요구사항과 기대 결과가 주석으로 적혀 있습니다.
  • 6번은 안전한 버전과 위험한 버전을 모두 작성하는 문제입니다. 위험한 버전을 직접 써 봐야 그 형태를 리뷰에서 알아볼 수 있습니다.
package com.example.shop.step13;

import com.example.shop.entity.Grade;
import com.example.shop.entity.OrderStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.core.types.dsl.StringExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

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

import static com.example.shop.entity.QCustomer.customer;
import static com.example.shop.entity.QOrder.order;
import static com.example.shop.entity.QProduct.product;

/**
 * Step 13 — 연습문제 7문제.
 *
 * 정답은 Solution.java 에 있습니다.
 * 답이 맞아도 **생성 SQL 이 다르면 틀린 것**입니다. 콘솔의 hibernate.SQL 을 반드시 확인하십시오.
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // ────────────────────────────────────────────────────────────────
    // 문제 1. 금액 구간별 주문 건수
    //
    // CaseBuilder 로 주문 금액(order.totalAmount)을 다음과 같이 분류하고,
    // 분류별 주문 건수를 세십시오.
    //   10만원 미만        → "소액"
    //   10만원 이상 50만 미만 → "중액"
    //   50만원 이상        → "고액"
    //
    // 요구사항:
    //   - 분류 표현식을 변수에 담아 select 와 groupBy 에 재사용할 것
    //   - 건수 내림차순 정렬
    //
    // 확인:
    //   생성 SQL 에 case 식이 몇 번 나오는지 세어 보십시오.
    //   select, group by, (정렬을 case 로 했다면) order by 에 각각 통째로 들어갑니다.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 1 — 금액 구간별 주문 건수")
    void ex1() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 2. 등급 순 정렬
    //
    // 고객을 VIP → GOLD → SILVER → BRONZE 순으로 정렬해 이름과 등급을 출력하십시오.
    // 같은 등급 안에서는 이름 오름차순입니다.
    //
    // 요구사항:
    //   - grade 는 @Enumerated(EnumType.STRING) 이므로 알파벳 정렬로는 원하는 순서가 안 나옵니다
    //   - 정렬용 표현식을 변수에 담아 select 와 orderBy 에 재사용할 것
    //
    // 확인:
    //   1) 생성된 SQL 을 MySQL 콘솔에 붙여 EXPLAIN 을 걸었을 때
    //      Extra 컬럼에 무엇이 뜰지 **먼저 예측하고** 확인하십시오.
    //   2) 왜 그렇게 되는지 한 문장으로 설명해 보십시오.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 2 — 등급 순 정렬과 그 대가")
    void ex2() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 3. 도시별 피벗 + NULL 방어
    //
    // 도시별로 다음 세 값을 한 행에 뽑으십시오.
    //   - shippingCity
    //   - status 가 PAID 인 주문의 totalAmount 합계
    //   - status 가 CANCELLED 인 주문의 건수
    //
    // 요구사항:
    //   - 합계가 NULL 이 되지 않도록 처리할 것 (해당 도시에 PAID 주문이 하나도 없을 수 있습니다)
    //   - 도시 이름 오름차순
    //
    // 함정:
    //   coalesce 를 붙이는 **위치**가 핵심입니다.
    //   .sum().coalesce(ZERO) 와 .coalesce(ZERO).sum() 은 전혀 다른 SQL 을 만듭니다.
    //   생성 SQL 이 coalesce(sum(...), ?) 인지 sum(coalesce(...)) 인지 확인하십시오.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 3 — 도시별 피벗 + coalesce 위치")
    void ex3() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 4. "이름(전화번호)" 문자열 — NULL 3명 포함
    //
    // 전 고객 30명에 대해 "이름(전화번호)" 형태의 문자열을 만드십시오.
    // 전화번호가 NULL 인 3명은 "이름(미등록)" 이 나와야 합니다.
    //
    // 요구사항:
    //   - 30건 전부 나와야 합니다 (NULL 인 3명이 빠지면 안 됩니다)
    //
    // 확인:
    //   먼저 coalesce 없이 concat 만으로 작성해 보고, 그 결과가 왜 NULL 인지
    //   생성 SQL 로 설명하십시오. (힌트: MySQL 의 CONCAT 과 NULL)
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 4 — concat 과 NULL")
    void ex4() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 5. 마진율 계산 — 0 나누기 방어
    //
    // 상품의 마진율 (price - cost) / price * 100 을 계산하십시오.
    //
    // 요구사항:
    //   - price 가 0 인 상품이 있어도 예외 없이 0 이 나올 것
    //   - 결과가 NULL 이 되지 않을 것
    //   - 상품명, price, cost, 마진율을 함께 출력
    //
    // 힌트:
    //   nullif 로 0 을 NULL 로 바꾸면 나눗셈 결과가 NULL 이 되고,
    //   그것을 coalesce 로 다시 0 으로 되돌립니다.
    //   "0으로 나누면 0으로 친다"는 규칙을 SQL 에 명시하는 것이 목적입니다.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 5 — nullif + coalesce 로 0 나누기 방어")
    void ex5() {
        // 여기에 작성:

    }

    // ────────────────────────────────────────────────────────────────
    // 문제 6. 안전한 템플릿 vs 위험한 템플릿 ★ 이 스텝의 핵심
    //
    // 주문 날짜를 "yyyy년 MM월" 형태로 포맷하는 코드를 두 가지 버전으로 작성하십시오.
    //
    // (a) 안전한 버전 — 포맷 문자열을 메서드 파라미터로 받되 인젝션이 불가능한 형태
    //     아래 safeFormat(String pattern) 을 구현하십시오.
    //
    // (b) 위험한 버전 — 포맷 문자열을 템플릿에 이어 붙인 형태
    //     아래 unsafeFormat(String pattern) 을 구현하십시오.
    //     **학습용입니다. 어떤 형태로도 운영 코드에 복사하지 마십시오.**
    //
    // 확인:
    //   1) 두 버전을 pattern = "%Y년 %m월" 로 각각 실행하고 생성 SQL 을 비교하십시오.
    //      한쪽에는 ? 가 있고 한쪽에는 리터럴이 박혀 있어야 합니다.
    //   2) 위험한 버전에 아래 공격 문자열을 넣고 SQL 이 어떻게 변하는지 확인하십시오.
    //      "%Y-%m') , (select email from customers where grade='VIP' limit 1"
    //   3) 안전한 버전에 같은 문자열을 넣으면 어떻게 되는지 확인하십시오.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 6 — 안전한 템플릿과 위험한 템플릿")
    void ex6() {
        String benign = "%Y년 %m월";
        String attack = "%Y-%m') , (select email from customers where grade='VIP' limit 1";

        // 여기에 작성: 아래 두 메서드를 구현하고 호출해 SQL 을 비교하십시오.

    }

    /** (a) 안전한 버전. 템플릿 문자열은 컴파일 시점 상수, 값은 {n} 인자로. */
    private List<String> safeFormat(String pattern) {
        // 여기에 작성:
        return List.of();
    }

    /** (b) 위험한 버전. ⚠️ 학습용. 절대 운영 코드에 쓰지 마십시오. */
    private List<String> unsafeFormat(String pattern) {
        // 여기에 작성:
        return List.of();
    }

    // ────────────────────────────────────────────────────────────────
    // 문제 7. 날짜 필터 두 가지 방식 비교
    //
    // 2025년 상반기(1월 1일 ~ 6월 30일) 주문을 조회하는 쿼리를
    // 두 가지 방식으로 작성하고 결과 건수가 같은지 확인하십시오.
    //
    //   ① year() + month() 를 쓰는 방식
    //   ② goe + lt 를 쓰는 방식 (경계값에 주의)
    //
    // 확인:
    //   1) 두 생성 SQL 을 나란히 적어 보십시오.
    //   2) 각각을 EXPLAIN 에 넣어 type 과 Extra 를 비교하십시오.
    //      (order_date 에 인덱스가 없다면 idx_orders_date 를 임시로 만들고 비교하십시오.
    //       실습이 끝나면 DROP INDEX 로 되돌립니다.)
    //   3) 왜 한쪽만 인덱스를 쓸 수 있는지 한 문장으로 설명하십시오.
    // ────────────────────────────────────────────────────────────────
    @Test
    @DisplayName("문제 7 — 날짜 필터와 인덱스")
    void ex7() {
        // 여기에 작성:

    }
}

Solution.java

  • 정답과 함께 왜 그 형태인지, 그리고 흔한 오답이 왜 틀렸는지를 주석으로 설명합니다.
  • 3번과 5번은 coalesce 의 위치가 핵심입니다. 답이 맞아도 SQL 이 다르면 틀린 것입니다.
package com.example.shop.step13;

import com.example.shop.entity.Grade;
import com.example.shop.entity.OrderStatus;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.core.types.dsl.StringExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

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

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

/**
 * Step 13 — 연습문제 정답과 해설.
 *
 * 답이 맞아도 생성 SQL 이 다르면 틀린 것입니다.
 * 각 문제의 주석에 "흔한 오답"을 함께 적어 두었습니다.
 */
@SpringBootTest
@Transactional
class Solution {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // ════════════════════════════════════════════════════════════════
    // 문제 1 — 금액 구간별 주문 건수
    // ════════════════════════════════════════════════════════════════
    //
    // 핵심은 "분류 표현식을 변수에 담아 재사용"입니다.
    // select 와 groupBy 에 인라인으로 두 번 쓰면, 나중에 구간 경계를 바꿀 때
    // 한쪽만 고치는 사고가 납니다. 두 case 가 달라지면 group by 가 select 와
    // 어긋나 ONLY_FULL_GROUP_BY 에서 에러가 나거나, 더 나쁘게는 조용히 틀린 집계가 됩니다.
    //
    // 생성 SQL 에서 case 식은 select 절과 group by 절에 **통째로 두 번** 들어갑니다.
    // QueryDSL 은 같은 자바 객체를 두 자리에 썼다고 해서 별칭으로 묶어 주지 않습니다.
    // (MySQL 은 select 별칭을 group by 에서 참조할 수 있지만, JPQL 은 그 문법을 만들지 않습니다.)
    //
    // 정렬을 건수(count) 로 하면 order by 에는 case 가 들어가지 않습니다.
    // 이것이 이 문제에서 정렬 기준을 건수로 잡은 이유입니다. 표현식 반복이 하나 줄어듭니다.
    //
    @Test
    @DisplayName("정답 1 — 금액 구간별 주문 건수")
    void ans1() {
        StringExpression amountTier = new CaseBuilder()
                .when(order.totalAmount.lt(new BigDecimal("100000"))).then("소액")
                .when(order.totalAmount.lt(new BigDecimal("500000"))).then("중액")
                .otherwise("고액");

        List<Tuple> result = queryFactory
                .select(amountTier, order.count())
                .from(order)
                .groupBy(amountTier)
                .orderBy(order.count().desc())
                .fetch();

        // 생성 SQL
        //   select
        //       case when o1_0.total_amount < ? then ?
        //            when o1_0.total_amount < ? then ?
        //            else ? end,
        //       count(o1_0.order_id)
        //   from orders o1_0
        //   group by
        //       case when o1_0.total_amount < ? then ?
        //            when o1_0.total_amount < ? then ?
        //            else ? end
        //   order by count(o1_0.order_id) desc
        //
        // 바인딩 파라미터 10개. case 하나당 5개씩입니다.

        result.forEach(t -> System.out.printf("%s | %d%n", t.get(0, String.class), t.get(1, Long.class)));

        long total = result.stream().mapToLong(t -> t.get(1, Long.class)).sum();
        assertThat(total).isEqualTo(600L);

        // 흔한 오답 ①: when 순서를 뒤집는 것.
        //   .when(lt(500000)).then("중액").when(lt(100000)).then("소액")
        //   → 5만원 주문도 "중액"이 됩니다. case 는 위에서부터 첫 매치에서 멈춥니다. 에러는 안 납니다.
        //
        // 흔한 오답 ②: BigDecimal 대신 int 리터럴을 넘기는 것.
        //   .when(order.totalAmount.lt(100000))
        //   → NumberExpression<BigDecimal>.lt(Number) 오버로드가 있어 컴파일은 됩니다.
        //     동작도 하지만, 금액 비교에 int 를 섞는 습관은 정산 코드에서 사고를 냅니다.
        //     금액은 끝까지 BigDecimal 로 다루십시오.
    }

    // ════════════════════════════════════════════════════════════════
    // 문제 2 — 등급 순 정렬과 그 대가
    // ════════════════════════════════════════════════════════════════
    //
    // EXPLAIN 예측의 정답: Extra 에 "Using filesort" 가 뜹니다. type 은 ALL 입니다.
    //
    // 이유 한 문장:
    //   인덱스는 "컬럼 값" 순으로 정렬돼 있는데, 정렬 대상이 컬럼 값이 아니라
    //   case 식의 "계산 결과"이므로 옵티마이저가 인덱스의 순서를 재활용할 방법이 없습니다.
    //
    // customers 는 30행이라 지금은 아무 문제가 없습니다.
    // Step 09 의 정렬 컬럼 함수 문제, Step 14 의 인덱스 죽이는 패턴 4가지와 같은 원리입니다.
    //
    // 30만 행 규모에서 이 정렬이 필요하다면 대안은 둘입니다.
    //   1) grade_rank TINYINT 컬럼을 두고 인덱스를 겁니다. 등급 체계는 거의 안 바뀌므로
    //      비정규화 비용이 낮습니다.
    //   2) 정렬을 애플리케이션에서 합니다. 페이징이 없다면 이쪽이 더 간단할 때가 많습니다.
    //      단, 페이징이 있으면 불가능합니다 — 전 행을 가져와야 하기 때문입니다.
    //
    // @Enumerated(EnumType.ORDINAL) 로 바꾸는 것은 답이 아닙니다.
    // enum 상수 사이에 하나를 끼워 넣는 순간 기존 데이터가 전부 어긋납니다.
    //
    @Test
    @DisplayName("정답 2 — 등급 순 정렬")
    void ans2() {
        NumberExpression<Integer> gradeRank = new CaseBuilder()
                .when(customer.grade.eq(Grade.VIP)).then(4)
                .when(customer.grade.eq(Grade.GOLD)).then(3)
                .when(customer.grade.eq(Grade.SILVER)).then(2)
                .otherwise(1);

        List<Tuple> result = queryFactory
                .select(customer.name, customer.grade, gradeRank)
                .from(customer)
                .orderBy(gradeRank.desc(), customer.name.asc())
                .fetch();

        // 생성 SQL 에서 case 식이 select 와 order by 에 두 번 들어갑니다.
        // 바인딩 파라미터 8개 (4 + 4).
        //
        // EXPLAIN SELECT name, grade FROM customers
        // ORDER BY CASE WHEN grade='VIP' THEN 4 ... END DESC, name;
        //   +------+---------------+------+------+----------------+
        //   | type | possible_keys | key  | rows | Extra          |
        //   | ALL  | NULL          | NULL |   30 | Using filesort |
        //   +------+---------------+------+------+----------------+

        result.stream().limit(6).forEach(System.out::println);

        assertThat(result).hasSize(30);
        assertThat(result.get(0).get(customer.grade)).isEqualTo(Grade.VIP);
        assertThat(result.get(29).get(customer.grade)).isEqualTo(Grade.BRONZE);
    }

    // ════════════════════════════════════════════════════════════════
    // 문제 3 — 도시별 피벗 + coalesce 위치
    // ════════════════════════════════════════════════════════════════
    //
    // 이 문제의 전부는 coalesce 를 **어디에** 붙이느냐입니다.
    //
    //   ✅ .sum().coalesce(BigDecimal.ZERO)   → coalesce(sum(case ...), ?)
    //      "합계 결과가 NULL 이면 0"
    //
    //   ❌ .coalesce(BigDecimal.ZERO).sum()   → sum(coalesce(case ..., ?))
    //      "각 행의 값이 NULL 이면 0 으로 보고 더하라"
    //      case 에 otherwise(ZERO) 가 이미 있으므로 각 행의 값은 절대 NULL 이 아닙니다.
    //      즉 이 coalesce 는 **아무 일도 하지 않습니다.**
    //      그런데 결과는 맞게 나옵니다 — 이 예제에서는 모든 도시에 PAID 주문이 있기 때문입니다.
    //      제주처럼 PAID 주문이 0건인 도시가 생기는 순간 NULL 이 흘러나갑니다.
    //
    // 이것이 "답이 맞아도 SQL 이 다르면 틀린 것"의 전형적인 사례입니다.
    // 두 코드는 현재 데이터에서 동일한 출력을 내지만, 하나는 방어가 되어 있고 하나는 아닙니다.
    //
    // 참고: groupBy 로 묶었으므로 각 그룹에는 최소 1행이 있습니다.
    //       따라서 sum(case ... else 0 end) 은 이 쿼리에서는 NULL 이 될 수 없습니다.
    //       그래도 coalesce 를 붙이는 이유는, 나중에 where 절이 추가되어
    //       "그룹은 있는데 조건에 맞는 행이 없는" 상황이 생겨도 깨지지 않게 하기 위해서입니다.
    //       리포지토리가 반환하는 집계값은 NULL 을 반환하지 않는 것이 계약으로 낫습니다.
    //
    @Test
    @DisplayName("정답 3 — 도시별 피벗 + coalesce 위치")
    void ans3() {
        NumberExpression<BigDecimal> paidSum = new CaseBuilder()
                .when(order.status.eq(OrderStatus.PAID)).then(order.totalAmount)
                .otherwise(BigDecimal.ZERO)
                .sum()
                .coalesce(BigDecimal.ZERO);      // ★ sum 을 감쌉니다

        NumberExpression<Integer> cancelledCount = new CaseBuilder()
                .when(order.status.eq(OrderStatus.CANCELLED)).then(1)
                .otherwise(0)
                .sum()
                .coalesce(0);

        List<Tuple> result = queryFactory
                .select(order.shippingCity, paidSum, cancelledCount)
                .from(order)
                .groupBy(order.shippingCity)
                .orderBy(order.shippingCity.asc())
                .fetch();

        // 생성 SQL
        //   select
        //       o1_0.shipping_city,
        //       coalesce(sum(case when o1_0.status = ? then o1_0.total_amount else ? end), ?),
        //       coalesce(sum(case when o1_0.status = ? then ? else ? end), ?)
        //   from orders o1_0
        //   group by o1_0.shipping_city
        //   order by o1_0.shipping_city
        //
        // coalesce 가 sum 을 바깥에서 감싸고 있는지 반드시 확인하십시오.

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

        assertThat(result).hasSize(6);
        assertThat(result).allSatisfy(t -> {
            assertThat(t.get(paidSum)).isNotNull();
            assertThat(t.get(cancelledCount)).isNotNull();
        });
    }

    // ════════════════════════════════════════════════════════════════
    // 문제 4 — concat 과 NULL
    // ════════════════════════════════════════════════════════════════
    //
    // 먼저 틀린 버전이 왜 틀렸는지입니다.
    //
    //   customer.name.concat("(").concat(customer.phone).concat(")")
    //   → concat(concat(concat(c1_0.name, ?), c1_0.phone), ?)
    //
    // MySQL 의 CONCAT 은 **인자 중 하나라도 NULL 이면 전체가 NULL** 입니다.
    // 따라서 phone 이 NULL 인 3명은 결과가 통째로 NULL 이 됩니다.
    // 이름조차 사라집니다. 행은 30건 나오지만 그중 3건이 null 입니다.
    //
    // 이것이 조용한 실패의 전형입니다.
    //   - 예외가 나지 않습니다
    //   - 건수도 30건으로 맞습니다
    //   - null 을 그대로 응답에 실으면 프론트에서 "undefined(undefined)" 같은 게 보입니다
    //
    // 처방은 concat 에 넣기 **전에** NULL 을 없애는 것입니다.
    // coalesce 뒤에는 .asString() 이 필요합니다. coalesce 의 반환 타입이
    // StringExpression 이 아니라서 concat 을 바로 이어 붙일 수 없기 때문입니다.
    //
    @Test
    @DisplayName("정답 4 — concat 과 NULL")
    void ans4() {
        // (a) 틀린 버전 — phone 이 NULL 인 3명이 통째로 null 이 됩니다
        List<String> wrong = queryFactory
                .select(customer.name.concat("(").concat(customer.phone).concat(")"))
                .from(customer)
                .fetch();

        long nullCount = wrong.stream().filter(s -> s == null).count();
        System.out.println("틀린 버전의 null 개수: " + nullCount);   // 3
        assertThat(nullCount).isEqualTo(3);

        // (b) 정답 — concat 에 넣기 전에 coalesce 로 NULL 을 없앱니다
        List<String> correct = queryFactory
                .select(customer.name
                        .concat("(")
                        .concat(customer.phone.coalesce("미등록").asString())
                        .concat(")"))
                .from(customer)
                .fetch();

        // 생성 SQL
        //   select concat(concat(concat(c1_0.name, ?), coalesce(c1_0.phone, ?)), ?)
        //   from customers c1_0

        correct.stream().limit(5).forEach(System.out::println);

        assertThat(correct).hasSize(30);
        assertThat(correct).doesNotContainNull();
        assertThat(correct).anyMatch(s -> s.endsWith("(미등록)"));

        // 흔한 오답: coalesce 를 맨 바깥에 붙이는 것.
        //   customer.name.concat("(").concat(customer.phone).concat(")").coalesce("미등록")
        //   → coalesce(concat(...), ?)
        //   결과는 3명 전부 "미등록" 이 됩니다. 이름이 사라집니다.
        //   NULL 은 concat 안쪽에서 이미 전체를 삼켰기 때문입니다.
        //   NULL 방어는 **NULL 이 생기는 지점에서** 해야 합니다.
    }

    // ════════════════════════════════════════════════════════════════
    // 문제 5 — nullif + coalesce 로 0 나누기 방어
    // ════════════════════════════════════════════════════════════════
    //
    // 세 단계입니다.
    //   1) nullif(price, 0)  — 분모가 0 이면 NULL 로 바꿉니다
    //   2) 나눗셈            — 분모가 NULL 이면 결과도 NULL 입니다 (에러 아님)
    //   3) coalesce(..., 0)  — NULL 을 0 으로 되돌립니다
    //
    // nullif 없이 그냥 나누면 MySQL 은 (sql_mode 에 따라) 경고와 함께 NULL 을 반환합니다.
    // 결과는 같아 보이지만 의미가 다릅니다.
    //   - nullif 없이: "우연히 NULL 이 나왔다"
    //   - nullif 있음: "0으로 나누는 경우는 0으로 친다는 규칙이 코드에 있다"
    // 코드를 읽는 사람이 이 차이를 볼 수 있어야 합니다.
    //
    // 소수점 둘째 자리 처리:
    //   MySQL 의 DECIMAL 나눗셈은 div_precision_increment(기본 4)에 따라 스케일이 정해집니다.
    //   자바에서 BigDecimal 로 받은 뒤 setScale(2, RoundingMode.HALF_UP) 하는 편이 예측 가능합니다.
    //   DB 에서 round() 를 부르려면 DB 함수 호출(13-10)이 필요하고, 이식성이 떨어집니다.
    //
    @Test
    @DisplayName("정답 5 — nullif + coalesce 로 0 나누기 방어")
    void ans5() {
        NumberExpression<BigDecimal> marginRate = product.price
                .subtract(product.cost)
                .multiply(100)
                .divide(Expressions.nullif(product.price, BigDecimal.ZERO))
                .coalesce(BigDecimal.ZERO);

        List<Tuple> result = queryFactory
                .select(product.name, product.price, product.cost, marginRate)
                .from(product)
                .orderBy(product.id.asc())
                .fetch();

        // 생성 SQL
        //   select
        //       p1_0.name, p1_0.price, p1_0.cost,
        //       coalesce((p1_0.price - p1_0.cost) * ? / nullif(p1_0.price, ?), ?)
        //   from products p1_0
        //   order by p1_0.product_id

        result.stream().limit(5).forEach(t -> System.out.printf("%s | %s | %s | %s%n",
                t.get(product.name), t.get(product.price), t.get(product.cost),
                t.get(marginRate)));

        assertThat(result).hasSize(40);
        assertThat(result).allSatisfy(t -> assertThat(t.get(marginRate)).isNotNull());

        // 자바에서 소수점 둘째 자리로 맞추기
        result.stream().limit(3).forEach(t -> {
            BigDecimal rate = t.get(marginRate).setScale(2, java.math.RoundingMode.HALF_UP);
            System.out.println(t.get(product.name) + " → " + rate + "%");
        });

        // 흔한 오답: 자바에서 divide 를 부르는 것.
        //   price.subtract(cost).multiply(100).divide(price)
        //   → ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result
        //   자바의 BigDecimal.divide 는 스케일을 안 주면 나누어떨어지지 않을 때 예외입니다.
        //   DB 는 조용히 반올림하고 자바는 예외를 던집니다. 같은 계산이 아닙니다.
    }

    // ════════════════════════════════════════════════════════════════
    // 문제 6 — 안전한 템플릿과 위험한 템플릿  ★ 이 스텝의 핵심
    // ════════════════════════════════════════════════════════════════
    //
    // 규칙은 하나입니다.
    //   **템플릿 문자열은 컴파일 시점 상수여야 하고, 변하는 값은 예외 없이 {n} 인자로 넘긴다.**
    //
    // 안전한 버전의 SQL:
    //   select date_format(o1_0.order_date, ?) from orders o1_0 limit ?
    //   binding parameter (1:VARCHAR) <- [%Y년 %m월]
    //   → pattern 이 무엇이든 "값"입니다. 공격 문자열을 넣어도 그 문자열로 포맷을 시도하고 끝납니다.
    //
    // 위험한 버전의 SQL (benign):
    //   select date_format(o1_0.order_date, '%Y년 %m월') from orders o1_0 limit ?
    //   → ? 가 없습니다. 값이 SQL 에 박혔습니다. 결과는 맞으므로 테스트도 리뷰도 통과합니다.
    //
    // 위험한 버전의 SQL (attack):
    //   select date_format(o1_0.order_date, '%Y-%m'),
    //          (select c1_0.email from customers c1_0 where c1_0.grade = 'VIP' limit 1)
    //   from orders o1_0
    //   → 월별 집계 API 가 고객 이메일을 반환합니다.
    //
    // Step 10 에서 "QueryDSL 은 JPQL 로 번역되니 대체로 안전하다"고 했던 안심이 여기서 깨집니다.
    // Expressions.*Template 의 첫 인자는 QueryDSL 이 검사하지 않고 그대로 JPQL 에 넣는
    // 원시 문자열입니다. PreparedStatement 에 "... where id = " + id 를 쓰는 것과 같습니다.
    //
    // 리뷰 체크리스트:
    //   - 템플릿 첫 인자에 + 가 있는가
    //   - 템플릿 첫 인자에 format( / formatted( 가 있는가
    //   - 템플릿 첫 인자가 변수인가 (변수라면 출처를 끝까지 추적)
    //   - 템플릿 안에 작은따옴표 ' 가 있는가 (리터럴을 직접 쓰고 있다는 뜻)
    //   - {n} 개수와 인자 개수가 맞는가 (불일치는 이어 붙였다는 신호)
    //
    // 컬럼명·정렬 방향은 바인딩 파라미터가 될 수 없습니다 (order by ? 는 상수 정렬입니다).
    // 그래서 동적 컬럼명은 구조적으로 템플릿에 넣을 수밖에 없고,
    // **화이트리스트가 유일한 방어**입니다. Step 10 의 SORT_KEYS Map 이 그것입니다.

    /** 템플릿은 컴파일 시점 상수. 이 필드가 방어의 전부입니다. */
    private static final String DATE_FORMAT_TPL = "function('date_format', {0}, {1})";

    @Test
    @DisplayName("정답 6 — 안전한 템플릿과 위험한 템플릿")
    void ans6() {
        String benign = "%Y년 %m월";
        String attack = "%Y-%m') , (select email from customers where grade='VIP' limit 1";

        System.out.println("── 안전한 버전 (정상 입력) ──");
        safeFormat(benign).forEach(System.out::println);

        System.out.println("── 안전한 버전 (공격 입력) ──");
        // 공격 문자열이 그냥 포맷 문자열로 취급됩니다. 유출 없음.
        safeFormat(attack).forEach(System.out::println);

        System.out.println("── 위험한 버전 (정상 입력) — SQL 에 ? 가 없습니다 ──");
        unsafeFormat(benign).forEach(System.out::println);

        System.out.println("── 위험한 버전 (공격 입력) — SQL 이 변조됩니다 ──");
        try {
            unsafeFormat(attack).forEach(System.out::println);
        } catch (Exception e) {
            System.out.println("실행 예외: " + e.getClass().getSimpleName());
            System.out.println("예외가 나도 변조된 SQL 은 이미 만들어졌습니다.");
        }

        assertThat(safeFormat(benign)).hasSize(3);
    }

    /** (a) 안전한 버전. */
    private List<String> safeFormat(String pattern) {
        StringExpression expr = Expressions.stringTemplate(
                DATE_FORMAT_TPL,      // 컴파일 시점 상수
                order.orderDate,
                pattern);             // 값은 {1} 자리 인자로 → ? 바인딩

        return queryFactory.select(expr).from(order).limit(3).fetch();
    }

    /**
     * (b) 위험한 버전.
     *
     * ⚠️ 학습용입니다. 이 형태를 어떤 이유로도 운영 코드에 복사하지 마십시오.
     *    이 메서드가 존재하는 유일한 이유는 "리뷰에서 이 형태를 알아보기 위해서"입니다.
     */
    private List<String> unsafeFormat(String pattern) {
        StringExpression expr = Expressions.stringTemplate(
                "function('date_format', {0}, '" + pattern + "')",   // ★ 사고 지점
                order.orderDate);

        return queryFactory.select(expr).from(order).limit(3).fetch();
    }

    // ════════════════════════════════════════════════════════════════
    // 문제 7 — 날짜 필터와 인덱스
    // ════════════════════════════════════════════════════════════════
    //
    // 이유 한 문장:
    //   인덱스는 컬럼 값 순으로 정렬돼 있는데, extract(...) 는 컬럼을 감싼 계산 결과이므로
    //   그 인덱스의 순서로 범위를 잘라낼 수 없습니다. 반면 >= / < 는 컬럼 값 자체에 대한
    //   범위 조건이므로 인덱스에서 시작점과 끝점을 바로 찾을 수 있습니다.
    //
    // 두 생성 SQL
    //   ① where extract(year from o1_0.order_date) = ?
    //       and extract(month from o1_0.order_date) between ? and ?
    //   ② where o1_0.order_date >= ? and o1_0.order_date < ?
    //
    // EXPLAIN 비교 (ALTER TABLE orders ADD INDEX idx_orders_date (order_date); 를 건 상태)
    //   ①  | type | key  | rows | Extra       |
    //      | ALL  | NULL |  600 | Using where |
    //
    //   ②  | type  | key             | rows | Extra                 |
    //      | range | idx_orders_date |  148 | Using index condition |
    //
    // 실습이 끝나면 되돌리십시오: DROP INDEX idx_orders_date ON orders;
    //
    // 경계값 주의:
    //   "6월 30일까지"를 lt(2025-06-30 23:59:59) 로 잡으면 23:59:59.5 를 놓칩니다.
    //   orders.order_date 는 DATETIME(소수 초 없음) 이라 이 예제에서는 문제가 없지만,
    //   DATETIME(6) 컬럼이라면 조용히 몇 건이 빠집니다.
    //   **goe(시작) + lt(다음 구간 시작)** 형태를 습관으로 쓰십시오. 경계 계산이 필요 없습니다.
    //
    @Test
    @DisplayName("정답 7 — 날짜 필터와 인덱스")
    void ans7() {
        // ① year() + month() — 인덱스를 못 씁니다
        long byFunction = queryFactory
                .selectFrom(order)
                .where(order.orderDate.year().eq(2025)
                        .and(order.orderDate.month().between(1, 6)))
                .fetch().size();

        // ② goe + lt — 인덱스를 쓸 수 있습니다
        long byRange = queryFactory
                .selectFrom(order)
                .where(order.orderDate.goe(LocalDateTime.of(2025, 1, 1, 0, 0))
                        .and(order.orderDate.lt(LocalDateTime.of(2025, 7, 1, 0, 0))))
                .fetch().size();

        System.out.println("① 함수 방식: " + byFunction + "건");
        System.out.println("② 범위 방식: " + byRange + "건");

        assertThat(byFunction).isEqualTo(byRange);

        // 정리:
        //   집계·그룹핑에는 year()/month() — 어차피 전 행을 훑으므로 손해가 없습니다
        //   필터링에는 between / goe+lt — where 의 함수 하나가 인덱스 전체를 버리게 합니다
        //
        // 이 원리는 Step 09(정렬 컬럼 함수)와 Step 14 의 "인덱스를 죽이는 4가지 패턴"에서
        // 같은 형태로 반복됩니다.
    }
}