Spring BootPaginationAPI Design

Spring에서 페이징 API를 설계할 때 고민한 것들

단순히 page와 size를 받는 수준을 넘어서, 정렬 기준과 count query 비용, 마지막 페이지 경험까지 실무에서 자주 부딪힌 고민을 정리했습니다.

Srue2026년 4월 1일
Spring에서 페이징 API를 설계할 때 고민한 것들

페이징 API는 처음 만들 때 꽤 단순해 보입니다.
page, size 받고 Page<T> 내려주면 끝이라고 느끼기 쉽기 때문입니다.

그런데 실무에서는 이 단순한 API가 생각보다 자주 발목을 잡았습니다.
정렬 기준이 흔들리거나, count query가 느려지거나, 페이지를 넘기는 사이 데이터가 바뀌면서 같은 데이터가 중복으로 보이는 일이 생겼기 때문입니다.

결국 페이징 API는 "목록을 자르는 기능"보다, 목록을 안정적으로 탐색하게 만드는 설계에 더 가깝다고 느꼈습니다.

이 글에서는 처음에 썼던 offset 기반 구현이 어디서 무너졌는지, 그리고 그걸 cursor(키셋) 방식으로 바꾸면서 응답과 SQL을 어떻게 다시 짰는지를 코드 중심으로 정리해 보려 합니다.

처음에는 Page 그대로 내렸다

초기 구현은 대부분 비슷했습니다. Pageable을 받아서 Page<T>를 그대로 내려주는 형태였습니다.

OrderController.java
@GetMapping("/orders")
public ResponseEntity<Page<OrderSummaryResponse>> getOrders(Pageable pageable) {
    return ResponseEntity.ok(orderQueryService.getOrders(pageable));
}
OrderQueryService.java
public Page<OrderSummaryResponse> getOrders(Pageable pageable) {
    // 1) content 쿼리 + 2) count(*) 쿼리, 총 2번 실행된다
    return orderRepository.findAllByOrderByCreatedAtDesc(pageable)
        .map(OrderSummaryResponse::from);
}

빠르게 만들기에는 편했지만, 시간이 지나면서 불편한 점이 계속 생겼습니다.

  • 프론트가 필요 없는 필드까지 Page 메타데이터에 섞였다
  • 정렬 기준이 명확하지 않았다
  • count query가 무거워질수록 응답이 느려졌다
  • 무한 스크롤과 페이지네이션을 같이 쓰기 어려웠다

그래서 지금은 Page<T>를 바로 노출하기보다, API 목적에 맞는 응답 구조를 따로 두는 쪽을 선호합니다.

count query는 생각보다 비쌌다

Page<T>가 무거웠던 핵심 이유는 매 요청마다 count(*)가 한 번 더 나간다는 점이었습니다.
백오피스 목록처럼 필터가 많고 조인이 많은 화면에서는 데이터 조회보다 count query가 더 느린 경우가 있었습니다.
이런 화면은 인덱스를 추가하기 전에 조회 패턴부터 확인하는 과정과도 맞닿아 있어서, count query의 실행 계획을 같이 봐야 했습니다.

실제로 Spring Data가 Page를 만들 때 내부적으로 실행하는 SQL은 대략 이렇게 두 갈래로 나뉩니다.

page-query.sql
-- 1) content: 실제로 보여줄 한 페이지
SELECT *
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 0;
 
-- 2) count: 전체 페이지 수를 계산하기 위한 별도 쿼리
SELECT count(*)
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'PAID';

content 쿼리는 LIMIT 20이라 인덱스만 잘 타면 금방 끝나는데, count 쿼리는 조건에 맞는 모든 행을 세야 해서 조인과 필터가 그대로 비용으로 돌아왔습니다.

여기에 깊은 페이지 문제가 겹쳤습니다. 페이지가 뒤로 갈수록 OFFSET이 커지는데, DB는 버린 행도 일단 읽고 건너뛰기 때문입니다.

deep-offset.sql
-- 100,000번째부터 20건을 보려면
-- DB는 앞의 100,000행을 읽어서 버린 뒤 그다음 20행을 돌려준다
SELECT *
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;

처음엔 당연히 total count를 내려줘야 한다고 생각했는데, 실제로는 그렇지 않은 화면도 많았습니다.

  • "다음 페이지가 있는지"만 알면 되는 화면
  • 무한 스크롤 중심 화면
  • 대략적인 개수만 중요하고 정확한 total이 덜 중요한 화면

이런 경우는 굳이 무거운 count query를 매번 같이 날리지 않는 편이 낫다고 봅니다.

count를 줄이는 두 가지 우회법

그래서 count 비용을 줄이는 방향을 두 가지로 나눠서 봤습니다.

첫째, total은 필요하지만 count 쿼리를 매번 자동으로 날리고 싶지 않을 때는 PageImpl로 count를 직접 통제했습니다.

OrderQueryService.java
public Page<OrderSummaryResponse> getOrders(OrderSearchCond cond, Pageable pageable) {
    List<OrderSummaryResponse> content = orderRepository.search(cond, pageable);
 
    // count 쿼리를 항상 실행하지 않고, 마지막 페이지가 아닐 때는 추정값을 쓴다
    JPAQuery<Long> countQuery = orderRepository.countQuery(cond);
 
    // content 크기가 size보다 작으면 굳이 count를 다시 세지 않는다
    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

PageableExecutionUtils.getPage(...)는 마지막 페이지처럼 굳이 count가 필요 없는 상황에서 count 쿼리 실행 자체를 건너뜁니다. 이 한 가지만으로도 count 호출 횟수가 줄었습니다.

둘째, total 자체가 필요 없는 화면은 Page를 버리고 Slice로 내려서 count 쿼리를 아예 없앴습니다.
Slice는 "다음이 있는지"만 알면 되므로 size + 1건을 조회해서 한 건이 더 있으면 다음 페이지가 있다고 판단합니다.

OrderRepository.java
// Slice는 count(*) 를 실행하지 않는다
Slice<OrderSummary> findByStatusOrderByCreatedAtDesc(OrderStatus status, Pageable pageable);
OrderQueryService.java
public SliceResponse<OrderSummaryResponse> getFeed(OrderStatus status, Pageable pageable) {
    Slice<OrderSummary> slice =
        orderRepository.findByStatusOrderByCreatedAtDesc(status, pageable);
 
    return new SliceResponse<>(
        slice.map(OrderSummaryResponse::from).getContent(),
        slice.getNumber(),
        slice.getSize(),
        slice.hasNext()
    );
}

그래서 지금은 화면 요구사항에 따라 Page보다 Slice를 먼저 검토하는 편입니다.

정렬 기준이 먼저 안정적이어야 했다

count를 정리하고 나니, 그다음으로 흔들린 건 정렬이었습니다.
createdAt desc만 두면 충분해 보였지만, 실무 데이터에서는 같은 시각에 여러 건이 생성되는 경우가 계속 생깁니다.

이럴 때 보조 정렬 기준(tie-breaker)이 없으면 같은 항목이 다음 페이지에 다시 보이거나, 반대로 빠지는 일이 생깁니다. created_at이 동률일 때 DB가 어떤 순서로 돌려줄지는 보장되지 않기 때문입니다.

그래서 정렬이 필요한 목록에서는 항상 유일성을 보장하는 키를 마지막에 붙였습니다.

  • 1차: created_at desc
  • 2차(tie-breaker): id desc

정렬이 안정적이지 않으면 페이징 자체를 신뢰하기 어려웠고, 특히 이 tie-breaker는 뒤에서 다룰 cursor 방식의 전제 조건이기도 했습니다.

offset에서 cursor(키셋)로 바꾼 이유

깊은 페이지의 OFFSET 비용과 페이지 사이 데이터 흔들림을 동시에 풀려면, 결국 "몇 번째부터"가 아니라 "어디 다음부터"를 묻는 방식이 필요했습니다. 그게 cursor(키셋) 페이징이었습니다.

핵심은 OFFSET을 정렬 키에 대한 조건절로 바꾸는 것입니다.

keyset-pagination.sql
-- before: offset 기반 (앞 행을 읽고 버린다)
SELECT *
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
 
-- after: cursor(키셋) 기반 (마지막으로 본 (created_at, id) 다음부터)
SELECT *
FROM orders
WHERE status = 'PAID'
  AND (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;

(created_at, id) < (:lastCreatedAt, :lastId) 같은 튜플 비교가 곧 tie-breaker를 포함한 cursor 조건입니다. (created_at, id) 복합 인덱스를 타면 깊은 페이지여도 읽어서 버리는 행 없이 바로 시작 지점으로 이동합니다.

QueryDSL로 옮기면 이렇게 됩니다.

OrderRepositoryImpl.java
public List<OrderSummary> findByCursor(OrderStatus status, OrderCursor cursor, int size) {
    return queryFactory
        .select(orderSummary(order))
        .from(order)
        .where(
            order.status.eq(status),
            cursorPredicate(cursor) // 첫 페이지면 null → 조건 미적용
        )
        .orderBy(order.createdAt.desc(), order.id.desc())
        .limit(size + 1) // 다음 페이지 존재 여부 판단용으로 한 건 더
        .fetch();
}
 
private BooleanExpression cursorPredicate(OrderCursor cursor) {
    if (cursor == null) {
        return null; // 첫 페이지
    }
    // (createdAt < c) OR (createdAt = c AND id < cId) — 튜플 비교를 풀어 쓴 형태
    return order.createdAt.lt(cursor.createdAt())
        .or(order.createdAt.eq(cursor.createdAt())
            .and(order.id.lt(cursor.id())));
}

JPQL로도 같은 조건을 표현할 수 있습니다.

cursor-query.jpql
SELECT o
FROM Order o
WHERE o.status = :status
  AND (o.createdAt < :lastCreatedAt
       OR (o.createdAt = :lastCreatedAt AND o.id < :lastId))
ORDER BY o.createdAt DESC, o.id DESC

cursor 전환 후의 요청·응답 설계

cursor로 바꾸면 클라이언트가 page 번호 대신 "마지막으로 본 위치"를 들고 다녀야 합니다.
그래서 응답에 다음 요청에 그대로 쓸 nextCursor를 같이 내려줬습니다.

CursorResponse.java
public record CursorResponse<T>(
    List<T> content,
    String nextCursor,  // base64로 인코딩한 (createdAt, id), 없으면 null
    boolean hasNext
) {
}
OrderQueryService.java
public CursorResponse<OrderSummaryResponse> getFeed(
        OrderStatus status, String cursorToken, int size) {
 
    OrderCursor cursor = OrderCursor.decode(cursorToken); // 첫 요청이면 null
    List<OrderSummary> rows = orderRepository.findByCursor(status, cursor, size);
 
    boolean hasNext = rows.size() > size;
    List<OrderSummary> page = hasNext ? rows.subList(0, size) : rows;
 
    // 마지막 항목의 정렬 키를 그대로 다음 cursor로 인코딩한다
    String nextCursor = hasNext
        ? OrderCursor.from(page.get(page.size() - 1)).encode()
        : null;
 
    return new CursorResponse<>(
        page.stream().map(OrderSummaryResponse::from).toList(),
        nextCursor,
        hasNext
    );
}

cursor 자체는 정렬 키 묶음이라, tie-breaker로 쓴 id를 반드시 같이 담아야 했습니다.
created_at 하나만 cursor로 쓰면 동일 시각 데이터에서 같은 문제가 다시 생기기 때문입니다.

OrderCursor.java
public record OrderCursor(Instant createdAt, Long id) {
 
    public static OrderCursor from(OrderSummary row) {
        return new OrderCursor(row.createdAt(), row.id());
    }
 
    public String encode() {
        String raw = createdAt.toEpochMilli() + ":" + id;
        return Base64.getUrlEncoder().withoutPadding()
            .encodeToString(raw.getBytes(StandardCharsets.UTF_8));
    }
 
    public static OrderCursor decode(String token) {
        if (token == null || token.isBlank()) {
            return null; // 첫 페이지
        }
        String raw = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8);
        String[] parts = raw.split(":");
        return new OrderCursor(Instant.ofEpochMilli(Long.parseLong(parts[0])), Long.parseLong(parts[1]));
    }
}

이렇게 응답을 따로 감싸는 방식은 Spring REST API의 Response 구조를 일관되게 설계한 방식과도 같은 맥락이었습니다. 프론트가 Spring 내부 PageImpl 구조에 직접 의존하지 않게 되는 것도 같은 이유에서 좋았습니다.

요청 파라미터도 제한이 있어야 했다

offset이든 cursor든, size를 아무 제한 없이 받으면 운영에서 바로 문제가 생겼습니다.
어떤 클라이언트가 실수로 size=1000을 보내거나, 테스트 도중 큰 값을 넣으면 목록 API 하나가 생각보다 쉽게 무거워졌습니다.

그래서 지금은 API 단에서 아예 제한을 둡니다.

OrderListRequest.java
public record OrderListRequest(
    @Min(0) int page,
    @Min(1) @Max(100) int size
) {
}

실무에서는 이런 제한이 "엄격한 검증"이라기보다, 운영 비용을 막는 장치에 가깝습니다.

offset과 cursor 비교

정리하면 두 방식은 우열이 아니라 화면 목적에 따라 갈렸습니다.

항목offsetcursor(키셋)
요청 파라미터page, sizecursor, size
깊은 페이지 비용OFFSET이 커질수록 느려짐정렬 키 인덱스로 일정하게 유지
total count가능(count(*) 필요)기본적으로 제공하지 않음
임의 페이지 점프가능(N페이지로 바로 이동)어려움(순차 탐색 위주)
데이터 추가 중 중복/누락페이지가 밀리며 발생하기 쉬움정렬 키 기준이라 안정적
tie-breaker 필요성권장필수(cursor에 포함)
적합한 화면백오피스 검색, 페이지 번호 UI무한 스크롤, 최근순 피드

그래서 지금은 모든 목록을 한쪽으로 통일하지 않습니다.

  • 백오피스 검색 결과: total과 페이지 점프가 필요해 offset이 편함
  • 최근순 피드 / 무한 스크롤: 데이터가 실시간으로 추가돼 cursor가 안정적임

응답 구조도 화면 방식에 따라 나눈다

페이징 방식이 갈리니 응답 구조도 자연스럽게 세 갈래가 됐습니다. 페이지 번호 기반 목록은 total을 포함한 형태로 감쌌습니다.

PageResponse.java
public record PageResponse<T>(
    List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean hasNext
) {
}

더보기 / 무한 스크롤은 앞서 본 SliceResponseCursorResponse를 썼습니다.

SliceResponse.java
public record SliceResponse<T>(
    List<T> content,
    int page,
    int size,
    boolean hasNextPage
) {
}

이렇게 나누면 프론트도 화면 목적에 맞는 데이터만 받게 됩니다.

  • 페이지 번호 목록: PageResponse (total 포함, offset)
  • 더보기 / 무한 스크롤: SliceResponse 또는 CursorResponse (total 없음)

실무에서는 하나의 PageResponse로 모든 목록을 밀어붙이기보다, 화면 탐색 방식에 맞춰 응답을 나누는 쪽이 협업에도 더 편했습니다.

마무리

페이징 API는 작은 기능처럼 보이지만, 화면 경험과 DB 비용과 정렬 안정성이 모두 걸려 있는 영역이었습니다.

실무에서는 page, size를 받는 것보다,
정렬 기준이 흔들리지 않는지, count query를 꼭 내려야 하는지, 깊은 페이지를 cursor로 바꿔야 하는지, 프론트가 실제로 어떤 탐색 경험을 원하는지를 먼저 보는 편이 훨씬 낫다고 느꼈습니다.