DEV Community

Cover image for 커서 기반 페이지네이션 vs 오프셋 페이지네이션: API는 무엇을 선택해야 할까?
Rihpig
Rihpig

Posted on Originally published at apidog.com

커서 기반 페이지네이션 vs 오프셋 페이지네이션: API는 무엇을 선택해야 할까?

오프셋 vs 커서 기반 페이지네이션: 대규모 API에서 올바른 선택하기

모든 목록 엔드포인트는 같은 질문에 부딪힙니다. 2백만 개의 주문을 클라이언트가 탐색할 수 있는 페이지로 어떻게 나눌까요? 오프셋 페이지네이션은 간단한 SQL과 이해하기 쉬운 페이지 번호를 제공하지만, 커서 기반 페이지네이션은 안정적인 결과와 일정한 지연 시간을 제공합니다. 대신 “47페이지로 이동”하는 기능은 포기해야 합니다.

지금 Apidog를 사용해 보세요

대부분의 팀은 튜토리얼에서 흔히 사용하는 오프셋 방식으로 시작합니다. 그러나 주문 테이블이 수백만 행에 도달하면 깊은 페이지에서 시간 초과가 발생하고, 사용자는 스크롤 중 같은 레코드를 두 번 보거나 일부 레코드가 사라졌다고 보고합니다.

이 글에서는 두 방식의 동작 원리, 오프셋의 한계, Stripe와 Slack이 커서를 사용하는 이유, 그리고 Apidog에서 두 방식을 테스트하는 방법을 살펴봅니다.

전체 전략을 먼저 비교하려면 API 페이지네이션 가이드를 참고하세요.

오프셋 페이지네이션 작동 방식

오프셋 페이지네이션은 SQL의 LIMITOFFSET에 직접 매핑됩니다.

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Enter fullscreen mode Exit fullscreen mode

이 쿼리는 페이지당 25개 행으로 3페이지를 반환합니다.

GET /v1/orders?page=3&per_page=25
Enter fullscreen mode Exit fullscreen mode

일반적인 응답은 다음과 같습니다.

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}
Enter fullscreen mode Exit fullscreen mode

장점은 명확합니다.

  • 임의의 페이지로 이동할 수 있습니다.
  • 총 개수와 총 페이지 수를 쉽게 반환할 수 있습니다.
  • 구현 비용이 낮습니다.

작은 관리용 테이블이라면 오프셋이 적절한 선택입니다. REST API의 페이지네이션 가이드에서도 전체 구축 과정을 확인할 수 있습니다.

하지만 프로덕션에서는 다음 두 가지 문제가 발생합니다.

문제 1: 페이지 드리프트

오프셋은 정렬된 결과의 시작점에서 행을 세지만, 클라이언트가 어떤 행을 이미 읽었는지는 기억하지 않습니다.

최신순 주문 목록에서 사용자가 1페이지를 읽는 동안 새 주문 3개가 추가되었다고 가정해 보겠습니다. 이후 클라이언트가 OFFSET 25로 2페이지를 요청하면, 첫 응답의 23~25번째 행이 뒤로 밀립니다. 결과적으로 일부 주문이 다시 나타납니다.

반대로 1페이지를 읽는 동안 3개 행이 삭제되면, OFFSET 25는 아직 보지 못한 행 3개를 건너뛸 수 있습니다. 오류 없이 데이터가 누락되는 셈입니다.

실시간 활동 피드, 동기화 엔드포인트, 지속적으로 쓰기가 발생하는 데이터에서는 페이지 드리프트가 곧 중복과 누락으로 이어집니다.

문제 2: 깊은 오프셋은 모든 행을 스캔합니다

OFFSET 500000은 500,001번째 행으로 바로 이동하지 않습니다. 데이터베이스는 인덱스에서 50만 개 항목을 탐색하고 버린 뒤, 다음 25개 행을 반환합니다.

비용은 오프셋에 비례해 증가하므로 복잡도는 O(n)입니다.

2백만 행이 있고 created_at에 인덱스가 있는 Postgres 주문 테이블을 예로 들면 다음과 같습니다.

  • LIMIT 25 OFFSET 0: 인덱스 항목 25개를 읽습니다. 수 밀리초가 걸립니다.
  • LIMIT 25 OFFSET 100000: 100,025개를 읽고 100,000개를 버립니다. 수십 밀리초가 걸립니다.
  • LIMIT 25 OFFSET 1500000: 150만 개 항목을 읽습니다. 수백 밀리초가 걸리고 CPU와 버퍼를 소모합니다.

Markus Winand의 no-offset writeup은 쿼리 계획을 통해 이 비용을 자세히 보여줍니다. 실제 운영 환경에서는 모든 페이지를 순회하는 크롤러 하나만으로도 p99 지연 시간이 크게 증가할 수 있습니다.

커서 기반 페이지네이션 작동 방식

커서 기반 페이지네이션은 키셋 페이지네이션이라고도 합니다. 클라이언트는 “50개를 건너뛰라”고 요청하는 대신, “특정 레코드 다음의 행을 달라”고 요청합니다.

SQL에서는 OFFSET 대신 정렬 키를 비교합니다.

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Enter fullscreen mode Exit fullscreen mode

created_at만 사용하지 않고 id를 함께 비교하는 점이 중요합니다. 여러 주문이 같은 밀리초에 생성될 수 있기 때문입니다. 고유하지 않은 정렬 키만 사용하면 페이지 경계에서 행이 누락되거나 반복될 수 있습니다.

(created_at, id)에 복합 인덱스를 추가하면 데이터베이스는 경계 위치로 바로 이동해 25개 행을 읽습니다. 따라서 1페이지와 60,000페이지의 페이지 조회 비용이 거의 같습니다.

불투명한 커서 토큰 사용하기

정렬 키를 API에 그대로 노출하지 말고, base64 등으로 인코딩한 불투명 토큰을 사용하세요.

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Enter fullscreen mode Exit fullscreen mode

불투명한 커서는 단순한 난독화가 아닙니다. 클라이언트가 커서를 파싱할 수 없으면 다음과 같은 내부 변경을 자유롭게 적용할 수 있습니다.

  • 정렬 키 변경
  • 샤드 힌트 추가
  • 저장 엔진 교체
  • 커서 페이로드 버전 변경

클라이언트가 지켜야 할 계약은 “서버가 준 값을 다음 요청에 그대로 돌려준다”는 것뿐입니다.

커서 페이지네이션의 절충점

커서에는 임의 페이지 이동 기능이 없습니다. 클라이언트는 한 번에 한 페이지씩 앞으로 이동하며, 이전 커서를 발행하면 뒤로 이동할 수 있습니다.

총 개수도 무료로 얻을 수 없습니다. 필요한 경우 별도의 COUNT(*) 쿼리를 실행해야 합니다. 대규모 데이터셋의 설계는 수백만 개의 레코드를 위한 API 페이지네이션 설계에서 더 자세히 다룹니다.

오프셋과 커서 비교

기준 오프셋 페이지네이션 커서 기반 페이지네이션
임의 페이지 이동 가능 불가능, 순차 탐색
총 개수 / 총 페이지 수 쉽게 포함 가능 별도 카운트 쿼리 필요
깊은 페이지 성능 O(n), 깊어질수록 저하 페이지당 O(1)에 가까움
쓰기 작업 중 안정성 드리프트로 중복 및 누락 발생 행 경계에 고정되어 안정적
구축 비용 낮음 중간: 인코딩, 동점 처리, 인덱스 설계 필요
정렬 조건 대부분의 ORDER BY 지원 고유하고 인덱싱된 정렬 키 필요
페이지 URL 캐싱 쉬움 커서가 요청마다 달라 어려움
클라이언트 복잡성 낮음 응답 형식이 일관되면 낮음

커서 페이지네이션은 확정적인 정렬을 요구합니다. status처럼 변경 가능하고 고유하지 않은 열로 정렬하도록 허용하면 키셋 로직이 복잡해집니다. 오프셋은 느슨한 정렬을 허용하지만 커서는 그렇지 않습니다.

어떤 방식을 선택해야 할까요?

데이터를 소비하는 방식에 맞춰 선택하세요.

관리 테이블과 대시보드: 오프셋

다음 조건이라면 오프셋이 적합합니다.

  • 수천 개 수준의 행
  • 사용자가 페이지 번호를 직접 클릭함
  • “1,848개 결과”처럼 총 개수를 표시해야 함
  • 드리프트가 크게 중요하지 않음
  • 깊은 페이지로 이동하지 않음

무한 스크롤 피드: 커서

무한 스크롤에서는 사용자가 특정 페이지 번호로 이동하지 않습니다. 쓰기가 계속 발생하는 동안 중복 레코드가 표시되면 바로 눈에 띄므로 커서가 적합합니다.

퍼블릭 API: 커서

퍼블릭 API에서는 소비자가 어떻게 데이터를 순회할지 통제할 수 없습니다. 누군가는 모든 페이지를 순회하는 스크립트를 작성할 수 있습니다.

커서는 깊은 페이지에서도 비용을 일정하게 유지하고, 불투명한 토큰 뒤에서 내부 구현을 변경할 수 있게 해 줍니다. URL과 헤더 규칙은 REST API 페이지네이션 가이드를 참고하세요.

내보내기와 동기화 작업: 커서

2백만 개의 주문을 모두 가져오는 배치 작업에는 두 가지 보장이 필요합니다.

  1. 동시 쓰기 중에도 행을 누락하지 않아야 합니다.
  2. 페이지마다 비용이 일정해야 합니다.

오프셋은 이 두 가지를 보장하지 않습니다. 반면 커서는 140만 번째 행에서 작업이 중단되어도 해당 커서부터 재개할 수 있습니다.

간단한 경험 법칙은 다음과 같습니다.

작고 사람이 탐색하며 총 개수가 중요한 인터페이스에는 오프셋을 사용하고, 크거나 실시간이거나 공개된 엔드포인트에는 커서를 사용하세요.

실제 API는 어떻게 처리할까요?

Stripe

Stripe는 완전히 커서 기반입니다. 모든 목록 엔드포인트에서 starting_afterlimit를 사용할 수 있으며, 응답에는 has_more가 포함됩니다.

다음 결제 페이지를 가져오려면 이전 응답의 마지막 결제 ID를 starting_after로 전달합니다. 대규모 쓰기 트래픽을 고려하면 총 개수를 제공하지 않는 것은 의도적인 설계입니다. 자세한 내용은 Stripe 페이지네이션 문서를 참고하세요.

GitHub

GitHub REST API는 대부분의 엔드포인트에서 여전히 pageper_page를 제공하며, 다음 페이지와 마지막 페이지 URL을 Link 헤더로 반환합니다.

다만 GitHub 페이지네이션 문서는 클라이언트가 URL을 직접 구성하지 말고 Link 헤더를 그대로 따르도록 안내합니다. 최근 엔드포인트는 커서 방식으로 전환되고 있습니다. 대규모 리포지토리에서 깊은 오프셋 조회가 성능에 해롭기 때문입니다.

Slack

Slack 웹 API는 커서 페이지네이션으로 마이그레이션했으며, 새로운 메서드에는 이 방식이 기본으로 사용됩니다.

conversations.history 같은 메서드는 response_metadata.next_cursor를 반환합니다. 빈 커서 문자열은 마지막 페이지에 도달했음을 의미합니다. 자세한 내용은 Slack 페이지네이션 문서를 확인하세요.

세 가지 트래픽이 많은 API가 공통적으로 향하는 방향은 커서 방식입니다.

응답 엔벨로프 설계

커서 API의 응답은 단순하고 예측 가능하게 설계하세요.

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Enter fullscreen mode Exit fullscreen mode

다음 네 가지 규칙을 지키면 구현이 견고해집니다.

  • 항상 has_more를 반환합니다. 데이터 조회 후 필터링이 적용되면 페이지가 짧아질 수 있으므로, 짧은 페이지를 마지막 페이지로 추론하게 하면 안 됩니다.
  • 마지막 페이지에서는 next_cursor: null을 반환하고 이를 문서화합니다. Slack처럼 빈 문자열을 사용할 수도 있지만 두 규칙을 섞어 사용하지 마세요.
  • 잘못된 커서는 빈 200 응답이 아니라 400으로 거부합니다. 기계가 읽을 수 있는 오류 코드도 함께 반환하세요.
  • 정렬 키 외의 값을 커서에 넣는다면 페이로드에 서명하거나 버전을 추가하세요. 이후 스키마를 변경할 때 호환성을 유지할 수 있습니다.

Apidog에서 두 방식 테스트하기

페이지네이션 버그는 다음 경계 조건에서 발생합니다.

  • 마지막 페이지
  • 빈 페이지
  • 삭제된 커서 앵커 행
  • 잘못된 커서
  • 데이터가 계속 추가되는 동안의 중복 및 누락

수동으로 페이지를 클릭해서는 이런 문제를 안정적으로 재현하기 어렵습니다. 연결된 요청과 어설션을 지원하는 Apidog 시나리오를 사용하면 자동화할 수 있습니다.

커서 엔드포인트 테스트

  1. 첫 요청을 실행하고 $.next_cursor를 JSONPath로 추출합니다.
  2. 추출한 값을 nextCursor 같은 변수에 저장합니다.
  3. 두 번째 요청부터 {{nextCursor}}를 커서 매개변수로 전달합니다.
  4. 각 반복마다 $.next_cursor를 다시 추출합니다.
  5. has_morefalse가 되면 루프를 종료합니다.
  6. 이전 페이지의 id가 반복되지 않고, 각 페이지가 limit를 초과하지 않는지 어설션합니다.

Apidog에서는 응답 패널에서 JSONPath를 직접 복사할 수 있습니다. 자세한 절차는 JSONPath로 어설션 설정 및 변수 추출하기를 참고하세요.

오프셋 엔드포인트 테스트

오프셋 방식은 카운터 변수를 사용해 다음과 같이 테스트할 수 있습니다.

  • page를 반복마다 증가시킵니다.
  • 마지막 페이지까지 data 길이가 per_page와 일치하는지 확인합니다.
  • 전체 순회 동안 total 값이 일관적인지 확인합니다.

예외 상황 추가하기

각 시나리오에 명시적인 상태 코드와 응답 어설션을 추가하세요.

  • 빈 페이지: 결과가 0개인 필터를 요청하고 data: [], has_more: false, 상태 코드 200을 검증합니다.
  • 잘못된 커서: cursor=not-a-real-cursor를 보내고 상태 코드 400과 기계 판독 가능한 오류 코드를 검증합니다.
  • 삭제된 앵커 행: 주문을 생성하고 해당 주문을 기준으로 커서를 발급한 뒤 주문을 삭제합니다. 이후 같은 커서로 요청했을 때 오류가 발생하지 않고 올바른 위치부터 탐색이 계속되는지 확인합니다.

키셋 비교는 앵커 행 자체가 존재하지 않아도 동작하므로, 마지막 시나리오는 커서 구현의 중요한 보장입니다.

로컬에서 시나리오가 통과하면 모든 병합 시 CI에서 실행하세요. Apidog를 무료로 다운로드하면 루프와 어설션을 포함한 전체 커서 탐색 시나리오를 30분 이내에 실행할 수 있습니다.

자주 묻는 질문

커서 페이지네이션이 항상 더 좋은가요?

아닙니다. 사용자가 페이지 번호, 총계, 임의 접근을 필요로 하는 중간 규모의 내부 관리 도구에는 오프셋이 더 적합합니다.

데이터셋이 크거나 쓰기가 빈번하거나 API가 공개되어 있다면 커서가 유리합니다. 공용 목록 엔드포인트에서 오프셋을 기본값으로 사용한 뒤, 출시 후 O(n) 비용을 발견하는 것이 흔한 실패 패턴입니다.

커서 페이지네이션에서 총 개수를 어떻게 얻나요?

동일한 필터로 별도의 SELECT COUNT(*)를 실행하세요. 별도 엔드포인트로 제공하거나 include_count=true 같은 선택적 쿼리 매개변수로 제공할 수 있습니다.

카운트 결과는 적극적으로 캐싱하는 것이 좋습니다. 1분마다 갱신되는 대략적인 값만으로도 대부분의 UI 요구를 충족할 수 있습니다. Stripe가 총계를 아예 제공하지 않는 것도 클라이언트의 실제 요구를 반영한 설계입니다.

하나의 엔드포인트에서 두 방식을 모두 제공할 수 있나요?

가능하지만 새로운 API에서는 피하는 편이 좋습니다. 두 방식은 서로 다른 예외 상황, 테스트 매트릭스, 클라이언트 사용법을 요구합니다.

가능하면 엔드포인트마다 하나의 방식을 선택하고, 처음부터 매개변수 명칭과 응답 형식을 일관되게 설계하세요. 관련 패턴은 REST API 페이지네이션 가이드에서 확인할 수 있습니다.

커서의 앵커 행이 삭제되면 어떻게 되나요?

키셋 페이지네이션에서는 문제가 발생하지 않습니다.

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

이 비교는 앵커 행이 실제로 존재할 것을 요구하지 않습니다. 데이터베이스는 해당 정렬 경계부터 탐색을 계속합니다.

이는 커서를 “행 조회”로 구현하는 방식보다 실용적인 장점입니다. Apidog 시나리오에서 앵커 삭제 후에도 탐색이 올바르게 이어지는지 반드시 검증하세요.

Top comments (0)