본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Backend

API 페이지네이션

작성 2026. 10. 21.추가
목차7개 항목
출처와 참고

ByteByteGo의 System Design 101 가이드 「How do we Perform Pagination in API Design?」을 바탕으로 재구성한 노트다.

페이지네이션이란

페이지네이션(pagination)은 큰 목록을 한 번에 다 주지 않고 조각으로 나눠 주는 API 설계다. 주문 100만 건을 한 응답에 담으면 DB, 네트워크, 클라이언트 메모리가 모두 버티지 못한다. 그래서 목록 API는 처음부터 "어디서부터, 몇 개"를 받는 형태로 만든다.

방식은 "다음 조각의 시작점을 무엇으로 표현하나" 로 갈린다. 몇 개를 건너뛸지(숫자)로 표현하면 오프셋 계열, 마지막으로 본 항목(값)으로 표현하면 커서·키셋 계열이다.

여섯 가지 방식

방식요청 예시작점장점단점
오프셋(offset)GET /orders?offset=40&limit=20건너뛸 개수가장 단순하다. 임의 위치로 바로 간다깊은 페이지일수록 느리다. 데이터가 바뀌면 중복·누락이 생긴다
페이지 번호(page)GET /items?page=3&size=20페이지 번호화면의 페이지 번호와 그대로 맞는다내부적으로 오프셋이라 같은 문제를 가진다
커서(cursor)GET /orders?cursor=eyJpZCI6MTAyfQ서버가 준 불투명한 토큰깊이와 상관없이 빠르다. 내부 구현을 숨기고 바꿀 수 있다특정 페이지로 바로 못 간다. 구현이 조금 복잡하다
키셋(keyset)GET /items?after_id=102&limit=20마지막 항목의 정렬 키 값인덱스를 타서 빠르다. 중간 삽입에도 안정적이다고유하고 인덱스가 있는 정렬 키가 필요하다
시간 기반GET /events?start_time=…&end_time=…시각 범위로그·피드처럼 시간순 데이터에 자연스럽다타임스탬프가 정확하고 일관돼야 한다. 같은 시각의 항목을 따로 다뤄야 한다
혼합(hybrid)GET /events?cursor=abc&start_time=…둘 이상 조합시간 범위 안에서 커서로 넘기는 식으로 장점을 섞는다설계와 테스트가 복잡해진다

커서와 키셋은 같은 아이디어의 두 얼굴이다. 키셋은 정렬 키 값을 그대로 노출하고, 커서는 그 값을 인코딩한 불투명 토큰으로 감싼다. 대부분의 커서 구현은 내부적으로 키셋 쿼리를 쓴다.

오프셋이 느려지는 이유

-- 오프셋: 앞의 100000행을 읽고 버린 뒤 20행을 준다
SELECT * FROM orders ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
 
-- 키셋: 인덱스에서 마지막으로 본 위치를 바로 찾아 20행만 읽는다
SELECT * FROM orders
WHERE (created_at, id) < ('2026-10-20 12:00:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 20;

오프셋은 DB가 건너뛸 행을 실제로 읽어야 하므로 페이지가 깊어질수록 비용이 선형으로 늘어난다. 키셋은 인덱스에서 시작 위치를 찾아 필요한 만큼만 읽는다.

데이터가 바뀔 때

목록을 넘기는 사이에 새 항목이 들어오거나 지워지면 오프셋 계열은 어긋난다.

flowchart TB
  n1["1페이지 요청: [A B C D E]<br/>(offset=0, limit=5)"] --> n2["그 사이 맨 앞에 새 항목 Z 추가"] --> n3["2페이지 요청: [E F G H I]<br/>(offset=5)"] --> n4["E가 두 번 보인다"]

삭제가 일어나면 반대로 한 항목을 건너뛴다. 키셋·커서는 "E 다음부터"를 값으로 기억하므로 이런 중복·누락이 없다. 무한 스크롤 피드처럼 계속 바뀌는 목록에는 커서 계열이 맞다.

설계 규칙

  • 정렬은 결정적이어야 한다. created_at만으로 정렬하면 같은 시각의 항목 순서가 요청마다 바뀔 수 있다. 고유한 키(id)를 마지막 정렬 기준으로 덧붙여 동점을 깬다.
  • 정렬 키에 맞는 복합 인덱스를 둔다. 키셋의 속도는 인덱스에서 나온다.
  • limit에 상한을 둔다. 클라이언트가 limit=100000을 보내면 페이지네이션이 무력해진다.
  • 전체 개수는 선택 사항으로. COUNT(*)는 큰 테이블에서 비싸다. 꼭 필요한 화면에서만 따로 요청하게 하거나 근사치를 쓴다.
  • 응답에 다음 위치를 실어 준다. 클라이언트가 커서를 직접 계산하게 하지 않는다.
{
  "data": [ ... ],
  "page_info": { "next_cursor": "eyJpZCI6OTg3NjV9", "has_next": true }
}

HTTP 헤더로 알리려면 Link: <https://api.example.com/orders?cursor=…>; rel="next"를 쓴다.

고르는 기준

상황방식
관리자 화면처럼 데이터가 작고 페이지 번호로 이동해야 한다페이지 번호·오프셋
무한 스크롤, 피드, 큰 테이블커서(내부는 키셋)
로그·이벤트를 기간으로 조회시간 기반, 기간 안에서는 커서
외부 공개 API커서. 내부 정렬 키가 바뀌어도 토큰 모양은 유지된다

정리

  • 페이지네이션은 시작점을 개수로 표현하느냐(오프셋·페이지), 값으로 표현하느냐(키셋·커서)로 나뉜다.
  • 오프셋은 단순하지만 깊은 페이지에서 느리고, 변경 중에 중복·누락이 생긴다.
  • 키셋·커서는 인덱스를 타서 빠르고 안정적이지만 임의 페이지 이동이 어렵다.
  • 정렬에 고유 키를 덧붙이고, limit 상한을 두고, 다음 커서를 응답에 실어 준다.

관련 노트: REST API 설계, Prisma, SQL 기초, 데이터베이스 샤딩, API 아키텍처 스타일

원본 기록1

댓글

GitHub 계정으로 댓글을 남길 수 있어요.