Spring BootPaginationAPI Design

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

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

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

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

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

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

처음에는 Page 그대로 내렸다

초기 구현은 대부분 비슷했습니다.

@GetMapping("/orders")
public ResponseEntity<Page<OrderSummaryResponse>> getOrders(Pageable pageable) {
    return ResponseEntity.ok(orderQueryService.getOrders(pageable));
}

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

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

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

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

페이징에서 제일 먼저 흔들린 건 정렬이었습니다.
createdAt desc만 두면 충분해 보였지만, 실무 데이터에서는 같은 시각에 여러 건이 생성되는 경우가 계속 생깁니다.

이럴 때 보조 정렬 기준이 없으면 같은 항목이 다음 페이지에 다시 보이거나, 반대로 빠지는 일이 생깁니다.

그래서 지금은 정렬이 필요한 목록에서는 아래처럼 기준을 더 명확히 둡니다.

  • createdAt desc
  • id desc

정렬이 안정적이지 않으면 페이징 자체를 신뢰하기 어려워졌습니다.

count query는 생각보다 비쌌다

백오피스 목록처럼 필터가 많고 조인이 많은 화면에서는 데이터 조회보다 count query가 더 느린 경우가 있었습니다.

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

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

이런 경우는 굳이 무거운 count query를 매번 같이 날리지 않는 편이 낫다고 봅니다.
그래서 지금은 화면 요구사항에 따라 Page보다 Slice를 먼저 검토하는 편입니다.

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

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

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

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

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

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

페이지 번호 기반 목록은 아래처럼 응답을 따로 감싸는 쪽을 더 선호합니다.

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

이 방식의 장점은 프론트가 필요한 값만 안정적으로 받는다는 점이었습니다.
Spring 내부 PageImpl 구조에 프론트가 직접 의존하지 않게 되는 것도 좋았습니다.

다만 더보기 기반 화면이라면 이 구조를 그대로 쓰는 게 꼭 좋은 건 아니었습니다.
실제로는 total count가 필요 없고, "다음 페이지가 있느냐"만 알면 되는 경우가 많았기 때문입니다.

그래서 무한 스크롤이나 더보기 버튼이 붙는 화면은 아래처럼 SliceResponse를 따로 두는 편이 더 자연스러웠습니다.

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

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

  • 페이지 번호 목록: PageResponse
  • 더보기 / 무한 스크롤: SliceResponse

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

오프셋과 커서는 화면 목적에 따라 나눈다

모든 목록을 page-number 기반으로 통일하려 했던 시기도 있었습니다.
그런데 사용자 행동 패턴이 다른 화면은 페이징 방식도 달라야 했습니다.

  • 백오피스 검색 결과: offset 기반이 편함
  • 최근순 피드 / 무한 스크롤: cursor 기반이 안정적임

특히 무한 스크롤에서는 페이지 번호보다 cursor가 더 자연스러웠습니다.
실시간으로 데이터가 추가되는 화면에서는 offset 기반이 쉽게 흔들렸기 때문입니다.

마무리

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

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