Step 04 — 조건과 동적 쿼리

학습 목표

  • where() 가 받는 BooleanExpression 이 무엇이고, customer.grade.eq(...) 가 왜 그 타입을 돌려주는지 설명한다
  • eq/in/between/like/contains/isNull 등 조건 메서드가 각각 어떤 SQL 조각과 바인딩을 만드는지 로그로 확인한다
  • where(a, b, c) 의 varargs 콤마가 and 라는 것과, where(null) 이 조용히 무시된다는 것을 확인한다
  • BooleanExpression 을 반환하는 메서드를 조립해 if 문 없는 동적 쿼리를 작성한다
  • BooleanBuilder 와 비교해 왜 BooleanExpression 방식이 선호되는지 근거를 댄다
  • or 를 체이닝에 섞었을 때 괄호가 사라져 결과 건수가 달라지는 것을 재현하고, 세 가지 처방을 적용한다
  • eq(null)NOT IN + NULL 에서 SQL 의 3값 논리가 그대로 적용된다는 것을 확인한다

선행 스텝: Step 03 — 기본 조회 예상 소요: 100분


4-0. 이 스텝이 QueryDSL 을 쓰는 이유입니다

Step 03 까지의 내용은 사실 JPQL 문자열로도 다 할 수 있습니다. 타입 안전성은 이득이지만, 그것만으로 빌드 설정을 늘리고 Q타입을 생성할 만큼은 아닐 수도 있습니다.

그런데 아래 요구사항이 들어오면 이야기가 달라집니다.

고객 검색 화면. 등급·도시·최소 포인트 세 조건 중 사용자가 입력한 것만 적용해 주십시오.

JPQL 문자열로는 이렇게 됩니다.

StringBuilder jpql = new StringBuilder("select c from Customer c where 1=1");
if (grade != null)     jpql.append(" and c.grade = :grade");
if (city != null)      jpql.append(" and c.city = :city");
if (minPoints != null) jpql.append(" and c.points >= :minPoints");

TypedQuery<Customer> query = em.createQuery(jpql.toString(), Customer.class);
if (grade != null)     query.setParameter("grade", grade);
if (city != null)      query.setParameter("city", city);
if (minPoints != null) query.setParameter("minPoints", minPoints);

if 가 여섯 번 나오고, 앞뒤 두 묶음이 항상 짝을 이뤄야 합니다. 하나만 빠뜨리면 IllegalArgumentException 이거나, 더 나쁘게는 조건이 조용히 빠진 SQL 입니다. 그리고 where 1=1 이라는, 오로지 and 를 편하게 붙이려고 존재하는 관용구가 등장합니다.

같은 것을 QueryDSL 로 쓰면 이렇게 됩니다.

queryFactory
        .selectFrom(customer)
        .where(gradeEq(grade), cityEq(city), pointsGoe(minPoints))
        .fetch();

if 가 한 번도 없습니다. 이 스텝은 이 다섯 줄을 이해하기 위한 것이고, 그 열쇠는 where(null) 이 무시된다는 성질 하나입니다.


4-1. BooleanExpression — where() 가 받는 것

where() 의 시그니처는 where(Predicate... o) 입니다. Predicate 는 "참/거짓을 판정하는 표현식"을 뜻하는 QueryDSL 의 최상위 인터페이스이고, 우리가 실제로 쓰는 것은 그 구현체인 BooleanExpression 입니다.

BooleanExpression cond = customer.grade.eq(Grade.VIP);

System.out.println(cond.getClass().getName());
System.out.println(cond);

결과

com.querydsl.core.types.dsl.BooleanOperation
customer.grade = VIP

customer.grade.eq(...) 는 SQL 을 실행하지 않습니다. "grade 가 VIP 와 같다"는 조건을 객체로 만들어 돌려줄 뿐입니다. Step 03 의 지연 실행과 같은 이야기입니다. 조건은 값이고, 값이므로 변수에 담고, 메서드가 돌려주고, 리스트에 넣고, 다른 조건과 합칠 수 있습니다.

BooleanExpression isVip = customer.grade.eq(Grade.VIP);
BooleanExpression inSeoul = customer.city.eq("서울");

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(isVip.and(inSeoul))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] '서울'
조회 2건 — 김서준, 류하나

조건을 변수에 담을 수 있다는 것이 이 스텝 전체의 토대입니다. 문자열 조립과의 결정적 차이가 여기 있습니다. 문자열은 합칠 때 띄어쓰기와 괄호를 사람이 관리해야 하지만, BooleanExpression트리 구조라 합치는 방식이 코드로 명시됩니다. 다만 그 "합치는 방식"을 잘못 쓰면 4-7 의 함정이 됩니다.

Q타입 필드의 타입에 따라 쓸 수 있는 조건 메서드가 달라집니다.

Q타입 필드클래스대표 메서드
customer.nameStringPatheq, like, contains, startsWith, upper()
customer.pointsNumberPath<Integer>eq, goe, lt, between, sum()
customer.gradeEnumPath<Grade>eq, ne, in
customer.createdAtDateTimePath<LocalDateTime>before, after, between
customer.ordersListPath<Order, QOrder>isEmpty, isNotEmpty, size()

customer.points.startsWith("1")컴파일 에러입니다. 숫자에 문자열 연산을 걸 수 없다는 것을 컴파일러가 압니다. Step 02 에서 Q타입이 왜 필드마다 다른 클래스를 쓰는지 설명했던 이유가 이것입니다.


4-2. 조건 메서드 총정리

자주 쓰는 것을 한 표에 모읍니다. c1_0 은 Hibernate 6 이 붙인 별칭입니다.

메서드생성되는 SQL 조각바인딩
.eq(v)c1_0.col = ?v
.ne(v)c1_0.col != ?v
.in(a, b)c1_0.col in (?, ?)a, b
.notIn(a, b)c1_0.col not in (?, ?)a, b
.between(a, b)c1_0.col between ? and ?a, b
.goe(v)c1_0.col >= ?v
.gt(v)c1_0.col > ?v
.loe(v)c1_0.col <= ?v
.lt(v)c1_0.col < ?v
.like("김%")c1_0.col like ? escape '!''김%'
.contains("김")c1_0.col like ? escape '!''%김%'
.startsWith("김")c1_0.col like ? escape '!''김%'
.endsWith("준")c1_0.col like ? escape '!''%준'
.isNull()c1_0.col is null없음
.isNotNull()c1_0.col is not null없음
.isEmpty()not exists (select 1 from ...)없음
.isNotEmpty()exists (select 1 from ...)없음

표만 보면 다 아는 것 같지만, like 세 형제는 반드시 로그로 확인해야 합니다. 생성되는 SQL 이 셋 다 똑같기 때문입니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(customer.name.contains("김"))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.name like ? escape '!'
바인딩: [1] '%김%'
조회 3건 — 김서준, 김도현, 김나윤

SQL 에는 % 가 없습니다. %김%바인딩 파라미터 값으로 들어갑니다. 그래서 SQL 로그만 보면 contains 인지 startsWith 인지 like 인지 구분할 수 없습니다. 바인딩 로그(org.hibernate.orm.jdbc.bind)를 켜야 하는 이유가 이것입니다.

세 개를 나란히 실행하면 이렇게 됩니다.

코드SQL바인딩 값결과
.startsWith("김")c1_0.name like ? escape '!''김%'3건
.contains("김")c1_0.name like ? escape '!''%김%'3건
.endsWith("준")c1_0.name like ? escape '!''%준'2건
.like("김_")c1_0.name like ? escape '!''김_'0건

마지막 like%_직접 넣어야 합니다. contains 처럼 자동으로 붙여 주지 않습니다.

💡 실무 팁 — escape '!' 는 무엇인가 QueryDSL 이 자동으로 붙입니다. 검색어에 %_ 가 들어 있을 때 그것을 와일드카드가 아닌 리터럴로 다루기 위한 이스케이프 문자 지정입니다. 사용자가 검색창에 50% 라고 입력해도 contains("50%")'%50!%%' 로 이스케이프되어 "50% 라는 글자"를 찾습니다. 문자열 JPQL 을 손으로 조립하면 이 처리를 직접 해야 하고, 대부분 잊습니다.

⚠️ 함정 — contains 는 인덱스를 못 씁니다 '%김%' 는 앞에 % 가 붙은 LIKE 이고, B+Tree 인덱스는 앞부분이 고정돼야 탈 수 있습니다. MySQL8 코스 Step 15 — 인덱스 에서 확인한 그대로입니다. 30행짜리 customers 에서는 체감이 없지만, 수십만 행 테이블에 contains 검색을 걸면 풀 스캔입니다. startsWith 로 대체할 수 있으면 대체하고, 안 되면 전문 검색(Full-Text)이나 검색 엔진을 검토하십시오.

숫자와 날짜 조건도 확인합니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(customer.points.between(10000, 30000))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.points between ? and ?
바인딩: [1] 10000  [2] 30000
조회 9건

in 은 여러 값을 한 번에 받습니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(customer.grade.in(Grade.VIP, Grade.GOLD))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade in (?, ?)
바인딩: [1] Grade.VIP  [2] Grade.GOLD
조회 13건

in 은 컬렉션도 받습니다. customer.grade.in(List.of(Grade.VIP, Grade.GOLD)) 도 같은 SQL 입니다. 이 형태를 4-7 의 처방에서 다시 씁니다.

isNull / isNotNull 은 바인딩이 없습니다.

List<Customer> noPhone = queryFactory
        .selectFrom(customer)
        .where(customer.phone.isNull())
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.phone is null
바인딩: 없음
조회 3건

마지막으로 컬렉션 조건입니다. isEmpty()exists 서브쿼리가 됩니다.

List<Customer> neverOrdered = queryFactory
        .selectFrom(customer)
        .where(customer.orders.isEmpty())
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where not exists (select 1 from orders o1_0 where c1_0.customer_id = o1_0.customer_id)
조회 0건 — 30명 모두 주문 이력이 있습니다

한 줄짜리 자바 코드가 서브쿼리를 만들어 냈다는 점을 기억해 두십시오. Step 07 — 서브쿼리 에서 이 구조를 직접 씁니다.


4-3. and / or 체이닝과 varargs

조건을 합치는 방법이 두 가지입니다.

(1) 체이닝.and() / .or() 를 이어 붙입니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(customer.grade.eq(Grade.VIP).and(customer.city.eq("서울")))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] '서울'
조회 2건 — 김서준, 류하나

(2) varargswhere() 에 콤마로 나열합니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.grade.eq(Grade.VIP),
                customer.city.eq("서울")
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] '서울'
조회 2건 — 김서준, 류하나

완전히 같은 SQL 입니다. 여기서 반드시 외워야 할 규칙이 나옵니다.

where(a, b, c) 의 콤마는 and 입니다. or 가 아닙니다.

QueryDSL 은 varargs 로 받은 조건들을 and 로 묶습니다. 그리고 이 규칙에 예외가 없기 때문에, or 를 varargs 로 표현할 방법이 없습니다. or 는 반드시 하나의 표현식 안에서 체이닝으로 만들어야 합니다. 이 비대칭이 4-7 함정의 원인입니다.

세 개 이상도 마찬가지입니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.grade.eq(Grade.GOLD),
                customer.city.eq("서울"),
                customer.points.goe(10000)
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.city = ? and c1_0.points >= ?
바인딩: [1] Grade.GOLD  [2] '서울'  [3] 10000
조회 3건 — 안지수, 한지호, 오하윤

4-4. where(null) 은 무시된다

이 절이 동적 쿼리의 전부입니다. 한 줄로 요약됩니다.

where() 에 넘긴 인자가 null 이면 QueryDSL 은 그 조건을 없는 것처럼 취급합니다.

직접 확인합니다. 가운데 조건을 null 로 넣습니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.grade.eq(Grade.GOLD),
                null,                              // ← null
                customer.points.goe(10000)
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.points >= ?
바인딩: [1] Grade.GOLD  [2] 10000
조회 6건

null 이 있던 자리가 SQL 에서 통째로 사라졌습니다. and null 도 아니고 and 1=1 도 아니고, 아무 흔적이 없습니다. NPE 도 나지 않습니다.

전부 null 이면 where 절 자체가 사라집니다.

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

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
바인딩: 없음
조회 30건 — 전체

where 라는 단어조차 나오지 않습니다. where 1=1 같은 관용구가 필요 없는 이유입니다.

⚠️ 함정 — and(null) 은 다릅니다 무시되는 것은 where() 의 인자이지 and() 의 인자가 아닙니다.

BooleanExpression cond = null;
.where(customer.grade.eq(Grade.GOLD).and(cond))   // cond 가 null

QueryDSL 의 and(null) 은 자기 자신을 그대로 돌려주도록 구현돼 있어 이 경우도 동작하지만, 체인의 첫 조각이 null 이면 이야기가 다릅니다.

BooleanExpression first = null;
.where(first.and(customer.city.eq("서울")))       // ← NullPointerException

null.and(...) 는 그냥 자바의 NPE 입니다. 동적 조건을 체이닝으로 이어 붙일 때 "첫 조건이 없을 수도 있다"는 경우를 처리하려면 결국 if 가 돌아옵니다. 동적 조건은 체이닝이 아니라 varargs 자리에 나열하십시오. 그것이 4-5 의 패턴입니다.


4-5. 동적 쿼리 — BooleanExpression 반환 메서드 조립

이제 4-0 의 요구사항을 완성합니다. 핵심은 조건 하나당 메서드 하나를 만들고, 값이 없으면 null 을 돌려주는 것입니다.

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

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

private BooleanExpression pointsGoe(Integer minPoints) {
    return minPoints != null ? customer.points.goe(minPoints) : null;
}

public List<Customer> search(Grade grade, String city, Integer minPoints) {
    return queryFactory
            .selectFrom(customer)
            .where(gradeEq(grade), cityEq(city), pointsGoe(minPoints))
            .fetch();
}

search 메서드에 if 가 없습니다. 조건이 있으면 BooleanExpression 이 반환되어 and 로 붙고, 없으면 null 이 반환되어 사라집니다.

💡 문자열 조건은 city != null 이 아니라 StringUtils.hasText(city) 를 쓰십시오. HTTP 요청 파라미터는 미입력 시 null 이 아니라 빈 문자열 "" 로 들어오는 경우가 많습니다. city != null 만 검사하면 where c1_0.city = '' 라는, 결과가 0건인 조건이 붙습니다. 예외도 안 나고 화면만 비어 있는 전형적인 "조용한 버그"입니다.

조합별 생성 SQL 전부

호출 인자에 따라 SQL 이 어떻게 달라지는지 전부 확인합니다. 하나의 메서드가 만들어 내는 SQL 입니다.

(1) search(null, null, null) — 조건 없음

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
바인딩: 없음
조회 30건

(2) search(Grade.GOLD, null, null) — 등급만

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ?
바인딩: [1] Grade.GOLD
조회 9건

(3) search(null, "서울", null) — 도시만

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.city = ?
바인딩: [1] '서울'
조회 8건

(4) search(null, null, 10000) — 최소 포인트만

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.points >= ?
바인딩: [1] 10000
조회 12건

(5) search(Grade.GOLD, "서울", null) — 등급 + 도시

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.city = ?
바인딩: [1] Grade.GOLD  [2] '서울'
조회 4건 — 안지수, 한지호, 오하윤, 문시우

(6) search(Grade.GOLD, "서울", 10000) — 셋 다

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.city = ? and c1_0.points >= ?
바인딩: [1] Grade.GOLD  [2] '서울'  [3] 10000
조회 3건 — 안지수, 한지호, 오하윤

여섯 개의 SQL 이 하나의 메서드 다섯 줄에서 나왔습니다. if 는 조건 메서드 안에 하나씩, 총 세 번만 존재합니다.

이 패턴의 진짜 이득 — 조합 가능성

BooleanExpression 을 반환한다는 것은 조건 메서드끼리 다시 합칠 수 있다는 뜻입니다.

// "서울에 사는 GOLD" 를 자주 쓴다면, 조건을 합친 메서드를 하나 더 만듭니다
private BooleanExpression seoulGold() {
    return gradeEq(Grade.GOLD).and(cityEq("서울"));
}

// "우수 고객" 의 정의를 한 곳에 모읍니다
private BooleanExpression isPremium() {
    return customer.grade.in(Grade.VIP, Grade.GOLD)
            .and(customer.points.goe(10000));
}
List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(isPremium())
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade in (?, ?) and c1_0.points >= ?
바인딩: [1] Grade.VIP  [2] Grade.GOLD  [3] 10000
조회 10건

"우수 고객"이라는 비즈니스 규칙이 코드 한 곳에 있습니다. 규칙이 바뀌면 이 메서드만 고칩니다. 여러 쿼리에 흩어진 where 절을 찾아다닐 필요가 없습니다.

💡 실무 팁 — 조건 메서드는 null 반환 가능성을 이름이나 주석에 남기십시오 gradeEq(Grade)null 을 돌려줄 수 있습니다. 그것을 모르고 .and() 로 체이닝하면 4-4 의 NPE 를 만납니다. 팀 규칙으로 "동적 조건 메서드는 varargs 자리에만 넣는다" 를 정하거나, @Nullable 을 붙이십시오.


4-6. BooleanBuilder — 그리고 왜 덜 쓰는가

같은 동적 쿼리를 BooleanBuilder 로도 쓸 수 있습니다. 오래된 코드베이스에서 자주 보이는 형태입니다.

public List<Customer> searchWithBuilder(Grade grade, String city, Integer minPoints) {
    BooleanBuilder builder = new BooleanBuilder();

    if (grade != null) {
        builder.and(customer.grade.eq(grade));
    }
    if (StringUtils.hasText(city)) {
        builder.and(customer.city.eq(city));
    }
    if (minPoints != null) {
        builder.and(customer.points.goe(minPoints));
    }

    return queryFactory
            .selectFrom(customer)
            .where(builder)
            .fetch();
}

결과searchWithBuilder(Grade.GOLD, "서울", 10000)

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? and c1_0.city = ? and c1_0.points >= ?
바인딩: [1] Grade.GOLD  [2] '서울'  [3] 10000
조회 3건

생성 SQL 이 4-5 와 완전히 같습니다. 기능적으로 못 하는 일은 없습니다. 그럼에도 대부분의 팀이 BooleanExpression 방식을 표준으로 삼는 이유가 있습니다.

기준BooleanExpression 조립BooleanBuilder
가독성where(gradeEq(g), cityEq(c), pointsGoe(p)) — 조건이 한 줄에 보임if 블록을 위에서 아래로 읽어야 조건 파악
재사용조건 메서드를 다른 쿼리에서 그대로 호출빌더 조립 로직을 통째로 복사하거나 메서드 추출
조합 가능성gradeEq(g).and(cityEq(c)) 로 메서드끼리 합침빌더끼리 합치려면 builder.and(otherBuilder) — 결과가 무엇인지 불명확
null 처리null 반환 → where 가 무시. if 가 조건 메서드 안에 캡슐화if 를 조립부에 노출. 조건이 늘면 if 도 늘어남
초기값 문제없음new BooleanBuilder() 는 비어 있고, 아무것도 and 안 하면 where 절이 사라짐 (의도인지 실수인지 구분 불가)
테스트조건 메서드를 단독으로 단언 가능쿼리를 실행해야 검증 가능
where 절 확인코드만 보면 어떤 조건이 후보인지 명확런타임에 어떤 조건이 들어갔는지 추적해야 함

특히 초기값 문제가 실무에서 사고를 냅니다.

BooleanBuilder builder = new BooleanBuilder();
// 조건을 하나도 안 붙였습니다 (버그로 if 조건이 다 false 였다고 합시다)

queryFactory.selectFrom(customer).where(builder).fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
조회 30건 — 전체 조회

빈 빌더는 조용히 "조건 없음"이 됩니다. 30행이면 괜찮지만, 삭제나 수정 쿼리에서 이러면 전체 행이 대상이 됩니다. BooleanExpression 방식도 전부 null 이면 같은 결과지만, where(gradeEq(g), cityEq(c)) 라는 코드는 "조건이 없을 수도 있다"는 것이 호출부에 그대로 드러납니다. 빌더는 조립 로직 안에 숨습니다.

💡 실무 팁 — BooleanBuilder 를 써야 할 때도 있습니다 조건 개수가 런타임에 정해지는 경우(예: 사용자가 필터를 N개 추가하는 화면)에는 반복문 안에서 builder.and(...) 를 누적하는 편이 자연스럽습니다. BooleanExpression 배열을 만들어 넘기는 것도 가능하지만, 그때는 빌더가 더 읽힙니다. 기본은 BooleanExpression, 반복 누적이 필요할 때만 빌더 로 기억하십시오.


4-7. ⚠️ or 를 섞으면 괄호가 사라진다

이 스텝의 핵심 함정입니다. 컴파일도 되고, 예외도 안 나고, SQL 도 정상이고, 결과 건수만 다릅니다.

의도한 쿼리

"VIP 또는 GOLD 등급이면서, 서울에 사는 고객"

논리식으로 쓰면 (VIP or GOLD) and 서울 입니다. 데이터를 미리 확인해 둡니다.

전체서울
VIP4명2명 (김서준, 류하나)
GOLD9명4명 (안지수, 한지호, 오하윤, 문시우)

따라서 정답은 6명입니다.

올바른 코드 — varargs 로 나눈다

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.grade.eq(Grade.VIP).or(customer.grade.eq(Grade.GOLD)),
                customer.city.eq("서울")
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] Grade.GOLD  [3] '서울'
조회 6건 — 김서준, 류하나, 안지수, 한지호, 오하윤, 문시우

괄호가 붙었습니다. varargs 의 첫 번째 인자 전체가 하나의 표현식이므로, QueryDSL 이 and 로 묶으면서 괄호를 씌워 줍니다. 의도한 6건.

실수 ① — .and().or() 를 이어 붙였다

가장 흔한 실수입니다. 사람은 "서울에 살고, VIP 또는 GOLD" 라고 말한 순서 그대로 씁니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.city.eq("서울")
                        .and(customer.grade.eq(Grade.VIP))
                        .or(customer.grade.eq(Grade.GOLD))
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.city = ? and c1_0.grade = ? or c1_0.grade = ?
바인딩: [1] '서울'  [2] Grade.VIP  [3] Grade.GOLD
조회 11건 — 김서준, 류하나, 안지수, 한지호, 오하윤, 문시우,
           강도윤, 윤서아, 임하준, 조은우, 신지아

괄호가 하나도 없습니다. 메서드 체이닝은 왼쪽에서 오른쪽으로 평가되므로 (서울 and VIP) or GOLD 가 됐습니다. SQL 로 넘어가서는 andor 보다 우선순위가 높으니 결과가 같습니다.

  • 의도: 서울 사는 VIP/GOLD → 6명
  • 실제: (서울 사는 VIP 2명) 또는 (전국의 GOLD 9명) → 11명

부산의 강도윤, 인천의 임하준, 광주의 신지아가 결과에 들어왔습니다. 서울을 조건으로 넣었는데 서울이 아닌 사람이 나옵니다.

실수 ② — 괄호는 쳤는데 위치를 잘못 잡았다

"괄호가 필요하다"는 것까지는 알았지만 어디에 치는지 헷갈린 경우입니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.grade.eq(Grade.VIP)
                        .or(customer.grade.eq(Grade.GOLD).and(customer.city.eq("서울")))
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade = ? or c1_0.grade = ? and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] Grade.GOLD  [3] '서울'
조회 8건 — 김서준, 류하나, 정  훈, 배채영, 안지수, 한지호, 오하윤, 문시우

A or (B and C) 입니다. 전국의 VIP 4명 + 서울 GOLD 4명 = 8명. 부산의 정 훈, 대구의 배채영이 들어왔습니다.

세 SQL 을 나란히

코드생성 SQL 의 where 절결과
의도where(A.or(B), C)where (grade = ? or grade = ?) and city = ?6건
실수 ①where(C.and(A).or(B))where city = ? and grade = ? or grade = ?11건 ❌
실수 ②where(A.or(B.and(C)))where grade = ? or grade = ? and city = ?8건 ❌

셋 다 컴파일 성공, 실행 성공, 예외 없음입니다. 6 / 11 / 8. 결과 건수만 다릅니다.

⚠️ 함정 — 이런 버그는 테스트를 통과합니다 개발 데이터가 서울 고객만으로 채워져 있으면 세 코드가 전부 같은 결과를 냅니다. 운영 데이터가 들어오는 순간 조용히 벌어집니다. 그리고 "왜 부산 고객이 서울 필터에 나오죠?" 라는 문의로 발견됩니다. 그때쯤이면 이미 그 쿼리를 여러 화면이 복사해 쓰고 있습니다.

근본 원인은 하나입니다. .and() / .or() 체이닝에는 우선순위가 없고, 무조건 왼쪽부터 묶입니다. 자바 코드의 a && b || c&& 가 먼저지만, 메서드 체인의 a.and(b).or(c) 는 그냥 순서대로입니다. 눈은 자바 연산자 우선순위를 기대하는데 코드는 체이닝 순서를 따릅니다. 이 어긋남이 함정의 정체입니다.

처방 세 가지

처방 1 — or 그룹을 별도 메서드로 뽑는다 (가장 권장)

or 로 묶인 덩어리를 이름 있는 하나의 조건으로 만들어 varargs 자리에 넣습니다.

private BooleanExpression isVipOrGold() {
    return customer.grade.eq(Grade.VIP).or(customer.grade.eq(Grade.GOLD));
}

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(isVipOrGold(), customer.city.eq("서울"))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] Grade.GOLD  [3] '서울'
조회 6건 ✅

or 가 메서드 안에 갇혀 있으므로 밖에서 체이닝 순서를 헷갈릴 여지가 없습니다. 게다가 isVipOrGold() 라는 이름이 비즈니스 의미를 드러냅니다.

처방 2 — Expressions.allOf / Expressions.anyOf

anyOfor, allOfand 로 묶습니다. 괄호를 라이브러리가 관리합니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                Expressions.allOf(
                        Expressions.anyOf(
                                customer.grade.eq(Grade.VIP),
                                customer.grade.eq(Grade.GOLD)
                        ),
                        customer.city.eq("서울")
                )
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] Grade.GOLD  [3] '서울'
조회 6건 ✅

중첩이 깊어지면 읽기 어려워지지만, 괄호 구조가 코드 들여쓰기와 1:1로 대응한다는 장점이 있습니다. 조건이 동적으로 결정되는 or 그룹에도 잘 맞습니다. Expressions.anyOfnull 인자를 무시합니다.

처방 3 — 애초에 or 를 쓰지 않는다

같은 컬럼에 대한 or 는 대부분 in 으로 바꿀 수 있습니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.grade.in(Grade.VIP, Grade.GOLD),
                customer.city.eq("서울")
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.grade in (?, ?) and c1_0.city = ?
바인딩: [1] Grade.VIP  [2] Grade.GOLD  [3] '서울'
조회 6건 ✅

or 가 사라졌으니 괄호를 걱정할 일도 없습니다. SQL 도 더 짧고, 옵티마이저도 in 을 더 잘 다룹니다. 같은 컬럼의 orin 으로 바꿀 수 있는지 먼저 확인하십시오.

정리하면 이렇습니다.

상황처방
같은 컬럼의 orin 으로 교체 (처방 3)
다른 컬럼의 or 그룹별도 메서드로 추출 (처방 1)
or 그룹 자체가 동적Expressions.anyOf (처방 2)
절대 하지 말 것where 안에서 .and().or() 를 섞어 체이닝

4-8. null 안전 조건 — isNull() vs eq(null)

customers 30명 중 phone 이 NULL 인 사람은 3명입니다. 이들을 찾는 코드입니다.

List<Customer> noPhone = queryFactory
        .selectFrom(customer)
        .where(customer.phone.isNull())
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.phone is null
조회 3건

그럼 eq(null) 은 어떨까요. 자바 감각으로는 "phone == null" 처럼 읽힙니다.

SQL 의 관점부터 확인합니다. MySQL8 코스 Step 05 — 연산자와 조건부록 A — NULL 완전 정복 에서 다룬 3값 논리입니다.

SELECT * FROM customers WHERE phone = NULL;

결과

Empty set (0.00 sec)

= NULL 은 참이 되지 않습니다. NULL 은 "값이 없음"이 아니라 "알 수 없음" 이고, "알 수 없는 것"과 무엇을 비교해도 결과는 참도 거짓도 아닌 UNKNOWN 입니다. WHERE 절은 UNKNOWN 을 통과시키지 않으므로 0건입니다. phone IS NULL 인 3명조차 나오지 않습니다.

QueryDSL 쪽 이야기입니다. customer.phone.eq(null) 은 컴파일이 됩니다. eq(String)null 을 넘기는 것이니까요. 그러나 이때 QueryDSL 이 취하는 동작은 버전과 경로에 따라 다를 수 있습니다. 조건 자체를 만들지 않고 null 을 돌려주는 경우도, 인자 검증에서 예외를 던지는 경우도, 그대로 = ?null 을 바인딩해 위 SQL 처럼 0건이 되는 경우도 보고돼 있습니다. 이 코스는 어느 하나로 단정하지 않습니다. 여러분이 쓰는 정확한 버전에서 직접 확인하십시오.

확인해야 할 이유는, 세 동작이 전부 다른 버그를 만들기 때문입니다.

만약 이렇게 동작한다면결과
조건을 무시 (null 반환)그 조건이 통째로 사라져 전체 조회
예외를 던짐즉시 실패 — 그나마 안전
= ?null 바인딩0건 (3값 논리)

"전체 조회"와 "0건"은 정반대 결과입니다. 어느 쪽이 나올지 코드를 봐서는 알 수 없다면, 그 코드는 쓰면 안 됩니다.

⚠️ 함정 — eq(변수) 에서 변수가 null 일 수 있다면 위험한 것은 리터럴 eq(null) 이 아닙니다. 그건 눈에 보입니다. 진짜 문제는 이것입니다.

public List<Customer> findByPhone(String phone) {
    return queryFactory.selectFrom(customer)
            .where(customer.phone.eq(phone))     // phone 이 null 로 들어올 수 있다면?
            .fetch();
}

호출부가 findByPhone(null) 을 하는 순간 위 표의 세 갈래 중 하나로 갑니다. 처방은 명시적으로 쓰는 것입니다.

.where(phone != null ? customer.phone.eq(phone) : customer.phone.isNull())

또는 그냥 "phone 이 없으면 이 조건을 빼겠다"가 의도라면 4-5 의 패턴을 쓰십시오.

private BooleanExpression phoneEq(String phone) {
    return phone != null ? customer.phone.eq(phone) : null;
}

지침은 하나입니다. NULL 을 찾고 싶으면 isNull() 을, 조건을 빼고 싶으면 null 반환을 쓰십시오. eq(null) 에 의미를 기대하지 마십시오.

isNotNull() 도 확인합니다.

List<Customer> hasPhone = queryFactory
        .selectFrom(customer)
        .where(customer.phone.isNotNull())
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.phone is not null
조회 27건

3 + 27 = 30. isNull()isNotNull() 은 전체를 정확히 둘로 나눕니다. = NULL!= NULL둘 다 0건이라 합이 0입니다. 이 대비가 3값 논리의 요약입니다.


4-9. NOT IN 과 NULL — SQL 의 3값 논리는 그대로 적용됩니다

📌 MySQL8 코스 Step 08 — 서브쿼리 에서 다룬 함정입니다. QueryDSL 로 써도 결과는 똑같습니다. QueryDSL 은 SQL 을 만들어 줄 뿐, SQL 의 의미를 바꾸지 않습니다.

특정 번호를 쓰는 고객을 제외하고 나머지를 찾습니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(customer.phone.notIn("010-1111-2222", "010-3333-4444"))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.phone not in (?, ?)
바인딩: [1] '010-1111-2222'  [2] '010-3333-4444'
조회 25건

25건입니다. 28건을 기대했다면 틀렸습니다.

고객은 30명이고 그 두 번호를 쓰는 사람이 2명이니 30 − 2 = 28이 상식입니다. 그런데 phone 이 NULL 인 3명이 결과에서 빠졌습니다.

이유는 4-8 과 같습니다. NOT IN 은 내부적으로 이렇게 전개됩니다.

phone NOT IN ('010-1111-2222', '010-3333-4444')
  ≡  phone != '010-1111-2222' AND phone != '010-3333-4444'

phone 이 NULL 이면 각 비교가 UNKNOWN 이고, UNKNOWN AND UNKNOWN 은 UNKNOWN 입니다. WHERE 는 UNKNOWN 을 통과시키지 않으므로 그 행은 제외됩니다.

⚠️ 함정 — "제외했더니 있어야 할 행까지 사라졌다" 이 버그의 특징은 누락 방향이라는 것입니다. 있어야 할 데이터가 안 나오는 쪽이라 화면에서 즉시 티가 나지 않습니다. "전체 30명인데 목록에 25명뿐" 을 알아채려면 누군가 세어 봐야 합니다.

그리고 이 함정은 컬럼이 NULL 을 허용하는 한 언제든 발생합니다. 지금은 안 나던 버그가 "phone 을 선택 입력으로 바꾸자"는 기획 변경 한 줄로 생겨납니다.

처방

처방 1 — NULL 을 명시적으로 살린다

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(
                customer.phone.notIn("010-1111-2222", "010-3333-4444")
                        .or(customer.phone.isNull())
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where c1_0.phone not in (?, ?) or c1_0.phone is null
바인딩: [1] '010-1111-2222'  [2] '010-3333-4444'
조회 28건 ✅

or 를 썼습니다. 4-7 을 배웠으니, 여기에 조건이 하나라도 더 붙는다면 이 표현식을 메서드로 뽑아야 한다는 것도 아실 겁니다.

private BooleanExpression phoneNotIn(String... phones) {
    return customer.phone.notIn(phones).or(customer.phone.isNull());
}

처방 2 — coalesce 로 NULL 을 값으로 바꾼다

List<Customer> result = queryFactory
        .selectFrom(customer)
        .where(customer.phone.coalesce("").asString()
                .notIn("010-1111-2222", "010-3333-4444"))
        .fetch();

결과

select c1_0.customer_id, c1_0.city, c1_0.created_at, c1_0.email,
       c1_0.grade, c1_0.name, c1_0.phone, c1_0.points
from customers c1_0
where coalesce(c1_0.phone, ?) not in (?, ?)
바인딩: [1] ''  [2] '010-1111-2222'  [3] '010-3333-4444'
조회 28건 ✅

결과는 맞지만 컬럼을 함수로 감쌌으므로 인덱스를 못 탑니다. MySQL8 코스 Step 15 — 인덱스 에서 "컬럼을 가공하지 말고 리터럴을 가공하라"고 했던 그 규칙입니다. 30행이면 상관없지만 큰 테이블에서는 처방 1 을 쓰십시오.

처방 3 — 애초에 컬럼을 NOT NULL 로 설계한다

가장 근본적인 해결입니다. NULL 을 허용할 이유가 정말 있는지 되묻고, 없으면 NOT NULL DEFAULT '' 로 바꿉니다. 3값 논리 문제 전체가 사라집니다.

📌 서브쿼리를 NOT IN 에 넣으면 이 함정이 훨씬 잘 숨습니다. 서브쿼리 결과에 NULL 이 한 행이라도 섞이면 전체 결과가 0건이 됩니다. Step 07 — 서브쿼리 에서 notIn(subquery) 대신 notExists(subquery) 를 쓰라는 이유가 여기 있습니다.


4-10. 검색 조건 객체로 정리하기

조건이 세 개일 때는 파라미터를 나열해도 괜찮습니다. 여섯 개가 되면 호출부가 이렇게 됩니다.

search(null, "서울", null, 10000, null, ProductStatus.ON_SALE);

어느 null 이 무엇인지 알 수 없습니다. 그래서 조건을 객체로 묶습니다.

public record ProductSearchCond(
        String nameKeyword,
        Long categoryId,
        BigDecimal minPrice,
        BigDecimal maxPrice,
        ProductStatus status,
        Boolean inStockOnly
) {}
public List<Product> search(ProductSearchCond cond) {
    return queryFactory
            .selectFrom(product)
            .where(
                    nameContains(cond.nameKeyword()),
                    categoryEq(cond.categoryId()),
                    priceGoe(cond.minPrice()),
                    priceLoe(cond.maxPrice()),
                    statusEq(cond.status()),
                    inStock(cond.inStockOnly())
            )
            .fetch();
}

private BooleanExpression nameContains(String keyword) {
    return StringUtils.hasText(keyword) ? product.name.contains(keyword) : null;
}
private BooleanExpression categoryEq(Long categoryId) {
    return categoryId != null ? product.category.id.eq(categoryId) : null;
}
private BooleanExpression priceGoe(BigDecimal min) {
    return min != null ? product.price.goe(min) : null;
}
private BooleanExpression priceLoe(BigDecimal max) {
    return max != null ? product.price.loe(max) : null;
}
private BooleanExpression statusEq(ProductStatus status) {
    return status != null ? product.status.eq(status) : null;
}
private BooleanExpression inStock(Boolean only) {
    return Boolean.TRUE.equals(only) ? product.stock.gt(0) : null;
}

search(new ProductSearchCond("노트북", null, new BigDecimal("500000"), null, ProductStatus.ON_SALE, true)) 를 호출하면 이렇게 됩니다.

결과

select p1_0.product_id, p1_0.attrs, p1_0.category_id, p1_0.cost,
       p1_0.created_at, p1_0.name, p1_0.price, p1_0.status, p1_0.stock
from products p1_0
where p1_0.name like ? escape '!'
  and p1_0.price >= ?
  and p1_0.status = ?
  and p1_0.stock > ?
바인딩: [1] '%노트북%'  [2] 500000.00  [3] ProductStatus.ON_SALE  [4] 0
조회 2건 — 게이밍 노트북 RTX4060(2190000), 보급형 노트북 15(690000)

여섯 개 조건 중 넷만 SQL 에 나갔습니다. categoryIdmaxPricenull 이라 사라졌습니다.

inStockBoolean.TRUE.equals(only) 표현을 눈여겨보십시오. onlynull 일 때 only == true 는 NPE, only 는 언박싱 NPE 입니다. Boolean.TRUE.equals(only)null 에 안전합니다. Boolean 을 조건에 쓸 때는 이 관용구를 쓰십시오.

📌 이 패턴을 저장소 계층으로 옮기고 Spring Data JPA 의 커스텀 리포지토리와 결합하는 것이 Step 10 — 동적 정렬Step 12 — Spring Data 통합 의 주제입니다. 정렬 조건까지 동적으로 만들면 OrderSpecifier 를 다뤄야 하고, 거기에는 여기와 다른 함정이 있습니다.


4-11. MySQL8 코스와 나란히

📌 MySQL8 코스 Step 05 — 연산자와 조건 의 SQL 을 QueryDSL 로 옮긴 표입니다.

#SQLQueryDSL
1WHERE grade = 'VIP'.where(customer.grade.eq(Grade.VIP))
2WHERE grade <> 'VIP'.where(customer.grade.ne(Grade.VIP))
3WHERE grade IN ('VIP','GOLD').where(customer.grade.in(Grade.VIP, Grade.GOLD))
4WHERE points BETWEEN 10000 AND 30000.where(customer.points.between(10000, 30000))
5WHERE points >= 10000.where(customer.points.goe(10000))
6WHERE name LIKE '김%'.where(customer.name.startsWith("김"))
7WHERE name LIKE '%김%'.where(customer.name.contains("김"))
8WHERE phone IS NULL.where(customer.phone.isNull())
9WHERE grade = 'VIP' AND city = '서울'.where(customer.grade.eq(Grade.VIP), customer.city.eq("서울"))
10WHERE (grade='VIP' OR grade='GOLD') AND city='서울'.where(customer.grade.in(Grade.VIP, Grade.GOLD), customer.city.eq("서울"))
11WHERE phone NOT IN (...).where(customer.phone.notIn(...))NULL 행 누락 주의

세 가지를 짚습니다.

첫째, >=goe 입니다. greater or equal 의 약자입니다. gt(greater than), loe(less or equal), lt(less than) 도 같은 규칙입니다. 처음에는 어색하지만, >= 를 문자열로 조립하다 > 를 오타 내는 것보다 훨씬 낫습니다.

둘째, 10번 줄이 이 스텝의 결론입니다. SQL 에서는 괄호를 손으로 칩니다. 손으로 치는 것이므로 빠뜨리면 문법 에러가 나거나, 최소한 눈에 보입니다. QueryDSL 에서는 괄호가 체이닝 구조로부터 유도되므로 눈에 보이지 않습니다. 그래서 in 으로 바꾸거나 varargs 로 나누는 처방이 필요합니다.

셋째, 11번의 NOT IN 은 QueryDSL 로 써도 SQL 과 똑같이 동작합니다. 이 표 전체를 관통하는 메시지이기도 합니다. QueryDSL 은 SQL 위의 얇은 층입니다. 타입 안전성과 조립 가능성을 얹어 줄 뿐, SQL 의 의미론(3값 논리, 연산자 우선순위, 인덱스 사용 여부)은 그대로 통과합니다. SQL 을 모르면 QueryDSL 도 제대로 못 씁니다.


정리

개념핵심
BooleanExpressionwhere() 가 받는 조건 객체. 실행이 아니라 값. 변수에 담고 메서드로 반환 가능
조건 메서드eq/ne/in/notIn/between/goe/gt/loe/lt/like/contains/startsWith/endsWith/isNull/isNotNull/isEmpty/isNotEmpty
contains("김")SQL 은 like ? escape '!', 바인딩이 '%김%'. SQL 로그만으로는 구분 불가
contains 성능% LIKE 는 인덱스를 못 탄다. 가능하면 startsWith
where(a, b, c)콤마는 and. or 는 varargs 로 표현할 수 없다
where(null)조용히 무시. and null1=1 도 아닌 완전 소거. 동적 쿼리의 토대
null.and(...)그냥 NPE. 동적 조건은 체이닝이 아니라 varargs 자리에
동적 쿼리 패턴조건당 BooleanExpression 반환 메서드 하나. 값 없으면 null 반환
조건 재사용메서드끼리 .and() 로 합쳐 비즈니스 규칙을 한 곳에 (isPremium())
문자열 조건!= null 이 아니라 StringUtils.hasText(). 빈 문자열이 조건으로 붙는 것을 막는다
BooleanBuilder같은 SQL 을 만들지만 가독성·재사용·조합성이 떨어짐. 빈 빌더 = 조건 없음(위험)
or 체이닝.and().or() 에 우선순위 없음. 왼쪽부터 묶임 → 괄호가 사라진다
or 함정 결과의도 6건 / 실수① 11건 / 실수② 8건. 셋 다 컴파일·실행 성공
or 처방① or 그룹을 메서드로 추출 ② Expressions.anyOf/allOf ③ 같은 컬럼이면 in
eq(null)동작이 상황에 따라 다를 수 있다. 의미를 기대하지 말 것. NULL 을 찾으려면 isNull()
= NULLSQL 에서 항상 UNKNOWN → 0건. IS NULL 과 완전히 다르다
NOT IN + NULLNULL 인 행이 통째로 누락된다 (28건 기대 → 25건). or isNull() 로 보정
검색 조건 객체파라미터 4개 이상이면 record 로 묶는다. BooleanBoolean.TRUE.equals()
큰 원칙QueryDSL 은 SQL 위의 얇은 층. SQL 의 의미론은 그대로 통과한다

연습문제

Exercise.java 에 6문제가 있습니다. 정답은 Solution.java. 모든 문제에서 생성 SQL 과 결과 건수를 함께 확인하십시오. 이 스텝은 "돌아가는 코드"와 "맞는 코드"의 차이를 배우는 스텝입니다.

  1. products 에서 이름에 "노트북"이 들어가고 가격이 500,000원 이상인 상품을 조회하고, 생성 SQL 의 like 에 바인딩된 값이 무엇인지 로그에서 확인하기
  2. customer.name.startsWith("김") / contains("김") / endsWith("준") 세 쿼리를 실행해 SQL 은 같은데 바인딩만 다르다는 것을 표로 기록하기
  3. 등급·도시·최소 포인트 세 조건을 받는 동적 검색 메서드를 BooleanExpression 반환 방식으로 작성하고, 조건 조합 4가지의 생성 SQL 을 로그로 남기기
  4. 3번과 동일한 기능을 BooleanBuilder 로 작성해 생성 SQL 이 같은지 확인하고, 두 방식의 코드 줄 수를 비교하기
  5. "VIP 또는 GOLD 이면서 서울" 을 (a) 올바른 varargs 방식 (b) .and().or() 체이닝 방식 두 가지로 작성하고, 두 결과 건수가 각각 6건과 11건임을 단언하기. 그리고 (b)의 생성 SQL 에 괄호가 없다는 것을 주석에 적기
  6. phone 이 특정 두 번호가 아닌 고객을 notIn 으로 조회해 25건이 나오는 것을 확인하고, .or(customer.phone.isNull()) 을 붙여 28건으로 고치기

다음 단계

지금까지 조회 결과는 엔티티 아니면 Tuple 이었습니다. Tuple 은 편하지만 row.get(customer.name) 이라고 매번 적어야 하고, 컨트롤러까지 흘러가면 QueryDSL 의존성이 화면 계층까지 번집니다.

다음 스텝에서는 조회 결과를 DTO 로 바로 받는 세 가지 방법(Projections.bean / fields / constructor)과 @QueryProjection 을 비교합니다. 그리고 "필드 이름이 다르면 조용히 null 이 채워지는" — 이 코스에서 가장 잡기 어려운 종류의 — 함정을 재현합니다.

Step 05 — 프로젝션과 DTO


실습 파일

이 스텝은 자바 파일 세 개로 진행합니다. Practice.java 로 4-1 ~ 4-11 의 모든 생성 SQL 을 확인하고, Exercise.java 의 6문제를 직접 푼 뒤, Solution.java 로 정답과 해설을 대조합니다.

Step 03 과 마찬가지로 @SpringBootTest + @Transactional 테스트 클래스입니다. 이 스텝은 특히 결과 건수가 학습의 핵심이므로, 세 파일 모두 단언(assert)에 정확한 숫자를 박아 두었습니다. 숫자가 안 맞으면 데이터가 다른 것이니 shop 스키마를 다시 적재하십시오.

Practice.java

본문(4-1 ~ 4-11)의 모든 예제를 절 번호 주석과 함께 담은 실습 파일입니다.

  • [4-2]like_세_형제()startsWith / contains / endsWith / like연달아 실행합니다. 네 개의 SQL 로그가 c1_0.name like ? escape '!' 로 전부 동일하고 바인딩 값만 다르다는 것을 한 화면에서 보는 것이 목적이므로, 하나씩 따로 돌리지 마십시오.
  • [4-4]where_null_은_무시된다()where(cond, null, cond)where(null, null, null) 을 둘 다 실행합니다. 후자의 SQL 에 where 라는 단어 자체가 없다는 것을 확인하는 것이 이 절의 전부입니다.
  • [4-5]동적쿼리_조합_여섯가지() 는 하나의 search(...) 메서드를 인자만 바꿔 여섯 번 호출합니다. 콘솔에 === (3) 도시만 === 같은 구분선을 찍어 두었으니, SQL 로그를 그 구분선과 짝지어 읽으십시오.
  • [4-7]or_함정_세가지() 가 이 파일의 핵심입니다. 의도(6건) / 실수①(11건) / 실수②(8건) 을 한 메서드 안에서 연달아 실행하고 각각의 건수를 단언합니다. 실수 쪽 단언이 통과하는 것이 정상입니다. 버그를 재현하는 테스트이기 때문입니다. 이어지는 or_처방_세가지() 가 셋 다 6건으로 수렴하는 것을 보여 줍니다.
  • [4-8]eq_null_은_쓰지_마십시오()의도적으로 아무것도 단언하지 않습니다. eq(null) 의 동작이 버전에 따라 다를 수 있어 고정된 기대값을 박을 수 없기 때문입니다. 대신 결과를 콘솔에 찍어 두었으니, 여러분의 환경에서 어느 갈래(전체 조회 / 예외 / 0건)로 가는지 직접 확인하고 주석에 적어 두십시오.
  • [4-9]notIn_함정() 은 25건을 단언합니다. 28건이 아닙니다. 이 숫자가 틀리면 phone NULL 이 3명이 아닌 것이니 데이터를 확인하십시오.
package com.example.shop.step04;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.example.shop.entity.Product;
import com.example.shop.entity.ProductStatus;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.Expressions;
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 org.springframework.util.StringUtils;

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

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

/**
 * Step 04 — 조건과 동적 쿼리 : 본문 예제 전체.
 *
 * 이 스텝은 "결과 건수" 가 학습의 핵심입니다.
 * 단언에 정확한 숫자를 박아 두었으니, 숫자가 안 맞으면 shop 스키마를 다시 적재하십시오.
 *
 * 참고 데이터
 *   customers 30명 — VIP 4 / GOLD 9 / SILVER 8 / BRONZE 9, phone NULL 3명
 *   도시        — 서울 8 / 부산 6 / 인천 5 / 대구 4 / 대전 4 / 광주 3
 *   VIP  4명    — 김서준(서울) 류하나(서울) 정  훈(부산) 배채영(대구)
 *   GOLD 9명    — 안지수(서울) 한지호(서울) 오하윤(서울) 문시우(서울)
 *                 강도윤(부산) 윤서아(부산) 임하준(인천) 조은우(대전) 신지아(광주)
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // [4-1] BooleanExpression — where() 가 받는 것
    // =================================================================

    @Test
    @DisplayName("[4-1] 조건은 실행이 아니라 값이다")
    void 조건은_값이다() {
        BooleanExpression cond = customer.grade.eq(Grade.VIP);

        // 이 시점까지 SQL 은 한 줄도 나가지 않았습니다.
        System.out.println(cond.getClass().getName());   // BooleanOperation
        System.out.println(cond);                        // customer.grade = VIP

        assertThat(cond).isNotNull();
    }

    @Test
    @DisplayName("[4-1] 조건을 변수에 담아 합친다")
    void 조건을_변수에_담는다() {
        BooleanExpression isVip = customer.grade.eq(Grade.VIP);
        BooleanExpression inSeoul = customer.city.eq("서울");

        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(isVip.and(inSeoul))
                .fetch();

        // 생성 SQL: ... where c1_0.grade = ? and c1_0.city = ?
        assertThat(result).hasSize(2);
        assertThat(result).extracting(Customer::getName)
                .containsExactlyInAnyOrder("김서준", "류하나");
    }

    // =================================================================
    // [4-2] 조건 메서드 총정리
    // =================================================================

    @Test
    @DisplayName("[4-2] like 세 형제 — SQL 은 같고 바인딩만 다르다")
    void like_세_형제() {
        // 네 개를 연달아 실행합니다. 한 화면에서 로그를 비교하는 것이 목적이므로
        // 하나씩 따로 돌리지 마십시오.

        System.out.println("=== startsWith(\"김\") → 바인딩 '김%' ===");
        List<Customer> a = queryFactory.selectFrom(customer)
                .where(customer.name.startsWith("김")).fetch();

        System.out.println("=== contains(\"김\") → 바인딩 '%김%' ===");
        List<Customer> b = queryFactory.selectFrom(customer)
                .where(customer.name.contains("김")).fetch();

        System.out.println("=== endsWith(\"준\") → 바인딩 '%준' ===");
        List<Customer> c = queryFactory.selectFrom(customer)
                .where(customer.name.endsWith("준")).fetch();

        System.out.println("=== like(\"김_\") → 바인딩 '김_' (직접 넣어야 함) ===");
        List<Customer> d = queryFactory.selectFrom(customer)
                .where(customer.name.like("김_")).fetch();

        // 네 SQL 모두 : where c1_0.name like ? escape '!'
        // SQL 로그만으로는 구분할 수 없습니다. 바인딩 로거를 켜야 합니다.
        assertThat(a).hasSize(3);
        assertThat(b).hasSize(3);
        assertThat(c).hasSize(2);
        assertThat(d).isEmpty();
    }

    @Test
    @DisplayName("[4-2] between / goe — 숫자 조건")
    void 숫자_조건() {
        List<Customer> between = queryFactory.selectFrom(customer)
                .where(customer.points.between(10000, 30000)).fetch();
        // 생성 SQL: where c1_0.points between ? and ?

        assertThat(between).hasSize(9);
    }

    @Test
    @DisplayName("[4-2] in — 여러 값을 한 번에")
    void in_조건() {
        List<Customer> result = queryFactory.selectFrom(customer)
                .where(customer.grade.in(Grade.VIP, Grade.GOLD)).fetch();
        // 생성 SQL: where c1_0.grade in (?, ?)

        assertThat(result).hasSize(13);   // VIP 4 + GOLD 9

        // 컬렉션도 받습니다. 같은 SQL 입니다.
        List<Customer> same = queryFactory.selectFrom(customer)
                .where(customer.grade.in(List.of(Grade.VIP, Grade.GOLD))).fetch();
        assertThat(same).hasSize(13);
    }

    @Test
    @DisplayName("[4-2] isNull / isNotNull — 바인딩이 없다")
    void null_조건() {
        List<Customer> noPhone = queryFactory.selectFrom(customer)
                .where(customer.phone.isNull()).fetch();
        // 생성 SQL: where c1_0.phone is null   (바인딩 없음)

        List<Customer> hasPhone = queryFactory.selectFrom(customer)
                .where(customer.phone.isNotNull()).fetch();
        // 생성 SQL: where c1_0.phone is not null

        assertThat(noPhone).hasSize(3);
        assertThat(hasPhone).hasSize(27);
        // 3 + 27 = 30. isNull/isNotNull 은 전체를 정확히 둘로 나눕니다.
    }

    @Test
    @DisplayName("[4-2] isEmpty() 는 not exists 서브쿼리가 된다")
    void 컬렉션_조건() {
        List<Customer> neverOrdered = queryFactory.selectFrom(customer)
                .where(customer.orders.isEmpty()).fetch();

        // 생성 SQL:
        //   where not exists (select 1 from orders o1_0
        //                     where c1_0.customer_id = o1_0.customer_id)
        // 자바 한 줄이 서브쿼리를 만들었습니다. Step 07 에서 직접 씁니다.
        assertThat(neverOrdered).isEmpty();   // 30명 모두 주문 이력이 있습니다
    }

    // =================================================================
    // [4-3] and / or 체이닝과 varargs
    // =================================================================

    @Test
    @DisplayName("[4-3] 체이닝과 varargs 는 같은 SQL 을 만든다")
    void 체이닝과_varargs() {
        System.out.println("=== (1) 체이닝 .and() ===");
        List<Customer> chained = queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP).and(customer.city.eq("서울")))
                .fetch();

        System.out.println("=== (2) varargs 콤마 ===");
        List<Customer> varargs = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.VIP),
                        customer.city.eq("서울")
                )
                .fetch();

        // 두 SQL 모두 : where c1_0.grade = ? and c1_0.city = ?
        // ** where(a, b, c) 의 콤마는 and 입니다. or 가 아닙니다. **
        assertThat(chained).hasSize(2);
        assertThat(varargs).hasSize(2);
    }

    @Test
    @DisplayName("[4-3] 세 개 이상도 전부 and 로 묶인다")
    void varargs_셋() {
        List<Customer> result = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.GOLD),
                        customer.city.eq("서울"),
                        customer.points.goe(10000)
                )
                .fetch();

        assertThat(result).hasSize(3);
    }

    // =================================================================
    // [4-4] where(null) 은 무시된다
    // =================================================================

    @Test
    @DisplayName("[4-4] null 은 SQL 에서 통째로 사라진다")
    void where_null_은_무시된다() {
        System.out.println("=== (1) 가운데가 null ===");
        List<Customer> partial = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.GOLD),
                        null,                            // ← 사라집니다
                        customer.points.goe(10000)
                )
                .fetch();
        // 생성 SQL: where c1_0.grade = ? and c1_0.points >= ?
        // "and null" 도 "and 1=1" 도 아닙니다. 아무 흔적이 없습니다.

        System.out.println("=== (2) 전부 null ===");
        List<Customer> all = queryFactory.selectFrom(customer)
                .where(null, null, null)
                .fetch();
        // 생성 SQL: select ... from customers c1_0
        // ** where 라는 단어 자체가 없습니다. ** 이것을 로그로 확인하십시오.

        assertThat(partial).hasSize(6);
        assertThat(all).hasSize(30);
    }

    @Test
    @DisplayName("[4-4] 그러나 null.and(...) 는 그냥 NPE 입니다")
    void null_체이닝은_NPE() {
        BooleanExpression first = null;

        // 아래 주석을 풀면 NullPointerException 이 납니다.
        // 무시되는 것은 where() 의 인자이지, 체인의 첫 조각이 아닙니다.
        //
        // queryFactory.selectFrom(customer)
        //         .where(first.and(customer.city.eq("서울")))
        //         .fetch();

        assertThat(first).isNull();
    }

    // =================================================================
    // [4-5] 동적 쿼리 — BooleanExpression 반환 메서드 조립
    // =================================================================

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

    private BooleanExpression cityEq(String city) {
        // != null 이 아니라 hasText 입니다.
        // HTTP 파라미터는 미입력 시 빈 문자열로 들어오는 경우가 많습니다.
        return StringUtils.hasText(city) ? customer.city.eq(city) : null;
    }

    private BooleanExpression pointsGoe(Integer minPoints) {
        return minPoints != null ? customer.points.goe(minPoints) : null;
    }

    /** if 가 한 번도 없습니다. */
    private List<Customer> search(Grade grade, String city, Integer minPoints) {
        return queryFactory
                .selectFrom(customer)
                .where(gradeEq(grade), cityEq(city), pointsGoe(minPoints))
                .fetch();
    }

    @Test
    @DisplayName("[4-5] 하나의 메서드가 만드는 여섯 가지 SQL")
    void 동적쿼리_조합_여섯가지() {
        System.out.println("=== (1) 조건 없음 ===");
        assertThat(search(null, null, null)).hasSize(30);

        System.out.println("=== (2) 등급만 ===");
        assertThat(search(Grade.GOLD, null, null)).hasSize(9);

        System.out.println("=== (3) 도시만 ===");
        assertThat(search(null, "서울", null)).hasSize(8);

        System.out.println("=== (4) 최소 포인트만 ===");
        assertThat(search(null, null, 10000)).hasSize(12);

        System.out.println("=== (5) 등급 + 도시 ===");
        assertThat(search(Grade.GOLD, "서울", null)).hasSize(4);

        System.out.println("=== (6) 셋 다 ===");
        assertThat(search(Grade.GOLD, "서울", 10000)).hasSize(3);
    }

    @Test
    @DisplayName("[4-5] 빈 문자열이 조건으로 붙지 않는 것을 확인한다")
    void 빈문자열_처리() {
        // hasText 덕분에 "" 는 조건이 되지 않습니다.
        // city != null 로만 검사했다면 where c1_0.city = '' 가 붙어 0건이 됩니다.
        assertThat(search(null, "", null)).hasSize(30);
        assertThat(search(null, "   ", null)).hasSize(30);
    }

    /** 비즈니스 규칙을 한 곳에 모읍니다. */
    private BooleanExpression isPremium() {
        return customer.grade.in(Grade.VIP, Grade.GOLD)
                .and(customer.points.goe(10000));
    }

    @Test
    @DisplayName("[4-5] 조건 메서드끼리 합쳐 비즈니스 규칙을 만든다")
    void 조건_재사용() {
        List<Customer> premium = queryFactory.selectFrom(customer)
                .where(isPremium())
                .fetch();

        // 생성 SQL: where c1_0.grade in (?, ?) and c1_0.points >= ?
        // "우수 고객" 의 정의가 코드 한 곳에 있습니다. 규칙이 바뀌면 여기만 고칩니다.
        assertThat(premium).hasSize(10);
    }

    // =================================================================
    // [4-6] BooleanBuilder
    // =================================================================

    private List<Customer> searchWithBuilder(Grade grade, String city, Integer minPoints) {
        BooleanBuilder builder = new BooleanBuilder();

        if (grade != null) {
            builder.and(customer.grade.eq(grade));
        }
        if (StringUtils.hasText(city)) {
            builder.and(customer.city.eq(city));
        }
        if (minPoints != null) {
            builder.and(customer.points.goe(minPoints));
        }

        return queryFactory.selectFrom(customer).where(builder).fetch();
    }

    @Test
    @DisplayName("[4-6] BooleanBuilder 는 같은 SQL 을 만든다")
    void builder_방식() {
        List<Customer> byBuilder = searchWithBuilder(Grade.GOLD, "서울", 10000);
        List<Customer> byExpression = search(Grade.GOLD, "서울", 10000);

        // 생성 SQL 이 완전히 같습니다.
        // 차이는 성능이 아니라 가독성 / 재사용 / 조합성 / null 처리입니다.
        assertThat(byBuilder).hasSize(3);
        assertThat(byExpression).hasSize(3);
    }

    @Test
    @DisplayName("[4-6] 빈 빌더는 조용히 '조건 없음' 이 된다")
    void 빈_빌더의_위험() {
        BooleanBuilder empty = new BooleanBuilder();

        List<Customer> all = queryFactory.selectFrom(customer).where(empty).fetch();

        // 생성 SQL 에 where 절이 없습니다. 전체 조회입니다.
        // 30행이면 괜찮지만, 삭제/수정 쿼리에서 이러면 전체 행이 대상이 됩니다.
        assertThat(all).hasSize(30);
    }

    // =================================================================
    // [4-7] ⚠️ or 를 섞으면 괄호가 사라진다 — 이 스텝의 핵심
    // =================================================================

    @Test
    @DisplayName("[4-7] 의도 6건 / 실수① 11건 / 실수② 8건")
    void or_함정_세가지() {
        // 목표: "VIP 또는 GOLD 이면서, 서울에 사는 고객" → (VIP or GOLD) and 서울 = 6명

        System.out.println("=== 의도한 코드 — varargs 로 나눈다 ===");
        List<Customer> intended = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.VIP).or(customer.grade.eq(Grade.GOLD)),
                        customer.city.eq("서울")
                )
                .fetch();
        // where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?
        //       ^^^ 괄호가 붙었습니다

        System.out.println("=== 실수 ① — .and() 와 .or() 를 이어 붙였다 ===");
        List<Customer> mistake1 = queryFactory.selectFrom(customer)
                .where(
                        customer.city.eq("서울")
                                .and(customer.grade.eq(Grade.VIP))
                                .or(customer.grade.eq(Grade.GOLD))
                )
                .fetch();
        // where c1_0.city = ? and c1_0.grade = ? or c1_0.grade = ?
        //       괄호가 없습니다 → (서울 and VIP) or GOLD
        //       부산의 강도윤, 인천의 임하준, 광주의 신지아가 들어옵니다.

        System.out.println("=== 실수 ② — 괄호 위치를 잘못 잡았다 ===");
        List<Customer> mistake2 = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.VIP)
                                .or(customer.grade.eq(Grade.GOLD).and(customer.city.eq("서울")))
                )
                .fetch();
        // where c1_0.grade = ? or c1_0.grade = ? and c1_0.city = ?
        //       A or (B and C) → 전국 VIP 4명 + 서울 GOLD 4명

        // ** 실수 쪽 단언이 통과하는 것이 정상입니다. 버그를 재현하는 테스트입니다. **
        assertThat(intended).hasSize(6);
        assertThat(mistake1).hasSize(11);
        assertThat(mistake2).hasSize(8);

        // 셋 다 컴파일 성공, 실행 성공, 예외 없음. 결과 건수만 다릅니다.
        System.out.println("의도=" + intended.size()
                + " 실수①=" + mistake1.size()
                + " 실수②=" + mistake2.size());
    }

    /** 처방 1 — or 그룹을 이름 있는 메서드로 뽑는다. */
    private BooleanExpression isVipOrGold() {
        return customer.grade.eq(Grade.VIP).or(customer.grade.eq(Grade.GOLD));
    }

    @Test
    @DisplayName("[4-7] 처방 세 가지 — 전부 6건으로 수렴한다")
    void or_처방_세가지() {
        System.out.println("=== 처방 1 — or 그룹을 메서드로 추출 ===");
        List<Customer> fix1 = queryFactory.selectFrom(customer)
                .where(isVipOrGold(), customer.city.eq("서울"))
                .fetch();
        // where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?

        System.out.println("=== 처방 2 — Expressions.allOf / anyOf ===");
        List<Customer> fix2 = queryFactory.selectFrom(customer)
                .where(
                        Expressions.allOf(
                                Expressions.anyOf(
                                        customer.grade.eq(Grade.VIP),
                                        customer.grade.eq(Grade.GOLD)
                                ),
                                customer.city.eq("서울")
                        )
                )
                .fetch();
        // where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?

        System.out.println("=== 처방 3 — 같은 컬럼의 or 는 in 으로 ===");
        List<Customer> fix3 = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.in(Grade.VIP, Grade.GOLD),
                        customer.city.eq("서울")
                )
                .fetch();
        // where c1_0.grade in (?, ?) and c1_0.city = ?
        // or 가 아예 사라졌으니 괄호를 걱정할 일도 없습니다.

        assertThat(fix1).hasSize(6);
        assertThat(fix2).hasSize(6);
        assertThat(fix3).hasSize(6);
    }

    // =================================================================
    // [4-8] null 안전 조건 — isNull() vs eq(null)
    // =================================================================

    @Test
    @DisplayName("[4-8] eq(null) 에 의미를 기대하지 마십시오")
    void eq_null_은_쓰지_마십시오() {
        // 이 테스트는 의도적으로 아무것도 단언하지 않습니다.
        // eq(null) 의 동작은 버전과 경로에 따라 다를 수 있어
        // 고정된 기대값을 박을 수 없기 때문입니다.
        //
        // 가능한 갈래
        //   (a) 조건을 무시 (null 반환) → 전체 30건
        //   (b) 인자 검증 예외        → 즉시 실패
        //   (c) = ? 에 null 바인딩    → 3값 논리로 0건
        //
        // (a)와 (c)는 정반대 결과입니다.
        // 여러분의 환경에서 어느 갈래로 가는지 직접 확인하고 아래 주석에 적어 두십시오.
        //
        // 내 환경의 결과 = ___________

        String nullPhone = null;
        try {
            List<Customer> result = queryFactory.selectFrom(customer)
                    .where(customer.phone.eq(nullPhone))
                    .fetch();
            System.out.println("eq(null) 결과 건수 = " + result.size());
        } catch (RuntimeException e) {
            System.out.println("eq(null) 예외 = " + e.getClass().getName()
                    + " : " + e.getMessage());
        }

        // 처방 — 명시적으로 씁니다.
        List<Customer> explicit = queryFactory.selectFrom(customer)
                .where(nullPhone != null
                        ? customer.phone.eq(nullPhone)
                        : customer.phone.isNull())
                .fetch();
        assertThat(explicit).hasSize(3);
    }

    /** 조건을 빼는 것이 의도라면 이 패턴을 쓰십시오. */
    private BooleanExpression phoneEq(String phone) {
        return phone != null ? customer.phone.eq(phone) : null;
    }

    @Test
    @DisplayName("[4-8] 조건을 빼고 싶으면 null 반환을 쓴다")
    void 조건을_빼는_패턴() {
        assertThat(queryFactory.selectFrom(customer).where(phoneEq(null)).fetch())
                .hasSize(30);
    }

    // =================================================================
    // [4-9] NOT IN 과 NULL — 3값 논리는 그대로 적용된다
    // =================================================================

    @Test
    @DisplayName("[4-9] notIn 은 NULL 인 행을 통째로 누락시킨다")
    void notIn_함정() {
        List<Customer> result = queryFactory.selectFrom(customer)
                .where(customer.phone.notIn("010-1111-2222", "010-3333-4444"))
                .fetch();

        // 생성 SQL: where c1_0.phone not in (?, ?)
        //
        // 30명 - 해당 번호 2명 = 28명을 기대했지만 25건입니다.
        // phone 이 NULL 인 3명이 빠졌습니다.
        //
        //   phone NOT IN (a, b)  ≡  phone != a AND phone != b
        //   phone 이 NULL 이면 각 항이 UNKNOWN
        //   UNKNOWN AND UNKNOWN = UNKNOWN
        //   WHERE 는 UNKNOWN 을 통과시키지 않는다 → 제외
        assertThat(result).hasSize(25);
    }

    /** 처방 1 — NULL 을 명시적으로 살린다. */
    private BooleanExpression phoneNotIn(String... phones) {
        return customer.phone.notIn(phones).or(customer.phone.isNull());
    }

    @Test
    @DisplayName("[4-9] 처방 1 — or isNull() 로 보정")
    void notIn_처방_orIsNull() {
        List<Customer> result = queryFactory.selectFrom(customer)
                .where(phoneNotIn("010-1111-2222", "010-3333-4444"))
                .fetch();

        // 생성 SQL: where c1_0.phone not in (?, ?) or c1_0.phone is null
        assertThat(result).hasSize(28);
    }

    @Test
    @DisplayName("[4-9] 처방 2 — coalesce. 맞지만 인덱스를 못 탄다")
    void notIn_처방_coalesce() {
        List<Customer> result = queryFactory.selectFrom(customer)
                .where(customer.phone.coalesce("").asString()
                        .notIn("010-1111-2222", "010-3333-4444"))
                .fetch();

        // 생성 SQL: where coalesce(c1_0.phone, ?) not in (?, ?)
        // 컬럼을 함수로 감쌌으므로 인덱스를 못 씁니다.
        // "컬럼을 가공하지 말고 리터럴을 가공하라" 는 규칙 위반입니다.
        assertThat(result).hasSize(28);
    }

    // =================================================================
    // [4-10] 검색 조건 객체
    // =================================================================

    public record ProductSearchCond(
            String nameKeyword,
            Long categoryId,
            BigDecimal minPrice,
            BigDecimal maxPrice,
            ProductStatus status,
            Boolean inStockOnly
    ) {}

    private BooleanExpression nameContains(String keyword) {
        return StringUtils.hasText(keyword) ? product.name.contains(keyword) : null;
    }
    private BooleanExpression categoryEq(Long categoryId) {
        return categoryId != null ? product.category.id.eq(categoryId) : null;
    }
    private BooleanExpression priceGoe(BigDecimal min) {
        return min != null ? product.price.goe(min) : null;
    }
    private BooleanExpression priceLoe(BigDecimal max) {
        return max != null ? product.price.loe(max) : null;
    }
    private BooleanExpression statusEq(ProductStatus status) {
        return status != null ? product.status.eq(status) : null;
    }
    private BooleanExpression inStock(Boolean only) {
        // only 가 null 일 때 only == true 는 언박싱 NPE 입니다.
        // Boolean.TRUE.equals(only) 는 null 에 안전합니다.
        return Boolean.TRUE.equals(only) ? product.stock.gt(0) : null;
    }

    private List<Product> searchProducts(ProductSearchCond cond) {
        return queryFactory
                .selectFrom(product)
                .where(
                        nameContains(cond.nameKeyword()),
                        categoryEq(cond.categoryId()),
                        priceGoe(cond.minPrice()),
                        priceLoe(cond.maxPrice()),
                        statusEq(cond.status()),
                        inStock(cond.inStockOnly())
                )
                .fetch();
    }

    @Test
    @DisplayName("[4-10] 여섯 조건 중 넷만 SQL 에 나간다")
    void 검색조건_객체() {
        List<Product> result = searchProducts(new ProductSearchCond(
                "노트북",
                null,                          // ← 사라짐
                new BigDecimal("500000"),
                null,                          // ← 사라짐
                ProductStatus.ON_SALE,
                true
        ));

        // 생성 SQL:
        //   where p1_0.name like ? escape '!'
        //     and p1_0.price >= ?
        //     and p1_0.status = ?
        //     and p1_0.stock > ?
        result.forEach(p -> System.out.println(p.getName() + " / " + p.getPrice()));
        assertThat(result).hasSize(2);
    }

    @Test
    @DisplayName("[4-10] 전부 null 이면 전체 조회")
    void 검색조건_객체_비어있음() {
        List<Product> all = searchProducts(
                new ProductSearchCond(null, null, null, null, null, null));
        assertThat(all).hasSize(40);
    }

    // =================================================================
    // [4-11] MySQL8 코스와 나란히
    // =================================================================

    @Test
    @DisplayName("[4-11] SQL 11개를 QueryDSL 로 옮긴다")
    void mysql8_대조() {
        assertThat(queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP)).fetch()).hasSize(4);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.grade.ne(Grade.VIP)).fetch()).hasSize(26);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.grade.in(Grade.VIP, Grade.GOLD)).fetch()).hasSize(13);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.points.between(10000, 30000)).fetch()).hasSize(9);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.points.goe(10000)).fetch()).hasSize(12);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.name.startsWith("김")).fetch()).hasSize(3);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.name.contains("김")).fetch()).hasSize(3);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.phone.isNull()).fetch()).hasSize(3);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP), customer.city.eq("서울")).fetch())
                .hasSize(2);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.grade.in(Grade.VIP, Grade.GOLD), customer.city.eq("서울"))
                .fetch()).hasSize(6);

        assertThat(queryFactory.selectFrom(customer)
                .where(customer.phone.notIn("010-1111-2222", "010-3333-4444")).fetch())
                .hasSize(25);   // ← NULL 3명 누락. 28 이 아닙니다.
    }
}

Exercise.java

6문제의 문제지입니다. 각 문제는 요구사항 주석과 // 여기에 작성: 자리로 구성돼 있습니다.

  • 문제 2 는 코드보다 주석 채우기가 본체입니다. 파일에 표 뼈대가 주석으로 그려져 있고, 여러분이 바인딩 값과 결과 건수를 채워 넣습니다. "SQL 이 같다"는 것을 손으로 적어 봐야 각인됩니다.
  • 문제 3·4 는 같은 기능을 두 방식으로 구현합니다. 문제 4 의 BooleanBuilder 버전은 // if 를 몇 번 썼는지 세어 보십시오: ___ 라는 빈칸으로 끝납니다. 코드 줄 수 비교가 문제의 일부입니다.
  • 문제 5 가 이 스텝의 시험입니다. (a)와 (b)의 결과 건수를 각각 단언하는데, (b)의 기대값 11 을 미리 알려 주지 않습니다. 직접 실행해 몇 건인지 확인하고 그 숫자를 넣으십시오. 답을 보고 넣으면 배우는 것이 없습니다.
  • 문제 6notIn 의 25건을 먼저 확인한 뒤 28건으로 고치는 2단계 문제입니다. 파일 하단에 phoneNotIn(String... phones) 메서드 시그니처가 비어 있는 채로 준비돼 있습니다.
  • 파일 상단에 Grade, ProductStatus enum 과 도시별/등급별 인원 표가 주석으로 붙어 있습니다. 단언에 넣을 숫자를 계산할 때 참고하십시오.
package com.example.shop.step04;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.example.shop.entity.Product;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
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 org.springframework.util.StringUtils;

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

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

/**
 * Step 04 — 연습문제 6개.
 *
 * 규칙
 *  - 각 문제의 "여기에 작성:" 아래를 채웁니다.
 *  - 모든 문제에서 생성 SQL 과 결과 건수를 함께 확인하십시오.
 *  - 주석 안의 빈칸(___)을 직접 채우는 것이 문제의 일부입니다.
 *  - 정답은 Solution.java 에 있습니다. 먼저 풀어 보십시오.
 *
 * ─────────────────────────────────────────────────────────────
 * 참고 데이터 (단언에 넣을 숫자를 계산할 때 쓰십시오)
 *
 *   customers 30명
 *     등급 : VIP 4 / GOLD 9 / SILVER 8 / BRONZE 9
 *     도시 : 서울 8 / 부산 6 / 인천 5 / 대구 4 / 대전 4 / 광주 3
 *     phone NULL : 3명
 *
 *   VIP  4명 — 김서준(서울) 류하나(서울) 정  훈(부산) 배채영(대구)
 *   GOLD 9명 — 안지수(서울) 한지호(서울) 오하윤(서울) 문시우(서울)
 *              강도윤(부산) 윤서아(부산) 임하준(인천) 조은우(대전) 신지아(광주)
 *
 *   products 40개 — 게이밍 노트북 RTX4060(2190000), 보급형 노트북 15(690000) 등
 * ─────────────────────────────────────────────────────────────
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // -----------------------------------------------------------------
    // 문제 1. contains + goe 를 조합하고 바인딩 값 확인하기
    //
    // 요구사항
    //  - products 에서 이름에 "노트북" 이 들어가고 price >= 500000 인 상품을 조회합니다.
    //  - 두 조건을 varargs 로 나열합니다 (체이닝 금지).
    //  - 실행 후 바인딩 로그를 보고 아래 빈칸을 채우십시오.
    //
    // 확인 1) like 에 바인딩된 값 = ___________
    // 확인 2) SQL 에 % 문자가 보이는가 (예/아니오) = ___
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 1 — contains 의 바인딩 값")
    void 문제1() {
        List<Product> result = null;

        // 여기에 작성:


        result.forEach(p -> System.out.println(p.getName() + " / " + p.getPrice()));
        assertThat(result).hasSize(2);
    }

    // -----------------------------------------------------------------
    // 문제 2. like 세 형제를 실행하고 표를 채우기
    //
    // 요구사항
    //  - customer.name 에 대해 startsWith("김") / contains("김") / endsWith("준")
    //    세 쿼리를 연달아 실행합니다.
    //  - 아래 표의 빈칸을 로그를 보고 채우십시오. 이 표를 채우는 것이 문제의 본체입니다.
    //
    //   | 코드              | 생성 SQL                     | 바인딩 | 건수 |
    //   |-------------------|------------------------------|--------|------|
    //   | startsWith("김")  | ____________________________ | ______ | ____ |
    //   | contains("김")    | ____________________________ | ______ | ____ |
    //   | endsWith("준")    | ____________________________ | ______ | ____ |
    //
    // 확인) 세 SQL 이 같은가 = ___
    //       그렇다면 SQL 로그만으로 셋을 구분할 수 있는가 = ___
    //       구분하려면 무엇을 켜야 하는가 = ___________________
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 2 — like 세 형제의 SQL 과 바인딩")
    void 문제2() {
        List<Customer> starts = null;
        List<Customer> contains = null;
        List<Customer> ends = null;

        // 여기에 작성:


        System.out.println("startsWith = " + starts.size());
        System.out.println("contains   = " + contains.size());
        System.out.println("endsWith   = " + ends.size());
    }

    // -----------------------------------------------------------------
    // 문제 3. BooleanExpression 방식 동적 검색 메서드 작성하기
    //
    // 요구사항
    //  - 아래 gradeEq / cityEq / pointsGoe 세 메서드를 완성합니다.
    //    값이 없으면 null 을 반환해야 합니다.
    //    cityEq 는 빈 문자열도 "값 없음" 으로 취급해야 합니다 (힌트: StringUtils.hasText).
    //  - search(...) 를 완성합니다. if 를 쓰지 마십시오.
    //  - 테스트에서 조건 조합 4가지를 호출하고 각각의 SQL 을 로그로 남깁니다.
    //
    // 확인) search 메서드 안에 if 가 몇 개 있는가 = ___
    // -----------------------------------------------------------------
    private BooleanExpression gradeEq(Grade grade) {
        // 여기에 작성:
        return null;
    }

    private BooleanExpression cityEq(String city) {
        // 여기에 작성:
        return null;
    }

    private BooleanExpression pointsGoe(Integer minPoints) {
        // 여기에 작성:
        return null;
    }

    private List<Customer> search(Grade grade, String city, Integer minPoints) {
        // 여기에 작성:
        return List.of();
    }

    @Test
    @DisplayName("문제 3 — BooleanExpression 방식 동적 검색")
    void 문제3() {
        System.out.println("=== (1) 조건 없음 ===");
        assertThat(search(null, null, null)).hasSize(30);

        System.out.println("=== (2) 등급만 ===");
        assertThat(search(Grade.GOLD, null, null)).hasSize(9);

        System.out.println("=== (3) 등급 + 도시 ===");
        assertThat(search(Grade.GOLD, "서울", null)).hasSize(4);

        System.out.println("=== (4) 셋 다 ===");
        assertThat(search(Grade.GOLD, "서울", 10000)).hasSize(3);

        // 빈 문자열도 조건이 되지 않아야 합니다.
        assertThat(search(null, "", null)).hasSize(30);
    }

    // -----------------------------------------------------------------
    // 문제 4. 같은 기능을 BooleanBuilder 로 작성하고 비교하기
    //
    // 요구사항
    //  - 문제 3 과 동일한 동작을 BooleanBuilder 로 구현합니다.
    //  - 두 방식의 결과 건수가 같은지 단언합니다.
    //  - 생성 SQL 을 로그에서 비교합니다.
    //
    // 확인 1) 두 SQL 이 같은가 = ___
    // 확인 2) searchWithBuilder 안에 if 를 몇 번 썼는가 = ___
    // 확인 3) 문제 3 의 search 는 몇 줄, 이쪽은 몇 줄인가 = ___ / ___
    // 확인 4) 조건이 3개에서 6개로 늘면 각각 몇 줄이 늘어나는가 = ___ / ___
    // -----------------------------------------------------------------
    private List<Customer> searchWithBuilder(Grade grade, String city, Integer minPoints) {
        BooleanBuilder builder = new BooleanBuilder();

        // 여기에 작성:


        return queryFactory.selectFrom(customer).where(builder).fetch();
    }

    @Test
    @DisplayName("문제 4 — BooleanBuilder 방식")
    void 문제4() {
        assertThat(searchWithBuilder(Grade.GOLD, "서울", 10000)).hasSize(3);
        assertThat(searchWithBuilder(null, null, null)).hasSize(30);

        // 두 방식이 같은 결과를 내는지 확인합니다.
        assertThat(searchWithBuilder(Grade.GOLD, null, null).size())
                .isEqualTo(search(Grade.GOLD, null, null).size());
    }

    // -----------------------------------------------------------------
    // 문제 5. ⚠️ or 함정 재현하기 — 이 스텝의 시험입니다
    //
    // 목표 쿼리: "VIP 또는 GOLD 이면서, 서울에 사는 고객"
    //
    // 요구사항
    //  (a) varargs 로 나눈 올바른 코드를 작성합니다.
    //      → where(A.or(B), C) 형태. 결과 6건을 단언하십시오.
    //
    //  (b) .and() 와 .or() 를 이어 붙인 코드를 작성합니다.
    //      → where(C.and(A).or(B)) 형태.
    //      ** 기대 건수를 미리 알려 주지 않습니다. **
    //      직접 실행해 몇 건인지 확인하고 그 숫자를 단언에 넣으십시오.
    //
    //  (c) (b) 의 생성 SQL 을 아래 주석에 그대로 옮겨 적으십시오.
    //
    // 확인 1) (a) 의 where 절 = _______________________________________
    // 확인 2) (b) 의 where 절 = _______________________________________
    // 확인 3) (b) 결과 건수 = ___
    // 확인 4) (b) 에서 서울이 아닌 고객이 나왔는가 = ___
    //         나왔다면 누구인가 = _______________________________
    // 확인 5) 왜 그렇게 되는가 (한 문장) = ___________________________
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 5 — or 를 섞으면 괄호가 사라진다")
    void 문제5() {
        System.out.println("=== (a) 올바른 varargs 방식 ===");
        List<Customer> correct = null;

        // (a) 여기에 작성:


        System.out.println("=== (b) .and().or() 체이닝 방식 ===");
        List<Customer> wrong = null;

        // (b) 여기에 작성:


        correct.forEach(c -> System.out.println("[a] " + c.getName() + " " + c.getCity()));
        wrong.forEach(c -> System.out.println("[b] " + c.getName() + " " + c.getCity()));

        assertThat(correct).hasSize(6);
        // assertThat(wrong).hasSize(__);   // ← 직접 확인한 숫자를 넣으십시오
    }

    // -----------------------------------------------------------------
    // 문제 6. NOT IN + NULL 함정을 재현하고 고치기
    //
    // 요구사항
    //  (a) phone 이 "010-1111-2222" 와 "010-3333-4444" 가 아닌 고객을
    //      notIn 으로 조회합니다. 결과 건수를 확인하십시오.
    //      고객 30명 중 그 두 번호를 쓰는 사람이 2명이므로 상식적으로는 28명입니다.
    //
    //  (b) 아래 phoneNotIn 메서드를 완성해 28건이 나오도록 고칩니다.
    //
    // 확인 1) (a) 결과 건수 = ___
    // 확인 2) 왜 28 이 아닌가 = _______________________________________
    // 확인 3) 이 버그는 데이터가 더 나오는 방향인가, 덜 나오는 방향인가 = ___
    //         그것이 왜 더 위험한가 = ___________________________
    // -----------------------------------------------------------------
    private BooleanExpression phoneNotIn(String... phones) {
        // 여기에 작성:
        return null;
    }

    @Test
    @DisplayName("문제 6 — NOT IN 과 NULL")
    void 문제6() {
        System.out.println("=== (a) 그냥 notIn ===");
        List<Customer> naive = null;

        // (a) 여기에 작성:


        System.out.println("=== (b) 보정한 버전 ===");
        List<Customer> fixed = queryFactory.selectFrom(customer)
                .where(phoneNotIn("010-1111-2222", "010-3333-4444"))
                .fetch();

        System.out.println("naive = " + naive.size());
        System.out.println("fixed = " + fixed.size());

        assertThat(naive).hasSize(25);
        assertThat(fixed).hasSize(28);
    }
}

Solution.java

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

  • 정답 2 는 네 줄짜리 표로 시작합니다. 요점은 "SQL 로그만 보고 디버깅하면 containsstartsWith 를 구분할 수 없다" 는 것이고, 그래서 org.hibernate.orm.jdbc.bind 로거를 켜라는 결론으로 이어집니다. 덧붙여 앞 % 가 인덱스를 죽인다는 것을 MySQL8 Step 15 와 연결해 설명합니다.
  • 정답 3 은 코드보다 여섯 개의 생성 SQL 을 주석에 전부 나열한 것이 본체입니다. 하나의 메서드가 조건 조합에 따라 여섯 가지 SQL 을 만든다는 것을 눈으로 훑을 수 있게 했습니다.
  • 정답 4 의 결론은 "SQL 은 같다" 입니다. 그래서 선택 기준은 성능이 아니라 가독성·재사용·조합성이라는 점, 그리고 빈 BooleanBuilder 가 조용히 전체 조회가 되는 위험을 주석에서 다시 짚습니다.
  • 정답 5 가 가장 깁니다. 세 코드의 where 절을 나란히 적고, 왜 11건인지를 "부산의 강도윤, 인천의 임하준, 광주의 신지아가 들어왔다" 는 구체적인 이름으로 설명합니다. 그리고 "자바 연산자 a && b || c&& 가 먼저지만, 메서드 체인 a.and(b).or(c) 는 그냥 왼쪽부터" 라는 한 줄이 이 함정의 정체라는 것을 강조합니다. 처방 세 가지의 코드와 SQL 도 함께 있습니다.
  • 정답 6 은 3값 논리를 NOT IN 전개식으로 풀어 씁니다. phone NOT IN (a, b)phone != a AND phone != b 이고, NULL 이면 각 항이 UNKNOWN, UNKNOWN AND UNKNOWN 은 UNKNOWN, WHERE 는 UNKNOWN 을 통과시키지 않는다 — 이 네 단계를 명시합니다. 그리고 "이 버그는 누락 방향이라 화면에서 티가 안 난다" 는 경고로 마무리합니다.
  • 파일 맨 아래 // 보너스 구간에 isEmpty() 가 만드는 not exists 서브쿼리와, 그것을 Step 07 에서 어떻게 직접 쓰게 되는지를 미리 적어 두었습니다.
package com.example.shop.step04;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.example.shop.entity.Product;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.Expressions;
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 org.springframework.util.StringUtils;

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

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

/**
 * Step 04 — 연습문제 정답과 해설.
 *
 * 문제를 먼저 풀어 본 뒤에 여십시오.
 * 각 정답에는 "왜 그 답인가" 를 설명하는 긴 주석이 붙어 있습니다.
 */
@SpringBootTest
@Transactional
class Solution {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // 정답 1 — contains 의 바인딩 값
    // =================================================================
    //
    // 확인 1) like 에 바인딩된 값 = '%노트북%'
    // 확인 2) SQL 에 % 문자가 보이는가 = 아니오
    //
    // 생성 SQL
    //   select p1_0.product_id, p1_0.attrs, p1_0.category_id, p1_0.cost,
    //          p1_0.created_at, p1_0.name, p1_0.price, p1_0.status, p1_0.stock
    //   from products p1_0
    //   where p1_0.name like ? escape '!' and p1_0.price >= ?
    //   바인딩: [1] '%노트북%'  [2] 500000.00
    //
    // 여기서 배울 것이 두 가지입니다.
    //
    // 첫째, % 는 SQL 문자열이 아니라 ** 바인딩 파라미터 값 ** 으로 들어갑니다.
    // 그래서 SQL 로그를 아무리 들여다봐도 검색어가 무엇이었는지 알 수 없습니다.
    // 운영 로그에서 "이 쿼리가 왜 느리지" 를 볼 때 like ? 만 보이면 손을 쓸 수 없습니다.
    // org.hibernate.orm.jdbc.bind 로거를 반드시 켜 두십시오.
    //
    // 둘째, escape '!' 는 QueryDSL 이 자동으로 붙입니다.
    // 사용자가 검색창에 "50%" 를 입력해도 '%50!%%' 로 이스케이프되어
    // "50% 라는 글자" 를 찾습니다. JPQL 문자열을 손으로 조립하면 이 처리를
    // 직접 해야 하고, 대부분 잊습니다. 잊으면 "50%" 검색이 사실상 전체 조회가 됩니다.
    //
    // 그리고 varargs 로 나열한 이유. 두 조건 모두 and 이므로 체이닝해도 같은 SQL 이지만,
    // 여기서 or 가 하나라도 끼어드는 순간 4-7 의 함정이 시작됩니다.
    // ** and 만 있어도 varargs 를 습관으로 삼는 것 ** 이 그 함정의 예방책입니다.
    // =================================================================
    @Test
    @DisplayName("정답 1 — contains 의 바인딩 값")
    void 정답1() {
        List<Product> result = queryFactory
                .selectFrom(product)
                .where(
                        product.name.contains("노트북"),
                        product.price.goe(new BigDecimal("500000"))
                )
                .fetch();

        result.forEach(p -> System.out.println(p.getName() + " / " + p.getPrice()));
        assertThat(result).hasSize(2);
    }

    // =================================================================
    // 정답 2 — like 세 형제의 SQL 과 바인딩
    // =================================================================
    //
    //   | 코드              | 생성 SQL                              | 바인딩 | 건수 |
    //   |-------------------|---------------------------------------|--------|------|
    //   | startsWith("김")  | where c1_0.name like ? escape '!'     | '김%'  |  3   |
    //   | contains("김")    | where c1_0.name like ? escape '!'     | '%김%' |  3   |
    //   | endsWith("준")    | where c1_0.name like ? escape '!'     | '%준'  |  2   |
    //
    // 확인) 세 SQL 이 같은가 = 예. 글자 하나 다르지 않습니다.
    //       SQL 로그만으로 구분할 수 있는가 = 아니오.
    //       구분하려면 = org.hibernate.orm.jdbc.bind 로거 (Hibernate 6 기준)
    //
    // Hibernate 5 시절의 org.hibernate.type.descriptor.sql.BasicBinder 를
    // 그대로 복사해 두면 SQL 만 찍히고 바인딩은 영원히 안 보입니다.
    // 이 코스 전체가 "코드 → 생성 SQL" 대조로 진행되므로 이 설정은 필수입니다.
    //
    // ── 성능 이야기 ──
    //
    // startsWith 와 contains 는 건수가 같지만 성능은 전혀 다릅니다.
    //
    //   '김%'  → 앞부분이 고정 → B+Tree 인덱스를 탈 수 있다 (range scan)
    //   '%김%' → 앞에 % → 인덱스를 탈 수 없다 (full scan)
    //
    // MySQL8 코스 Step 15 — 인덱스에서 확인한 "인덱스를 못 타는 5가지 패턴" 중 하나입니다.
    // customers 30행에서는 체감이 0 입니다. 그래서 개발 중에 발견되지 않습니다.
    // 같은 코드가 수십만 행 테이블로 옮겨가면 그대로 장애가 됩니다.
    //
    // 실무 판단 순서
    //   1) startsWith 로 요구사항을 만족할 수 있는가? → 만족하면 그것으로.
    //   2) 안 되면 전문 검색(Full-Text) 인덱스를 검토.
    //   3) 그것도 부족하면 검색 엔진(Elasticsearch 등) 분리.
    // "일단 contains 로 해 두고 나중에 최적화" 는 대부분 나중이 오지 않습니다.
    //
    // 참고로 like("김_") 는 0건입니다. contains 와 달리 % 나 _ 를 자동으로
    // 붙여 주지 않으므로, 와일드카드를 직접 넣어야 합니다.
    // "김" 다음에 정확히 한 글자인 이름이 없으므로 0건입니다.
    // =================================================================
    @Test
    @DisplayName("정답 2 — like 세 형제의 SQL 과 바인딩")
    void 정답2() {
        System.out.println("=== startsWith(\"김\") ===");
        List<Customer> starts = queryFactory.selectFrom(customer)
                .where(customer.name.startsWith("김")).fetch();

        System.out.println("=== contains(\"김\") ===");
        List<Customer> contains = queryFactory.selectFrom(customer)
                .where(customer.name.contains("김")).fetch();

        System.out.println("=== endsWith(\"준\") ===");
        List<Customer> ends = queryFactory.selectFrom(customer)
                .where(customer.name.endsWith("준")).fetch();

        assertThat(starts).hasSize(3);
        assertThat(contains).hasSize(3);
        assertThat(ends).hasSize(2);
    }

    // =================================================================
    // 정답 3 — BooleanExpression 방식 동적 검색
    // =================================================================
    //
    // 확인) search 메서드 안에 if 가 몇 개 있는가 = 0개.
    //       if 는 조건 메서드 안에 하나씩, 총 3개만 존재합니다.
    //
    // 하나의 search 메서드가 만들어 내는 SQL 을 전부 나열합니다.
    //
    //  (1) search(null, null, null)
    //      select c1_0.customer_id, ... from customers c1_0
    //      바인딩: 없음 / 30건
    //      ** where 라는 단어 자체가 없습니다. **
    //
    //  (2) search(GOLD, null, null)
    //      ... where c1_0.grade = ?
    //      바인딩: [1] GOLD / 9건
    //
    //  (3) search(null, "서울", null)
    //      ... where c1_0.city = ?
    //      바인딩: [1] '서울' / 8건
    //
    //  (4) search(null, null, 10000)
    //      ... where c1_0.points >= ?
    //      바인딩: [1] 10000 / 12건
    //
    //  (5) search(GOLD, "서울", null)
    //      ... where c1_0.grade = ? and c1_0.city = ?
    //      바인딩: [1] GOLD [2] '서울' / 4건
    //
    //  (6) search(GOLD, "서울", 10000)
    //      ... where c1_0.grade = ? and c1_0.city = ? and c1_0.points >= ?
    //      바인딩: [1] GOLD [2] '서울' [3] 10000 / 3건
    //
    // 여섯 가지 SQL 이 다섯 줄짜리 메서드 하나에서 나왔습니다.
    // 원리는 4-4 한 줄뿐입니다. ** where() 의 인자가 null 이면 그 조건이 소거된다. **
    // "and null" 도 "and 1=1" 도 아니고 완전 소거입니다. 그래서 JPQL 문자열 조립에서
    // 쓰던 "where 1=1" 관용구가 필요 없습니다.
    //
    // ── cityEq 에 hasText 를 쓴 이유 ──
    //
    // city != null 만 검사하면 빈 문자열이 통과합니다.
    //   → where c1_0.city = ''  → 0건
    // 사용자는 도시를 입력하지 않았는데 결과가 비어 있습니다.
    // 예외도 없고 로그도 정상입니다. 화면만 비어 있습니다.
    // HTTP 요청 파라미터는 미입력 시 null 이 아니라 "" 로 들어오는 경우가 흔하므로,
    // ** 문자열 동적 조건에는 무조건 StringUtils.hasText ** 로 기억하십시오.
    // (hasText 는 공백만 있는 "   " 도 걸러 줍니다.)
    //
    // ── 이 패턴의 진짜 이득 ──
    //
    // 조건 메서드가 BooleanExpression 을 반환한다는 것은
    // ** 조건끼리 다시 합칠 수 있다 ** 는 뜻입니다.
    //
    //   private BooleanExpression isPremium() {
    //       return customer.grade.in(VIP, GOLD).and(customer.points.goe(10000));
    //   }
    //
    // "우수 고객" 이라는 비즈니스 규칙이 코드 한 곳에 있습니다.
    // 규칙이 바뀌면 이 메서드만 고칩니다. 여러 쿼리에 흩어진 where 절을
    // 찾아다닐 필요가 없습니다. BooleanBuilder 로는 이렇게 되지 않습니다 (정답 4).
    //
    // 단, 주의점 하나. gradeEq(null) 은 null 을 반환하므로
    // gradeEq(g).and(...) 처럼 체이닝하면 NPE 입니다.
    // ** 동적 조건 메서드는 varargs 자리에만 넣으십시오. **
    // =================================================================
    private BooleanExpression gradeEq(Grade grade) {
        return grade != null ? customer.grade.eq(grade) : null;
    }

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

    private BooleanExpression pointsGoe(Integer minPoints) {
        return minPoints != null ? customer.points.goe(minPoints) : null;
    }

    private List<Customer> search(Grade grade, String city, Integer minPoints) {
        return queryFactory
                .selectFrom(customer)
                .where(gradeEq(grade), cityEq(city), pointsGoe(minPoints))
                .fetch();
    }

    @Test
    @DisplayName("정답 3 — BooleanExpression 방식 동적 검색")
    void 정답3() {
        System.out.println("=== (1) 조건 없음 ===");
        assertThat(search(null, null, null)).hasSize(30);

        System.out.println("=== (2) 등급만 ===");
        assertThat(search(Grade.GOLD, null, null)).hasSize(9);

        System.out.println("=== (3) 도시만 ===");
        assertThat(search(null, "서울", null)).hasSize(8);

        System.out.println("=== (4) 포인트만 ===");
        assertThat(search(null, null, 10000)).hasSize(12);

        System.out.println("=== (5) 등급 + 도시 ===");
        assertThat(search(Grade.GOLD, "서울", null)).hasSize(4);

        System.out.println("=== (6) 셋 다 ===");
        assertThat(search(Grade.GOLD, "서울", 10000)).hasSize(3);

        // 빈 문자열은 조건이 되지 않습니다.
        assertThat(search(null, "", null)).hasSize(30);
        assertThat(search(null, "   ", null)).hasSize(30);
    }

    // =================================================================
    // 정답 4 — BooleanBuilder 방식
    // =================================================================
    //
    // 확인 1) 두 SQL 이 같은가 = 예. 완전히 같습니다.
    // 확인 2) searchWithBuilder 안의 if = 3개 (조립부에 노출)
    // 확인 3) 줄 수 = search 는 5줄 / searchWithBuilder 는 13줄
    // 확인 4) 조건 6개로 늘면 = search 는 +3줄(인자 3줄) / builder 는 +9줄(if 블록 3개)
    //
    // ** 성능은 같습니다. ** 그러므로 선택 기준은 성능이 아닙니다.
    //
    //   가독성   : where(gradeEq(g), cityEq(c), pointsGoe(p)) 한 줄이면
    //              후보 조건 전부가 눈에 들어옵니다. 빌더는 if 블록을
    //              위에서 아래로 읽어야 합니다.
    //
    //   재사용   : gradeEq(g) 는 다른 쿼리에서 그대로 호출됩니다.
    //              빌더는 조립 로직을 통째로 복사하거나 메서드로 추출해야 합니다.
    //
    //   조합성   : gradeEq(g).and(cityEq(c)) 로 조건끼리 합쳐
    //              isPremium() 같은 이름 있는 규칙을 만들 수 있습니다.
    //              builder.and(otherBuilder) 는 되긴 하지만 결과가
    //              무엇인지(괄호가 어떻게 묶이는지) 코드만 보고 알기 어렵습니다.
    //
    //   테스트   : gradeEq(null) == null 을 단독으로 단언할 수 있습니다.
    //              빌더는 쿼리를 실행해야 검증됩니다.
    //
    // ── 빈 빌더의 위험 ──
    //
    //   BooleanBuilder builder = new BooleanBuilder();
    //   // if 조건이 전부 false 여서 아무것도 안 붙었다면?
    //   queryFactory.selectFrom(customer).where(builder).fetch();
    //   → where 절 없음 → 전체 30건
    //
    // 조회면 느릴 뿐이지만, 이 패턴이 update/delete 로 옮겨가면 전체 행이 대상입니다.
    // BooleanExpression 방식도 전부 null 이면 결과는 같지만,
    // where(gradeEq(g), cityEq(c)) 라는 코드는 "조건이 없을 수도 있다" 는 사실이
    // ** 호출부에 그대로 드러납니다. ** 빌더는 조립 로직 안에 숨습니다.
    // 위험의 크기가 같아도, 보이는 위험이 안 보이는 위험보다 낫습니다.
    //
    // ── 그래도 빌더를 쓸 때 ──
    //
    // 조건 개수가 런타임에 정해지는 경우(사용자가 필터를 N개 추가하는 화면)에는
    // 반복문 안에서 builder.and(...) 를 누적하는 편이 자연스럽습니다.
    // ** 기본은 BooleanExpression, 반복 누적이 필요할 때만 빌더. **
    // =================================================================
    private List<Customer> searchWithBuilder(Grade grade, String city, Integer minPoints) {
        BooleanBuilder builder = new BooleanBuilder();

        if (grade != null) {
            builder.and(customer.grade.eq(grade));
        }
        if (StringUtils.hasText(city)) {
            builder.and(customer.city.eq(city));
        }
        if (minPoints != null) {
            builder.and(customer.points.goe(minPoints));
        }

        return queryFactory.selectFrom(customer).where(builder).fetch();
    }

    @Test
    @DisplayName("정답 4 — BooleanBuilder 방식")
    void 정답4() {
        assertThat(searchWithBuilder(Grade.GOLD, "서울", 10000)).hasSize(3);
        assertThat(searchWithBuilder(null, null, null)).hasSize(30);

        assertThat(searchWithBuilder(Grade.GOLD, null, null).size())
                .isEqualTo(search(Grade.GOLD, null, null).size());

        // 빈 빌더는 조용히 전체 조회입니다.
        List<Customer> all = queryFactory.selectFrom(customer)
                .where(new BooleanBuilder()).fetch();
        assertThat(all).hasSize(30);
    }

    // =================================================================
    // 정답 5 — or 를 섞으면 괄호가 사라진다  (가장 중요한 정답)
    // =================================================================
    //
    // 확인 1) (a) where 절 = where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?
    // 확인 2) (b) where 절 = where c1_0.city = ? and c1_0.grade = ? or c1_0.grade = ?
    // 확인 3) (b) 결과 건수 = 11건
    // 확인 4) 서울이 아닌 고객이 나왔는가 = 예.
    //         강도윤(부산), 윤서아(부산), 임하준(인천), 조은우(대전), 신지아(광주)
    // 확인 5) 왜 = 메서드 체이닝에는 연산자 우선순위가 없고 왼쪽부터 묶이므로
    //         (서울 and VIP) or GOLD 가 되어, GOLD 는 도시 조건 없이 전부 걸린다.
    //
    // 세 코드를 나란히 놓습니다.
    //
    //   [의도]  where(A.or(B), C)
    //           where (c1_0.grade = ? or c1_0.grade = ?) and c1_0.city = ?
    //           → (VIP or GOLD) and 서울
    //           → 서울 VIP 2 + 서울 GOLD 4 = 6건  ✅
    //
    //   [실수①] where(C.and(A).or(B))
    //           where c1_0.city = ? and c1_0.grade = ? or c1_0.grade = ?
    //           → (서울 and VIP) or GOLD
    //           → 서울 VIP 2 + 전국 GOLD 9 = 11건  ❌
    //
    //   [실수②] where(A.or(B.and(C)))
    //           where c1_0.grade = ? or c1_0.grade = ? and c1_0.city = ?
    //           → VIP or (GOLD and 서울)
    //           → 전국 VIP 4 + 서울 GOLD 4 = 8건  ❌
    //
    // 6 / 11 / 8. 셋 다 컴파일 성공, 실행 성공, 예외 없음, SQL 문법 정상입니다.
    //
    // ── 함정의 정체 ──
    //
    //   자바 연산자   :  a && b || c   →  (a && b) || c    ← && 가 우선
    //   메서드 체인   :  a.and(b).or(c) →  (a.and(b)).or(c) ← 그냥 왼쪽부터
    //
    // 이 두 줄은 우연히 같은 결과입니다. 그래서 더 헷갈립니다.
    // 진짜 문제는 사람이 자연어 순서대로 쓴다는 것입니다.
    // "서울에 살고, VIP 또는 GOLD" 라고 말한 순서 그대로
    //   city.eq("서울").and(grade.eq(VIP)).or(grade.eq(GOLD))
    // 라고 쓰면, 말의 의미와 코드의 의미가 갈라집니다.
    // 눈은 "또는" 이 VIP/GOLD 를 묶는다고 읽는데, 코드는 앞의 두 개를 먼저 묶습니다.
    //
    // ── 왜 varargs 는 안전한가 ──
    //
    // where(a, b, c) 의 콤마는 항상 and 이고, 각 인자는 하나의 완결된 표현식입니다.
    // QueryDSL 이 and 로 묶으면서 필요한 괄호를 씌워 줍니다.
    // 반대로 말하면 ** or 는 varargs 로 표현할 수 없습니다. **
    // 이 비대칭이 함정의 구조적 원인이고, 동시에 처방의 근거입니다.
    // or 그룹을 "하나의 인자" 로 만들면 됩니다.
    //
    // ── 처방 세 가지 ──
    //
    //  1) or 그룹을 이름 있는 메서드로 추출 (가장 권장)
    //       private BooleanExpression isVipOrGold() {
    //           return customer.grade.eq(VIP).or(customer.grade.eq(GOLD));
    //       }
    //       where(isVipOrGold(), customer.city.eq("서울"))
    //     or 가 메서드 안에 갇혀 밖에서 순서를 헷갈릴 여지가 없고,
    //     이름이 비즈니스 의미를 드러냅니다.
    //
    //  2) Expressions.allOf / anyOf
    //       Expressions.allOf(
    //           Expressions.anyOf(A, B),
    //           C
    //       )
    //     괄호 구조가 코드 들여쓰기와 1:1 로 대응합니다.
    //     or 그룹 자체가 동적으로 결정될 때 특히 좋습니다 (null 인자는 무시됩니다).
    //
    //  3) 같은 컬럼의 or 는 in 으로 교체
    //       where(customer.grade.in(VIP, GOLD), customer.city.eq("서울"))
    //     or 가 아예 사라지므로 괄호 문제가 발생할 수 없습니다.
    //     SQL 도 짧고 옵티마이저도 in 을 더 잘 다룹니다.
    //     ** 같은 컬럼의 or 를 보면 먼저 in 으로 바꿀 수 있는지 확인하십시오. **
    //
    // ── 마지막 경고 ──
    //
    // 개발 데이터가 서울 고객만으로 채워져 있으면 세 코드가 전부 같은 결과를 냅니다.
    // 테스트도 통과합니다. 운영 데이터에서만 벌어집니다.
    // 그리고 "왜 부산 고객이 서울 필터에 나오죠?" 라는 문의로 발견됩니다.
    // 그때쯤이면 이미 여러 화면이 그 쿼리를 복사해 쓰고 있습니다.
    // =================================================================
    private BooleanExpression isVipOrGold() {
        return customer.grade.eq(Grade.VIP).or(customer.grade.eq(Grade.GOLD));
    }

    @Test
    @DisplayName("정답 5 — or 함정과 처방")
    void 정답5() {
        System.out.println("=== (a) 올바른 varargs 방식 ===");
        List<Customer> correct = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.eq(Grade.VIP).or(customer.grade.eq(Grade.GOLD)),
                        customer.city.eq("서울")
                )
                .fetch();

        System.out.println("=== (b) .and().or() 체이닝 방식 ===");
        List<Customer> wrong = queryFactory.selectFrom(customer)
                .where(
                        customer.city.eq("서울")
                                .and(customer.grade.eq(Grade.VIP))
                                .or(customer.grade.eq(Grade.GOLD))
                )
                .fetch();

        correct.forEach(c -> System.out.println("[a] " + c.getName() + " " + c.getCity()));
        wrong.forEach(c -> System.out.println("[b] " + c.getName() + " " + c.getCity()));

        assertThat(correct).hasSize(6);
        assertThat(wrong).hasSize(11);

        // (b) 에는 서울이 아닌 고객이 섞여 있습니다.
        assertThat(wrong).extracting(Customer::getCity).contains("부산", "인천", "광주");
        assertThat(correct).extracting(Customer::getCity).containsOnly("서울");

        // ----- 처방 세 가지 — 전부 6건으로 수렴 -----
        List<Customer> fix1 = queryFactory.selectFrom(customer)
                .where(isVipOrGold(), customer.city.eq("서울"))
                .fetch();

        List<Customer> fix2 = queryFactory.selectFrom(customer)
                .where(Expressions.allOf(
                        Expressions.anyOf(
                                customer.grade.eq(Grade.VIP),
                                customer.grade.eq(Grade.GOLD)
                        ),
                        customer.city.eq("서울")
                ))
                .fetch();

        List<Customer> fix3 = queryFactory.selectFrom(customer)
                .where(
                        customer.grade.in(Grade.VIP, Grade.GOLD),
                        customer.city.eq("서울")
                )
                .fetch();

        assertThat(fix1).hasSize(6);
        assertThat(fix2).hasSize(6);
        assertThat(fix3).hasSize(6);
    }

    // =================================================================
    // 정답 6 — NOT IN 과 NULL
    // =================================================================
    //
    // 확인 1) (a) 결과 건수 = 25건
    // 확인 2) 왜 28 이 아닌가 = phone 이 NULL 인 3명이 통째로 누락되기 때문
    // 확인 3) 덜 나오는 방향(누락). 화면에 티가 안 나므로 더 위험함
    //
    // 생성 SQL
    //   where c1_0.phone not in (?, ?)
    //   바인딩: [1] '010-1111-2222'  [2] '010-3333-4444'
    //
    // 고객 30명, 해당 번호를 쓰는 사람 2명 → 상식적으로는 28명입니다.
    // 그런데 25건입니다. phone 이 NULL 인 3명이 빠졌습니다.
    //
    // ── 왜 그런가 : 네 단계 ──
    //
    //   1) NOT IN 은 이렇게 전개됩니다.
    //        phone NOT IN (a, b)  ≡  phone != a AND phone != b
    //
    //   2) phone 이 NULL 이면 각 비교의 결과는 참도 거짓도 아닌 UNKNOWN 입니다.
    //        NULL != '010-1111-2222'  →  UNKNOWN
    //      NULL 은 "값이 없음" 이 아니라 "알 수 없음" 이고,
    //      알 수 없는 것과 무엇을 비교해도 알 수 없습니다.
    //
    //   3) UNKNOWN AND UNKNOWN = UNKNOWN
    //
    //   4) WHERE 절은 ** 참인 행만 ** 통과시킵니다.
    //      UNKNOWN 은 참이 아니므로 그 행은 제외됩니다.
    //
    // 이것이 SQL 의 3값 논리(TRUE / FALSE / UNKNOWN)입니다.
    // MySQL8 코스 Step 05 — 연산자와 조건, 부록 A — NULL 완전 정복,
    // Step 08 — 서브쿼리에서 다룬 그대로이고,
    // ** QueryDSL 로 써도 하나도 달라지지 않습니다. **
    // QueryDSL 은 SQL 위의 얇은 층입니다. 타입 안전성과 조립 가능성을 얹어 줄 뿐,
    // SQL 의 의미론은 그대로 통과합니다.
    //
    // ── 이 버그가 특히 위험한 이유 ──
    //
    // 방향이 ** 누락 ** 입니다. 있어야 할 데이터가 안 나오는 쪽이라
    // 화면에서 즉시 티가 나지 않습니다.
    // "제외 목록을 걸었더니 결과가 줄었다" 는 것은 지극히 자연스러워 보입니다.
    // 30명 중 25명만 나온다는 것을 알아채려면 누군가 세어 봐야 합니다.
    //
    // 그리고 이 버그는 ** 컬럼이 NULL 을 허용하는 한 언제든 ** 생깁니다.
    // 지금은 멀쩡한 코드가 "전화번호를 선택 입력으로 바꿉시다" 라는
    // 기획 변경 한 줄로 조용히 틀리기 시작합니다.
    //
    // ── 처방 ──
    //
    //  1) or isNull() 로 명시적으로 살린다  ← 정답
    //       customer.phone.notIn(phones).or(customer.phone.isNull())
    //       → where c1_0.phone not in (?, ?) or c1_0.phone is null
    //       or 를 썼으니, 여기에 조건이 더 붙는다면 이 표현식을
    //       반드시 메서드로 뽑아 varargs 자리에 넣어야 합니다 (정답 5 참고).
    //       아래 phoneNotIn 이 이미 그렇게 돼 있습니다.
    //
    //  2) coalesce 로 NULL 을 값으로 바꾼다
    //       coalesce(c1_0.phone, '') not in (?, ?)
    //       결과는 맞지만 컬럼을 함수로 감쌌으므로 인덱스를 못 탑니다.
    //       "컬럼을 가공하지 말고 리터럴을 가공하라" 는 규칙 위반입니다.
    //
    //  3) 애초에 NOT NULL 로 설계한다
    //       가장 근본적입니다. NULL 을 허용할 이유가 정말 있는지 되묻고,
    //       없으면 NOT NULL DEFAULT '' 로 바꾸면 문제 전체가 사라집니다.
    //
    // ── 예고 ──
    //
    // 서브쿼리를 NOT IN 에 넣으면 이 함정이 훨씬 잘 숨습니다.
    // 서브쿼리 결과에 NULL 이 ** 한 행이라도 ** 섞이면 전체 결과가 0건이 됩니다.
    // Step 07 에서 notIn(subquery) 대신 notExists(subquery) 를 쓰라고 하는 이유입니다.
    // =================================================================
    private BooleanExpression phoneNotIn(String... phones) {
        return customer.phone.notIn(phones).or(customer.phone.isNull());
    }

    @Test
    @DisplayName("정답 6 — NOT IN 과 NULL")
    void 정답6() {
        System.out.println("=== (a) 그냥 notIn ===");
        List<Customer> naive = queryFactory.selectFrom(customer)
                .where(customer.phone.notIn("010-1111-2222", "010-3333-4444"))
                .fetch();

        System.out.println("=== (b) 보정한 버전 ===");
        List<Customer> fixed = queryFactory.selectFrom(customer)
                .where(phoneNotIn("010-1111-2222", "010-3333-4444"))
                .fetch();

        System.out.println("naive = " + naive.size());   // 25
        System.out.println("fixed = " + fixed.size());   // 28

        assertThat(naive).hasSize(25);
        assertThat(fixed).hasSize(28);

        // 3값 논리 확인: = NULL 과 IS NULL 은 완전히 다릅니다.
        assertThat(queryFactory.selectFrom(customer)
                .where(customer.phone.isNull()).fetch()).hasSize(3);
        assertThat(queryFactory.selectFrom(customer)
                .where(customer.phone.isNotNull()).fetch()).hasSize(27);
        // 3 + 27 = 30. isNull/isNotNull 은 전체를 정확히 둘로 나눕니다.
        // 반면 = NULL 과 != NULL 은 둘 다 0건이라 합이 0 입니다.
    }

    // =================================================================
    // 보너스 — isEmpty() 가 만드는 not exists 서브쿼리
    // =================================================================
    //
    // 연습문제에는 없지만 Step 07 로 이어지는 다리입니다.
    //
    //   customer.orders.isEmpty()
    //     → where not exists (select 1 from orders o1_0
    //                         where c1_0.customer_id = o1_0.customer_id)
    //
    //   customer.orders.isNotEmpty()
    //     → where exists (select 1 from orders o1_0
    //                     where c1_0.customer_id = o1_0.customer_id)
    //
    // 자바 한 줄이 상관 서브쿼리(correlated subquery)를 만들어 냈습니다.
    // 편하지만, 이 편함에는 대가가 있습니다.
    //
    //  - 어떤 서브쿼리가 나갈지 코드만 봐서는 알 수 없습니다.
    //  - 조건을 추가할 수 없습니다. "취소되지 않은 주문이 있는 고객" 은
    //    isNotEmpty() 로 표현할 수 없습니다.
    //
    // 그래서 Step 07 에서 JPAExpressions 로 서브쿼리를 직접 씁니다.
    //
    //   .where(JPAExpressions.selectOne()
    //           .from(order)
    //           .where(order.customer.eq(customer),
    //                  order.status.ne(OrderStatus.CANCELLED))
    //           .exists())
    //
    // isEmpty()/isNotEmpty() 는 "조건 없는 존재 여부" 에만 쓰십시오.
    // 조건이 하나라도 붙으면 서브쿼리를 직접 쓰는 편이 명확합니다.
    // =================================================================
    @Test
    @DisplayName("보너스 — isEmpty() 는 not exists 서브쿼리")
    void 보너스_컬렉션_조건() {
        List<Customer> neverOrdered = queryFactory.selectFrom(customer)
                .where(customer.orders.isEmpty()).fetch();

        List<Customer> hasOrdered = queryFactory.selectFrom(customer)
                .where(customer.orders.isNotEmpty()).fetch();

        assertThat(neverOrdered).isEmpty();     // 0명
        assertThat(hasOrdered).hasSize(30);     // 30명 모두 주문 이력 있음
    }
}