페이지가 뒤로 갈수록 느려질 때 쓰는 커서 페이지네이션

페이지가 뒤로 갈수록

게시판이나 상품 목록에서 1페이지는 빠른데 50페이지, 100페이지로 넘어갈수록 응답이 눈에 띄게 느려지는 현상은 대부분 OFFSET을 쓰는 쿼리 구조 때문에 생깁니다. 페이지가 뒤로 갈수록 느려지는 이유는 간단합니다. DB가 매 요청마다 앞쪽 행을 전부 읽어서 세고, 그중 필요 없는 부분을 버린 다음에야 실제로 원하는 20개를 돌려주기 때문입니다. 이 글에서는 왜 이런 구조가 생기는지, 그리고 커서(키셋) 페이지네이션으로 바꾸면 무엇이 달라지는지 PostgreSQL 15 기준으로 구체적으로 짚어 보겠습니다.

OFFSET·LIMIT 방식에서 뒤 페이지로 갈수록 쿼리가 느려지는 이유

SELECT * FROM posts ORDER BY id DESC OFFSET 20000 LIMIT 20 같은 쿼리를 실행하면, 옵티마이저는 인덱스를 따라 정렬 순서대로 20,020행을 차례로 읽습니다. 그중 앞의 20,000행은 결과에 필요 없는데도 일단 읽고 건너뜁니다. 1페이지(OFFSET 0)는 20행만 읽으면 끝나지만 1,000페이지(OFFSET 20000)는 20,020행을 읽어야 하니, 읽어야 하는 행 수가 페이지 번호에 비례해서 늘어납니다.

인덱스가 걸려 있어도 이 비용 자체가 없어지지는 않습니다. 인덱스는 ‘어디서부터 읽을지’를 빠르게 찾아주는 게 아니라 정렬 순서를 보장해 주는 역할이라서, 건너뛸 행도 결국 인덱스를 타고 하나씩 지나가야 합니다. 이 동작은 PostgreSQL 공식 문서의 LIMIT 설명에도 나와 있는데, OFFSET이 큰 값일 때는 건너뛸 행을 계산하는 데만도 비효율이 생긴다고 명시돼 있습니다.

EXPLAIN ANALYZE로는 어떻게 보일까요?

posts 테이블에 100만 행이 있고 id에 기본키(btree) 인덱스가 있다고 가정하면, OFFSET이 커질수록 실행 계획의 Limit 노드가 읽어야 하는 하위 행 수가 그만큼 늘어나는 구조가 그대로 드러납니다.

EXPLAIN ANALYZE
SELECT id, title FROM posts
ORDER BY id DESC
OFFSET 20000 LIMIT 20;

-- 실행 계획 구조(환경에 따라 수치는 달라짐)
Limit  (actual rows=20)
  ->  Index Scan Backward using posts_pkey on posts
        (actual rows=20020 loops=1)
Planning Time: 0.112 ms
Execution Time: 늘어난 OFFSET만큼 증가

핵심은 actual rows 값입니다. 결과로 돌려주는 행은 20개뿐인데, 그 아래 Index Scan 노드는 20,020행을 실제로 거쳐 갑니다. 이 값은 OFFSET 숫자와 거의 같은 비율로 커지기 때문에, 페이지 번호가 커질수록 응답 시간이 선형으로 늘어나는 패턴을 보이게 됩니다. 정확한 ms 수치는 서버 사양·캐시 상태·테이블 크기에 따라 달라지므로, 본인 환경에서 직접 EXPLAIN ANALYZE를 돌려 actual rows와 Execution Time을 비교해 보는 과정이 꼭 필요합니다.

커서(키셋) 페이지네이션의 동작 구조

커서 방식은 ‘몇 번째부터 건너뛰어 달라’고 요청하지 않고, ‘이 값보다 작은(또는 큰) 것부터 달라’고 요청합니다. 마지막으로 받은 행의 정렬 기준값(보통 id나 created_at)을 다음 요청에 그대로 넘기는 방식입니다.

-- 첫 페이지
SELECT id, title FROM posts
ORDER BY id DESC
LIMIT 20;

-- 다음 페이지 (마지막으로 받은 id가 48000이라면)
SELECT id, title FROM posts
WHERE id < 48000
ORDER BY id DESC
LIMIT 20;

이 쿼리는 id 인덱스를 48000이라는 지점에서 바로 찾아 들어가(Index Range Scan), 그 뒤 20행만 읽고 멈춥니다. 1페이지든 1,000페이지든 읽는 행 수가 항상 20개 근처로 고정되기 때문에, 응답 시간이 페이지 깊이와 무관하게 거의 일정합니다. WHERE 절에서 인덱스가 걸린 컬럼으로 범위를 좁히는 이 접근은 MySQL 공식 문서의 인덱스 최적화 설명에서도 OFFSET이 큰 경우의 대안으로 소개하고 있습니다.

정렬 기준이 두 개 이상일 때 커서 구현하기

실무에서는 id 하나만으로 정렬하는 경우보다 created_at DESC, id DESC처럼 복합 정렬을 쓰는 경우가 많습니다. 이때는 커서도 두 값을 함께 넘겨야 하고, WHERE 조건도 튜플 비교로 바꿔야 정확하게 동작합니다.

SELECT id, title, created_at FROM posts
WHERE (created_at, id) < ('2026-10-01 09:00:00', 48000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

이 튜플 비교 구문은 PostgreSQL과 MySQL 8.0 이상에서 모두 지원하지만, 같은 순서의 복합 인덱스(created_at, id)가 있어야 인덱스를 제대로 타게 됩니다. 인덱스 순서가 정렬 순서와 다르면 옵티마이저가 전체 정렬(Sort)을 다시 수행하면서 커서 방식의 이점이 사라지니, 복합 정렬을 쓴다면 인덱스 컬럼 순서를 쿼리의 ORDER BY 순서와 맞추는 작업이 먼저입니다.

커서 페이지네이션이 맞지 않는 경우

웹 페이지 무한 스크롤 페이지네이션 구현 화면

커서 방식이 모든 상황에 더 나은 답은 아닙니다. 아래 상황에서는 OFFSET 쪽이 오히려 더 적합하거나, 커서 방식에 추가 작업이 필요합니다.

상황 OFFSET 커서(키셋)
"7페이지로 바로 이동" 같은 임의 페이지 점프 가능 기본적으로 불가능, 별도 설계 필요
"총 1,234개 중 21~40번째" 같은 전체 개수 표시 쉬움 별도 COUNT 쿼리 필요
뒤 페이지로 갈수록 성능 느려짐 거의 일정
정렬 기준이 자주 바뀌는 목록 구현 단순 커서 인코딩을 매번 다시 설계
데이터가 실시간으로 추가·삭제되는 목록 중복·누락 발생 가능 상대적으로 안정적

총 페이지 수를 보여줘야 하는 관리자 화면이나, 사용자가 숫자 버튼으로 임의 페이지에 바로 가야 하는 화면이라면 커서 방식만으로는 요구사항을 못 채웁니다. 이런 화면은 앞쪽 몇 페이지(예: 1~10페이지)까지는 OFFSET을 쓰고, 그 이후나 무한 스크롤 구간에서만 커서로 전환하는 혼합 설계도 실무에서 종종 씁니다.

새로 글이 올라오는 목록인데 커서가 겹치거나 빠지지 않나요?

OFFSET 방식은 사용자가 1페이지를 보는 동안 누군가 새 글을 올리면, 2페이지로 넘어갈 때 글 하나가 두 번 보이거나 아예 안 보이는 현상이 생길 수 있습니다. 행 번호 자체가 밀리기 때문입니다. 커서 방식은 '마지막으로 본 id보다 작은 것'이라는 조건으로 움직이기 때문에, 새 글이 앞에 추가돼도 이미 지나간 커서 위치는 그대로 유지돼서 이런 밀림 현상이 덜 생깁니다.

다만 정렬 기준으로 쓰는 컬럼이 중복값을 가질 수 있다면(예: created_at만 쓰고 같은 시각에 여러 행이 들어오는 경우) 커서로도 중복·누락이 생길 수 있습니다. 이 경우 id처럼 유일성이 보장된 컬럼을 보조 정렬 기준으로 반드시 함께 넣어야 합니다.

API 응답을 설계할 때는 아래처럼 next_cursor를 함께 내려주는 구조가 일반적입니다.

{
  "items": [...],
  "next_cursor": "48000",
  "has_more": true
}

클라이언트는 이 next_cursor 값을 다음 요청의 커서 파라미터로 그대로 전달하기만 하면 되므로, 프런트엔드 쪽 변경도 "페이지 번호 state"를 "커서 문자열 state"로 바꾸는 정도로 끝납니다. 목록이 100페이지든 1,000페이지든 매 요청이 비슷한 속도로 응답하게 만드는 것이 이 구조의 목적이고, 임의 페이지 점프나 총 개수 표시가 꼭 필요한 화면에는 앞서 짚은 트레이드오프를 먼저 따져보는 편이 안전합니다.

기본키를 UUID로 했더니 느려질 때 — UUIDv4·UUIDv7·BIGSERIAL 비교

Leave a Comment