페이지네이션 설계는 결국 무엇을 기준으로 갈리나?
결론부터 적으면, 페이지네이션 방식은 "얼마나 깊이 들어가나"와 "정렬이 얼마나 안정적인가"로 갈린다. 얕은 목록·페이지 점프·총건수 표시가 중요하면 offset이 여전히 편하고, 무한스크롤이나 대용량 목록에서는 cursor가 안전하다. API 스타일(REST/GraphQL/RPC) 자체는 이 계약을 "누가, 어떤 형태로" 노출하느냐의 문제로 봐야 한다.
핵심 요약
- REST는 offset이 구현은 쉽지만 깊은 페이지로 갈수록 스캔 비용이 커져 cursor가 대안으로 굳어졌다.Siemens, Zendesk
- GraphQL은 페이지네이션 자체보다 "클라이언트가 어떤 필드 조합을 요청하나"라는 오버페칭 통제 문제가 먼저 온다.
- RPC·브리지 계층은 클라이언트 계약보다 백엔드가 이미 정한 페이지 토큰(PAGE_NUMBER/OFFSET/NEXT_CURSOR)을 그대로 보존하는 쪽에 가깝다.
- cursor가 "항상 정답"은 아니다. 정렬 키가 unique하고 안정적이어야 중복·누락 없이 동작한다.
- 실무 판단은 "총건수·페이지 점프가 필요한가" vs "무한스크롤·대용량인가"로 먼저 나눠보는 게 빠르다.
목록 API를 처음 만들 때는 다들 ?page=2&size=20 정도로 시작한다. 관리 화면에서 100건, 1000건짜리 목록을 넘기는 동안은 별문제가 안 보인다. 문제는 레코드가 수십만 건으로 늘고, 정렬 조건에 걸리는 행이 계속 삽입·삭제되는 상황에서 터진다. 이때 "왜 이 방식을 골랐나"를 설명할 근거가 없으면, 장애가 나고 나서야 cursor로 바꾸는 작업을 하게 된다.
offset 기반 페이지네이션은 왜 깊은 페이지에서 느려지나?
offset은 "N번째부터 M개"를 요청하는 구조라, DB 입장에서는 앞의 N개 행을 읽고 버린 뒤에야 원하는 구간을 돌려준다. OFFSET 10000 LIMIT 20 같은 쿼리는 10000개를 스캔하고 버린 다음 20개만 반환하는 식이라, 페이지 번호가 커질수록 스캔량이 선형으로 늘어난다. Atatus는 이 방식이 앞부분 행을 스캔·폐기해야 해서 skip 값이 커질수록 비싸지고, cursor 기반은 인덱스를 활용해 페이지 깊이에 상대적으로 덜 민감하다고 설명한다.
Siemens의 API 가이드도 비슷한 입장이다. SQL 기반 서비스처럼 레코드 스킵과 총건수 계산이 자연스럽게 되는 경우에 한해서만 offset 기반을 허용하고, 그 외의 경우에는 cursor 기반을 권장한다. 즉 offset이 "나쁜 방식"이라기보다는, 그 방식이 잘 맞는 데이터 소스가 따로 있다는 뜻이다. 관리자 화면에서 페이지 번호를 눌러 이동하거나, "전체 몇 건 중 몇 페이지"를 보여줘야 하는 화면이라면 offset이 여전히 직관적이다. 반대로 목록이 실시간으로 삽입·삭제되는 피드라면, 사용자가 3페이지를 넘기는 사이 앞쪽에 새 글이 꽂히면서 같은 항목이 두 번 보이거나 한 항목이 아예 안 보이는 일이 생긴다.
cursor 기반은 실제로 어떻게 동작하나?
cursor 기반의 핵심은 "opaque cursor + limit" 패턴이다. Siemens는 cursor를 서버만 해석할 수 있는 불투명한 식별자로 정의하고, 항상 limit와 함께 쓰라고 안내한다. Attio와 Thought Industries의 API 문서도 같은 흐름을 설명한다 — 첫 요청에서는 cursor를 아예 생략하고, 응답에 담긴 next_cursor를 다음 요청에 그대로 넘기면 된다.
GET /items?limit=20
→ { "items": [...], "next_cursor": "abc123" }
GET /items?limit=20&cursor=abc123
→ { "items": [...], "next_cursor": "def456" }
여기서 중요한 건 "클라이언트가 cursor 내부를 해석하려 들면 안 된다"는 점이다. cursor는 base64 문자열일 수도, 암호화된 토큰일 수도 있는데, 서버 구현이 바뀌면 그 형식도 언제든 바뀔 수 있어야 한다. Attio는 모든 요청에서 limit와 필터 조건을 일관되게 유지해야 예상치 못한 결과를 피할 수 있다고 적는데, 이는 cursor뿐 아니라 offset 쪽에도 그대로 적용되는 조언이다.
GraphQL에서 페이지네이션이 REST와 다르게 다뤄지는 이유는?
GraphQL은 페이지네이션보다 "필드 단위 데이터 계약"이 먼저 오는 문제다. REST에서는 엔드포인트가 응답 형태를 고정하기 때문에 화면마다 필요 없는 필드까지 받아오는 오버페칭이 흔한데, Atatus는 GraphQL이 한 번의 요청으로 클라이언트가 지정한 필드만 받게 해 over-fetching과 under-fetching을 함께 줄인다고 설명한다. 문제는 이 유연성이 그대로 페이지네이션·캐시·N+1 관리 부담으로 넘어온다는 점이다.
GraphQL.org는 과도한 operation load를 막기 위해 필드 단위 페이지네이션과 함께 operation depth·breadth 제한, rate limiting을 같이 보라고 권고한다. 클라이언트가 중첩 필드를 원하는 만큼 확장할 수 있는 구조이다 보니, 페이지네이션 설계 하나만으로는 부하를 통제할 수 없다는 뜻이다. Apollo Client 문서는 다음 페이지를 가져올 때 fetchMore를 호출하고, 각 페이지 결과를 클라이언트 캐시에 병합해야 한다고 안내하는데, 실제로 이 병합 로직을 잘못 짜면 같은 항목이 캐시에서 중복되거나, 스크롤을 되돌렸을 때 순서가 어긋나는 버그로 이어진다.
N+1 문제도 같은 맥락에서 나온다. Atatus는 "100개 아이템 리스트 요청"이 정상이라면 총 DB 쿼리 수가 대략 2~5개 수준이어야 하고, 101개나 201개 쿼리가 나가면 N+1 문제일 가능성이 높다고 적는다. 다만 이건 벤더성 가이드라인이고, 서비스마다 리졸버 구조가 다르니 이 숫자를 범용 기준으로 그대로 가져다 쓰긴 어렵다. 리스트 API에 리졸버를 붙일 때 DB 쿼리 수를 로그로 찍어보는 습관 정도로 참고할 만하다.
cursor 기반이면 무조건 안전하다는 건 오해다
cursor로 바꿨다고 정렬 문제가 저절로 사라지는 건 아니다. cursor의 안정성은 결국 "정렬 키가 unique하고 안정적인가"에 달려 있다. created_at만으로 정렬하면 같은 타임스탬프를 가진 레코드가 여러 개일 때 그중 일부가 중복되거나 누락될 수 있다. 실무에서는 created_at + id처럼 tie-breaker 컬럼을 추가해 정렬을 유일하게 만드는 방식을 많이 쓴다.
offset 쪽 오해도 있다. "offset은 그냥 페이지 번호 곱하기 개수니까 단순하다"고 생각하기 쉬운데, Attio는 모든 요청에서 limit와 필터 조건을 일관되게 유지해야 예상치 못한 결과를 피할 수 있다고 명시한다. 즉 페이지를 넘기는 중간에 필터나 정렬 파라미터가 바뀌면, offset 값이 가리키는 "N번째"의 의미 자체가 달라져 버린다.
RPC·브리지 계층에서는 또 다른 함정이 있다. IBM API Connect 문서에 따르면, GraphQL 서비스가 REST API를 데이터 소스로 삼을 때 PAGE_NUMBER, OFFSET, NEXT_CURSOR 유형을 지원하는데, 이 계층에서 토큰을 임의로 재가공하면 백엔드가 원래 보장하던 순서·중복 방지 로직이 깨질 수 있다. RPC 계층의 페이지네이션은 "클라이언트가 원하는 형태"보다 "백엔드가 이미 효율적으로 줄 수 있는 형태"를 그대로 통과시키는 쪽에 가깝다.
언제 offset을 쓰고 언제 cursor·필드 페이지네이션을 쓰나?
적합·부적합 조건으로 나눠보면 판단이 쉬워진다.
| 상황 | 적합한 방식 |
|---|---|
| 관리자 화면, 페이지 번호 점프 필요 | offset |
| 총건수·전체 페이지 수 표시 필요 | offset |
| 무한스크롤, 실시간 삽입 많은 피드 | cursor |
| 대용량 목록, 깊은 페이지 탐색 | cursor |
| 화면마다 필요한 필드가 다른 다중 클라이언트 | GraphQL 필드 페이지네이션 |
| 백엔드가 이미 페이지 토큰 체계를 정해둔 경우 | RPC/브리지에서 토큰 그대로 전달 |
Zendesk는 offset pagination이 모든 리소스에 지원되지만, cursor pagination이 매우 큰 레코드셋에서 성능이 크게 개선된다며 cursor를 우선 권장한다고 안내한다. 2026년 기준으로도 이 원칙 자체는 크게 달라지지 않았고, "둘 다 지원하되 대용량 목록에는 cursor를 기본값으로 안내"하는 구성이 여러 벤더 문서에서 반복된다. 반대로 소규모 관리 화면이나 배치 리포트처럼 총건수·페이지 점프가 핵심 요구사항인 곳에 cursor만 강제하면, "몇 페이지가 남았는지" 같은 단순한 요구를 구현하기 위해 오히려 코드가 복잡해지는 역효과가 난다.
정리하면 무엇을 점검해야 하나?
- 목록이 얼마나 깊이 스크롤/페이지되나 — 얕으면 offset, 깊으면 cursor 쪽 부담이 덜하다.
- 정렬 키가 unique하고 안정적인가 — 아니라면 tie-breaker 컬럼부터 추가한다.
- 화면마다 필요한 필드가 다른가 — 다르다면 GraphQL의 필드 계약이 오버페칭 문제를 줄여준다.
- GraphQL을 쓴다면 depth/breadth 제한과 N+1 쿼리 수를 별도로 감시해야 한다.
- RPC·브리지 계층이라면 백엔드 페이지 토큰을 그대로 보존해 전달하는지부터 확인한다.
핵심 정리
- offset은 구현이 쉽지만 깊은 페이지에서 스캔 비용이 커지고, cursor는 인덱스를 활용해 깊이에 덜 민감하다.
- cursor의 안정성은 unique하고 안정적인 정렬 키에 달려 있으며, tie-breaker 없이는 중복·누락이 생길 수 있다.
- GraphQL은 페이지네이션보다 필드 단위 오버페칭 통제, 캐시 병합, N+1 감시가 함께 오는 문제다.
- RPC/브리지 계층은 클라이언트 계약보다 백엔드 페이지 토큰을 그대로 보존하는 쪽이 우선이다.
- 총건수·페이지 점프가 필요하면 offset, 무한스크롤·대용량이면 cursor로 먼저 나눠 판단한다.