본문 바로가기
Database2026년 7월 26일5분 읽기

키셋 페이지네이션 — OFFSET이 느린 이유와 커서 방식

YS
김영삼
조회 5
키셋 페이지네이션 — OFFSET이 느린 이유와 커서 방식

키셋 페이지네이션(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

아직 댓글이 없습니다.
Ctrl+Enter로 등록