OFFSET 페이지네이션은 뒤로 갈수록 느려진다 — 커서로 바꾸기
2025-10-05 · 2 min read
$ EXPLAIN … LIMIT 20 OFFSET 100000
rows read 100,020 → 20
OFFSET은 건너뛰는 게 아니라 읽고 버린다
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 100000;DB는 100,020행을 실제로 읽은 뒤 앞의 10만 행을 버린다. 1페이지는 즉시 나오고 5000페이지는 몇 초 걸리는 이유가 이거다. 인덱스를 아무리 잘 걸어도 안 없어진다. OFFSET의 정의 자체가 그렇다.
버그도 하나 딸려온다. 1페이지를 보는 동안 새 글이 하나 올라오면, 2페이지 첫 항목이 1페이지 마지막 항목과 같아진다. 전체가 한 칸씩 밀려서다.
커서: 위치 대신 값으로 자른다
"몇 번째부터"가 아니라 "이 값 다음부터"로 묻는다.
SELECT * FROM posts
WHERE created_at < $1 -- 이전 페이지 마지막 항목의 created_at
ORDER BY created_at DESC
LIMIT 20;인덱스를 타고 시작 지점으로 바로 점프한다. 100페이지든 5000페이지든 읽는 행 수는 20개로 같다. 밀림 현상도 없다. 기준이 위치가 아니라 값이니까.
정렬 키가 유니크하지 않으면 깨진다
여기서 대부분 한 번 데인다. created_at이 같은 행이 두 개 있으면,
< $1 조건이 그 둘을 동시에 버리거나 동시에 포함한다. 항목이 사라지거나 중복된다.
밀리초 단위라 안 겹칠 거라고 생각하면 안 된다. 벌크 INSERT는 겹친다.
해결은 유니크한 컬럼을 정렬 키에 덧붙이는 것이다.
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2) -- 튜플 비교
ORDER BY created_at DESC, id DESC
LIMIT 20;Postgres/MySQL의 튜플 비교는 사전식으로 동작한다.
created_at이 같으면 id로 갈린다. 인덱스도 (created_at DESC, id DESC)로 맞춰준다.
튜플 비교가 없는 DB라면 풀어 쓴다. 같은 뜻이다.
WHERE created_at < $1 OR (created_at = $1 AND id < $2)커서는 불투명하게 내보낸다
{ "items": [...], "next_cursor": "eyJ0IjoiMjAyNS0xMC0wNVQxMjowMDowMFoiLCJpZCI6OTkxfQ" }created_at과 id를 그대로 노출하면 클라이언트가 그 값을 조작하거나,
나중에 정렬 기준을 바꿀 때 하위호환이 깨진다. base64로 감싸서 내보낸다.
암호화까지는 필요 없지만, 디코딩 실패는 500이 아니라 400으로 처리한다.
사용자가 URL을 잘라먹은 것도 흔한 케이스라서.
안 되는 것
커서 방식은 "5페이지로 점프"를 못 한다. 원리상 불가능하다.
전체 페이지 수도 모른다(COUNT(*)를 따로 돌리면 그것도 느리다).
그래서 판단 기준은 단순하다.
| 화면 | 방식 |
|---|---|
| 무한 스크롤, 피드, API | 커서 |
| 관리자 테이블, 페이지 번호가 필요한 곳 | OFFSET |
관리자 화면은 데이터도 적고 트래픽도 낮다. 거기까지 커서로 바꾸는 건 과설계다. 느려지는 곳만 바꾼다.
검증
def test_cursor_no_dup_or_gap():
"""같은 created_at이 섞여 있어도 전수 조회에서 중복·누락이 없어야 한다"""
seen, cursor = [], None
while True:
page, cursor = fetch(limit=20, cursor=cursor)
seen += [r.id for r in page]
if not cursor:
break
assert len(seen) == len(set(seen)) # 중복 없음
assert set(seen) == all_ids() # 누락 없음정렬 키를 created_at 하나로만 두고 이 테스트를 돌리면 실패한다.
그게 위에서 말한 함정이 실재한다는 증거다.
한 줄 요약
(정렬키, id)튜플로 자르고 같은 순서의 복합 인덱스를 건다. 페이지 번호가 필요하면 OFFSET을 남겨둔다.