커서 페이지네이션은 offset의 중복·누락과 deep skip 비용을 정렬 키 seek로 해결한다
요약
- offset 페이지네이션은 "N개 건너뛰기"라서 페이지 사이에 데이터가 삽입/삭제되면 항목이 중복·누락되고, deep page 일수록 skip 분량을 실제로 읽고 버려 선형으로 느려진다.
- 커서는 페이지 번호가 아니라 마지막으로 본 항목의 정렬 키 값(
{createdAt, id})으로 위치를 가리킨다 — 위에 뭐가 쌓여도 기준이 안 변하고, 인덱스 seek 한 번이라 어느 페이지든 비용이 동일하다. - 커서는 DB에 저장되지 않는다. 응답 직전 문서 필드에서 즉석 생성 → 클라이언트가 보관 → 다음 요청에 그대로 반환하는 stateless 토큰이다.
본문
offset의 두 가지 문제
1. 데이터가 움직이면 페이지가 밀린다. 최신순 피드에서 1페이지(skip 0, limit 20)를 보는 동안 새 항목 3개가 쌓이면, 2페이지(skip 20)는 "새 항목 3개 + 기존 #1~#17"을 건너뛴다 → #18~#20을 두 번 보게 됨. 삭제가 일어나면 반대로 누락.
2. deep page는 선형으로 느려진다. MongoDB의 skip(N)은 인덱스가 있어도 N건을 실제로 읽고 버린다. skip 10000, limit 20이면 10,020건 읽고 10,000건 폐기.
커서의 동작
커서 = 마지막 반환 항목의 정렬 키 필드 묶음. 정렬 키가 중복 가능하면(createdAt이 같은 밀리초) 유니크 필드(_id)를 tie-breaker로 반드시 포함한다.
// 쿼리: "이 키 다음부터" — 두 단계 비교
query.$or = [
{ createdAt: { $lt: cursor.createdAt } },
{ createdAt: cursor.createdAt, _id: { $lt: cursor.id } }, // tie-break
];
// 정렬 축과 쿼리 조건이 동일해야 누락/중복 없음
sort: { createdAt: -1, _id: -1 }
- limit+1 fetch 트릭: limit보다 1개 더 가져와
length === limit + 1이면 다음 페이지 존재 → 마지막 항목으로 nextCursor 생성, 아니면null. - DB 측 요구사항은 인덱스뿐:
{createdAt: -1, _id: -1}복합 인덱스가 있어야 B-tree seek 한 번으로 위치를 찾는다. 커서용 컬렉션/세션 테이블은 불필요.
base64로 감싸는 이유 (암호화 아님)
{createdAt, id} JSON을 url-safe base64로 인코딩해 opaque 문자열로 내보낸다.
- URL 안전 — JSON의
{,",:를 이스케이프 없이 단일 토큰으로 운반 - opaque 계약 — 내부 구조가 노출되면 클라이언트가 커서를 직접 조립/파싱하기 시작하고, 그 순간 정렬 키 구성이 API 계약이 돼 변경 불가가 된다. opaque면 서버가 커서 내부를 자유롭게 바꿀 수 있음
- 복합 키를 토큰 하나로 — 파라미터 2개로 쪼개면 반쪽 커서 조합 오류 여지
디코딩은 누구나 가능하므로(atob 한 줄) 민감 정보는 넣으면 안 된다. 변조 방지가 필요하면 HMAC 서명을 붙인다.
멀티 소스 merge 확장
두 컬렉션(예: 개인 알림 + 공용 공지)을 단일 피드로 합칠 때도 같은 커서 하나로 동작한다: 두 소스의 시간 필드를 동일 축(sortedAt)으로 취급 → 양쪽에 같은 커서 조건으로 limit+1씩 fetch → in-memory merge-sort 후 상위 N건 + nextCursor. 커서가 "두 컬렉션 공통 시간축 위의 위치"를 가리키므로 merge 결과도 중복/누락 없이 이어진다. 한쪽 소스가 bounded(수십 건)면 매 페이지 양쪽 쿼리 비용은 무시 가능.
트레이드오프 — 선택 기준
커서가 포기하는 것: 임의 페이지 점프("7페이지로 바로"), 전체 카운트/페이지 수 표시.
| 상황 | 선택 |
|---|---|
| 항목이 계속 쌓이는 최신순 피드 + 무한 스크롤 (알림, 댓글, 히스토리) | 커서 |
| 정적 데이터 + 페이지 점프 UI (어드민 테이블 "총 1,234건 중 3/62") | offset |
구현 시 함정
tie-break 비교 연산자와 nextCursor 생성 기준이 짝이 맞아야 한다: nextCursor를 "마지막 반환 항목" 기준으로 만들면 strict <, "다음 페이지 첫 항목" 기준이면 <=. 둘을 섞으면 경계 항목이 중복되거나 빠진다.
관련 노트
- Redis KEYS와 SCAN은 둘 다 keyspace bucket을 순회하며 차이는 한 호흡에 보느냐 커서로 잘라서 보느냐다
- 24 데이터 베이스 인덱스
- B+Tree는 디스크 접근을 최소화하기 위해 설계된 균형 트리 자료구조다
참고
- https://www.mongodb.com/docs/manual/reference/method/cursor.skip/ (skip의 선형 비용)
- https://use-the-index-luke.com/no-offset (keyset pagination 일반론)
함께 읽기 좋은 글
- DB 커넥션은 풀 크기를 늘리기 전에 트랜잭션 점유시간을 줄여 회전율을 높인다
- MongoDB 드라이버의 SDAM 하트비트는 백그라운드에서 주기적으로 replica set 토폴로지를 추적한다
- 간헐 getaddrinfo ENOTFOUND는 DNS 부하가 아니라 libuv 스레드풀 starvation을 먼저 의심한다
- minimumIdle을 maximumPoolSize와 같게 둔 고정 풀은 버스트 때 커넥션 생성 지연을 없앤다
- IN 리스트가 eq_range_index_dive_limit를 넘으면 옵티마이저가 index dive를 포기해 잘못된 plan을 고른다