성능 최적화 12편 - No Offset 페이지네이션으로 대용량 목록 조회 최적화하기
5편에서 인덱스를 다루며 조회 성능을 개선하는 방법을 살펴봤습니다. 하지만 인덱스를 잘 타고 있는데도, 페이지가 뒤로 갈수록 목록 조회가 점점 느려지는 경우가 있습니다. 이번 글에서는 그 원인이 되는 OFFSET 방식의 한계와, 이를 해결하는 No Offset(커서 기반) 페이지네이션을 정리합니다.
1. OFFSET 방식의 문제
가장 흔하게 사용하는 페이지네이션은 LIMIT과 OFFSET을 조합하는 방식입니다.
-- 1페이지 (1~20번째)
select * from orders order by id desc limit 20 offset 0;
-- 100페이지 (1981~2000번째)
select * from orders order by id desc limit 20 offset 1980;
문제는 OFFSET이 커질수록 데이터베이스가 앞의 데이터를 건너뛰기 위해 읽는 행 자체가 늘어난다는 점입니다. OFFSET 1980이면 데이터베이스는 1980개의 행을 읽고 버린 뒤에야 원하는 20개를 반환합니다. 인덱스를 타고 있어도, 뒷페이지로 갈수록 이 스캔 범위가 계속 늘어나 응답 속도가 점점 느려집니다.
explain analyze
select * from orders order by id desc limit 20 offset 100000;
Limit (cost=15234.50..15237.60 rows=20 width=120)
-> Index Scan Backward using orders_pkey on orders
(cost=0.43..152340.12 rows=1000000 width=120)
실행 계획에서도 OFFSET이 커질수록 스캔해야 하는 비용(cost)이 함께 커지는 것을 확인할 수 있습니다.
2. No Offset(커서 기반) 방식이란
OFFSET으로 몇 번째 행부터 가져올지 지정하는 대신, 마지막으로 조회한 데이터의 기준값(커서) 을 조건절에 활용하는 방식입니다.
-- 1페이지: 최신순 20건
select * from orders
where status = 'COMPLETED'
order by id desc
limit 20;
-- 2페이지: 직전 페이지의 마지막 id(예: 9980)보다 작은 값부터
select * from orders
where status = 'COMPLETED'
and id < 9980
order by id desc
limit 20;
OFFSET으로 건너뛰는 대신, WHERE id < 9980 조건으로 바로 원하는 지점부터 조회합니다. 인덱스가 잡혀 있다면 몇 페이지를 가더라도 항상 일정한 속도로 조회됩니다.
3. Spring Data JPA로 구현하기
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o " +
"where o.status = :status " +
"and (:cursorId is null or o.id < :cursorId) " +
"order by o.id desc")
List<Order> findOrdersByCursor(@Param("status") OrderStatus status,
@Param("cursorId") Long cursorId,
Pageable pageable);
}
public CursorPageResponse<OrderDto> getOrders(Long cursorId, int size) {
Pageable pageable = PageRequest.of(0, size);
List<Order> orders = orderRepository.findOrdersByCursor(OrderStatus.COMPLETED, cursorId, pageable);
Long nextCursor = orders.isEmpty() ? null : orders.get(orders.size() - 1).getId();
List<OrderDto> content = orders.stream().map(OrderDto::from).toList();
return new CursorPageResponse<>(content, nextCursor, !orders.isEmpty());
}
응답에는 데이터 목록과 함께 다음 조회에 사용할 커서 값을 함께 내려줍니다.
{
"content": [ { "id": 9980, "..." : "..." }, ... ],
"nextCursor": 9960,
"hasNext": true
}
클라이언트는 다음 페이지를 요청할 때 이 nextCursor 값을 그대로 넘겨주면 됩니다.
GET /api/orders?size=20 (첫 페이지)
GET /api/orders?size=20&cursorId=9960 (다음 페이지)
4. 정렬 기준이 여러 개일 때
단순히 id만으로 정렬하면 충분하지만, "최신순"이 아니라 "가격순" 같은 별도 정렬 기준이 필요하면 커서도 그에 맞게 복합적으로 구성해야 합니다.
select * from orders
where (total_price, id) < (:cursorPrice, :cursorId)
order by total_price desc, id desc
limit 20;
가격이 같은 주문이 여러 건 있을 수 있으므로, id를 보조 정렬 기준으로 함께 사용해 커서를 유일하게 식별합니다. 이때 인덱스도 (total_price, id) 순서의 복합 인덱스로 만들어줘야 효과가 있습니다.
create index idx_orders_price_id
on orders (total_price desc, id desc);
5. OFFSET 방식이 여전히 필요한 경우
No Offset 방식은 만능이 아닙니다. 다음과 같은 경우에는 OFFSET 방식이 더 적합할 수 있습니다.
- 특정 페이지 번호로 바로 이동해야 하는 UI (예: "7페이지로 이동"): 커서 방식은 이전/다음 탐색에는 적합하지만, 임의의 페이지 번호로 점프하는 데는 구조적으로 맞지 않습니다.
- 전체 페이지 수를 화면에 보여줘야 하는 경우: 커서 방식은 "다음 페이지가 있는지"는 알 수 있지만, "총 몇 페이지인지"를 알려면 별도의 count 쿼리가 追加로 필요합니다.
그래서 관리자 페이지처럼 페이지 번호 이동이 중요한 화면은 OFFSET 방식을, 무한 스크롤이나 피드처럼 순차 탐색이 중심인 화면은 No Offset 방식을 선택하는 것이 일반적입니다.
6. 정리
- OFFSET 방식은 뒷페이지로 갈수록 건너뛰는 행이 늘어나 응답 속도가 느려진다
- No Offset(커서 기반) 방식은 마지막으로 조회한 값을 조건절로 활용해, 몇 페이지를 가든 일정한 속도를 유지한다
- 정렬 기준이 여러 개라면 커서도 복합 조건으로 구성하고, 그에 맞는 복합 인덱스를 함께 설계해야 한다
- 페이지 번호 점프가 필요한 화면에는 OFFSET, 무한 스크롤처럼 순차 탐색이 중심인 화면에는 No Offset이 적합하다
5편에서 다룬 인덱스 설계와 이번 편의 페이지네이션 전략은 함께 고려해야 진가를 발휘합니다. 아무리 좋은 인덱스를 만들어도, 조회 쿼리 자체가 OFFSET으로 설계되어 있다면 데이터가 쌓일수록 결국 느려질 수밖에 없습니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 14 - 캐시 스탬피드 방지하기 (0) | 2026.07.10 |
|---|---|
| 성능 최적화 STEP 13 - JVM GC 튜닝 (0) | 2026.07.10 |
| 성능 최적화 STEP 11 - Kafka로 무거운 작업 비동기 분리하기 (0) | 2026.07.09 |
| 성능 최적화 STEP 10 - JMeter로 부하 테스트하고 개선 효과 검증하기 (0) | 2026.07.08 |
| 성능 최적화 STEP 9 - @Async와 CompletableFuture로 비동기 처리하기 (0) | 2026.07.08 |