Step 09 — 정렬과 페이징

학습 목표

  • orderBy 로 단일·다중 정렬을 걸고, 생성 SQL 의 order by 절과 1:1 로 대조한다
  • nullsFirst() / nullsLast() 가 MySQL 방언에서 어떤 SQL 로 풀려 나가는지 확인한다
  • offset / limit 과 Spring Pageable 을 연결해 Page 를 만든다
  • count 쿼리를 콘텐츠 쿼리와 분리해 최적화하고, PageableExecutionUtils 로 아예 생략시킨다
  • 정렬 컬럼에 함수를 씌워 인덱스를 죽이는 사고를 재현하고 Using filesort 를 눈으로 본다
  • 깊은 offset 의 비용을 실측하고 키셋(커서) 페이징으로 100배 이상 줄인다
  • 정렬 기준이 유일하지 않을 때 페이지 사이에서 행이 새거나 중복되는 현상을 재현한다

선행 스텝: Step 08 — 집계와 그룹핑 예상 소요: 100분


9-0. 실습 준비

이 스텝의 코드는 모두 아래를 전제로 합니다. Q타입은 static import 로 씁니다.

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

@SpringBootTest
@Transactional
class Practice {

    @Autowired JPAQueryFactory queryFactory;
    @PersistenceContext EntityManager em;
}

정렬과 페이징은 데이터가 적으면 어떤 방식으로 짜도 똑같이 빠릅니다. orders 는 600건, customers 는 30건이라 잘못 짠 쿼리와 잘 짠 쿼리의 실행시간이 구별되지 않습니다. 그래서 이 스텝의 성능 측정 구간(9-7, 9-8)은 MySQL8 코스의 access_logs 100만 행을 씁니다. QueryDSL 로 만든 SQL 을 그대로 복사해 access_logs 에 대고 돌린 수치입니다.

📌 access_logs 는 엔티티로 매핑하지 않습니다. MySQL8 코스 Step 15 — 인덱스 의 실습 테이블이며, 이 스텝에서는 "같은 모양의 SQL 이 100만 행에서 어떻게 동작하는가"를 보기 위한 대조군으로만 씁니다.


9-1. orderBy — 정렬의 기본

QueryDSL 의 모든 비교 가능한 경로(ComparableExpression 계열)에는 .asc().desc() 가 있습니다. 이 메서드가 반환하는 것이 OrderSpecifier 이고, orderBy(...) 는 이 타입만 받습니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .orderBy(customer.points.desc())
        .fetch();

결과hibernate.SQL 로그

select c1_0.customer_id, c1_0.birth_date, 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
order by c1_0.points desc
조회 30건
1. 류하나   (VIP,   48200p)
2. 정  훈   (VIP,   41500p)
3. 배채영   (VIP,   37800p)
4. 김서준   (VIP,   30100p)
5. 오하윤   (GOLD,  19600p)
...

.desc()desc 로, .asc()asc 로 그대로 번역됩니다. QueryDSL 에서 정렬만큼은 SQL 과의 대응이 거의 완벽합니다.

다중 정렬

orderBy 는 가변 인자를 받습니다. 인자 순서가 곧 정렬 우선순위입니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .orderBy(
                customer.grade.desc(),      // 1순위: 등급 내림차순
                customer.points.desc(),     // 2순위: 포인트 내림차순
                customer.customerId.asc()   // 3순위: PK 오름차순 (타이브레이커)
        )
        .fetch();

결과

select c1_0.customer_id, c1_0.birth_date, 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
order by c1_0.grade desc, c1_0.points desc, c1_0.customer_id asc
조회 30건
1. 류하나   (VIP,    48200p, id=7)
2. 정  훈   (VIP,    41500p, id=12)
3. 배채영   (VIP,    37800p, id=23)
4. 김서준   (VIP,    30100p, id=3)
5. 오하윤   (GOLD,   19600p, id=15)
...

⚠️ grade 는 ENUM 입니다 — desc 가 무엇을 뜻하는지 확인하십시오 grade@Enumerated(EnumType.STRING) 으로 매핑돼 있고 DB 컬럼은 MySQL ENUM('BRONZE','SILVER','GOLD','VIP') 입니다. MySQL 의 ENUM선언 순서의 정수값으로 정렬됩니다. 즉 grade desc 는 문자열 사전순(VIP > SILVER > GOLD > BRONZE)이 아니라 선언 순서 역순(VIP > GOLD > SILVER > BRONZE)입니다. 우연히 원하는 결과가 나오지만 그 이유는 사전순이 아닙니다. 등급별 우선순위를 확실히 하려면 CaseBuilder 로 순위를 만들어 정렬하십시오 (Step 13 에서 다룹니다).

세 개 이상의 정렬 키와 가독성

정렬 키가 많아지면 OrderSpecifier 배열로 뽑아 두는 편이 읽기 쉽습니다. 이 형태는 Step 10 의 동적 정렬로 그대로 이어집니다.

OrderSpecifier<?>[] sortByGradeThenPoints = {
        customer.grade.desc(),
        customer.points.desc(),
        customer.customerId.asc()
};

List<Customer> result = queryFactory
        .selectFrom(customer)
        .orderBy(sortByGradeThenPoints)
        .fetch();

생성 SQL 은 위와 완전히 동일합니다. orderBy(OrderSpecifier<?>...) 오버로드가 배열을 그대로 받습니다.


9-2. NULL 정렬 — nullsFirst() / nullsLast()

customers 30명 중 전화번호가 NULL 인 고객이 3명 있습니다. 정렬할 때 이 3명을 어디에 둘지가 문제입니다.

먼저 아무것도 지정하지 않고 정렬합니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .orderBy(customer.phone.asc())
        .fetch();

결과

select c1_0.customer_id, c1_0.birth_date, 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
order by c1_0.phone asc
조회 30건
1. 한지호   phone=NULL
2. 안지수   phone=NULL
3. 문시우   phone=NULL
4. 김서준   phone=010-1002-...
...

MySQL 은 ASC 정렬에서 NULL 을 가장 작은 값으로 취급해 앞에 놓습니다. 문제는 이게 DB 마다 다르다는 점입니다. PostgreSQL 과 Oracle 은 ASC 에서 NULL 을 뒤에 놓습니다. DB 를 바꾸면 페이지 1의 첫 3건이 조용히 달라집니다.

그래서 명시합니다.

List<Customer> nullsLast = queryFactory
        .selectFrom(customer)
        .orderBy(customer.phone.asc().nullsLast())
        .fetch();

결과 — MySQL 8 + Hibernate 6.4

select c1_0.customer_id, c1_0.birth_date, 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
order by case when c1_0.phone is null then 1 else 0 end, c1_0.phone asc
조회 30건
1. 김서준   phone=010-1002-...
...
28. 한지호  phone=NULL
29. 안지수  phone=NULL
30. 문시우  phone=NULL

여기가 이 절의 핵심입니다. nullsLast() 는 SQL 표준의 nulls last 키워드로 나가지 않았습니다. MySQL 에는 NULLS FIRST / NULLS LAST 문법이 없기 때문에, Hibernate 의 MySQL 방언이 case when ... is null then 1 else 0 end 라는 정렬 키를 앞에 하나 더 끼워 넣어 흉내 냅니다.

nullsFirst()then 0 else 1 로 뒤집힌 형태가 됩니다.

queryFactory.selectFrom(customer)
        .orderBy(customer.phone.asc().nullsFirst())
        .fetch();

결과

order by case when c1_0.phone is null then 0 else 1 end, c1_0.phone asc
DBnulls last 지원Hibernate 가 내보내는 SQL
MySQL 8없음case when col is null then 1 else 0 end, col asc (에뮬레이션)
PostgreSQL있음col asc nulls last
Oracle있음col asc nulls last
H2있음col asc nulls last

⚠️ 함정 — NULL 정렬 에뮬레이션이 인덱스를 죽입니다 위 SQL 의 첫 번째 정렬 키는 case when c1_0.phone is null then 1 else 0 end 라는 계산식입니다. 인덱스는 phone 원본 값으로 정렬돼 있으므로, phone 에 인덱스가 있어도 이 정렬은 그 인덱스로 처리할 수 없습니다. EXPLAINUsing filesort 가 남습니다. 이유는 9-7 에서 다루는 "함수를 씌우면 인덱스를 못 탄다"와 정확히 같습니다. 처방: 정렬 대상 컬럼을 NOT NULL + 기본값으로 설계하거나, NULL 행을 where col is not null 로 먼저 걸러낸 뒤 정렬하십시오. 정렬 순서가 DB 기본값과 일치한다면(MySQL 의 asc + NULL 앞) nullsFirst()생략하는 것도 정당한 선택입니다. 다만 그 선택은 "이 애플리케이션은 MySQL 을 벗어나지 않는다"는 약속과 짝을 이룹니다.

💡 정확한 에뮬레이션 SQL 의 모양(then 1 else 0 인지 is null 을 그대로 쓰는지)은 Hibernate 버전과 방언에 따라 달라질 수 있습니다. 위 로그는 Hibernate 6.4 + MySQL 8 에서 확인한 것입니다. 여러분의 콘솔에 다른 형태가 찍힌다면 그것이 여러분 환경의 정답이며, 중요한 것은 "표준 키워드가 아니라 계산식으로 풀린다" 는 사실입니다.


9-3. offset / limit

List<Order> page = queryFactory
        .selectFrom(order)
        .orderBy(order.orderDate.desc(), order.orderId.desc())
        .offset(20)     // 0-based. 앞의 20건을 건너뛴다
        .limit(10)      // 10건을 가져온다
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
바인딩: [1] 20  [2] 10
조회 10건 — 3페이지(0-based 로는 index 2)
order_id=571  2025-11-28  DELIVERED  268,000
order_id=568  2025-11-26  PAID       97,000
...

offset 은 0-based 입니다. offset(0) 이 1페이지, offset(10) 이 2페이지(페이지 크기 10 기준)입니다. 페이지 번호 → offset 변환은 pageNumber * pageSize 입니다.

💡 MySQL 의 limit ?, ?limit offset, rowcount 순서입니다. Hibernate 6 의 MySQL 방언은 offset 이 있을 때 limit ?, ? 를, offset 이 없을 때 limit ? 를 내보냅니다. 방언에 따라 limit ? offset ? 형태로 나가는 경우도 있으니 여러분의 로그를 기준으로 읽으십시오. 어느 쪽이든 바인딩 값의 의미(건너뛸 수 / 가져올 수)는 같습니다.

offset 없이 limit 만 쓰면 limit ? 하나만 나갑니다.

List<Order> top5 = queryFactory
        .selectFrom(order)
        .orderBy(order.totalAmount.desc(), order.orderId.asc())
        .limit(5)
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.total_amount desc, o1_0.order_id asc
limit ?
바인딩: [1] 5
조회 5건 — 최고 금액 주문 TOP 5
order_id=412  2,847,000
order_id=88   2,631,000
order_id=305  2,514,000
order_id=177  2,190,000
order_id=534  2,190,000

9-4. Spring Pageable 을 받아서

컨트롤러가 ?page=2&size=10 을 받으면 Spring 이 Pageable 로 바인딩해 줍니다. Pageable 은 offset 계산을 대신해 줍니다.

public List<Order> findOrders(Pageable pageable) {
    return queryFactory
            .selectFrom(order)
            .orderBy(order.orderDate.desc(), order.orderId.desc())
            .offset(pageable.getOffset())        // long. page * size 를 계산해 준다
            .limit(pageable.getPageSize())       // int
            .fetch();
}

PageRequest.of(2, 10) 을 넘기면:

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
바인딩: [1] 20  [2] 10
pageable.getOffset() = 20   (page=2 * size=10)
조회 10건
Pageable 메서드반환QueryDSL 대응
getOffset()long.offset(long)
getPageSize()int.limit(long) — 자동 확장
getPageNumber()int (0-based)직접 쓸 일은 거의 없음
getSort()SortorderBy직접 변환해야 함 → Step 10

⚠️ PageableSort 는 QueryDSL 에 자동으로 적용되지 않습니다. .offset() / .limit() 은 받아 쓸 수 있지만, pageable.getSort() 를 QueryDSL 이 알아서 order by 로 바꿔 주지 않습니다. 클라이언트가 ?sort=totalAmount,desc 를 보내도 아무 일도 일어나지 않습니다. 에러도 안 납니다. SortOrderSpecifier[] 변환기를 직접 만들어야 하고, 그것이 Step 10 의 주제입니다.


9-5. Page 만들기 — 콘텐츠 쿼리 + count 쿼리

Page<T>콘텐츠 + 전체 건수를 함께 담습니다. 전체 건수를 알아야 "총 60페이지 중 3페이지" 를 그릴 수 있습니다. 그러므로 쿼리가 두 번 나갑니다.

public Page<Order> searchOrders(Pageable pageable) {

    // ① 콘텐츠 쿼리
    List<Order> content = queryFactory
            .selectFrom(order)
            .where(order.status.eq(OrderStatus.DELIVERED))
            .orderBy(order.orderDate.desc(), order.orderId.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    // ② count 쿼리
    Long total = queryFactory
            .select(order.count())
            .from(order)
            .where(order.status.eq(OrderStatus.DELIVERED))
            .fetchOne();

    return new PageImpl<>(content, pageable, total);
}

결과 — 쿼리 2건

-- ① 콘텐츠
select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
where o1_0.status = ?
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
-- ② count
select count(o1_0.order_id)
from orders o1_0
where o1_0.status = ?
바인딩 ①: [1] DELIVERED  [2] 0  [3] 10
바인딩 ②: [1] DELIVERED
content 10건 / total 214건 / totalPages 22

order.count()count(o1_0.order_id) 로 번역됩니다. count(*) 가 아닙니다. PK 는 NOT NULL 이므로 결과값은 count(*) 와 같지만, 생성 SQL 은 다릅니다.

📌 MySQL8 코스 Step 06 — 집계와 GROUP BY 에서 다뤘던 COUNT(*) vs COUNT(컬럼) 의 차이(NULL 을 세지 않는다)가 여기서 그대로 적용됩니다. order.count() 는 PK 를 세므로 안전하지만, order.customer.customerId.count() 처럼 NULL 가능 컬럼을 세면 결과가 달라집니다. Step 08 의 8-2 에서 확인했습니다.


9-6. count 쿼리를 따로 최적화한다

이 절이 이 스텝에서 가장 실무적인 부분입니다.

fetchCount() 를 쓰지 않는가

QueryDSL 5.0 부터 fetchCount()fetchResults()deprecated 입니다. 6.x 에서도 그대로 deprecated 입니다.

// deprecated — 쓰지 마십시오
long total = queryFactory.selectFrom(order)
        .where(order.status.eq(OrderStatus.DELIVERED))
        .fetchCount();

문제는 이 메서드가 원래 쿼리를 그대로 감싸서 count 로 바꾼다는 데 있습니다. select 절이 복잡하거나 groupBy 가 붙어 있거나 조인이 여럿이면, 자동 변환이 만들어내는 SQL 이 의도한 것과 다르거나 아예 실패합니다. QueryDSL 팀이 "자동 변환은 신뢰할 수 없다"고 판단해 폐기했습니다.

📌 Step 03 — 기본 조회 의 3-6 에서 fetchResults() / fetchCount() 의 deprecation 배경과 경고 로그를 다뤘습니다. 여기서는 대체 방법에 집중합니다.

count 쿼리는 콘텐츠 쿼리와 다른 쿼리다

핵심 통찰은 이것입니다. 콘텐츠 쿼리에 필요한 것들이 count 쿼리에는 대부분 불필요합니다.

요소콘텐츠 쿼리count 쿼리
where 조건필요필요 (건수가 달라짐)
order by필요불필요 — 세는 데 순서는 의미 없음
join (표시용 컬럼 때문에)필요불필요
join (where 조건 때문에)필요필요
limit / offset필요불필요
select 컬럼 목록필요불필요 (count() 하나)

그러니 count 쿼리는 직접 씁니다. 조인과 정렬을 뺍니다.

// 콘텐츠 쿼리 — 고객 이름을 함께 보여줘야 하므로 join 이 필요하다
List<OrderListDto> content = queryFactory
        .select(new QOrderListDto(
                order.orderId, order.orderDate, order.totalAmount, customer.name))
        .from(order)
        .join(order.customer, customer)
        .where(order.status.eq(OrderStatus.DELIVERED))
        .orderBy(order.orderDate.desc(), order.orderId.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

// count 쿼리 — join 도 orderBy 도 없다
JPAQuery<Long> countQuery = queryFactory
        .select(order.count())
        .from(order)
        .where(order.status.eq(OrderStatus.DELIVERED));

결과 — 콘텐츠

select o1_0.order_id, o1_0.order_date, o1_0.total_amount, c1_0.name
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
where o1_0.status = ?
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?

결과 — count

select count(o1_0.order_id)
from orders o1_0
where o1_0.status = ?

join customers 가 사라졌습니다. order by 도 사라졌습니다. 600건에서는 차이가 없지만, orders 가 수백만 건이고 조인 대상이 3~4개면 이 차이가 응답시간 전부입니다.

⚠️ 함정 — 조인을 무조건 빼면 안 됩니다 where 조건이 조인 대상 테이블의 컬럼을 참조한다면 그 조인은 count 쿼리에도 반드시 남아야 합니다. 예를 들어 where(customer.grade.eq(Grade.VIP)) 가 있으면 join(order.customer, customer) 를 빼는 순간 조건이 성립하지 않아 컴파일은 되는데 JPQL 파싱에서 터지거나, 조건이 무시된 엉뚱한 건수가 나옵니다. 규칙: 표시(select)를 위한 조인만 뺀다. 필터(where)를 위한 조인은 남긴다. 또한 @ManyToOneNOT NULL FK 일 때 inner join 은 행 수를 바꾸지 않지만, @OneToMany 조인은 행이 뻥튀기되어 count 가 커집니다(Step 08 의 8-7 에서 다룬 문제). count 쿼리에 컬렉션 조인이 남아 있으면 countDistinct() 가 필요한지 반드시 확인하십시오.

PageableExecutionUtils — count 쿼리를 아예 안 보낸다

여기서 한 걸음 더 갈 수 있습니다. count 쿼리가 필요 없는 경우가 있습니다.

  • 첫 페이지인데 조회 결과가 페이지 크기보다 작다 → 전체 건수 = 조회된 건수 (offset=0, size=10 인데 7건 나왔다면 전체가 7건입니다)
  • 마지막 페이지다 → 전체 건수 = offset + 조회된 건수 (offset=200, size=10 인데 4건 나왔다면 전체가 204건입니다)

이 두 경우에 count 쿼리를 보내는 것은 순수한 낭비입니다. Spring Data 가 이 판단을 대신해 주는 유틸이 PageableExecutionUtils 입니다.

public Page<OrderListDto> searchOrders(Pageable pageable) {

    List<OrderListDto> content = queryFactory
            .select(new QOrderListDto(
                    order.orderId, order.orderDate, order.totalAmount, customer.name))
            .from(order)
            .join(order.customer, customer)
            .where(order.status.eq(OrderStatus.DELIVERED))
            .orderBy(order.orderDate.desc(), order.orderId.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    JPAQuery<Long> countQuery = queryFactory
            .select(order.count())
            .from(order)
            .where(order.status.eq(OrderStatus.DELIVERED));

    // countQuery::fetchOne 은 Supplier<Long>. 필요할 때만 호출된다.
    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

핵심은 세 번째 인자가 Supplier<Long> 이라는 점입니다. countQuery.fetchOne() 을 미리 호출해 값을 넘기는 게 아니라, countQuery::fetchOne 이라는 호출 가능한 것을 넘깁니다. PageableExecutionUtils 가 위 조건을 검사해 필요할 때만 get() 을 부릅니다.

결과 ① — PageRequest.of(0, 10), DELIVERED 214건

select o1_0.order_id, o1_0.order_date, o1_0.total_amount, c1_0.name
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
where o1_0.status = ?
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
select count(o1_0.order_id)
from orders o1_0
where o1_0.status = ?
쿼리 2건 — 10건이 꽉 찼으므로 전체 건수를 알 수 없다. count 실행됨.
total = 214

결과 ② — PageRequest.of(0, 500), DELIVERED 214건

select o1_0.order_id, o1_0.order_date, o1_0.total_amount, c1_0.name
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
where o1_0.status = ?
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
쿼리 1건 — count 쿼리가 나가지 않았다.
offset=0 이고 조회 결과(214)가 pageSize(500)보다 작으므로 total = 214 로 확정.
total = 214

결과 ③ — PageRequest.of(21, 10) (마지막 페이지), DELIVERED 214건

select o1_0.order_id, o1_0.order_date, o1_0.total_amount, c1_0.name
from orders o1_0
join customers c1_0 on c1_0.customer_id = o1_0.customer_id
where o1_0.status = ?
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
바인딩: [1] DELIVERED  [2] 210  [3] 10
쿼리 1건 — count 쿼리가 나가지 않았다.
조회 4건 < pageSize 10 → total = offset(210) + 4 = 214 로 확정.
total = 214

②와 ③에서 count SQL 로그가 아예 없습니다. 이것을 눈으로 확인하는 것이 이 절의 목표입니다. hibernate.SQL 로그를 켜 두고 페이지 번호를 바꿔 가며 실행해 보십시오.

⚠️ PageableExecutionUtils 의 패키지가 버전에 따라 다릅니다 초기 Spring Data 에서는 org.springframework.data.repository.support.PageableExecutionUtils 였고, 이후 org.springframework.data.support.PageableExecutionUtils 로 옮겨졌습니다. 블로그 글을 복사하면 import 가 안 잡히는 일이 흔합니다. 정확한 패키지는 Spring Data 버전에 따라 다르므로, IDE 자동완성으로 확인하십시오. 클래스명 PageableExecutionUtils 만 타이핑하고 자동 import 를 쓰는 것이 가장 확실합니다. (이 코스는 Spring Boot 3.2.5 기준입니다.)

💡 실무 팁 — count 를 아예 없애는 것도 선택지입니다 총 페이지 수를 화면에 꼭 보여줘야 합니까? 무한 스크롤이나 "더 보기" UI 라면 필요 없습니다. 그런 화면에는 Slice (9-10) 나 키셋 페이징 (9-8) 이 훨씬 낫습니다. count 쿼리는 화면 요구사항이 강제할 때만 지불하는 비용입니다.


9-7. ⚠️ 정렬 컬럼에 함수를 씌워 인덱스를 죽인다

이 스텝의 대표 함정입니다. 컴파일도 되고, 결과도 정확하고, 30건에서는 즉시 응답합니다.

잘못된 코드

"연도 기준 최신순으로 주문을 보여 달라"는 요구를 이렇게 씁니다.

List<Order> result = queryFactory
        .selectFrom(order)
        .orderBy(order.orderDate.year().desc(), order.orderId.desc())
        .limit(20)
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by year(o1_0.order_date) desc, o1_0.order_id desc
limit ?
바인딩: [1] 20
조회 20건 — 결과는 정확합니다

고객 이름을 대소문자 무시하고 정렬하는 것도 같은 문제입니다.

List<Customer> result = queryFactory
        .selectFrom(customer)
        .orderBy(customer.name.lower().asc())
        .fetch();

결과

select c1_0.customer_id, c1_0.birth_date, 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
order by lower(c1_0.name) asc

왜 틀렸는가

인덱스는 컬럼의 원본 값으로 정렬돼 있습니다. (order_date) 인덱스의 리프 노드는 2024-01-02, 2024-01-05, ... 순으로 물리적으로 늘어서 있습니다. year(order_date) 는 그와 다른 값입니다. 인덱스 어디에도 2024, 2025 라는 값은 저장돼 있지 않습니다. 따라서 MySQL 은 인덱스의 정렬 순서를 재사용할 수 없고, 모든 행을 읽어 메모리(또는 디스크)에서 다시 정렬합니다.

📌 MySQL8 코스 Step 15 — 인덱스 의 15-7 (1) 에서 "인덱스 컬럼에 함수를 씌우면 못 탄다"를 WHERE MONTH(logged_at) = 6 으로 확인했습니다. ORDER BY 에서도 똑같습니다. WHERE 만의 이야기가 아닙니다. 함수 자체의 동작은 Step 12 — 내장 함수 를 참고하십시오.

100만 행 access_logs 에서 같은 모양의 SQL 을 돌려 확인합니다. (idx_time (logged_at) 인덱스가 있는 상태입니다.)

EXPLAIN SELECT log_id, logged_at FROM access_logs
ORDER BY YEAR(logged_at) DESC, log_id DESC LIMIT 20;

결과

+------+---------------+------+---------+--------+----------------+
| type | possible_keys | key  | key_len | rows   | Extra          |
| ALL  | NULL          | NULL | NULL    | 996151 | Using filesort |
+------+---------------+------+---------+--------+----------------+

type: ALL + Using filesort. 100만 행을 다 읽고 전부 정렬한 다음 20건만 꺼냅니다.

SELECT log_id, logged_at FROM access_logs
ORDER BY YEAR(logged_at) DESC, log_id DESC LIMIT 20;
20 rows in set (1.284 sec)

1.284초.

고친 코드

정렬은 원본 컬럼으로 합니다. 함수는 표시용으로만 씁니다.

List<Order> result = queryFactory
        .selectFrom(order)
        .orderBy(order.orderDate.desc(), order.orderId.desc())   // 원본 컬럼
        .limit(20)
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.order_date desc, o1_0.order_id desc
limit ?

order_date 로 내림차순 정렬한 결과는 year(order_date) 로 내림차순 정렬한 것과 연도 단위로는 동일한 순서입니다. 애초에 함수가 필요 없었습니다.

100만 행에서 다시 측정합니다.

EXPLAIN SELECT log_id, logged_at FROM access_logs
ORDER BY logged_at DESC, log_id DESC LIMIT 20;

결과

+-------+---------------+----------+---------+-------+---------------------+
| type  | possible_keys | key      | key_len | rows  | Extra               |
| index | NULL          | idx_time | 5       | 20    | Backward index scan |
+-------+---------------+----------+---------+-------+---------------------+

rows: 20. 인덱스를 뒤에서부터 20건만 읽고 끝냅니다. Using filesort 가 사라졌습니다.

SELECT log_id, logged_at FROM access_logs ORDER BY logged_at DESC, log_id DESC LIMIT 20;
20 rows in set (0.002 sec)
정렬 방식EXPLAIN읽은 행실행시간
ORDER BY YEAR(logged_at) DESCALL + Using filesort996,1511.284초
ORDER BY logged_at DESCindex + Backward index scan200.002초

1.284초 → 0.002초. 약 640배.

함수 정렬이 정말 필요할 때

대소문자 무시 정렬(lower(name))처럼 원본 컬럼으로는 대체할 수 없는 경우가 있습니다. 두 가지 처방이 있습니다.

(1) 컬레이션을 바꾼다. shop 스키마는 이미 utf8mb4_0900_ai_ci대소문자 구분 없는(ci) 컬레이션입니다. 즉 ORDER BY name 이 이미 대소문자를 무시합니다. lower() 는 처음부터 불필요했습니다. 이 사실을 모르고 lower() 를 씌우는 것이 실무에서 가장 흔한 형태입니다.

(2) 생성 컬럼 + 인덱스. 정말 다른 값으로 정렬해야 한다면 그 값을 컬럼으로 만들고 인덱스를 겁니다.

ALTER TABLE customers
  ADD COLUMN name_sort VARCHAR(50) AS (LOWER(name)) STORED,
  ADD INDEX idx_name_sort (name_sort);

📌 생성 컬럼(Generated Column)과 함수 기반 인덱스는 MySQL8 코스 Step 14 — 뷰와 생성 컬럼 에서 다룹니다. QueryDSL 에서는 이 컬럼을 엔티티 필드(@Column(insertable=false, updatable=false))로 매핑하면 customer.nameSort.asc() 로 타입 안전하게 정렬할 수 있습니다.

⚠️ 공용 테이블에 인덱스를 만들지 마십시오.ALTER TABLE 은 설명용입니다. customers 를 포함한 공용 실습 테이블에는 인덱스를 추가하지 마십시오. 다른 스텝의 EXPLAIN 결과가 달라집니다. 실험은 access_logs 에서만 하고 끝나면 DROP INDEX 로 정리하십시오.


9-8. ⚠️ 깊은 offset 의 비용

두 번째 대표 함정입니다. 이건 코드가 틀린 게 아니라 접근 방식이 확장되지 않는 경우입니다.

문제

List<Order> page5001 = queryFactory
        .selectFrom(order)
        .orderBy(order.orderId.desc())
        .offset(100_000)
        .limit(20)
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.order_id desc
limit ?, ?
바인딩: [1] 100000  [2] 20

SQL 자체는 완벽합니다. 문제는 DB 가 이 SQL 을 어떻게 실행하는가입니다.

LIMIT 100000, 20 은 DB 에게 이렇게 말합니다. "정렬된 순서로 100,020개를 읽어라. 그리고 앞의 100,000개는 버려라."

DB 에는 "정렬 결과의 100,001번째로 바로 점프하는" 방법이 없습니다. B+Tree 인덱스도 "N번째 리프"를 직접 가리키지 못합니다. 처음부터 세면서 가야 합니다. offset 이 깊어질수록 버리는 행의 수만큼 비용이 증가합니다.

100만 행 access_logs 로 측정합니다.

SELECT log_id, logged_at FROM access_logs ORDER BY log_id DESC LIMIT 0, 20;
20 rows in set (0.001 sec)
SELECT log_id, logged_at FROM access_logs ORDER BY log_id DESC LIMIT 100000, 20;
20 rows in set (0.087 sec)
SELECT log_id, logged_at FROM access_logs ORDER BY log_id DESC LIMIT 500000, 20;
20 rows in set (0.412 sec)
SELECT log_id, logged_at FROM access_logs ORDER BY log_id DESC LIMIT 900000, 20;
20 rows in set (0.741 sec)
offset실제로 읽는 행실행시간
0200.001초
100,000100,0200.087초
500,000500,0200.412초
900,000900,0200.741초

offset 에 정비례합니다. 반환하는 행은 늘 20건인데 말입니다.

💡 사용자가 5,001페이지를 실제로 볼까요? 대부분 안 봅니다. 하지만 크롤러와 배치는 봅니다. "전체 목록을 페이지 단위로 순회하는" 배치 잡이 offset 을 계속 늘려 가면 뒤로 갈수록 느려지다가 타임아웃으로 죽습니다. 이 사고는 항상 운영 데이터가 커진 뒤에 발생합니다.

처방 — 키셋(커서) 페이징

발상을 뒤집습니다. "몇 개를 건너뛸까" 대신 "어디부터 읽을까" 를 지정합니다. 직전 페이지의 마지막 행 값(커서)을 클라이언트가 들고 오게 하고, where 로 그 지점부터 읽습니다.

public List<Order> nextPage(Long lastSeenOrderId, int size) {
    return queryFactory
            .selectFrom(order)
            .where(lastSeenOrderId == null ? null : order.orderId.lt(lastSeenOrderId))
            .orderBy(order.orderId.desc())
            .limit(size)
            .fetch();
}

wherenull 을 넘기면 QueryDSL 이 그 조건을 무시합니다(Step 04 의 4-4). 그래서 첫 페이지(lastSeenOrderId == null)는 조건 없이 나갑니다.

결과 — 첫 페이지

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.order_id desc
limit ?
바인딩: [1] 20
조회 20건 — 마지막 order_id = 581

결과 — 다음 페이지 (lastSeenOrderId = 581)

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
where o1_0.order_id < ?
order by o1_0.order_id desc
limit ?
바인딩: [1] 581  [2] 20
조회 20건 — 마지막 order_id = 561

offset 이 사라지고 where order_id < ? 가 생겼습니다. 이 조건은 인덱스로 즉시 탐색(seek)됩니다. PK 인덱스에서 581 지점으로 바로 내려간 뒤 거기서부터 20건을 읽고 끝냅니다. 몇 번째 페이지든 읽는 행은 항상 20건입니다.

100만 행에서 확인합니다.

EXPLAIN SELECT log_id, logged_at FROM access_logs
WHERE log_id < 100000 ORDER BY log_id DESC LIMIT 20;

결과

+-------+---------------+---------+---------+------+-------------+
| type  | possible_keys | key     | key_len | rows | Extra       |
| range | PRIMARY       | PRIMARY | 8       | 20   | Using where |
+-------+---------------+---------+---------+------+-------------+
SELECT log_id, logged_at FROM access_logs WHERE log_id < 100000 ORDER BY log_id DESC LIMIT 20;
20 rows in set (0.001 sec)
SELECT log_id, logged_at FROM access_logs WHERE log_id < 500000 ORDER BY log_id DESC LIMIT 20;
20 rows in set (0.001 sec)
방식offset 100,000offset 500,000offset 900,000
offset 페이징0.087초0.412초0.741초
키셋 페이징0.001초0.001초0.001초

0.412초 → 0.001초. 400배 이상이며, 더 중요한 것은 offset 이 깊어져도 느려지지 않는다는 점입니다.

정렬 키가 PK 가 아닐 때의 키셋

order_date 로 정렬하면서 키셋을 쓰려면 커서가 복합 값이 됩니다. order_date 는 중복될 수 있으므로 PK 를 함께 들고 가야 합니다.

public List<Order> nextPageByDate(LocalDateTime lastDate, Long lastId, int size) {
    BooleanExpression cursor = (lastDate == null) ? null :
            order.orderDate.lt(lastDate)
                    .or(order.orderDate.eq(lastDate).and(order.orderId.lt(lastId)));

    return queryFactory
            .selectFrom(order)
            .where(cursor)
            .orderBy(order.orderDate.desc(), order.orderId.desc())
            .limit(size)
            .fetch();
}

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
where o1_0.order_date < ? or o1_0.order_date = ? and o1_0.order_id < ?
order by o1_0.order_date desc, o1_0.order_id desc
limit ?
바인딩: [1] 2025-11-26T00:00  [2] 2025-11-26T00:00  [3] 568  [4] 20
조회 20건

생성 SQL 의 괄호를 보십시오. a < ? or a = ? and b < ? 로 괄호가 없습니다. SQL 에서 ANDOR 보다 우선순위가 높으므로 이것은 a < ? or (a = ? and b < ?) 와 같습니다. 의도한 대로입니다. 그러나 이건 운입니다.

⚠️ 함정 — .or() 조립에서 사라지는 괄호 Step 04 의 4-5 에서 다뤘듯이 QueryDSL 은 불필요하다고 판단한 괄호를 생략합니다. 위 경우는 우연히 의도와 일치했지만, 조건 순서가 조금만 바뀌면 달라집니다. 키셋 조건처럼 and/or 가 섞이는 표현식은 반드시 생성 SQL 을 눈으로 확인하십시오. 확실하게 하려면 Expressions.anyOf(...) / Expressions.allOf(...) 로 그룹을 명시하십시오.

💡 키셋 페이징의 한계

  • "7페이지로 바로 가기" 가 불가능합니다. 순차 이동(다음/이전)만 됩니다.
  • 정렬 조건이 바뀌면 커서 형태도 바뀝니다. 동적 정렬(Step 10)과 결합하기가 까다롭습니다.
  • 전체 건수를 모릅니다(별도 count 가 필요). 그래서 "무한 스크롤·피드·배치 순회" 에는 키셋, "페이지 번호를 찍어 주는 관리자 화면" 에는 offset 이 보통의 선택입니다. 관리자 화면이라면 offset 이 깊어질 일 자체가 드뭅니다.

9-9. ⚠️ 정렬 기준이 유일하지 않으면 행이 새거나 중복된다

가장 조용한 함정입니다. 에러도 없고, 한 번 실행해서는 절대 안 보입니다.

재현

주문을 날짜 내림차순으로 10건씩 페이징합니다.

// 1페이지
List<Order> page1 = queryFactory
        .selectFrom(order)
        .orderBy(order.orderDate.desc())     // 정렬 키가 하나뿐
        .offset(0).limit(10)
        .fetch();

// 2페이지
List<Order> page2 = queryFactory
        .selectFrom(order)
        .orderBy(order.orderDate.desc())
        .offset(10).limit(10)
        .fetch();

결과 — 1페이지

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.order_date desc
limit ?, ?
바인딩: [1] 0  [2] 10
order_id=598  2025-12-30
order_id=597  2025-12-30
order_id=600  2025-12-30      ← 같은 날짜 3건. 이 셋의 순서는 보장되지 않는다
order_id=595  2025-12-28
...
order_id=588  2025-12-21

결과 — 2페이지

바인딩: [1] 10  [2] 10
order_id=587  2025-12-20
...
order_id=578  2025-12-14

여기까지는 멀쩡해 보입니다. 그런데 같은 코드를 다시 실행하면 1페이지가 이렇게 나올 수 있습니다.

order_id=600  2025-12-30      ← 순서가 바뀌었다
order_id=598  2025-12-30
order_id=597  2025-12-30
order_id=595  2025-12-28
...

왜 이런 일이 벌어지는가

SQL 표준은 ORDER BY 로 지정되지 않은 부분의 순서를 보장하지 않습니다. order_date 가 같은 3건 사이의 순서는 DB 가 마음대로 정합니다. 그리고 그 "마음대로"는 실행할 때마다 달라질 수 있습니다.

  • 옵티마이저가 다른 실행 계획을 고르면 (통계 갱신, 인덱스 추가, 데이터 증가)
  • 정렬 알고리즘이 메모리 정렬에서 디스크 정렬로 넘어가면 (sort_buffer_size 초과)
  • 병렬 읽기나 버퍼 풀 상태가 달라지면

1페이지 조회와 2페이지 조회는 서로 다른 SQL 실행입니다. 그 사이에 순서가 뒤바뀌면 어떤 일이 일어날까요?

1페이지 실행 시점의 순서:  ... 598, 597, 600 | 595 ...    → 1페이지에 600 포함
2페이지 실행 시점의 순서:  ... 600, 598, 597 | 595 ...    → 2페이지 시작은 595
                                                            → 600 은 1페이지에만

반대 방향으로 뒤바뀌면:

1페이지 실행 시점:  ... 598, 597, 600 | 595 ...           → 1페이지 마지막은 588
2페이지 실행 시점:  ... 597, 600, 598 | 595 ...           → 598 이 11번째로 밀림
                                                            → 598 이 1페이지와 2페이지에 모두 등장

결과적으로 어떤 행은 어느 페이지에도 안 나오고(누락), 어떤 행은 두 페이지에 나옵니다(중복). 사용자는 "목록에서 방금 본 주문이 다음 페이지에 또 있네" 정도로 느끼고 넘어갑니다. 버그 리포트로 올라와도 재현이 안 됩니다.

처방 — 타이브레이커

정렬 키의 조합이 행을 유일하게 결정하도록 만듭니다. 가장 쉬운 방법은 PK 를 마지막 정렬 키로 추가하는 것입니다.

List<Order> page1 = queryFactory
        .selectFrom(order)
        .orderBy(order.orderDate.desc(), order.orderId.desc())   // PK 타이브레이커
        .offset(0).limit(10)
        .fetch();

결과

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
order_id=600  2025-12-30      ← 몇 번을 실행해도 이 순서
order_id=598  2025-12-30
order_id=597  2025-12-30
order_id=595  2025-12-28
...

order_id 는 PK 이므로 중복이 없습니다. 따라서 (order_date, order_id) 조합은 모든 행에 대해 유일합니다. 전순서(total order)가 확정되어 실행 계획이 어떻게 바뀌든 결과 순서가 같습니다.

💡 규칙으로 만드십시오 — 페이징 쿼리의 orderBy 마지막은 언제나 PK 코드 리뷰 체크리스트에 넣을 만한 규칙입니다. "offset/limit 이 있는데 orderBy 에 PK 가 없다" 는 그 자체로 결함입니다. 정렬 키가 이미 유니크 컬럼(예: email)이라면 생략해도 되지만, 판단할 시간에 PK 를 붙이는 편이 낫습니다. 비용은 order by 절에 컬럼 하나 추가하는 것뿐이며, 대개 인덱스의 마지막 컬럼이라 추가 비용이 없습니다.

⚠️ 이 문제는 PageSlice 든 키셋이든 모든 페이징 방식에 공통입니다. 특히 키셋 페이징에서는 더 치명적입니다. 커서 값이 유일하지 않으면 커서 지점을 잘못 잡아 행을 통째로 건너뜁니다. 9-8 의 복합 커서(orderDate + orderId)가 바로 이 문제에 대한 대응입니다.


9-10. Slice — count 를 아예 쓰지 않는다

"다음 페이지가 있는가"만 알면 되는 화면(무한 스크롤, 더 보기 버튼)에서는 전체 건수가 필요 없습니다. Spring Data 의 Slice<T> 가 이 모델입니다.

방법은 단순합니다. 요청한 크기보다 1건 더 가져옵니다.

public Slice<Order> searchSlice(Pageable pageable) {

    List<Order> content = queryFactory
            .selectFrom(order)
            .where(order.status.eq(OrderStatus.DELIVERED))
            .orderBy(order.orderDate.desc(), order.orderId.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize() + 1)      // +1
            .fetch();

    boolean hasNext = content.size() > pageable.getPageSize();
    if (hasNext) {
        content.remove(pageable.getPageSize());     // 여분 1건 제거
    }

    return new SliceImpl<>(content, pageable, hasNext);
}

결과PageRequest.of(0, 10)

select o1_0.order_id, o1_0.customer_id, o1_0.order_date,
       o1_0.shipping_city, o1_0.status, o1_0.total_amount
from orders o1_0
where o1_0.status = ?
order by o1_0.order_date desc, o1_0.order_id desc
limit ?, ?
바인딩: [1] DELIVERED  [2] 0  [3] 11      ← 10 이 아니라 11
쿼리 1건 — count 쿼리 없음
조회 11건 → hasNext=true, 1건 제거 후 content 10건

결과 — 마지막 슬라이스 (PageRequest.of(21, 10), DELIVERED 214건)

바인딩: [1] DELIVERED  [2] 210  [3] 11
조회 4건 → 11보다 작으므로 hasNext=false, content 4건

count 쿼리가 한 번도 나가지 않습니다.

⚠️ content.remove(...)UnsupportedOperationException 을 던질 수 있습니다 fetch() 가 반환하는 리스트가 항상 수정 가능한 ArrayList 라는 보장은 없습니다. 안전하게 하려면 new ArrayList<>(content) 로 감싸거나 content.subList(0, pageable.getPageSize()) 로 잘라내십시오. 후자는 뷰(view)를 반환하므로 원본 참조가 남는다는 점에 주의하십시오.

💡 Sliceoffset 을 쓰므로 9-8 의 깊은 offset 문제를 그대로 안습니다. Slice 가 없애는 것은 count 쿼리이지 offset 비용이 아닙니다. 무한 스크롤을 아주 깊게 내려가는 화면이라면 Slice + offset 이 아니라 키셋 페이징을 쓰십시오. 이 둘을 혼동하는 글이 많습니다.


9-11. 페이징 전략 비교

세 가지 방식의 성격을 정리합니다.

항목offset 페이징 (Page)offset 페이징 (Slice)키셋(커서) 페이징
쿼리 수2 (count 생략 가능)11
전체 건수알 수 있음모름모름
총 페이지 수 표시가능불가불가
N페이지 직접 이동가능가능불가
깊은 offset 성능선형 악화선형 악화일정
페이지 사이 누락/중복데이터 변경 시 발생데이터 변경 시 발생원리적으로 없음
동적 정렬과의 궁합좋음좋음나쁨(커서 재설계 필요)
대표 용도관리자 목록 화면더 보기 버튼무한 스크롤, 배치 순회, 피드

💡 "페이지 사이 누락/중복" 은 9-9 와 다른 이야기입니다 9-9 는 정렬이 유일하지 않아서 생기는 문제이고, 타이브레이커로 해결됩니다. 여기 표의 항목은 1페이지와 2페이지 조회 사이에 데이터가 삽입/삭제되어 생기는 문제입니다. 1페이지를 본 뒤 누군가 새 주문을 넣으면 모든 행이 한 칸씩 밀려 2페이지 첫 행이 1페이지 마지막 행과 같아집니다. offset 페이징에서는 원리적으로 막을 수 없습니다. 키셋은 "값 기준"이라 이 문제가 없습니다.

선택 기준

  1. 화면에 총 건수/총 페이지 수를 표시해야 하는가? → 그렇다면 Page
  2. 아니라면, offset 이 깊어질 수 있는가? → 그렇다면 키셋
  3. 깊어지지 않는다면 Slice 로 충분
  4. Page 를 쓰더라도 count 쿼리는 직접 작성하고 PageableExecutionUtils 로 감쌀 것 (9-6)
  5. 어느 방식이든 orderBy 마지막은 PK (9-9)

정리

개념핵심
orderByOrderSpecifier 가변 인자. 인자 순서 = 정렬 우선순위
.asc() / .desc()SQL 의 asc / desc 로 그대로 번역
nullsFirst/LastMySQL 에는 표준 문법이 없어 case when ... is null 계산식으로 에뮬레이션 → 인덱스 못 탐
offset / limit0-based. MySQL 방언에서 limit ?, ? (offset, rowcount)
PageablegetOffset()/getPageSize() 는 쓸 수 있지만 getSort() 는 자동 적용 안 됨
PageImpl콘텐츠 + total. 쿼리가 2번 나감
count 쿼리 분리표시용 join 과 orderBy 를 뺀다. 필터용 join 은 남긴다
fetchCount()deprecated. 자동 변환을 신뢰할 수 없어 폐기됨
PageableExecutionUtils첫 페이지가 다 안 찼거나 마지막 페이지면 count 쿼리 생략. 패키지는 버전마다 다르니 IDE 로 확인
정렬 컬럼에 함수year(col), lower(col)Using filesort. 1.284초 vs 0.002초
깊은 offsetlimit 100000, 20 은 100,020행 읽고 100,000행 버림. offset 에 정비례
키셋 페이징where(id.lt(lastSeen)). offset 깊이와 무관하게 0.001초
타이브레이커정렬이 유일하지 않으면 페이지 사이 누락/중복. orderBy 마지막은 PK
Slicelimit(size + 1) 로 다음 페이지 존재만 판단. count 쿼리 없음. offset 비용은 그대로

연습문제

Exercise.java 에 7문제가 있습니다. 정답은 Solution.java. 답이 맞아도 생성 SQL 이 다르면 틀린 것입니다. 콘솔 로그를 반드시 대조하십시오.

  1. VIP 고객을 포인트 내림차순, 이름 오름차순으로 정렬하고 생성 SQL 의 order by 절을 확인하기
  2. 전화번호가 NULL 인 고객 3명을 맨 뒤로 보내는 정렬을 작성하고, 생성 SQL 에 nulls last 가 아니라 무엇이 나오는지 기록하기
  3. PageRequest 를 받아 배송 완료 주문의 Page<Order> 를 만들되, count 쿼리를 직접 작성하고 PageableExecutionUtils 로 감싸기
  4. 3번에서 만든 메서드에 PageRequest.of(0, 1000) 을 넘겨 count 쿼리가 나가지 않는 것을 로그로 확인하기
  5. orderBy(order.orderDate.year().desc()) 로 짠 쿼리를 인덱스를 탈 수 있는 형태로 고치고, 두 SQL 을 나란히 적기
  6. order_id 커서 기반 키셋 페이징 메서드를 작성하고, 첫 페이지와 두 번째 페이지의 생성 SQL 차이를 기록하기
  7. 정렬 키가 orderDate 하나뿐인 페이징 코드에 타이브레이커를 추가하고, 왜 필요한지를 두 문장으로 설명하기

다음 단계

지금까지의 정렬은 전부 컴파일 시점에 정해진 정렬이었습니다. 그러나 실제 목록 화면은 클라이언트가 ?sort=price,desc 처럼 정렬 기준을 보냅니다. orderByOrderSpecifier 만 받는데, 문자열을 어떻게 그것으로 바꿀까요. 그리고 그 문자열을 그대로 경로로 쓰면 무슨 일이 일어날까요.

검색 조건도 마찬가지입니다. 키워드·카테고리·가격 범위·상태가 있을 수도 없을 수도 있는 조건들을 어떻게 조립해야 null 하나에 NPE 로 죽지 않을까요.

Step 10 — 동적 정렬과 검색 조건 조립


실습 파일

이 스텝은 Java 파일 세 개로 진행합니다. Practice.java 를 위에서부터 실행하며 9-1 ~ 9-11 의 모든 생성 SQL 을 콘솔에서 직접 확인한 뒤, Exercise.java 의 7문제를 풀고, Solution.java 로 대조합니다. 세 파일 모두 @SpringBootTest + @Transactional 테스트 클래스이며 src/test/java/com/example/shop/step09/ 에 그대로 넣으면 실행됩니다.

Practice.java

본문의 모든 예제를 // [9-3] 형태의 절 번호 주석과 함께 담은 실행 파일입니다.

  • s0_... 부터 순서대로 배치돼 있으므로 테스트 메서드를 하나씩 실행하며 콘솔의 hibernate.SQL 로그를 읽으십시오. 전체를 한 번에 돌리면 로그가 뒤섞여 어느 SQL 이 어느 절인지 구분하기 어렵습니다.
  • s6_pageWithSeparateCount / s6_pageableExecutionUtils같은 데이터에 페이지 크기만 바꿔 세 번 호출합니다. PageRequest.of(0, 10) → count 실행, PageRequest.of(0, 500) → count 생략, PageRequest.of(21, 10) → count 생략. 로그에 count SQL 이 몇 번 찍히는지 세는 것이 이 메서드의 목적입니다.
  • s7_sortByFunctionKillsIndex 는 600건짜리 orders 에서는 실행시간 차이가 나지 않습니다. 본문 9-7 의 수치는 100만 행 access_logs 에 SQL 을 직접 던진 것이므로, 그 부분은 MySQL 클라이언트에서 재현하십시오. 이 메서드가 확인시켜 주는 것은 생성 SQL 에 year(o1_0.order_date) 가 들어간다는 사실입니다.
  • s9_missingTiebreaker 는 같은 쿼리를 5회 반복 실행해 결과 순서를 출력합니다. 600건 규모에서는 순서가 안정적으로 나올 가능성이 높으므로, 매번 같은 순서가 나와도 그것이 "보장된다"는 뜻이 아니라는 점을 주석으로 못박아 두었습니다.
  • 파일 상단의 PageableExecutionUtils import 는 여러분의 Spring Data 버전에 맞게 IDE 가 다시 잡아 줘야 할 수 있습니다. 빨간 줄이 뜨면 import 를 지우고 클래스명을 다시 타이핑해 자동완성을 쓰십시오.
package com.example.shop.step09;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.example.shop.entity.Order;
import com.example.shop.entity.OrderStatus;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
// ⚠️ PageableExecutionUtils 의 패키지는 Spring Data 버전에 따라 다릅니다.
//    구버전: org.springframework.data.repository.support.PageableExecutionUtils
//    현행:   org.springframework.data.support.PageableExecutionUtils
//    빨간 줄이 뜨면 이 import 를 지우고 클래스명을 다시 타이핑해 IDE 자동완성을 쓰십시오.
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.transaction.annotation.Transactional;

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

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

/**
 * Step 09 — 정렬과 페이징 : 본문 예제 실행 파일
 *
 * 실행 전 반드시 application.yml 에서 SQL 로그가 켜져 있는지 확인하십시오.
 *
 *   logging.level.org.hibernate.SQL: debug
 *   logging.level.org.hibernate.orm.jdbc.bind: trace
 *
 * 이 파일의 목적은 "결과가 맞는가" 가 아니라 "어떤 SQL 이 나갔는가" 입니다.
 * 테스트 메서드를 하나씩 실행하며 콘솔 로그를 교재의 SQL 과 한 글자씩 대조하십시오.
 * 전체를 한 번에 돌리면 로그가 뒤섞여 어느 SQL 이 어느 절인지 구분되지 않습니다.
 */
@SpringBootTest
@Transactional
class Practice {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // [9-1] orderBy — 정렬의 기본
    // =================================================================

    @Test
    @DisplayName("[9-1] 단일 정렬 — points 내림차순")
    void s1_singleOrder() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .orderBy(customer.points.desc())
                .fetch();

        // 생성 SQL:
        //   select c1_0.customer_id, ... from customers c1_0
        //   order by c1_0.points desc
        print("[9-1] 단일 정렬", result.size());
        result.stream().limit(5).forEach(c ->
                System.out.printf("  %s (%s, %dp)%n", c.getName(), c.getGrade(), c.getPoints()));
    }

    @Test
    @DisplayName("[9-1] 다중 정렬 — 인자 순서가 정렬 우선순위")
    void s1_multipleOrder() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .orderBy(
                        customer.grade.desc(),       // 1순위
                        customer.points.desc(),      // 2순위
                        customer.customerId.asc()    // 3순위 (타이브레이커)
                )
                .fetch();

        // 생성 SQL:
        //   order by c1_0.grade desc, c1_0.points desc, c1_0.customer_id asc
        //
        // ⚠️ grade 는 MySQL ENUM 입니다. desc 는 "문자열 사전순 역순" 이 아니라
        //    ENUM 선언 순서(BRONZE,SILVER,GOLD,VIP)의 역순입니다.
        print("[9-1] 다중 정렬", result.size());
        result.stream().limit(5).forEach(c ->
                System.out.printf("  %s (%s, %dp, id=%d)%n",
                        c.getName(), c.getGrade(), c.getPoints(), c.getCustomerId()));
    }

    @Test
    @DisplayName("[9-1] OrderSpecifier 배열로 뽑아 쓰기")
    void s1_orderSpecifierArray() {
        OrderSpecifier<?>[] sortByGradeThenPoints = {
                customer.grade.desc(),
                customer.points.desc(),
                customer.customerId.asc()
        };

        List<Customer> result = queryFactory
                .selectFrom(customer)
                .orderBy(sortByGradeThenPoints)
                .fetch();

        // 생성 SQL 은 s1_multipleOrder 와 완전히 동일합니다.
        // orderBy(OrderSpecifier<?>...) 오버로드가 배열을 그대로 받습니다.
        // 이 형태가 Step 10 의 동적 정렬로 그대로 이어집니다.
        print("[9-1] OrderSpecifier 배열", result.size());
    }

    // =================================================================
    // [9-2] NULL 정렬
    // =================================================================

    @Test
    @DisplayName("[9-2] 지정 없음 — MySQL 은 asc 에서 NULL 을 앞에 둔다")
    void s2_nullDefault() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .orderBy(customer.phone.asc())
                .fetch();

        // 생성 SQL:
        //   order by c1_0.phone asc
        //
        // MySQL 은 NULL 을 가장 작은 값으로 취급 → 앞의 3건이 phone=NULL.
        // PostgreSQL / Oracle 은 asc 에서 NULL 을 뒤에 둡니다. DB 를 바꾸면 조용히 달라집니다.
        print("[9-2] NULL 기본 동작", result.size());
        result.stream().limit(5).forEach(c ->
                System.out.printf("  %s phone=%s%n", c.getName(), c.getPhone()));
    }

    @Test
    @DisplayName("[9-2] nullsLast — MySQL 에서는 case when 으로 에뮬레이션된다")
    void s2_nullsLast() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .orderBy(customer.phone.asc().nullsLast())
                .fetch();

        // 생성 SQL (Hibernate 6.4 + MySQL 8):
        //   order by case when c1_0.phone is null then 1 else 0 end, c1_0.phone asc
        //
        // ★ 이 절의 핵심 — MySQL 에는 NULLS LAST 문법이 없어서 계산식으로 풀립니다.
        //   그 계산식은 인덱스로 처리할 수 없습니다 (Using filesort).
        print("[9-2] nullsLast", result.size());
        result.stream().skip(result.size() - 3).forEach(c ->
                System.out.printf("  %s phone=%s%n", c.getName(), c.getPhone()));
    }

    @Test
    @DisplayName("[9-2] nullsFirst — then 0 else 1 로 뒤집힌다")
    void s2_nullsFirst() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .orderBy(customer.phone.asc().nullsFirst())
                .fetch();

        // 생성 SQL:
        //   order by case when c1_0.phone is null then 0 else 1 end, c1_0.phone asc
        print("[9-2] nullsFirst", result.size());
        result.stream().limit(3).forEach(c ->
                System.out.printf("  %s phone=%s%n", c.getName(), c.getPhone()));
    }

    // =================================================================
    // [9-3] offset / limit
    // =================================================================

    @Test
    @DisplayName("[9-3] offset + limit — MySQL 방언에서 limit ?, ?")
    void s3_offsetLimit() {
        List<Order> page = queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(20)   // 0-based. 앞의 20건을 건너뛴다
                .limit(10)
                .fetch();

        // 생성 SQL:
        //   order by o1_0.order_date desc, o1_0.order_id desc
        //   limit ?, ?        바인딩: [1] 20  [2] 10
        //
        // MySQL 의 limit 은 (offset, rowcount) 순서입니다.
        // 방언에 따라 limit ? offset ? 로 나가기도 하므로 여러분의 로그를 기준으로 읽으십시오.
        print("[9-3] offset + limit", page.size());
    }

    @Test
    @DisplayName("[9-3] limit 만 — limit ? 하나")
    void s3_limitOnly() {
        List<Order> top5 = queryFactory
                .selectFrom(order)
                .orderBy(order.totalAmount.desc(), order.orderId.asc())
                .limit(5)
                .fetch();

        // 생성 SQL:
        //   order by o1_0.total_amount desc, o1_0.order_id asc
        //   limit ?           바인딩: [1] 5
        print("[9-3] limit only (TOP 5)", top5.size());
        top5.forEach(o ->
                System.out.printf("  order_id=%d  %s%n", o.getOrderId(), o.getTotalAmount()));
    }

    // =================================================================
    // [9-4] Spring Pageable 을 받아서
    // =================================================================

    @Test
    @DisplayName("[9-4] Pageable 로 offset 계산 위임")
    void s4_pageable() {
        Pageable pageable = PageRequest.of(2, 10);   // getOffset() == 20

        List<Order> result = findOrders(pageable);

        // ⚠️ pageable.getSort() 는 QueryDSL 에 자동 적용되지 않습니다.
        //    클라이언트가 ?sort=totalAmount,desc 를 보내도 아무 일도 일어나지 않고, 에러도 안 납니다.
        //    Sort -> OrderSpecifier[] 변환기는 Step 10 에서 직접 만듭니다.
        System.out.println("  pageable.getOffset() = " + pageable.getOffset());
        print("[9-4] Pageable", result.size());
    }

    private List<Order> findOrders(Pageable pageable) {
        return queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
    }

    // =================================================================
    // [9-5] Page 만들기 — 콘텐츠 쿼리 + count 쿼리
    // =================================================================

    @Test
    @DisplayName("[9-5] PageImpl — 쿼리가 2번 나간다")
    void s5_pageImpl() {
        Pageable pageable = PageRequest.of(0, 10);
        Page<Order> page = searchOrdersNaive(pageable);

        // 생성 SQL 2건:
        //   ① select ... from orders o1_0 where o1_0.status = ?
        //      order by o1_0.order_date desc, o1_0.order_id desc limit ?, ?
        //   ② select count(o1_0.order_id) from orders o1_0 where o1_0.status = ?
        //
        // order.count() 는 count(*) 가 아니라 count(o1_0.order_id) 로 번역됩니다.
        // PK 는 NOT NULL 이므로 결과값은 같지만 생성 SQL 은 다릅니다.
        System.out.printf("  content=%d  total=%d  totalPages=%d%n",
                page.getContent().size(), page.getTotalElements(), page.getTotalPages());
    }

    private Page<Order> searchOrdersNaive(Pageable pageable) {
        List<Order> content = queryFactory
                .selectFrom(order)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long total = queryFactory
                .select(order.count())
                .from(order)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .fetchOne();

        return new PageImpl<>(content, pageable, total == null ? 0L : total);
    }

    // =================================================================
    // [9-6] count 쿼리를 따로 최적화한다
    // =================================================================

    @Test
    @DisplayName("[9-6] count 쿼리 분리 — join 과 orderBy 를 뺀다")
    void s6_pageWithSeparateCount() {
        Pageable pageable = PageRequest.of(0, 10);

        // 콘텐츠 쿼리 — 고객 이름을 함께 보여줘야 하므로 join 이 필요
        List<Object[]> content = queryFactory
                .select(order.orderId, order.orderDate, order.totalAmount, customer.name)
                .from(order)
                .join(order.customer, customer)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch()
                .stream()
                .map(t -> new Object[]{t.get(order.orderId), t.get(order.orderDate),
                        t.get(order.totalAmount), t.get(customer.name)})
                .toList();

        // count 쿼리 — join 도 orderBy 도 없다
        Long total = queryFactory
                .select(order.count())
                .from(order)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .fetchOne();

        // 생성 SQL 비교:
        //   콘텐츠 : select ... from orders o1_0
        //            join customers c1_0 on c1_0.customer_id = o1_0.customer_id
        //            where o1_0.status = ? order by ... limit ?, ?
        //   count  : select count(o1_0.order_id) from orders o1_0 where o1_0.status = ?
        //
        // ⚠️ 규칙 — "표시(select)를 위한 조인만 뺀다. 필터(where)를 위한 조인은 남긴다."
        //    where 가 customer.grade 를 참조한다면 count 쿼리에도 join 이 반드시 있어야 합니다.
        System.out.printf("  content=%d  total=%d%n", content.size(), total);
    }

    @Test
    @DisplayName("[9-6] PageableExecutionUtils — count 쿼리가 생략되는지 로그로 확인")
    void s6_pageableExecutionUtils() {
        // ★ 이 메서드의 목적은 반환값이 아니라 "count SQL 이 몇 번 찍히는가" 입니다.
        //    콘솔에서 select count(...) 로그를 직접 세십시오.

        System.out.println("=== ① PageRequest.of(0, 10) — count 실행됨 (10건이 꽉 참) ===");
        Page<Order> p1 = searchOrders(PageRequest.of(0, 10));
        System.out.printf("  total=%d%n", p1.getTotalElements());

        System.out.println("=== ② PageRequest.of(0, 500) — count 생략됨 ===");
        Page<Order> p2 = searchOrders(PageRequest.of(0, 500));
        System.out.printf("  total=%d  (offset=0 이고 결과 %d < pageSize 500 이므로 계산으로 확정)%n",
                p2.getTotalElements(), p2.getContent().size());

        System.out.println("=== ③ PageRequest.of(21, 10) — 마지막 페이지, count 생략됨 ===");
        Page<Order> p3 = searchOrders(PageRequest.of(21, 10));
        System.out.printf("  total=%d  (offset 210 + 결과 %d)%n",
                p3.getTotalElements(), p3.getContent().size());
    }

    private Page<Order> searchOrders(Pageable pageable) {
        List<Order> content = queryFactory
                .selectFrom(order)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(order.count())
                .from(order)
                .where(order.status.eq(OrderStatus.DELIVERED));

        // 세 번째 인자는 Supplier<Long> 입니다.
        // countQuery.fetchOne() 을 미리 호출하는 것이 아니라, 호출 가능한 것을 넘깁니다.
        // PageableExecutionUtils 가 필요할 때만 get() 을 부릅니다.
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    // =================================================================
    // [9-7] ⚠️ 정렬 컬럼에 함수를 씌워 인덱스를 죽인다
    // =================================================================

    @Test
    @DisplayName("[9-7] 잘못된 코드 — year() 를 씌운 정렬")
    void s7_sortByFunctionKillsIndex() {
        List<Order> bad = queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.year().desc(), order.orderId.desc())
                .limit(20)
                .fetch();

        // 생성 SQL:
        //   order by year(o1_0.order_date) desc, o1_0.order_id desc limit ?
        //
        // 결과는 정확합니다. 문제는 인덱스입니다.
        // 인덱스는 order_date 의 "원본 값" 으로 정렬돼 있습니다.
        // year(order_date) 는 인덱스 어디에도 저장돼 있지 않은 다른 값입니다.
        //
        // 100만 행 access_logs 에서 같은 모양의 SQL:
        //   ORDER BY YEAR(logged_at) DESC LIMIT 20  → type: ALL, Using filesort, 1.284초
        //   ORDER BY logged_at DESC LIMIT 20        → Backward index scan,        0.002초
        //   ★ 약 640배
        //
        // orders 는 600건이므로 이 테스트로는 시간 차이가 보이지 않습니다.
        // 확인할 것은 "생성 SQL 에 year(o1_0.order_date) 가 들어간다" 는 사실입니다.
        print("[9-7] 함수 정렬 (나쁨)", bad.size());
    }

    @Test
    @DisplayName("[9-7] 고친 코드 — 원본 컬럼으로 정렬")
    void s7_sortByRawColumn() {
        List<Order> good = queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .limit(20)
                .fetch();

        // 생성 SQL:
        //   order by o1_0.order_date desc, o1_0.order_id desc limit ?
        //
        // order_date 내림차순은 year(order_date) 내림차순과 "연도 단위로는 동일한 순서" 입니다.
        // 애초에 함수가 필요 없었습니다.
        print("[9-7] 원본 컬럼 정렬 (좋음)", good.size());
    }

    @Test
    @DisplayName("[9-7] lower() 정렬 — 컬레이션이 이미 해결하고 있다")
    void s7_lowerIsUnnecessary() {
        List<Customer> withLower = queryFactory
                .selectFrom(customer)
                .orderBy(customer.name.lower().asc())
                .fetch();
        // 생성 SQL: order by lower(c1_0.name) asc

        List<Customer> withoutLower = queryFactory
                .selectFrom(customer)
                .orderBy(customer.name.asc())
                .fetch();
        // 생성 SQL: order by c1_0.name asc

        // shop 스키마의 컬레이션은 utf8mb4_0900_ai_ci — ci = case insensitive.
        // 즉 ORDER BY name 이 이미 대소문자를 무시합니다. lower() 는 처음부터 불필요했습니다.
        // 이 사실을 모르고 lower() 를 씌우는 것이 실무에서 가장 흔한 형태입니다.
        System.out.printf("  lower 정렬 %d건 / 원본 정렬 %d건 — 순서 동일 여부: %s%n",
                withLower.size(), withoutLower.size(),
                withLower.get(0).getCustomerId().equals(withoutLower.get(0).getCustomerId()));
    }

    // =================================================================
    // [9-8] ⚠️ 깊은 offset 의 비용 / 키셋(커서) 페이징
    // =================================================================

    @Test
    @DisplayName("[9-8] 깊은 offset — SQL 은 완벽하지만 확장되지 않는다")
    void s8_deepOffset() {
        List<Order> deep = queryFactory
                .selectFrom(order)
                .orderBy(order.orderId.desc())
                .offset(500)
                .limit(20)
                .fetch();

        // 생성 SQL:
        //   order by o1_0.order_id desc limit ?, ?     바인딩: [1] 500  [2] 20
        //
        // LIMIT 500, 20 은 DB 에게 "520개를 읽고 앞의 500개를 버려라" 라고 말합니다.
        // "정렬 결과의 501번째로 점프" 하는 방법은 DB 에 없습니다.
        //
        // 100만 행 access_logs 실측:
        //   LIMIT      0, 20  → 0.001초
        //   LIMIT 100000, 20  → 0.087초
        //   LIMIT 500000, 20  → 0.412초
        //   LIMIT 900000, 20  → 0.741초
        //   ★ offset 에 정비례. 반환 행은 늘 20건인데도.
        print("[9-8] 깊은 offset", deep.size());
    }

    @Test
    @DisplayName("[9-8] 키셋(커서) 페이징 — offset 을 where 로 바꾼다")
    void s8_keysetPaging() {
        // 첫 페이지 — lastSeenId 가 null 이므로 where 조건이 무시된다
        List<Order> first = nextPage(null, 20);
        Long cursor = first.get(first.size() - 1).getOrderId();
        // 생성 SQL: order by o1_0.order_id desc limit ?

        // 다음 페이지
        List<Order> second = nextPage(cursor, 20);
        // 생성 SQL: where o1_0.order_id < ? order by o1_0.order_id desc limit ?

        // offset 이 사라지고 where order_id < ? 가 생겼습니다.
        // 이 조건은 PK 인덱스로 즉시 탐색(seek)됩니다.
        // 몇 번째 페이지든 읽는 행은 항상 20건입니다.
        //
        // 100만 행 실측 (WHERE log_id < N ORDER BY log_id DESC LIMIT 20):
        //   N=100000 → 0.001초 / N=500000 → 0.001초 / N=900000 → 0.001초
        //   ★ 0.412초 → 0.001초, 그리고 깊이와 무관하게 일정
        System.out.printf("  1페이지 %d건 (커서=%d) → 2페이지 %d건 (커서=%d)%n",
                first.size(), cursor, second.size(),
                second.get(second.size() - 1).getOrderId());
    }

    private List<Order> nextPage(Long lastSeenOrderId, int size) {
        return queryFactory
                .selectFrom(order)
                // where 에 null 을 넘기면 QueryDSL 이 그 조건을 무시합니다 (Step 04 의 4-4).
                .where(lastSeenOrderId == null ? null : order.orderId.lt(lastSeenOrderId))
                .orderBy(order.orderId.desc())
                .limit(size)
                .fetch();
    }

    @Test
    @DisplayName("[9-8] 복합 커서 — 정렬 키가 PK 가 아닐 때")
    void s8_compositeCursor() {
        List<Order> first = nextPageByDate(null, null, 20);
        Order last = first.get(first.size() - 1);

        List<Order> second = nextPageByDate(last.getOrderDate(), last.getOrderId(), 20);

        // 생성 SQL (2페이지):
        //   where o1_0.order_date < ? or o1_0.order_date = ? and o1_0.order_id < ?
        //   order by o1_0.order_date desc, o1_0.order_id desc limit ?
        //
        // ⚠️ 괄호가 없습니다. SQL 에서 AND 가 OR 보다 우선순위가 높으므로
        //    a < ? or (a = ? and b < ?) 와 같아 의도대로입니다. 그러나 이건 운입니다.
        //    Step 04 의 4-5 에서 다뤘듯 QueryDSL 은 불필요하다고 판단한 괄호를 생략합니다.
        //    and/or 가 섞인 표현식은 반드시 생성 SQL 을 눈으로 확인하십시오.
        System.out.printf("  1페이지 %d건 → 2페이지 %d건%n", first.size(), second.size());
    }

    private List<Order> nextPageByDate(LocalDateTime lastDate, Long lastId, int size) {
        BooleanExpression cursor = (lastDate == null) ? null :
                order.orderDate.lt(lastDate)
                        .or(order.orderDate.eq(lastDate).and(order.orderId.lt(lastId)));

        return queryFactory
                .selectFrom(order)
                .where(cursor)
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .limit(size)
                .fetch();
    }

    // =================================================================
    // [9-9] ⚠️ 정렬 기준이 유일하지 않으면 행이 새거나 중복된다
    // =================================================================

    @Test
    @DisplayName("[9-9] 타이브레이커 없음 — 같은 쿼리를 5회 반복")
    void s9_missingTiebreaker() {
        // 정렬 키가 orderDate 하나뿐. 같은 날짜의 주문 순서는 보장되지 않습니다.
        for (int i = 1; i <= 5; i++) {
            List<Order> page1 = queryFactory
                    .selectFrom(order)
                    .orderBy(order.orderDate.desc())
                    .offset(0).limit(10)
                    .fetch();

            System.out.printf("  %d회차 상위 3건: %s%n", i,
                    page1.stream().limit(3).map(o -> String.valueOf(o.getOrderId())).toList());
        }

        // ⚠️ 600건 규모에서는 매번 같은 순서가 나올 가능성이 높습니다.
        //    그것은 "보장된다" 는 뜻이 아닙니다. SQL 표준은 ORDER BY 로 지정되지 않은
        //    부분의 순서를 보장하지 않습니다. 순서가 바뀌는 계기는 다음과 같습니다.
        //      - 옵티마이저가 다른 실행 계획을 고를 때 (통계 갱신, 인덱스 추가, 데이터 증가)
        //      - 정렬이 메모리에서 디스크로 넘어갈 때 (sort_buffer_size 초과)
        //      - 병렬 읽기나 버퍼 풀 상태가 달라질 때
        //
        //    1페이지 조회와 2페이지 조회는 서로 다른 SQL 실행입니다.
        //    그 사이에 순서가 뒤바뀌면 어떤 행은 어느 페이지에도 안 나오고(누락),
        //    어떤 행은 두 페이지에 모두 나옵니다(중복).
    }

    @Test
    @DisplayName("[9-9] 타이브레이커 추가 — orderBy 마지막은 언제나 PK")
    void s9_withTiebreaker() {
        List<Order> page1 = queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(0).limit(10)
                .fetch();

        // 생성 SQL:
        //   order by o1_0.order_date desc, o1_0.order_id desc limit ?, ?
        //
        // order_id 는 PK 이므로 중복이 없습니다.
        // 따라서 (order_date, order_id) 조합은 모든 행에 대해 유일하고,
        // 전순서(total order)가 확정되어 실행 계획이 바뀌어도 결과 순서가 같습니다.
        //
        // 💡 코드 리뷰 규칙으로 만드십시오:
        //    "offset/limit 이 있는데 orderBy 에 PK 가 없다" 는 그 자체로 결함이다.
        print("[9-9] 타이브레이커 있음", page1.size());
        System.out.printf("  상위 3건: %s%n",
                page1.stream().limit(3).map(o -> String.valueOf(o.getOrderId())).toList());
    }

    // =================================================================
    // [9-10] Slice — count 를 아예 쓰지 않는다
    // =================================================================

    @Test
    @DisplayName("[9-10] Slice — limit(size + 1) 로 다음 페이지 존재만 판단")
    void s10_slice() {
        System.out.println("=== 첫 슬라이스 PageRequest.of(0, 10) ===");
        Slice<Order> s1 = searchSlice(PageRequest.of(0, 10));
        System.out.printf("  content=%d  hasNext=%s%n", s1.getContent().size(), s1.hasNext());

        System.out.println("=== 마지막 슬라이스 PageRequest.of(21, 10) ===");
        Slice<Order> s2 = searchSlice(PageRequest.of(21, 10));
        System.out.printf("  content=%d  hasNext=%s%n", s2.getContent().size(), s2.hasNext());

        // 생성 SQL: limit ?, ?     바인딩: [2] offset  [3] 11   ← 10 이 아니라 11
        // count 쿼리가 한 번도 나가지 않습니다.
        //
        // 💡 Slice 가 없애는 것은 count 쿼리이지 offset 비용이 아닙니다.
        //    무한 스크롤을 깊게 내려가는 화면이라면 Slice + offset 이 아니라 키셋 페이징을 쓰십시오.
    }

    private Slice<Order> searchSlice(Pageable pageable) {
        List<Order> fetched = queryFactory
                .selectFrom(order)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1L)      // +1
                .fetch();

        // ⚠️ fetch() 가 반환하는 리스트가 항상 수정 가능한 ArrayList 라는 보장은 없습니다.
        //    UnsupportedOperationException 을 피하려면 감싸십시오.
        List<Order> content = new ArrayList<>(fetched);

        boolean hasNext = content.size() > pageable.getPageSize();
        if (hasNext) {
            content.remove(pageable.getPageSize());
        }

        return new SliceImpl<>(content, pageable, hasNext);
    }

    // =================================================================
    // [9-11] 페이징 전략 비교 — 세 방식을 같은 조건으로 호출해 본다
    // =================================================================

    @Test
    @DisplayName("[9-11] 세 전략을 나란히 실행")
    void s11_compareStrategies() {
        Pageable pageable = PageRequest.of(0, 10);

        System.out.println("--- ① Page (count 쿼리 포함/생략) ---");
        Page<Order> page = searchOrders(pageable);
        System.out.printf("  total=%d  totalPages=%d%n", page.getTotalElements(), page.getTotalPages());

        System.out.println("--- ② Slice (count 없음) ---");
        Slice<Order> slice = searchSlice(pageable);
        System.out.printf("  hasNext=%s  (전체 건수는 모른다)%n", slice.hasNext());

        System.out.println("--- ③ 키셋 (count 없음, offset 없음) ---");
        List<Order> keyset = nextPage(null, 10);
        System.out.printf("  %d건, 다음 커서=%d%n",
                keyset.size(), keyset.get(keyset.size() - 1).getOrderId());

        // 선택 기준
        //   1. 총 건수/총 페이지 수를 표시해야 하는가?          → Page
        //   2. 아니라면, offset 이 깊어질 수 있는가?            → 키셋
        //   3. 깊어지지 않는다면                                → Slice
        //   4. Page 를 쓰더라도 count 는 직접 작성 + PageableExecutionUtils
        //   5. 어느 방식이든 orderBy 마지막은 PK
    }

    // =================================================================
    // 보조 메서드
    // =================================================================

    private void print(String label, int size) {
        System.out.printf("%s — 조회 %d건%n", label, size);
    }

    @SuppressWarnings("unused")
    private void vipOnlyExample() {
        // 다른 절에서 Grade 를 쓰는 예시가 필요할 때를 위한 자리입니다.
        queryFactory.selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .orderBy(customer.points.desc(), customer.customerId.asc())
                .fetch();
    }
}

Exercise.java

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

  • 문제 2·5 는 코드를 짜는 것보다 생성 SQL 을 기록하는 것이 본체입니다. 주석의 "생성 SQL 을 여기에 적으십시오" 칸을 반드시 채우십시오. 답만 맞히고 넘어가면 이 스텝에서 배울 것이 없습니다.
  • 문제 4 는 코드를 새로 짜지 않습니다. 문제 3에서 만든 메서드를 호출하고 로그를 관찰하는 문제입니다. assertThat 으로 검증할 수 있는 것이 아니라 눈으로 확인해야 하므로, 콘솔 출력에 count SQL 이 없다는 것을 확인한 뒤 주석에 기록하십시오.
  • 문제 6 의 키셋 페이징은 lastSeenIdnull 인 첫 호출을 반드시 처리해야 합니다. where(null) 이 조건 무시로 동작한다는 Step 04 의 성질을 쓰는 것이 가장 깔끔합니다. if 분기로 쿼리를 두 벌 만드는 답도 동작하지만, 왜 그럴 필요가 없는지 Solution.java 에서 설명합니다.
  • 문제 7 은 코드가 두 줄이고 설명이 본체입니다. "행이 중복되거나 누락된다"까지만 쓰면 절반입니다. 왜 실행마다 순서가 달라질 수 있는가를 함께 쓰십시오.
package com.example.shop.step09;

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

import java.util.List;

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

/**
 * Step 09 — 정렬과 페이징 : 연습문제 7문제
 *
 * 규칙
 *   - 답이 맞아도 생성 SQL 이 다르면 틀린 것입니다. 반드시 콘솔 로그를 확인하십시오.
 *   - 주석의 "생성 SQL 을 여기에 적으십시오" 칸을 비워 두지 마십시오.
 *     그 칸을 채우는 것이 이 스텝의 학습 목표 자체입니다.
 *   - 정답은 Solution.java 에 있습니다. 반드시 먼저 풀어 보고 여십시오.
 */
@SpringBootTest
@Transactional
class Exercise {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // 문제 1. 다중 정렬
    // =================================================================
    // VIP 등급 고객만 조회하되,
    //   1순위: 포인트 내림차순
    //   2순위: 이름 오름차순
    // 으로 정렬하십시오.
    //
    // 확인할 것:
    //   - 생성 SQL 의 order by 절에 컬럼이 몇 개, 어떤 순서로 나오는가
    //   - where 절이 order by 앞에 오는가
    // =================================================================
    @Test
    @DisplayName("문제 1 — VIP 를 포인트 내림차순, 이름 오름차순으로")
    void problem1() {
        // 여기에 작성:
        List<Customer> result = null;

        // 생성 SQL 을 여기에 적으십시오:
        //   select ...
        //   from ...
        //   where ...
        //   order by ...

        result.forEach(c ->
                System.out.printf("  %s %dp%n", c.getName(), c.getPoints()));
    }

    // =================================================================
    // 문제 2. NULL 정렬
    // =================================================================
    // 전체 고객을 전화번호 오름차순으로 정렬하되,
    // 전화번호가 NULL 인 3명을 "맨 뒤" 로 보내십시오.
    //
    // 이 문제의 본체는 코드가 아니라 생성 SQL 입니다.
    //   - MySQL 8 에는 NULLS LAST 문법이 없습니다.
    //   - 그렇다면 Hibernate 는 무엇을 대신 내보냅니까?
    //   - 그 SQL 은 phone 컬럼에 인덱스가 있을 때 그 인덱스를 쓸 수 있습니까?
    // =================================================================
    @Test
    @DisplayName("문제 2 — phone NULL 3명을 맨 뒤로")
    void problem2() {
        // 여기에 작성:
        List<Customer> result = null;

        // 생성 SQL 을 여기에 적으십시오 (order by 절 전문):
        //   order by ...
        //
        // 이 정렬이 인덱스를 탈 수 있습니까? 답과 이유를 한 문장으로:
        //   →

        result.stream().skip(result.size() - 5).forEach(c ->
                System.out.printf("  %s phone=%s%n", c.getName(), c.getPhone()));
    }

    // =================================================================
    // 문제 3. Page + 분리된 count 쿼리 + PageableExecutionUtils
    // =================================================================
    // 배송 완료(DELIVERED) 주문의 Page<Order> 를 반환하는 메서드를 완성하십시오.
    //
    // 요구사항
    //   (a) 콘텐츠 쿼리는 orderDate 내림차순 + orderId 내림차순으로 정렬
    //   (b) count 쿼리를 별도 JPAQuery<Long> 으로 "직접" 작성 (orderBy 없이)
    //   (c) PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne) 로 반환
    //
    // ⚠️ fetchCount() 나 fetchResults() 를 쓰면 오답입니다. deprecated 입니다.
    // ⚠️ PageableExecutionUtils 의 import 패키지는 Spring Data 버전마다 다릅니다.
    //    클래스명만 타이핑하고 IDE 자동완성을 쓰십시오.
    // =================================================================
    @Test
    @DisplayName("문제 3 — count 쿼리를 직접 작성한 Page")
    void problem3() {
        Page<Order> page = searchDeliveredOrders(PageRequest.of(0, 10));

        System.out.printf("  content=%d  total=%d  totalPages=%d%n",
                page.getContent().size(), page.getTotalElements(), page.getTotalPages());
    }

    private Page<Order> searchDeliveredOrders(Pageable pageable) {
        // 여기에 작성:
        //   ① 콘텐츠 쿼리
        //   ② count 쿼리 (JPAQuery<Long> 로 "실행하지 않고" 만들어 두기)
        //   ③ PageableExecutionUtils 로 감싸 반환

        return null;
    }

    // =================================================================
    // 문제 4. count 쿼리 생략 확인 (관찰 문제)
    // =================================================================
    // 문제 3 에서 만든 searchDeliveredOrders 를 세 가지 Pageable 로 호출하고,
    // 각각 count SQL 이 로그에 찍히는지 관찰하십시오.
    //
    //   ① PageRequest.of(0, 10)     →  count SQL 이 찍힙니까?  →
    //   ② PageRequest.of(0, 1000)   →  count SQL 이 찍힙니까?  →
    //   ③ 마지막 페이지 (직접 계산) →  count SQL 이 찍힙니까?  →
    //
    // 그리고 ②와 ③에서 total 값이 어떻게 계산됐는지 설명하십시오.
    //   ② total = ?
    //   ③ total = ?
    //
    // ⚠️ 이 문제는 assertThat 으로 검증할 수 없습니다. 콘솔을 눈으로 보십시오.
    // =================================================================
    @Test
    @DisplayName("문제 4 — count 쿼리가 생략되는 조건을 로그로 확인")
    void problem4() {
        System.out.println("=== ① PageRequest.of(0, 10) ===");
        // 여기에 작성:

        System.out.println("=== ② PageRequest.of(0, 1000) ===");
        // 여기에 작성:

        System.out.println("=== ③ 마지막 페이지 ===");
        // 여기에 작성:
    }

    // =================================================================
    // 문제 5. 함수 정렬을 인덱스가 탈 수 있는 형태로
    // =================================================================
    // 아래 코드는 컴파일도 되고 결과도 정확하지만, 100만 행에서 1.284초가 걸립니다.
    //
    //   queryFactory.selectFrom(order)
    //           .orderBy(order.orderDate.year().desc(), order.orderId.desc())
    //           .limit(20)
    //           .fetch();
    //
    // (a) 이 코드를 그대로 실행해 생성 SQL 을 기록하십시오.
    // (b) 인덱스를 탈 수 있는 형태로 고치고 생성 SQL 을 기록하십시오.
    // (c) 두 SQL 의 order by 절만 나란히 적고, 왜 (a) 가 인덱스를 못 타는지 쓰십시오.
    // =================================================================
    @Test
    @DisplayName("문제 5 — year() 정렬을 인덱스가 타게 고치기")
    void problem5() {
        // (a) 나쁜 코드 — 여기에 작성:
        List<Order> bad = null;

        // (a) 생성 SQL 의 order by 절:
        //   order by

        // (b) 고친 코드 — 여기에 작성:
        List<Order> good = null;

        // (b) 생성 SQL 의 order by 절:
        //   order by

        // (c) 왜 (a) 는 인덱스를 못 탑니까? 두 문장으로:
        //   →

        System.out.printf("  bad=%d good=%d%n", bad.size(), good.size());
    }

    // =================================================================
    // 문제 6. 키셋(커서) 페이징
    // =================================================================
    // order_id 내림차순 커서 기반 페이징 메서드를 완성하십시오.
    //
    // 요구사항
    //   - lastSeenOrderId 가 null 이면 첫 페이지 (조건 없음)
    //   - null 이 아니면 그보다 작은 order_id 만
    //   - order_id 내림차순, size 건
    //
    // 💡 힌트: where(null) 이 조건 무시로 동작한다는 Step 04 의 성질을 쓰면
    //    if 분기로 쿼리를 두 벌 만들지 않아도 됩니다.
    //
    // 그리고 첫 호출과 두 번째 호출의 생성 SQL 차이를 기록하십시오.
    // =================================================================
    @Test
    @DisplayName("문제 6 — 키셋 페이징")
    void problem6() {
        List<Order> first = nextPage(null, 20);
        Long cursor = first.get(first.size() - 1).getOrderId();
        List<Order> second = nextPage(cursor, 20);

        // 첫 호출의 생성 SQL:
        //   select ... from orders o1_0
        //   ...

        // 두 번째 호출의 생성 SQL:
        //   select ... from orders o1_0
        //   ...

        // 두 SQL 의 차이를 한 문장으로:
        //   →

        System.out.printf("  1페이지 %d건 (커서=%d) → 2페이지 %d건%n",
                first.size(), cursor, second.size());
    }

    private List<Order> nextPage(Long lastSeenOrderId, int size) {
        // 여기에 작성:
        return null;
    }

    // =================================================================
    // 문제 7. 타이브레이커
    // =================================================================
    // 아래 페이징 코드에는 결함이 있습니다.
    //
    //   queryFactory.selectFrom(order)
    //           .orderBy(order.orderDate.desc())
    //           .offset(pageable.getOffset())
    //           .limit(pageable.getPageSize())
    //           .fetch();
    //
    // (a) 결함을 고치십시오. (코드는 한 줄 수정이면 됩니다)
    // (b) 왜 필요한지 두 문장으로 설명하십시오.
    //     "행이 중복되거나 누락된다" 까지만 쓰면 절반입니다.
    //     왜 실행마다 순서가 달라질 수 있는가를 함께 쓰십시오.
    // =================================================================
    @Test
    @DisplayName("문제 7 — 타이브레이커 추가")
    void problem7() {
        Pageable pageable = PageRequest.of(0, 10);

        // (a) 고친 코드 — 여기에 작성:
        List<Order> page1 = null;

        // (b) 왜 필요합니까? 두 문장으로:
        //   →
        //   →

        System.out.printf("  %s%n",
                page1.stream().limit(5).map(o -> String.valueOf(o.getOrderId())).toList());
    }

    // =================================================================
    // 참고 — 문제를 풀 때 쓸 수 있는 상수
    // =================================================================
    //   Grade.VIP / Grade.GOLD / Grade.SILVER / Grade.BRONZE
    //   OrderStatus.PENDING / PAID / SHIPPED / DELIVERED / CANCELLED
    //
    //   customers 30명 (VIP 4 / GOLD 9 / SILVER 8 / BRONZE 9, phone NULL 3명)
    //   orders 600건 (DELIVERED 214건)
    // =================================================================

    @SuppressWarnings("unused")
    private void hintsForUnusedImports() {
        // import 가 회색으로 뜨는 것을 막기 위한 자리입니다. 답안 작성에는 필요 없습니다.
        Grade g = Grade.VIP;
        OrderStatus s = OrderStatus.DELIVERED;
        JPAQuery<Long> q = queryFactory.select(order.count()).from(order);
        System.out.println(g + " " + s + " " + q);
        System.out.println(customer.getType() + " " + em.isOpen());
    }
}

Solution.java

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

  • 정답 2nullsLast() 를 쓰는 것 자체보다, 생성 SQL 이 order by case when c1_0.phone is null then 1 else 0 end, c1_0.phone asc 로 나온다는 사실과 그것이 인덱스를 못 탄다는 결론이 핵심입니다. MySQL 이 기본적으로 NULL 을 앞에 놓는다는 사실도 함께 짚습니다.
  • 정답 3 은 count 쿼리에서 join(order.customer, customer)뺄 수 있는 경우와 없는 경우를 나눠 설명합니다. whereorder.status 만 본다면 뺄 수 있고, customer.grade 를 본다면 남겨야 합니다. 이 판단 기준이 9-6 의 전부입니다.
  • 정답 5 의 교훈은 한 줄입니다: "정렬 컬럼을 가공하지 말고, 요구사항을 원본 컬럼으로 번역하라." year(order_date) desc 가 필요했던 게 아니라 order_date desc 면 충분했다는 것을 보여줍니다. 정말 함수 정렬이 필요한 경우(대소문자 무시)에는 컬레이션이 이미 해결하고 있다는 점도 함께 설명합니다.
  • 정답 6 은 복합 커서 버전까지 함께 제시합니다. orderDate 로 정렬하는 키셋에서 .or() 로 조립한 조건의 괄호가 생성 SQL 에서 어떻게 나오는지, 그리고 AND 우선순위 덕분에 우연히 맞는 상황에 기대면 안 되는 이유를 주석으로 길게 다룹니다.
  • 정답 7 은 "정렬 키가 유일하지 않으면 나머지 순서는 DB 재량" 이라는 SQL 표준의 성질에서 출발해, 실행 계획이 바뀌는 세 가지 계기(통계 갱신 / 정렬 버퍼 초과 / 인덱스 변경)를 나열합니다. 그리고 키셋 페이징에서는 이 문제가 훨씬 치명적이라는 점으로 9-8 과 연결합니다.
package com.example.shop.step09;

import com.example.shop.entity.Customer;
import com.example.shop.entity.Grade;
import com.example.shop.entity.Order;
import com.example.shop.entity.OrderStatus;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.transaction.annotation.Transactional;

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

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

/**
 * Step 09 — 정렬과 페이징 : 연습문제 정답과 해설
 *
 * 문제를 직접 풀어 본 뒤에 여십시오.
 * 각 정답에는 "왜 그 답인가" 를 설명하는 긴 주석이 붙어 있습니다.
 * 코드보다 주석이 본체입니다.
 */
@SpringBootTest
@Transactional
class Solution {

    @Autowired
    JPAQueryFactory queryFactory;

    @PersistenceContext
    EntityManager em;

    // =================================================================
    // 정답 1 — 다중 정렬
    // =================================================================
    @Test
    @DisplayName("정답 1 — VIP 를 포인트 내림차순, 이름 오름차순으로")
    void solution1() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .where(customer.grade.eq(Grade.VIP))
                .orderBy(customer.points.desc(), customer.name.asc())
                .fetch();

        result.forEach(c -> System.out.printf("  %s %dp%n", c.getName(), c.getPoints()));

        // 생성 SQL
        //   select c1_0.customer_id, c1_0.birth_date, 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 = ?
        //   order by c1_0.points desc, c1_0.name asc
        //
        //   바인딩: [1] VIP
        //   조회 4건 — 류하나(48200), 정  훈(41500), 배채영(37800), 김서준(30100)
        //
        // 해설
        //   orderBy 의 인자 순서가 곧 정렬 우선순위입니다.
        //   .desc() 와 .asc() 가 각각 OrderSpecifier 를 만들고, 그것이 order by 절의
        //   한 항목이 됩니다. QueryDSL 에서 정렬만큼은 SQL 과의 대응이 거의 완벽합니다.
        //
        //   ★ 그런데 이 쿼리는 페이징이 없으므로 타이브레이커가 없어도 괜찮습니다.
        //     전건을 한 번에 가져오면 페이지 사이 누락/중복 문제가 없기 때문입니다.
        //     반대로 말하면, offset/limit 이 붙는 순간 name 만으로는 부족합니다
        //     (동명이인이 있으면 순서가 흔들립니다). 문제 7 을 참고하십시오.
        //
        //   ★ where 가 order by 앞에 오는 것은 SQL 문법이지 QueryDSL 의 메서드 호출 순서와는
        //     무관합니다. .orderBy(...).where(...) 로 써도 같은 SQL 이 나갑니다.
        //     QueryDSL 은 호출을 모아 두었다가 마지막에 JPQL 을 조립합니다.
    }

    // =================================================================
    // 정답 2 — NULL 정렬
    // =================================================================
    @Test
    @DisplayName("정답 2 — phone NULL 3명을 맨 뒤로")
    void solution2() {
        List<Customer> result = queryFactory
                .selectFrom(customer)
                .orderBy(customer.phone.asc().nullsLast())
                .fetch();

        result.stream().skip(result.size() - 5).forEach(c ->
                System.out.printf("  %s phone=%s%n", c.getName(), c.getPhone()));

        // 생성 SQL (Hibernate 6.4 + MySQL 8)
        //   select c1_0.customer_id, ... from customers c1_0
        //   order by case when c1_0.phone is null then 1 else 0 end, c1_0.phone asc
        //
        //   조회 30건 — 마지막 3건이 한지호, 안지수, 문시우 (phone=NULL)
        //
        // 해설 — 이 문제의 본체는 코드 한 줄이 아니라 생성 SQL 입니다.
        //
        //   ① nullsLast() 는 SQL 표준의 "nulls last" 키워드로 나가지 않았습니다.
        //      MySQL 8 에는 NULLS FIRST / NULLS LAST 문법이 아예 없습니다.
        //      그래서 Hibernate 의 MySQL 방언이
        //        case when c1_0.phone is null then 1 else 0 end
        //      이라는 정렬 키를 앞에 하나 더 끼워 넣어 흉내 냅니다.
        //      nullsFirst() 는 then 0 else 1 로 뒤집힌 형태가 됩니다.
        //
        //   ② PostgreSQL / Oracle / H2 에서는 "c1_0.phone asc nulls last" 가 그대로 나갑니다.
        //      즉 같은 자바 코드가 DB 에 따라 전혀 다른 SQL 을 만듭니다.
        //
        //   ③ 인덱스를 탈 수 있습니까? — 못 탑니다.
        //      첫 번째 정렬 키가 case 식이라는 "계산 결과" 이기 때문입니다.
        //      인덱스는 phone 의 원본 값으로 정렬돼 있으므로 이 순서를 재사용할 수 없고,
        //      EXPLAIN 에 Using filesort 가 남습니다.
        //      이유는 9-7 의 "함수를 씌우면 인덱스를 못 탄다" 와 정확히 동일합니다.
        //
        //   ④ MySQL 은 asc 정렬에서 NULL 을 가장 작은 값으로 취급해 기본적으로 앞에 놓습니다.
        //      따라서 nullsFirst() 는 MySQL 에서 "지정하지 않은 것" 과 결과가 같으면서
        //      SQL 만 더 무거워집니다. MySQL 만 쓴다고 확신한다면 nullsFirst() 를 생략하는 것이
        //      더 나은 선택입니다. 다만 그 선택은 "이 애플리케이션은 MySQL 을 벗어나지 않는다" 는
        //      약속과 짝을 이룹니다.
        //
        //   ⑤ 근본 처방은 정렬 대상 컬럼을 NOT NULL + 기본값으로 설계하거나,
        //      where col is not null 로 NULL 행을 먼저 걸러낸 뒤 정렬하는 것입니다.
    }

    // =================================================================
    // 정답 3 — Page + 분리된 count 쿼리 + PageableExecutionUtils
    // =================================================================
    @Test
    @DisplayName("정답 3 — count 쿼리를 직접 작성한 Page")
    void solution3() {
        Page<Order> page = searchDeliveredOrders(PageRequest.of(0, 10));

        System.out.printf("  content=%d  total=%d  totalPages=%d%n",
                page.getContent().size(), page.getTotalElements(), page.getTotalPages());
    }

    private Page<Order> searchDeliveredOrders(Pageable pageable) {
        // ① 콘텐츠 쿼리
        List<Order> content = queryFactory
                .selectFrom(order)
                .where(order.status.eq(OrderStatus.DELIVERED))
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // ② count 쿼리 — 여기서 fetch 하지 않습니다. 쿼리 객체만 만들어 둡니다.
        JPAQuery<Long> countQuery = queryFactory
                .select(order.count())
                .from(order)
                .where(order.status.eq(OrderStatus.DELIVERED));

        // ③ Supplier 로 넘긴다
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    // 해설
    //   ★ 핵심은 "count 쿼리는 콘텐츠 쿼리와 다른 쿼리다" 라는 인식입니다.
    //
    //   생성 SQL
    //     ① select o1_0.order_id, ... from orders o1_0 where o1_0.status = ?
    //        order by o1_0.order_date desc, o1_0.order_id desc limit ?, ?
    //     ② select count(o1_0.order_id) from orders o1_0 where o1_0.status = ?
    //
    //   count 쿼리에서 order by 가 사라진 것에 주목하십시오.
    //   건수를 세는 데 순서는 아무 의미가 없습니다. 정렬은 순수한 낭비입니다.
    //
    //   ★ 조인은 뺄 수 있는 경우와 없는 경우가 나뉩니다.
    //
    //     - 뺄 수 있는 경우: 조인이 "표시(select)" 를 위해서만 있을 때.
    //       예) 목록에 고객 이름을 보여주려고 join(order.customer, customer) 를 했다면,
    //           count 에는 이름이 필요 없으므로 조인을 통째로 뺍니다.
    //
    //     - 빼면 안 되는 경우: 조인이 "필터(where)" 를 위해 있을 때.
    //       예) where(customer.grade.eq(Grade.VIP)) 가 있으면 조인을 빼는 순간
    //           JPQL 파싱에서 터지거나 조건이 무시된 엉뚱한 건수가 나옵니다.
    //
    //     규칙: 표시를 위한 조인만 뺀다. 필터를 위한 조인은 남긴다.
    //
    //   ★ 컬렉션 조인은 더 위험합니다.
    //     @OneToMany 를 조인하면 행이 뻥튀기되어 count 가 커집니다 (Step 08 의 8-7).
    //     count 쿼리에 컬렉션 조인이 남아 있다면 countDistinct() 가 필요한지 반드시 확인하십시오.
    //
    //   ★ fetchCount() 를 쓰면 오답인 이유
    //     QueryDSL 5.0 부터 fetchCount() / fetchResults() 는 deprecated 입니다.
    //     이 메서드들은 원래 쿼리를 그대로 감싸 count 로 "자동 변환" 하는데,
    //     select 절이 복잡하거나 groupBy 가 있거나 조인이 여럿이면 그 변환이
    //     의도와 다른 SQL 을 만들거나 아예 실패합니다.
    //     QueryDSL 팀이 "자동 변환은 신뢰할 수 없다" 고 판단해 폐기했습니다 (Step 03 의 3-6).
    //
    //   ★ new PageImpl<>(content, pageable, total) 도 정답이지만 한 단계 덜 최적화된 답입니다.
    //     PageImpl 은 total 값을 이미 받았으므로 count 쿼리를 항상 실행한 셈입니다.
    //     PageableExecutionUtils 는 그것을 Supplier 로 미뤄 조건부로만 실행합니다 (정답 4).

    // =================================================================
    // 정답 4 — count 쿼리 생략 확인
    // =================================================================
    @Test
    @DisplayName("정답 4 — count 쿼리가 생략되는 조건")
    void solution4() {
        System.out.println("=== ① PageRequest.of(0, 10) — count 실행됨 ===");
        Page<Order> p1 = searchDeliveredOrders(PageRequest.of(0, 10));
        System.out.printf("  total=%d%n", p1.getTotalElements());

        System.out.println("=== ② PageRequest.of(0, 1000) — count 생략됨 ===");
        Page<Order> p2 = searchDeliveredOrders(PageRequest.of(0, 1000));
        System.out.printf("  total=%d%n", p2.getTotalElements());

        System.out.println("=== ③ PageRequest.of(21, 10) 마지막 페이지 — count 생략됨 ===");
        Page<Order> p3 = searchDeliveredOrders(PageRequest.of(21, 10));
        System.out.printf("  total=%d%n", p3.getTotalElements());

        // 관찰 결과
        //   ① count SQL 이 찍힙니다.
        //      offset=0 이지만 결과가 10건으로 pageSize 를 꽉 채웠습니다.
        //      "뒤에 더 있는지" 를 알 수 없으므로 세어 봐야 합니다.
        //
        //   ② count SQL 이 찍히지 않습니다.
        //      offset=0 이고 결과 214건이 pageSize 1000 보다 작습니다.
        //      → 첫 페이지에 전부 담겼다는 뜻이므로 total = 214 로 확정됩니다.
        //
        //   ③ count SQL 이 찍히지 않습니다.
        //      offset=210, 결과 4건 < pageSize 10.
        //      → 마지막 페이지이므로 total = offset(210) + 4 = 214 로 확정됩니다.
        //
        // 해설
        //   PageableExecutionUtils.getPage 의 세 번째 인자가 Supplier<Long> 이라는 점이 전부입니다.
        //   countQuery.fetchOne() 을 미리 호출해 값을 넘기는 게 아니라
        //   countQuery::fetchOne 이라는 "호출 가능한 것" 을 넘깁니다.
        //   유틸이 위 조건을 검사해 필요할 때만 get() 을 부릅니다.
        //
        //   ★ 실무 효과는 생각보다 큽니다.
        //     대부분의 목록 화면에서 사용자는 1페이지만 보고 떠납니다.
        //     그리고 검색 조건을 좁게 준 결과는 첫 페이지를 다 채우지 못하는 경우가 많습니다.
        //     그런 요청마다 count 쿼리 하나씩을 절약합니다.
        //
        //   ★ import 주의
        //     PageableExecutionUtils 의 패키지는 Spring Data 버전에 따라 다릅니다.
        //       구버전: org.springframework.data.repository.support
        //       현행:   org.springframework.data.support
        //     블로그 글을 복사하면 import 가 안 잡히는 일이 흔합니다.
        //     클래스명만 타이핑하고 IDE 자동완성을 쓰십시오.
        //
        //   ★ 더 근본적인 질문
        //     총 페이지 수를 화면에 꼭 보여줘야 합니까?
        //     무한 스크롤이나 "더 보기" UI 라면 Slice (9-10) 나 키셋 페이징 (9-8) 이 낫습니다.
        //     count 쿼리는 화면 요구사항이 강제할 때만 지불하는 비용입니다.
    }

    // =================================================================
    // 정답 5 — 함수 정렬을 인덱스가 탈 수 있는 형태로
    // =================================================================
    @Test
    @DisplayName("정답 5 — year() 정렬을 인덱스가 타게 고치기")
    void solution5() {
        // (a) 나쁜 코드
        List<Order> bad = queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.year().desc(), order.orderId.desc())
                .limit(20)
                .fetch();

        // (b) 고친 코드
        List<Order> good = queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .limit(20)
                .fetch();

        System.out.printf("  bad=%d good=%d%n", bad.size(), good.size());

        // (a) 생성 SQL 의 order by 절
        //     order by year(o1_0.order_date) desc, o1_0.order_id desc
        //
        // (b) 생성 SQL 의 order by 절
        //     order by o1_0.order_date desc, o1_0.order_id desc
        //
        // (c) 왜 (a) 는 인덱스를 못 탑니까?
        //
        //     인덱스는 컬럼의 "원본 값" 으로 정렬돼 있습니다.
        //     (order_date) 인덱스의 리프 노드는 2024-01-02, 2024-01-05, ... 순으로
        //     물리적으로 늘어서 있고, 그 어디에도 2024 / 2025 라는 값은 저장돼 있지 않습니다.
        //     year(order_date) 는 인덱스가 모르는 값이므로 MySQL 은 인덱스의 정렬 순서를
        //     재사용하지 못하고, 모든 행을 읽어 메모리(또는 디스크)에서 다시 정렬합니다.
        //
        //     100만 행 access_logs 실측 (idx_time (logged_at) 있는 상태):
        //       ORDER BY YEAR(logged_at) DESC LIMIT 20
        //         → type: ALL, Using filesort, rows 996151, 1.284초
        //       ORDER BY logged_at DESC LIMIT 20
        //         → type: index, Backward index scan, rows 20, 0.002초
        //       ★ 1.284초 → 0.002초. 약 640배.
        //
        // ★ 교훈 한 줄
        //   "정렬 컬럼을 가공하지 말고, 요구사항을 원본 컬럼으로 번역하라."
        //
        //   이 문제에서 year() 는 애초에 필요 없었습니다.
        //   order_date 내림차순은 year(order_date) 내림차순과 연도 단위로 동일한 순서입니다.
        //   "연도 기준 최신순" 이라는 요구를 곧이곧대로 year() 로 옮긴 것이 실수였습니다.
        //
        // ★ 정말 함수 정렬이 필요할 때
        //   대소문자 무시 정렬(lower(name))처럼 원본 컬럼으로 대체 불가해 보이는 경우가 있습니다.
        //   그러나 shop 스키마의 컬레이션은 utf8mb4_0900_ai_ci 이고 ci 는 case insensitive 입니다.
        //   즉 ORDER BY name 이 이미 대소문자를 무시합니다. lower() 는 처음부터 불필요했습니다.
        //   이것을 모르고 lower() 를 씌우는 것이 실무에서 가장 흔한 형태입니다.
        //
        //   그래도 다른 값으로 정렬해야 한다면 생성 컬럼 + 인덱스가 정석입니다.
        //     ALTER TABLE customers
        //       ADD COLUMN name_sort VARCHAR(50) AS (LOWER(name)) STORED,
        //       ADD INDEX idx_name_sort (name_sort);
        //   (MySQL8 코스 Step 14 — 뷰와 생성 컬럼)
        //   QueryDSL 에서는 이 컬럼을 @Column(insertable=false, updatable=false) 필드로 매핑하면
        //   customer.nameSort.asc() 로 타입 안전하게 정렬할 수 있습니다.
        //
        //   ⚠️ 공용 테이블(customers/orders 등)에 실제로 인덱스를 만들지 마십시오.
        //      다른 스텝의 EXPLAIN 결과가 달라집니다.
    }

    // =================================================================
    // 정답 6 — 키셋(커서) 페이징
    // =================================================================
    @Test
    @DisplayName("정답 6 — 키셋 페이징")
    void solution6() {
        List<Order> first = nextPage(null, 20);
        Long cursor = first.get(first.size() - 1).getOrderId();
        List<Order> second = nextPage(cursor, 20);

        System.out.printf("  1페이지 %d건 (커서=%d) → 2페이지 %d건%n",
                first.size(), cursor, second.size());
    }

    private List<Order> nextPage(Long lastSeenOrderId, int size) {
        return queryFactory
                .selectFrom(order)
                .where(lastSeenOrderId == null ? null : order.orderId.lt(lastSeenOrderId))
                .orderBy(order.orderId.desc())
                .limit(size)
                .fetch();
    }

    // 해설
    //   첫 호출의 생성 SQL
    //     select o1_0.order_id, ... from orders o1_0
    //     order by o1_0.order_id desc
    //     limit ?                      바인딩: [1] 20
    //
    //   두 번째 호출의 생성 SQL
    //     select o1_0.order_id, ... from orders o1_0
    //     where o1_0.order_id < ?
    //     order by o1_0.order_id desc
    //     limit ?                      바인딩: [1] 581  [2] 20
    //
    //   차이: offset 이 사라지고 where order_id < ? 가 생겼습니다.
    //
    // ★ where(null) 을 쓰는 이유
    //   QueryDSL 의 where 는 null 인자를 "조건 없음" 으로 무시합니다 (Step 04 의 4-4).
    //   그래서 if 분기로 쿼리를 두 벌 만들지 않아도 됩니다.
    //   if 분기 답안도 동작하지만, 조건이 늘어날수록 분기가 지수적으로 늘어납니다.
    //   Step 10 의 동적 조건 조립이 이 성질 위에 세워집니다.
    //
    // ★ 왜 빠른가
    //   offset 페이징의 LIMIT 100000, 20 은 DB 에게
    //   "정렬된 순서로 100,020개를 읽고 앞의 100,000개를 버려라" 라고 말합니다.
    //   DB 에는 "정렬 결과의 N번째로 점프" 하는 방법이 없습니다.
    //   B+Tree 인덱스도 "N번째 리프" 를 직접 가리키지 못합니다.
    //
    //   반면 where order_id < 581 은 PK 인덱스에서 581 지점으로 즉시 내려간 뒤
    //   거기서부터 20건만 읽습니다. 몇 번째 페이지든 읽는 행은 항상 20건입니다.
    //
    //   100만 행 실측:
    //     offset 방식: 100000 → 0.087초 / 500000 → 0.412초 / 900000 → 0.741초
    //     키셋 방식:   어느 지점이든 0.001초
    //     ★ 0.412초 → 0.001초. 그리고 깊이와 무관하게 일정.
    //
    // ★ 복합 커서 — 정렬 키가 PK 가 아닐 때
    //   order_date 로 정렬하면서 키셋을 쓰려면 커서가 복합 값이 됩니다.
    //   order_date 는 중복될 수 있으므로 PK 를 함께 들고 가야 합니다.
    //
    //   생성 SQL:
    //     where o1_0.order_date < ? or o1_0.order_date = ? and o1_0.order_id < ?
    //     order by o1_0.order_date desc, o1_0.order_id desc limit ?
    //
    //   ⚠️ 괄호가 없습니다.
    //     SQL 에서 AND 가 OR 보다 우선순위가 높으므로
    //       a < ? or (a = ? and b < ?)
    //     와 같아 의도대로 동작합니다. 그러나 이건 운입니다.
    //     Step 04 의 4-5 에서 다뤘듯 QueryDSL 은 불필요하다고 판단한 괄호를 생략하고,
    //     조건 순서가 조금만 바뀌어도 결과가 달라질 수 있습니다.
    //     키셋 조건처럼 and/or 가 섞이는 표현식은 반드시 생성 SQL 을 눈으로 확인하고,
    //     확실히 하려면 Expressions.anyOf(...) / Expressions.allOf(...) 로 그룹을 명시하십시오.
    //
    // ★ 키셋의 한계
    //   - "7페이지로 바로 가기" 가 불가능합니다. 순차 이동만 됩니다.
    //   - 정렬 조건이 바뀌면 커서 형태도 바뀝니다. 동적 정렬(Step 10)과 결합하기 까다롭습니다.
    //   - 전체 건수를 모릅니다.
    //   그래서 무한 스크롤·피드·배치 순회에는 키셋, 페이지 번호를 찍는 관리자 화면에는 offset 이
    //   보통의 선택입니다. 관리자 화면이라면 offset 이 깊어질 일 자체가 드뭅니다.

    private List<Order> nextPageByDate(LocalDateTime lastDate, Long lastId, int size) {
        BooleanExpression cursor = (lastDate == null) ? null :
                order.orderDate.lt(lastDate)
                        .or(order.orderDate.eq(lastDate).and(order.orderId.lt(lastId)));

        return queryFactory
                .selectFrom(order)
                .where(cursor)
                .orderBy(order.orderDate.desc(), order.orderId.desc())
                .limit(size)
                .fetch();
    }

    // =================================================================
    // 정답 7 — 타이브레이커
    // =================================================================
    @Test
    @DisplayName("정답 7 — 타이브레이커 추가")
    void solution7() {
        Pageable pageable = PageRequest.of(0, 10);

        List<Order> page1 = queryFactory
                .selectFrom(order)
                .orderBy(order.orderDate.desc(), order.orderId.desc())   // ← PK 추가
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        System.out.printf("  %s%n",
                page1.stream().limit(5).map(o -> String.valueOf(o.getOrderId())).toList());

        // 생성 SQL
        //   order by o1_0.order_date desc, o1_0.order_id desc
        //   limit ?, ?
        //
        // (b) 왜 필요합니까?
        //
        //   [문장 1] SQL 표준은 ORDER BY 로 지정되지 않은 부분의 순서를 보장하지 않으므로,
        //            order_date 가 같은 주문들 사이의 순서는 DB 재량이며 실행마다 달라질 수 있습니다.
        //
        //   [문장 2] 1페이지 조회와 2페이지 조회는 서로 다른 SQL 실행이라, 그 사이에 순서가
        //            뒤바뀌면 어떤 행은 어느 페이지에도 나오지 않고(누락) 어떤 행은
        //            두 페이지에 모두 나옵니다(중복).
        //
        // 해설 — 왜 실행마다 순서가 달라지는가
        //   순서가 바뀌는 계기는 최소 세 가지입니다.
        //     ① 옵티마이저가 다른 실행 계획을 고를 때
        //        (통계 갱신, 인덱스 추가/삭제, 데이터가 늘어 선택도가 달라질 때)
        //     ② 정렬이 메모리에서 디스크로 넘어갈 때 (sort_buffer_size 초과)
        //        MySQL 은 이때 알고리즘 자체를 바꿉니다.
        //     ③ 버퍼 풀 상태나 읽기 순서가 달라질 때
        //
        //   ★ 600건 규모에서는 매번 같은 순서가 나올 가능성이 높습니다.
        //     그것을 "보장된다" 로 오해하는 것이 이 함정의 본질입니다.
        //     데이터가 커지고 인덱스가 추가되는 순간, 아무도 코드를 건드리지 않았는데
        //     목록 화면에서 행이 중복되기 시작합니다. 재현이 안 되므로 버그 리포트도 닫힙니다.
        //
        // ★ 처방
        //   정렬 키의 조합이 행을 유일하게 결정하도록 만듭니다.
        //   가장 쉬운 방법은 PK 를 마지막 정렬 키로 추가하는 것입니다.
        //   order_id 는 PK 이므로 중복이 없고, 따라서 (order_date, order_id) 조합은
        //   모든 행에 대해 유일합니다. 전순서(total order)가 확정됩니다.
        //
        //   비용은 order by 절에 컬럼 하나가 늘어나는 것뿐이며,
        //   대개 인덱스의 마지막 컬럼이거나 커버링되므로 추가 비용이 사실상 없습니다.
        //
        // ★ 코드 리뷰 규칙으로 만드십시오
        //   "offset/limit 이 있는데 orderBy 에 PK 가 없다" 는 그 자체로 결함입니다.
        //   정렬 키가 이미 유니크 컬럼(예: email)이라면 생략해도 되지만,
        //   그것을 판단할 시간에 PK 를 붙이는 편이 낫습니다.
        //
        // ★ 키셋 페이징에서는 훨씬 치명적입니다
        //   커서 값이 유일하지 않으면 "그 값보다 작은 것" 을 걸러낼 때
        //   같은 값을 가진 행들을 통째로 건너뜁니다. 누락이 확정적으로 발생합니다.
        //   정답 6 의 복합 커서(orderDate + orderId)가 바로 이 문제에 대한 대응입니다.
    }
}