Step 13 — 고급 표현식
학습 목표
CaseBuilder 로 단순/복합 CASE WHEN 을 만들고 생성 SQL 을 확인한다
case 를 orderBy 에 넣어 커스텀 정렬 순서를 만들고, 그 대가로 인덱스를 잃는 것을 확인한다
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 ... end 가 select 절 안에 그대로 들어갑니다.
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 = ... 로 변수에 담으면
select 와 orderBy 와 groupBy 에 같은 표현을 넣을 수 있습니다.
인라인으로 세 번 쓰면 셋 중 하나만 고치는 사고가 납니다.
타입별 반환 표현
then 에 무엇을 넣느냐에 따라 반환 타입이 달라집니다.
then 인자 | 결과 타입 | 쓸 수 있는 연산 |
|---|
String | StringExpression | concat, like, lower |
Integer / Long | NumberExpression<T> | sum, avg, add, desc |
BigDecimal | NumberExpression<BigDecimal> | sum, multiply |
경로 (예: order.totalAmount) | 그 경로의 타입 | 경로와 동일 |
Boolean | BooleanExpression | and, or, not |
then 들의 타입이 서로 다르면 컴파일 에러입니다. 이건 QueryDSL 이 잡아 줍니다.
13-2. case 를 orderBy 에 — 커스텀 정렬 순서
등급을 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).
⚠️ 함정 — orderBy 의 case 는 인덱스를 쓸 수 없습니다
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만 행이 되면 문제가 됩니다. 그때는 아래 두 대안 중 하나를 씁니다.
- 정렬용 숫자 컬럼을 테이블에 둡니다.
grade_rank TINYINT 를 만들고 인덱스를 겁니다.
등급 체계가 바뀌는 일은 거의 없으므로 비정규화 비용이 낮습니다.
- 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 ?
SQL 에 'A' 가 없습니다.
값은 정상적으로 붙어 나오는데 SQL 에는 흔적이 없습니다.
QueryDSL 이 "이건 행마다 달라지지 않는 값이니 DB 에 보낼 이유가 없다"고 판단해
JPQL 에서 빼고 결과 조립 단계에서 붙입니다.
이 최적화는 상황에 따라 적용될 수도 있고 아닐 수도 있습니다.
Expressions.constant 를 where 조건이나 다른 표현식의 피연산자로 쓰면
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. 문자열 연산 — concat 과 stringValue()
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> 를 넘김
concat 은 Expression<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)
💡 실무 팁 — 문자열 조립은 애플리케이션에서 하는 편이 낫습니다
cast 와 concat 이 중첩될수록 SQL 은 읽기 어려워지고, 그 결과 컬럼에는 인덱스도 못 탑니다.
DTO 로 name 과 points 를 따로 받아 자바에서 조립하는 편이
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 와 달리 산술 연산은 안전합니다.
⚠️ 함정 — BigDecimal 의 divide 와 스케일
위 결과의 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.000000 과 30.00 은 equals 가 false 입니다.
비교는 compareTo(...) == 0 으로 하십시오.
- 금액 정산처럼 정확성이 요구되는 계산은 한쪽에서만 하십시오. DB 와 자바에 나눠 두면
반올림 지점이 두 곳이 되어 1원씩 어긋나기 시작합니다.
divide 로 0 을 나누면 MySQL 은 기본 설정에서 NULL 을 반환합니다 (ERROR_FOR_DIVISION_BY_ZERO
가 sql_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
coalesce 가 sum 을 감싼다는 데 주의하십시오.
order.totalAmount.coalesce(BigDecimal.ZERO).sum() 은 의미가 완전히 다릅니다.
그건 "각 행의 금액이 NULL 이면 0 으로 보고 더하라"이고,
total_amount 는 NOT 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) 는 a 와 b 가 같으면 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 팩토리 총정리
Expressions 는 Q타입 경로로 표현할 수 없는 것을 만들 때 쓰는 정적 팩토리입니다.
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) | BooleanExpression | AND 결합, null 인자 무시 | 안전 |
Expressions.anyOf(a, b, c) | BooleanExpression | OR 결합, 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)
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 5 | Hibernate 6 |
|---|
| 방식 A | Dialect 상속 후 생성자에서 registerFunction(...) | Dialect 상속 후 함수 기여 지점 오버라이드 |
| 방식 B | MetadataBuilder.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_date 는 DATETIME (소수 초 없음) 이므로 이 예제에서는 문제가 없지만,
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. 정리 — 표현식을 고르는 순서
이 스텝에서 다룬 도구들을 안전한 순서로 나열하면 이렇게 됩니다.
위에서부터 시도하고, 안 될 때만 아래로 내려가십시오.
| 순위 | 도구 | 타입 안전 | 이식성 | 인젝션 위험 |
|---|
| 1 | Q타입 경로 메서드 (.eq, .concat, .year()) | 완전 | 높음 | 없음 |
| 2 | CaseBuilder, coalesce, nullif | 완전 | 높음 | 없음 |
| 3 | Expressions.constant / asNumber / allOf / anyOf | 완전 | 높음 | 없음 |
| 4 | Expressions.*Template + 상수 문자열 + {n} 인자 | 부분 | 낮음 | 없음 |
| 5 | Expressions.*Template + 조립된 문자열 | 없음 | 낮음 | 높음 |
5번은 선택지가 아닙니다. 표에 넣은 것은 그것을 알아보기 위해서입니다.
4번까지 내려왔다면 다음을 함께 남기십시오.
- 왜 표준 방법으로 안 되는지 주석 한 줄
- 템플릿 상수를
private static final 로 분리
- 그 쿼리를 검증하는 테스트 (DB 를 바꿀 때 여기서 깨져야 합니다)
정리
| 개념 | 핵심 |
|---|
| 단순 case | path.when(v).then(r).otherwise(d) — 비교값과 결과값 모두 ? 바인딩 |
| 복합 case | new CaseBuilder().when(조건식)... — 임의 조건 분기 |
| case 정렬 | orderBy 에 넣으면 select 와 order by 에 두 번 들어가고 인덱스를 못 씀 |
| 조건부 집계 | new CaseBuilder().when(...).then(금액).otherwise(ZERO).sum() — 한 번 읽고 피벗 |
constant | 최적화로 JPQL 에서 빠질 수 있음. 쓰인 위치에 따라 다르니 SQL 로 확인 |
concat | 2항. 체인하면 중첩 concat. NULL 이 섞이면 전체 NULL |
stringValue() | cast(x as char). enum/숫자를 문자열 연산에 넣을 때 필수 |
| 산술 | 체인 순서대로 좌결합. 괄호는 QueryDSL 이 넣어 줌 |
BigDecimal.divide | DB 는 반올림, 자바는 예외. compareTo 로 비교하고 스케일을 명시 |
coalesce | .sum().coalesce(ZERO) 가 맞고 .coalesce(ZERO).sum() 은 다른 뜻 |
nullif | 0 을 NULL 로 → coalesce 로 다시 0. "0으로 나누면 0" 규칙을 SQL 에 명시 |
allOf/anyOf | null 인자를 무시하며 결합. 괄호를 잃을 수 없는 구조 |
| DB 함수 | function('name', {0}, {1}) 이 표준. 이름 직접 호출은 Dialect 의존 |
| 커스텀 함수 | Hibernate 5 applySqlFunction → Hibernate 6 FunctionContributor. 시그니처는 문서 확인 |
| 템플릿 인젝션 | 템플릿 문자열은 컴파일 시점 상수만. 값은 예외 없이 {n} 으로 |
| 인젝션 신호 | 템플릿 첫 인자의 +, format(, 작은따옴표 리터럴 |
| 날짜 | 집계는 year()/month(), 필터는 between / goe+lt |
연습문제
Exercise.java 에 7문제가 있습니다. 정답은 Solution.java.
CaseBuilder 로 주문 금액을 10만원 미만 → "소액", 10만~50만 → "중액", 50만 이상 → "고액" 으로
분류하고, 분류별 주문 건수를 세십시오. 생성 SQL 에 case 가 몇 번 나오는지 확인하십시오.
- 고객을 등급 순(VIP → GOLD → SILVER → BRONZE)으로 정렬해 이름과 등급을 출력하십시오.
그리고 그 SQL 을
EXPLAIN 에 넣었을 때 Extra 에 무엇이 뜨는지 예측한 뒤 확인하십시오.
- 도시별로
PAID 매출 합계와 CANCELLED 건수를 한 행에 뽑는 쿼리를 조건부 집계로 작성하십시오.
단, 매출 합계가 NULL 이 되지 않도록 처리하십시오.
- 전화번호가 NULL 인 고객 3명을 포함해 전 고객의
"이름(전화번호)" 문자열을 만드십시오.
NULL 인 경우 "이름(미등록)" 이 나와야 합니다.
concat 만 쓰면 왜 NULL 이 나오는지 SQL 로 설명하십시오.
- 상품의 마진율(
(price - cost) / price * 100)을 소수점 둘째 자리까지 계산하되,
price 가 0 인 경우에도 예외 없이 0 이 나오도록 작성하십시오.
Expressions.stringTemplate 으로 주문 날짜를 yyyy년 MM월 형태로 포맷하는 코드를 작성하십시오.
포맷 문자열을 메서드 파라미터로 받되 인젝션이 불가능한 형태여야 합니다.
그리고 인젝션이 가능한 잘못된 버전도 함께 작성해 두 SQL 을 비교하십시오.
- 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가지 패턴"에서
// 같은 형태로 반복됩니다.
}
}