본문으로 건너뛰기
yutils

pagination 은 어떻게 동작할까?

offset/limit vs cursor(keyset) pagination, deep offset 가 왜 치명적으로 느려지나, shifting · duplicate row 의 일관성 문제, 그리고 opaque cursor 로 깔끔한 API 를 설계하는 법.

약 10분 읽기

목록 100만 건을 한 번에 못 내려준다. 그래서 페이지로 쪼갠다. 거의 모두가 ?page=2&limit=20 같은 offset 방식으로 시작하는데, 데이터가 커지고 자주 바뀌면 이게 두 가지로 무너진다 — deep offset 성능일관성. 이 가이드는 offset vs cursor(keyset) 의 진짜 차이, 왜 깊은 페이지가 느려지는지, 그리고 opaque cursor 로 깔끔한 API 를 설계하는 법을 정리한다.

offset / limit — 익숙하지만 함정

SELECT * FROM posts ORDER BY created_at DESC
LIMIT 20 OFFSET 40;     -- 3페이지 (20 * 2)

장점:
  - 직관적: page N = OFFSET (N-1)*limit
  - 임의 페이지 점프 가능("47페이지로")
  - 총 개수 알면 "총 1234페이지" 표시 가능

함정 (아래 두 섹션에서 자세히):
  - OFFSET 이 커질수록 느려진다 (deep offset)
  - 페이징 중 데이터가 바뀌면 행이 밀리거나 중복된다

왜 deep offset 가 느려지나

핵심 오해: "OFFSET 1000000 이면 DB 가 그 위치로 점프한다." 아니다. DB 는 앞의 1,000,000행을 실제로 읽고 버린다.

LIMIT 20 OFFSET 1000000:
  DB: 정렬 순서대로 행을 스캔...
      1,000,000 개를 읽고 → 전부 버림 → 그 다음 20개만 반환
  즉 "버리려고 읽는" 비용이 OFFSET 에 비례 → O(offset)

1페이지(OFFSET 0): 20행만 읽음 → 빠름
50000페이지(OFFSET 1000000): 1000020행 읽음 → 수십 배~수백 배 느림

인덱스가 있어도 마찬가지인 이유:
  인덱스로 정렬 순서는 빨리 따라가도, OFFSET 만큼의 엔트리를
  "세면서 건너뛰어야" 한다. 건너뛰기 자체가 일이다.
  (how-database-indexes-work 가 인덱스의 동작을 설명한다)

그래서 무한 스크롤이나 깊은 목록에서 offset 페이지네이션은 뒤로 갈수록 체감 속도가 무너진다. "마지막 페이지" 로 갈수록 가장 느린, 거꾸로 된 성능 곡선.

cursor (keyset) pagination

OFFSET 을 버리고 "마지막으로 본 값보다 다음" 을 조건으로 건다. 위치를 세지 않고 바로 그 지점으로 인덱스를 타고 들어간다.

첫 페이지:
  SELECT * FROM posts ORDER BY id DESC LIMIT 20;
  → 마지막 행의 id = 9981 이라 하자. 이게 cursor.

다음 페이지:
  SELECT * FROM posts
  WHERE id < 9981          -- "방금 본 마지막보다 다음"
  ORDER BY id DESC LIMIT 20;

왜 빠른가:
  WHERE id < 9981 은 인덱스로 곧장 그 지점에 seek → 20개만 읽음.
  버리는 행이 없음 → O(limit), OFFSET 깊이와 무관하게 항상 빠름.

대가:
  - 임의 페이지 점프 불가("47페이지로"는 안 됨) — next/prev 만
  - "총 N페이지" 표시 어려움(전체를 세야 하므로)
  - 정렬 키가 인덱스 + (사실상) 유일해야 함

tie-break — 정렬 키가 유일하지 않을 때

keyset 의 함정 하나: 정렬 컬럼에 중복 값이 있으면 경계에서 행이 새거나 빠진다.

created_at 으로 정렬하는데 같은 타임스탬프가 여러 개라면?
  WHERE created_at < '2026-05-28 10:00:00'
  → 정확히 그 시각의 행들이 경계에서 잘림/중복될 수 있음.

해결: (정렬키, 고유키) 복합 cursor + 복합 정렬
  ORDER BY created_at DESC, id DESC
  WHERE (created_at, id) < ('2026-05-28 10:00:00', 9981)
    -- 행 값 비교: created_at 같으면 id 로 tie-break

이렇게 하면 경계가 항상 유일하게 결정 → 누락/중복 없음.
복합 인덱스 (created_at, id) 가 있어야 빠르다.

일관성 — shifting 과 duplicate rows

offset 의 더 은근한 문제. 사용자가 페이지를 넘기는 동안 데이터가 바뀌면 결과가 어긋난다.

최신순 목록을 OFFSET 으로 볼 때, 1페이지를 본 뒤
새 글이 1건 맨 앞에 추가되면:

  추가 전:  [A B C D E] [F G H I J] ...   (page1) (page2)
  새 글 X 추가 → 전체가 한 칸씩 밀림:
  추가 후:  [X A B C D] [E F G H I] ...
                          ↑ page2 첫 항목이 E
  사용자: page1 에서 E 를 이미 못 봤는데(원래 page2였음) → 누락
          또는 반대로 같은 행을 두 페이지에서 두 번 봄 → 중복

cursor 는 왜 덜 깨지나:
  "id < 9981" 은 절대 기준점. 앞에 뭐가 추가되든 9981 아래는 그대로.
  새 글은 9981 보다 큰 id → 다음이 아니라 "이전" 쪽에 생김 → 안 밀림.
  (완전 면역은 아님 — 9981 자체가 삭제되면 경계가 사라지므로
   보통 등호 처리/삭제 tombstone 으로 보완)

API 설계 — opaque cursor

cursor 를 클라이언트에 노출할 때 id<9981 같은 raw 값을 그대로 주지 마라. 불투명한(opaque) 토큰으로 감싼다.

나쁜 예 — 내부 구현 노출:
  GET /posts?after_id=9981&sort=created_at
  → 클라이언트가 컬럼명/정렬 구조에 의존하게 됨.
    나중에 정렬 키를 (created_at,id) 로 바꾸면 API 깨짐.

좋은 예 — opaque cursor:
  GET /posts?limit=20
  응답:
    {
      "data": [ ...20건... ],
      "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0yOCIsImlkIjo5OTgxfQ=="
    }
  다음 요청:
    GET /posts?limit=20&cursor=eyJjcmVhdGVkX2F0...

  cursor 안에는 (정렬값, 고유id, 정렬방향 등)을 인코딩(base64/JSON).
  클라이언트는 내용 모름 → 그냥 돌려보냄 → 서버만 해석.
  장점:
   - 내부 정렬 구조를 바꿔도 cursor 포맷만 서버가 관리 → API 안정
   - 서명/만료 넣어 변조 방지 가능
   - next/prev 모두 cursor 로 표현(forward/backward 방향 포함)

언제 무엇을

상황권장
관리자 테이블, 페이지 번호/점프 필요, 데이터 작음offset/limit (단순함이 이득)
무한 스크롤, 피드, 큰 목록, 자주 변함cursor(keyset) — 빠르고 일관됨
공개 API (외부 클라이언트)opaque cursor (내부 구현 은폐 + 안정성)
"총 몇 페이지" 가 꼭 필요offset + 별도 COUNT (또는 근사치 표시)

흔한 함정

  • cursor 인데 정렬 키가 안 유일 — 경계에서 행 누락/중복. (정렬값, id) 복합 cursor + 복합 인덱스로 tie-break.
  • 매 요청 COUNT(*) — 큰 테이블에서 총개수 세기는 그 자체가 무거운 쿼리. 근사치(추정 row count)나 "더 보기" UI 로 회피.
  • cursor 에 raw id 노출 — 클라이언트가 내부 구조에 결합됨. opaque 토큰으로 감싸 미래 변경 여지 확보.
  • 정렬 컬럼에 인덱스 없음 — keyset 의 WHERE seek 가 결국 full scan 으로 떨어져 offset 만큼 느려짐. 정렬 키 = 인덱스가 전제.
  • OFFSET 으로 무한 스크롤 — 스크롤이 깊어질수록 느려지는 최악 조합. 무한 스크롤은 거의 항상 cursor.

마무리

Pagination 의 핵심 통찰 하나: offset 은 "위치를 세고", cursor 는 "값으로 찾는다". 세는 비용은 깊이에 비례하고, 찾는 비용은 일정하다. 그래서 큰·자주 변하는 목록은 cursor 가 성능과 일관성 모두에서 이긴다. 공개 API 라면 그 cursor 를 opaque 토큰으로 감싸 내부 정렬 구조를 미래에도 자유롭게 바꿀 수 있게 하라. 인덱스가 왜 이 모든 걸 가능하게 하는지는 how-database-indexes-work 가 배경을 채워준다.

가이드 목록으로