Zettelkasten

커서 페이지네이션은 offset의 중복·누락과 deep skip 비용을 정렬 키 seek로 해결한다

·수정 1

요약

  • 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 문자열로 내보낸다.

  1. URL 안전 — JSON의 {, ", :를 이스케이프 없이 단일 토큰으로 운반
  2. opaque 계약 — 내부 구조가 노출되면 클라이언트가 커서를 직접 조립/파싱하기 시작하고, 그 순간 정렬 키 구성이 API 계약이 돼 변경 불가가 된다. opaque면 서버가 커서 내부를 자유롭게 바꿀 수 있음
  3. 복합 키를 토큰 하나로 — 파라미터 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 <, "다음 페이지 첫 항목" 기준이면 <=. 둘을 섞으면 경계 항목이 중복되거나 빠진다.

관련 노트

참고