키셋 페이지네이션(keyset pagination)은 페이지 번호 대신 "마지막으로 본 행의 값"을 기준점으로 삼아 다음 페이지를 가져오는 방식이다. OFFSET처럼 앞의 행을 세어 건너뛰지 않고, WHERE 조건으로 커서 이후만 곧장 집어오기 때문에 뒤로 갈수록 느려지지 않는다. 커서 기반 페이지네이션이라고도 부른다.
왜 이게 필요한지 먼저 이야기하고 싶다. LIMIT ... OFFSET은 처음엔 멀쩡하다. 문제는 데이터가 쌓이고 사용자가 깊은 페이지로 갈 때 터진다.
OFFSET이 느려지는 진짜 이유
많은 사람이 OFFSET 100000이 100,000번째 행으로 "점프"한다고 오해한다. 아니다. 데이터베이스는 조건에 맞는 행을 처음부터 10만 개 읽어서 버리고, 그다음 20개만 돌려준다. 버리는 데 든 비용은 고스란히 응답 시간이 된다.
-- 페이지가 깊어질수록 스캔·폐기가 늘어난다
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000; -- 100,020행 읽고 100,000행 버림
초반 페이지에서는 티가 안 난다. 그래서 개발 단계에선 잘 넘어간다. 나도 예전에 게시판을 이렇게 만들었다가, 크롤러가 마지막 페이지들을 훑는 순간 DB CPU가 튀는 걸 보고서야 문제를 알았다. 사람은 깊은 페이지를 잘 안 가지만, 봇은 아주 성실하게 끝까지 간다.
키셋으로 바꾸기
핵심은 "몇 개를 건너뛸까"가 아니라 "어디서부터 이어볼까"로 질문을 바꾸는 것이다. 마지막으로 본 행의 정렬 키를 기억했다가, 그보다 작은(또는 큰) 것만 가져온다.
-- 첫 페이지
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 다음 페이지: 마지막 행의 (created_at, id)를 커서로 사용
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-08-01 10:00:00', 48213)
ORDER BY created_at DESC, id DESC
LIMIT 20;
여기서 (created_at, id) < (...)는 행 값 비교(row value comparison)다. 튜플을 통째로 사전식으로 비교해주기 때문에 정렬 순서가 그대로 유지된다. id를 함께 넣는 이유는 created_at이 같은 행들 사이에서 순서를 못 정해 누락·중복이 생기는 걸 막기 위해서다. 정렬 키만으로 유일성이 보장되지 않으면 반드시 기본키를 타이브레이커로 붙여야 한다.
OFFSET vs 키셋 한눈에
| 항목 | OFFSET | 키셋(커서) |
|---|---|---|
| 깊은 페이지 성능 | 점점 느려짐 | 일정하게 빠름 |
| 임의 페이지 점프 | 쉬움(N페이지로) | 어려움(앞뒤만) |
| 데이터 삽입 중 일관성 | 밀림·중복 발생 | 안정적 |
| 인덱스 요구 | 정렬 인덱스 | 정렬키 복합 인덱스 |
표에서 보이듯 만능은 아니다. "27페이지로 바로 가기" 같은 UI가 필수라면 키셋은 곤란하다. 반대로 무한 스크롤, 피드, 채팅 로그, API 페이징처럼 앞으로만 계속 넘기는 화면에는 키셋이 압도적으로 유리하다.
실전에서 챙길 것
- 정렬 키 순서와 완전히 같은 복합 인덱스가 있어야 한다. 위 예라면
(created_at DESC, id DESC). 없으면 결국 풀 정렬이 걸려 의미가 없다. - 커서 값은 클라이언트에 그대로 노출하기보다 base64로 인코딩해 넘기는 편이 깔끔하다. 내부 컬럼 구조가 URL에 새는 걸 막는다.
- 오름차순·내림차순을 섞으면 행 값 비교가 어긋난다. 이럴 땐 부등호를 직접 풀어서 조건을 쓰거나 정렬 방향을 통일하라.
개인적으로는 새 목록 API를 만들 때 기본을 키셋으로 두고, 정말 페이지 번호가 필요할 때만 OFFSET을 쓴다. 습관을 바꾸고 나서 "왜 마지막 페이지만 느리냐"는 문의가 사라졌다.
자주 묻는 질문
키셋 페이지네이션에서 전체 개수는 어떻게 보여주나요?
정확한 총 개수는 별도의 COUNT 쿼리로 구해야 하며, 이건 키셋이든 OFFSET이든 마찬가지로 무겁습니다. 대용량이라면 근사치(예: 통계 기반 추정)나 "1,000+"식 표기, 혹은 총 개수를 아예 보여주지 않는 무한 스크롤 UX를 권합니다.
이전 페이지로 돌아가려면요?
정렬 방향을 뒤집어 같은 방식으로 조회한 뒤 결과를 다시 뒤집으면 됩니다. 보통은 클라이언트가 이미 본 페이지를 캐시하고 있어 앞으로 가기만 서버에 요청하는 구조가 흔합니다.
created_at이 중복되면 왜 문제가 되나요?
같은 시각의 행들 사이에는 순서가 정해지지 않아, 부등호 하나만으로는 경계에 걸친 행이 누락되거나 중복될 수 있습니다. 그래서 기본키 같은 유일한 컬럼을 정렬·비교 조건에 함께 넣어 순서를 확정해야 합니다.
ORM에서도 키셋을 쓸 수 있나요?
대부분 가능합니다. Prisma의 cursor 옵션, Django의 조건 필터, JPA의 Specification 등으로 구현할 수 있습니다. 다만 튜플 비교를 지원하지 않는 ORM에서는 조건을 직접 풀어 써야 할 때가 있습니다.

댓글 0