페이지네이션은 큰 컬렉션을 한 번에 다 내려보내지 않고 잘라서 제공하는 방식입니다. 수백만 건을 한 응답에 담으면 서버 메모리·대역폭·클라이언트 처리량이 모두 감당하기 어렵기 때문에, 목록 API는 거의 항상 페이지 단위로 나눠 응답합니다. 어떤 방식을 고르느냐가 성능·정확성·사용성을 크게 좌우합니다.
offset/limit 방식
가장 직관적인 방식은 몇 개를 건너뛰고(offset) 몇 개를 가져올지(limit) 지정하는 것입니다. GET /items?offset=40&limit=20은 41번째부터 20개를 달라는 뜻입니다. 페이지 번호로 바로 점프할 수 있고 구현이 쉬워서 초기 개발에 널리 쓰입니다.
GET /items?offset=40&limit=20 HTTP/1.1
Host: api.example.com
하지만 두 가지 큰 약점이 있습니다. 첫째, 깊은 offset의 성능입니다. 데이터베이스는 OFFSET 100000을 만나면 앞의 10만 건을 실제로 훑어 세고 버려야 하므로, 뒤쪽 페이지로 갈수록 급격히 느려집니다. 둘째, 불안정성입니다. 사용자가 페이지를 넘기는 사이에 앞쪽에 새 항목이 삽입되면 offset이 밀려 같은 항목을 두 번 보거나 건너뛰게 됩니다.
cursor/keyset 방식
이 문제를 해결하는 것이 커서(cursor) 또는 keyset 페이지네이션입니다. 몇 개를 건너뛸지가 아니라 '마지막으로 본 지점 다음부터' 를 기준으로 삼습니다. 서버는 각 응답에 다음 위치를 가리키는 불투명한(opaque) 커서를 실어 주고, 클라이언트는 그걸 그대로 되돌려 보냅니다.
GET /items?limit=20&after=eyJpZCI6MTA0Mn0 HTTP/1.1
Host: api.example.com
- 안정적 정렬: 정렬 키는 유일하고 불변이어야 합니다.
created_at처럼 중복될 수 있는 값만 쓰면 경계에서 항목이 누락·중복되므로, 보통(created_at, id)처럼 동점 해소용 유일 키(tie-breaker) 를 덧붙입니다. - 성능:
WHERE (created_at, id) > (?, ?)조건은 인덱스를 타고 곧바로 시작점으로 점프하므로, 페이지가 깊어져도 속도가 일정합니다. - 트레이드오프: '5페이지로 바로 점프' 같은 임의 접근이 어렵고, '이전 페이지'를 지원하려면 양방향 커서가 필요합니다.
- 커서의 성격: 커서는 내부 상태를 노출하지 말고 불투명 토큰(예: base64로 인코딩)으로 다루세요.
Link 헤더로 페이지 관계 알리기 (RFC 8288)
페이지 간 이동 링크를 응답 본문 대신 Link 헤더에 담는 것이 웹 표준(RFC 8288, Web Linking) 방식입니다. rel 값으로 각 링크의 관계를 나타냅니다.
HTTP/1.1 200 OK
Link: <https://api.example.com/items?after=eyJpZCI6MTA2Mn0&limit=20>; rel="next",
<https://api.example.com/items?before=eyJpZCI6MTA0Mn0&limit=20>; rel="prev",
<https://api.example.com/items?limit=20>; rel="first"
Content-Type: application/json
rel="next"·rel="prev"·rel="first"·rel="last"가 각각 다음·이전·처음·마지막 페이지를 가리킵니다. 클라이언트는 URL을 스스로 조립할 필요 없이 서버가 준 링크를 따라가기만 하면 됩니다. 커서 방식에서는 데이터가 계속 추가되어 마지막 페이지가 고정되지 않으므로 rel="last"를 생략하기도 합니다.
total count와 메타데이터 — '총 몇 건'을 알려주는 total count는 UI에 유용하지만 공짜가 아닙니다. 큰 테이블에서 정확한 전체 개수를 세려면 전량 스캔에 가까운 비용이 들어, 커서 페이지네이션의 성능 이점을 상쇄해 버립니다. 그래서 많은 대형 API는 정확한 총계를 아예 제공하지 않거나, 근사치(estimated count)를 주거나, 별도 요청으로만 제공합니다. 페이지 메타데이터(limit, 다음 커서, 데이터가 더 있는지 여부 has_more)는 본문의 별도 객체나 Link 헤더로 일관되게 전달하고, 팀 규칙을 정해 모든 목록 엔드포인트에서 동일한 형태를 유지하세요.