OFFSET 페이지네이션은 뒤로 갈수록 죽는다
목록 1페이지는 10ms 인데 100페이지쯤 가면 타임아웃이 나요. 사람보다 크롤러가 깊은 페이지를 훑을 때 터지는 이 문제를, 페이지 번호 대신 이어 읽기로 푸는 법을 보여 드려요.
목록 API 가 1페이지는 10ms 인데 깊은 페이지로 갈수록 느려져요. 100페이지쯤에서 눈에 띄고, 어느 순간 타임아웃이 나요.
문제는 사람이 100페이지까지 안 넘긴다는 거예요. 그래서 이 증상은 사용자가 아니라 크롤러나 배치가 만들어요. 검색 엔진 봇이 목록을 끝까지 훑거나, 데이터 export 배치가 페이지를 순회하는 순간 DB 전체가 느려져요.
그리고 이건 부하 테스트에서도 잘 안 잡혀요. 보통 1~2페이지만 때리거든요. 깊은 페이지를 섞어서 때리는 시나리오를 따로 만들지 않으면 통과합니다.
증상이 특이한 것도 한몫해요. 평균 응답 시간은 멀쩡한데 가끔 한 요청이 수십 초를 먹어요.
왜 그런가
OFFSET 99980 은 그 자리로 점프하지 않아요. 처음부터 9만 9980행을 읽고, 정렬하고, 버린 다음, 그 뒤의 50행을 돌려줘요. 건너뛴다는 말은 결과에 대한 표현이지 실행에 대한 표현이 아니에요.
그래서 비용이 페이지 번호에 비례해요. 1페이지는 50행을 읽고, 2000페이지는 10만 행을 읽어요. 같은 API, 같은 쿼리인데 비용이 2000배 차이 나요. 그리고 그 일은 전부 DB 의 CPU 와 I/O 예요 — 읽고 버리는 데 쓰는 자원이라 아무 가치도 생산하지 않아요.
정렬이 인덱스로 해결되지 않으면 더 나빠져요. ORDER BY 를 인덱스로 못 타면 전체를 읽어서 정렬해야 하고, 그 결과가 work_mem 을 넘으면 디스크로 넘어가요. 깊은 페이지 하나가 디스크 정렬을 유발하면서 다른 쿼리들의 캐시까지 밀어내요.
정확성 문제도 같이 있어요. OFFSET 은 "지금 이 순간의 목록에서 N번째부터"라는 뜻이에요. 1페이지를 본 뒤 새 글이 하나 올라오면 모든 항목이 한 칸씩 밀려서 2페이지에 같은 항목이 다시 나와요. 삭제되면 반대로 하나가 조용히 누락되고요. 페이지를 넘길수록 어긋남이 쌓여요.
코드로 보면
건너뛰기 → 이어서 읽기:
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 99980;
-- 10만 행을 읽고 버린 뒤 50행을 준다
· 커서(키셋) 방식 — 마지막으로 본 곳부터
SELECT * FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
· 인덱스가 정렬과 정확히 같아야 한다
CREATE INDEX idx_posts_cursor
ON posts (created_at DESC, id DESC);
· 커서는 불투명하게 감싸서 준다
· {"created_at":"...","id":123} 을 base64 로
· 그래야 나중에 정렬 키를 바꿔도 클라이언트가 안 깨진다✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
커서 방식의 핵심은 정렬 키가 유일해야 한다는 거예요. created_at 만 쓰면 같은 시각에 만들어진 항목이 여럿일 때 경계에서 다시 어긋나요. 어떤 건 두 번 나오고 어떤 건 빠져요. 그래서 (created_at, id) 처럼 마지막에 유일한 컬럼을 붙여 복합으로 만들어요.
행 값 비교 (a, b) < (:a, :b) 는 사전식 비교라 이 용도에 딱 맞아요. PostgreSQL 과 MySQL 8 에서 지원하고, 인덱스도 탈 수 있어요. 다만 엔진에 따라 최적화 정도가 달라서, 실제로 인덱스를 타는지 EXPLAIN 으로 확인해야 해요. 안 탄다면 (a < :a) OR (a = :a AND b < :b) 로 풀어 쓰는 편이 나을 때도 있어요.
인덱스가 ORDER BY 와 정확히 같아야 한다는 조건도 중요해요. 컬럼 순서만이 아니라 방향까지요. ORDER BY created_at DESC, id DESC 인데 인덱스가 (created_at, id) 오름차순이면 엔진에 따라 역방향 스캔으로 해결되기도 하고 안 되기도 해요.
총 개수도 같이 생각해야 해요. "전체 1,234페이지" 같은 UI 는 COUNT(*) 를 요구하는데, 이것도 전체를 세는 일이라 큰 테이블에서는 그 자체로 비쌉니다. 무한 스크롤이면 총 개수가 애초에 필요 없어요. 꼭 필요하면 근삿값(통계 기반)이나 캐시된 값을 쓰는 편이 나아요.
깊은 페이지가 필요한 게 진짜 사용자가 아니라 배치라면, API 를 나누는 것도 방법이에요. 사용자용 목록은 커서로, 배치용 export 는 키 범위로 잘라서 병렬로.
처방
- 목록 API 는 커서(키셋) 방식으로 만드세요. 정렬 키는 (정렬 컬럼, 유일 컬럼) 복합으로 잡고요.
- 커서 값을 그대로 노출하지 말고 불투명한 토큰으로 감싸세요. base64 로 감싸 두면 나중에 정렬 키를 바꿔도 클라이언트 계약이 안 깨져요.
- 인덱스를 ORDER BY 와 정확히 맞추세요. 그리고 EXPLAIN 으로 실제로 타는지 확인하세요. 커서 방식으로 바꿨는데도 느리면 십중팔구 인덱스가 안 맞는 거예요.
- OFFSET 을 당장 못 걷어낸다면 상한을 두세요. OFFSET 이 10,000 을 넘으면 400 을 돌려주는 것도 방법이에요. 크롤러가 만드는 깊은 페이지 조회를 그 자리에서 끊어요.
- 총 개수는 분리하세요. 무한 스크롤이면 없애고, 필요하면 별도 엔드포인트나 캐시로 빼세요. 목록 조회 한 번에 COUNT 를 같이 도는 구조는 깊은 페이지가 아니어도 비쌉니다.
- 배치·export 는 페이지 순회 대신 키 범위 분할로 바꾸세요. id 를 구간으로 잘라 병렬로 읽으면 OFFSET 이 아예 필요 없어요.
지금 확인해 보세요
깊은 페이지의 진짜 비용:
· 읽고 버린 양이 그대로 보인다
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 100000;
Rows Removed by Filter 와 shared read 를 보라
50행을 주려고 10만 행을 읽었으면 그게 비용이다
· 커서 방식이 인덱스를 실제로 타는지
EXPLAIN (ANALYZE)
SELECT * FROM posts
WHERE (created_at, id) < (now(), 0)
ORDER BY created_at DESC, id DESC LIMIT 50;
Index Scan 이어야 한다
Sort 가 나오면 인덱스가 정렬과 안 맞는 것이다
· 운영에서 깊은 OFFSET 이 실제로 들어오는지
· 접근 로그에서 offset 파라미터 분포를 본다핵심 한 줄건너뛰지 말고, 마지막으로 본 곳부터 읽어라.
자주 묻는 것
커서 페이지네이션의 정렬 키를 created_at 하나로 잡으면 안 되나요?
같은 시각에 만들어진 항목이 여럿이면 페이지 경계에서 어떤 건 두 번 나오고 어떤 건 빠져요. 그래서 (created_at, id) 처럼 마지막에 유일한 컬럼을 붙여 복합 키로 잡아요.