"몇 번째부터"가 아니라 "이 지점 이후" 로 다음 페이지를 요청하는 방식.
오프셋 방식의 문제
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000;
문제 ① — 뒤로 갈수록 느려진다
DB는 OFFSET만큼을 건너뛰기 위해 실제로 읽는다. 100,020건을 읽어 100,000건을 버리고 20건만 반환한다.
OFFSET 0 → 5ms
OFFSET 10000 → 50ms
OFFSET 100000 → 500ms
OFFSET 1000000 → 5s ← 선형으로 나빠진다
문제 ② — 데이터가 밀린다
1페이지 조회: [글100, 글99, …, 글81] 그 사이 새 글이 3개 등록됨 2페이지 조회(OFFSET 20): [글83, 글82, …]
- 글81, 82, 83 이 중복으로 보인다
- 반대로 글이 삭제되면 건너뛴다
무한 스크롤에서 같은 글이 반복되는 현상의 원인이다.
커서 방식
SELECT * FROM posts
WHERE id < 100 -- 마지막으로 본 지점
ORDER BY id DESC
LIMIT 20;
{ "items": [...], "nextCursor": "80" }
다음 요청은 ?cursor=80.
왜 빠른가
- 인덱스에서 id=100 위치를 바로 찾아(O(log n)) 거기서 20건만 읽는다
- 몇 페이지째든 항상 같은 속도
건너뛸 것을 읽지 않는다는 것이 핵심이다.
데이터가 밀려도 안전한 이유
기준이 "몇 번째"가 아니라 "어떤 값 이후"이기 때문이다. 중간에 글이 추가되거나 삭제돼도 "id가 80보다 작은 것"은 변하지 않는다.
정렬 기준이 유일하지 않으면 — 복합 커서
-- created_at 만으로는 같은 시각의 행에서 누락·중복이 생긴다
WHERE (created_at, id) < ('2024-01-01 10:00:00', 500)
ORDER BY created_at DESC, id DESC;
튜플 비교를 쓰면 정확하다. MySQL은 이 문법을 지원하지만 인덱스 활용을 확인해야 하고, 지원이 애매하면 풀어 쓴다.
WHERE created_at < ? OR (created_at = ? AND id < ?)
(created_at, id) 복합 인덱스가 반드시 있어야 한다.
커서 인코딩
-
커서를 그대로 노출하면 내부 구조가 드러난다
-
Base64 로 감싸 불투명하게 만든다
-
{"createdAt":"2024-01-01T10:00:00","id":500}
-
eyJjcmVhdGVkQXQiOiIyMDI0LTAx…
암호화가 아니므로 보안 수단은 아니다. 클라이언트가 내부 값에 의존해 파싱하는 것을 막고, 나중에 구조를 바꿀 여지를 남기는 것이 목적이다.
한계 — 커서가 항상 답은 아니다
❌ "7페이지로 바로 가기" 불가능 — 순차적으로만 이동 ❌ 전체 페이지 수를 알기 어렵다 ❌ 임의 정렬(가격순, 인기순)을 바꾸면 커서가 무효
언제 무엇을 쓰나
| 상황 | 방식 |
|---|---|
| 무한 스크롤·피드 | 커서 |
| 실시간으로 데이터가 추가되는 목록 | 커서 |
| 데이터가 많은 API | 커서 |
| 관리자 화면(페이지 번호로 이동해야) | 오프셋 |
| 데이터가 적고 정적 | 오프셋 (단순함이 이득) |
오프셋을 유지해야 한다면 — 지연 조인
SELECT p.* FROM posts p
JOIN (SELECT id FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000) t
ON p.id = t.id;
안쪽 서브쿼리가 커버링 인덱스로만 처리되어 버릴 행의 본문을 안 읽는다. 근본 해결은 아니지만 상당히 개선된다.