OFFSET 페이지네이션의 문제

가장 흔한 페이지네이션은 LIMIT ... OFFSET ... 방식이다. 코드가 단순해서 초기 개발에는 편하지만, 데이터가 쌓일수록 뒤 페이지에서 급격히 느려진다. 핵심 이유는 OFFSET이 "건너뛴 행을 실제로 읽은 뒤 버리는" 동작이기 때문이다. 100만 번째 행부터 20개를 원하면, DB는 앞의 100만 개 행을 스캔·정렬해서 폐기하고 마지막 20개만 반환한다.

-- 뒤로 갈수록 느려지는 전형적인 쿼리
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

실행 계획으로 확인하기

추측 대신 EXPLAIN ANALYZE로 비용을 확인한다. OFFSET이 커질수록 실제 스캔·정렬한 행 수가 늘어나는 것을 볼 수 있다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

-- Limit  (actual rows=20 loops=1)
--   -> Index Scan Backward using idx_posts_created_id
--        (actual rows=1000020 loops=1)   -- 100만 행을 읽고 버림
-- Execution Time: 480 ms

커서(키셋) 페이지네이션의 원리

커서 페이지네이션은 "몇 개를 건너뛸까"가 아니라 "마지막으로 본 행 다음부터"를 조건으로 삼는다. 정렬 키의 마지막 값을 커서로 넘기고, 다음 요청에서 WHERE 조건으로 그 지점 이후만 조회한다. 인덱스를 타고 바로 시작 위치로 점프하므로, 몇 페이지 뒤든 비용이 일정하다.

-- 마지막으로 본 (created_at, id)를 커서로 사용
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-09-15 10:00:00', 84213)
ORDER BY created_at DESC, id DESC
LIMIT 20;

여기서 핵심은 복합 튜플 비교다. created_at이 같은 행이 여러 개일 수 있으므로, 반드시 유니크한 tie-breaker(보통 PK인 id)를 정렬·조건에 함께 넣어야 행이 누락되거나 중복되지 않는다.

인덱스 설계가 전부다

커서 방식이 빠른 이유는 인덱스 순서와 ORDER BY 순서가 정확히 일치하기 때문이다. 정렬 컬럼과 방향을 그대로 반영한 복합 인덱스가 있어야 인덱스 스캔만으로 결과가 나온다.

CREATE INDEX idx_posts_created_id
ON posts (created_at DESC, id DESC);

인덱스 정렬 방향과 쿼리의 방향이 어긋나면 PostgreSQL이 별도 정렬 단계를 넣거나 인덱스를 제대로 활용하지 못한다. 컬럼 순서, 방향, 튜플 비교 순서를 모두 일치시키는 것이 원칙이다.

애플리케이션 코드 예시

커서는 클라이언트에 노출되므로 보통 마지막 행의 키를 Base64로 인코딩해 next_cursor로 내려준다.

import base64, json

def encode_cursor(row):
    return base64.urlsafe_b64encode(
        json.dumps({"ts": row["created_at"].isoformat(),
                    "id": row["id"]}).encode()).decode()

def fetch_page(conn, cursor=None, size=20):
    if cursor:
        c = json.loads(base64.urlsafe_b64decode(cursor))
        rows = conn.execute(
            """SELECT id, title, created_at FROM posts
               WHERE (created_at, id) < (%s, %s)
               ORDER BY created_at DESC, id DESC LIMIT %s""",
            (c["ts"], c["id"], size)).fetchall()
    else:
        rows = conn.execute(
            """SELECT id, title, created_at FROM posts
               ORDER BY created_at DESC, id DESC LIMIT %s""",
            (size,)).fetchall()
    next_cursor = encode_cursor(rows[-1]) if len(rows) == size else None
    return rows, next_cursor

두 방식 비교

항목OFFSET커서(키셋)
뒤 페이지 성능선형으로 저하일정(인덱스 점프)
임의 페이지 이동가능(N페이지 바로)불가(순차 이동만)
전체 건수/마지막 페이지쉬움어려움
데이터 변경 중 정합성중복·누락 발생안정적
구현 난이도낮음중간

도입 시 주의점

커서 방식이 만능은 아니다. 몇 가지를 미리 정해야 한다.

  • 임의 페이지 점프 불가: "5페이지로 바로" 같은 UI에는 맞지 않는다. 무한 스크롤·다음/이전 이동에 적합하다.
  • 정렬 키의 유니크성: tie-breaker가 없으면 같은 값 경계에서 행이 새거나 겹친다. PK를 항상 포함한다.
  • NULL 처리: 정렬 컬럼에 NULL이 있으면 튜플 비교 결과가 달라진다. NOT NULL이 아니라면 NULLS LAST 정책을 명확히 하고 조건도 맞춘다.
  • 양방향 페이징: 이전 페이지는 부등호와 정렬을 뒤집어 조회한 뒤, 결과를 다시 뒤집어 반환한다.

정리하면, 임의 접근이 꼭 필요한 관리자 화면은 OFFSET을 유지하되 페이지 범위를 제한하고, 사용자 피드처럼 대량·순차 조회가 많은 곳은 커서 방식으로 전환하는 것이 실용적이다.