목록 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 가 배경을 채워준다.