정의
리스트를 "몇 페이지"가 아니라 "마지막으로 본 항목 다음부터" 가져오고 싶을 때 쓰는 페이지네이션 방식이다. 항목마다 붙은 정렬 기준값(정렬키)을 좌표로 삼아, 그 값 이후(또는 이전) 행만 요청한다.
정확히는, Keyset pagination(다른 이름으로 cursor pagination, seek method)은 WHERE (sort_key, id) < :cursor ORDER BY sort_key, id LIMIT n 형태로, "n번째부터 세어라"(offset) 대신 커서(cursor — 마지막으로 본 행의 정렬키를 담은 불투명 토큰) 가 가리키는 지점을 인덱스로 바로 찾아(seek) 그 다음 묶음만 읽는다.
왜 필요한가
정수 offset으로 페이지를 넘기면 세 가지 문제가 생긴다.
- 뒤로 갈수록 느려진다.
LIMIT 10 OFFSET 10000은 DB가 앞 1만 행을 실제로 세어 버린 뒤 10개를 준다 — O(offset). 페이지가 깊어질수록 비용이 선형으로 증가한다. - 삽입/삭제로 항목이 밀린다. 1페이지를 본 사이에 맨 앞에 새 글 1개가 추가되면, 2페이지 요청 시 방금 본 마지막 글이 다시 나온다(중복). 삭제되면 반대로 건너뛴다(누락). 무한 스크롤처럼 목록이 실시간으로 자라는 화면에서 특히 자주 깨진다.
- "3페이지"라는 좌표 자체가 불안정하다. 순서가 바뀌면 같은 페이지 번호가 다른 내용을 가리킨다.
keyset은 좌표를 페이지 번호가 아니라 값(마지막 본 항목의 정렬키)에 붙인다. 사이에 무엇이 삽입·삭제돼도 "이 값 다음"은 변하지 않으므로 중복·누락이 없고, 인덱스 seek이라 O(log n)으로 깊은 페이지도 일정하게 빠르다.
동작 원리
정렬키 k와 동점을 깨는 tiebreaker(보통 id)로 만든 복합 좌표 (k, id)를 커서로 쓴다.
- 커서 인코딩:
(created_at, id)를 base64 등 불투명 토큰으로 감싸 클라이언트에 준다. 클라이언트가 내부 구조에 의존하지 못하게 해, 나중에 정렬 방식이 바뀌어도 API를 깨지 않는다. - 양방향(before / after): 무한 리스트는 아래로도(더 오래된 것,
after) 위로도(더 최신 것,before) 늘어날 수 있다.before는 부등호와ORDER BY방향을 뒤집어 조회한 뒤, 결과 순서를 다시 뒤집어 클라이언트에 넘긴다(화면 표시 순서를 맞추기 위해). - 인덱스:
(sort_key, id)복합 인덱스가 있어야 seek이 O(log n)이다. 인덱스가 없으면 keyset도 풀스캔한다.
실무 적용
무한 스크롤 API의 전형적 계약:
- 첫 요청은 커서 없이 시작한다(
firstPageParam = null). 양방향 리스트는 앞(prevCursor)·뒤(nextCursor) 두 커서를 분리해서 관리한다. - 클라이언트가 위로 스크롤하면
dir=before&cursor=prevCursor, 아래로 스크롤하면dir=after&cursor=nextCursor. - 입력 검증은 경계에서: 파싱 불가능한 커서나 범위 밖
limit은 400으로 즉시 거절한다. 조용히 첫 페이지로 폴백하면 클라이언트가 위치를 잃은 걸 눈치채지 못한다.
React Query류의 무한 쿼리와 결합할 때는 getNextPageParam/getPreviousPageParam이 응답의 nextCursor/prevCursor를 그대로 돌려주게 하고, 유지 페이지 수 상한(maxPages) 을 둬서 오래된 페이지를 evict한다(메모리 bound).
트레이드오프
| 항목 | offset | keyset |
|---|---|---|
| 깊은 페이지 성능 | O(offset), 느려짐 | O(log n), 일정 |
| 삽입/삭제 안정성 | 중복·누락 발생 | 안정 |
| "N번째 페이지로 점프" | 쉬움 | 어려움(임의 페이지 직접 이동 불가) |
| 총 페이지 수 표시 | 가능 | 별도 count 쿼리 필요 |
| 구현 난이도 | 낮음 | 복합 정렬·커서 인코딩 필요 |
keyset의 가장 큰 대가는 임의 페이지 점프가 안 된다는 것이다. "5페이지로 바로 가기" 같은 UI가 필요하면 offset이나 하이브리드가 낫다.
사용하면 안 되는 경우
- 번호가 매겨진 페이지네이션 UI(1 2 3 … 10 점프)가 요구사항이면 keyset은 맞지 않는다.
- 정렬 기준이 매 요청 자유롭게 바뀌는 화면 — 커서는 특정 정렬
(k, id)에 묶이므로, 정렬을 바꾸면 커서를 버리고 처음부터 다시 시작해야 한다. - 데이터가 작고 거의 안 바뀌는 목록이면 offset의 단순함이 이득이다(YAGNI).
흔한 실수
- tiebreaker 누락: 정렬키에 동점이 있는데 커서에
id같은 유일값을 넣지 않으면, 경계에서 행이 누락되거나 중복된다. 항상(sort_key, unique_id)복합 좌표를 쓴다. - 복합 인덱스 없음:
(sort_key, id)인덱스가 없으면 seek이 풀스캔으로 떨어져 keyset의 성능 이점이 사라진다. 쿼리 플랜을 실제로 확인한다. - 양방향에서 순서 안 뒤집기:
before조회 결과를 reverse하지 않고 그대로 주면 화면에서 항목이 거꾸로 쌓인다. - 잘못된 커서를 조용히 폴백: 파싱 실패 시 첫 페이지를 돌려주면 사용자가 위치를 잃은 걸 모른다 — 400으로 명시적으로 실패한다.
- maxPages 상한 없음: append-only 무한 스크롤에서 페이지를 무한정 쌓으면 힙이 계속 자란다.
관련 개념
- fractional-index-ordering — 삽입 시 이웃을 바꾸지 않는 안정 정렬키를 만드는 다른 접근
- list-virtualization-windowing — keyset으로 받은 페이지를 화면에 보이는 만큼만 렌더
- virtual-list-scroll-restore-authority — 양방향 fetch 후 뒤로가기/새로고침 위치를 복원