본문 바로가기
inpilot.dev

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의 정의 자체가 그렇다.

OFFSET은 앞의 10만 행을 읽고 버리지만 커서는 20행만 읽는다
같은 20건을 받는데 디스크에서 읽는 양이 5000배 차이 난다

버그도 하나 딸려온다. 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_atid를 그대로 노출하면 클라이언트가 그 값을 조작하거나, 나중에 정렬 기준을 바꿀 때 하위호환이 깨진다. 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을 남겨둔다.

새 글이 올라오면 받아보기

스팸 없이, 새 글이 올라올 때만 보내드려요.

댓글

댓글은 giscus 설정 후 표시됩니다. (docs/SETUP-features.md 참고)