남기다
홈 › 인덱스와 성능 › OFFSET 페이지네이션이 느려지는 이유와 키셋 전환기

OFFSET 페이지네이션이 느려지는 이유와 키셋 전환기

한서윤 · · 인덱스와 성능

주문 내역을 엑셀로 내려받는 기능이 있었다. 관리자가 버튼을 누르면 서버가 주문을 1,000건씩 끊어 읽고 파일에 이어 붙이는 구조였다. 처음 몇 달은 조용했다. 주문이 50만 건을 넘긴 무렵부터 내려받기가 끝나지 않는다는 문의가 들어오기 시작했다. 로그를 열어 보니 앞쪽 묶음은 수십 밀리초 만에 끝났다. 그런데 뒤로 갈수록 한 묶음을 읽는 시간이 늘어서 막바지에는 묶음 하나에 1초를 넘기고 있었다.

쿼리는 단순했다. ORDER BY created_at DESC LIMIT 1000 OFFSET ?였고 created_at에는 인덱스도 걸려 있었다. 인덱스를 타는데 왜 뒤로 갈수록 느려지는지 그때는 바로 납득이 가지 않았다.

아래는 그 OFFSET이 실제로 몇 행을 읽고 버리는지 재 본 기록이다. 키셋 페이지네이션으로 옮기면서 치른 값도 함께 적는다.

OFFSET은 건너뛰지 않는다

PostgreSQL 문서는 이 점을 한 줄로 적어 두었다. OFFSET으로 건너뛴 행도 서버 안에서는 계산해야 하므로 OFFSET이 크면 비효율적일 수 있다는 것이다. SQL 표준이 정한 순서도 같다. ORDER BY로 먼저 정렬하고, 그다음 앞에서부터 지정한 수만큼 행을 버린다.

인덱스가 있으면 정렬 비용은 사라진다. 하지만 버리는 일은 그대로 남는다. B-트리 인덱스는 어떤 항목이 몇 번째인지 세어 두지 않는다. 그래서 10만 번째 항목을 찾으려면 맨 앞 항목부터 하나씩 밟아 가며 세는 수밖에 없다. 게다가 SELECT 목록에 인덱스에 없는 컬럼이 있으면 그 10만 건마다 테이블까지 다녀온다. 나는 인덱스가 있으니 원하는 위치로 바로 뛰어갈 거라고 막연히 믿고 있었다. 인덱스는 순서를 알 뿐이고 번호는 모른다.

EXPLAIN ANALYZE로 재 보기

말로만 이해하면 금방 잊어서 직접 재 보기로 했다. 테이블은 이렇게 단순하게 만들었다.

CREATE TABLE orders (
  id         bigint PRIMARY KEY,
  user_id    bigint NOT NULL,
  amount     integer NOT NULL,
  created_at timestamptz NOT NULL
);

CREATE INDEX orders_created_id_idx ON orders (created_at DESC, id DESC);

그리고 OFFSET 100000으로 20건을 읽었다.

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, user_id, amount, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
 Limit (actual time=148.311..148.335 rows=20 loops=1)
   Buffers: shared hit=100342
   ->  Index Scan using orders_created_id_idx on orders (actual time=0.031..143.902 rows=100020 loops=1)
         Buffers: shared hit=100342
 Execution Time: 148.371 ms

눈여겨볼 곳은 Index Scan 노드의 rows=100020이다. 화면에 나간 행은 20건인데 그 아래에서는 100,020건을 읽었다. 버린 10만 건에도 버퍼를 10만 번 넘게 건드렸다. 숫자를 보고 좀 허탈했다. 페이지 하나를 보여 주려고 앞의 5,000페이지를 매번 다시 읽고 있었던 셈이다. 엑셀 내려받기가 뒤로 갈수록 느려진 것도 결국 이 줄 하나로 설명이 됐다.

행 값 비교로 바꾸기

키셋 방식은 몇 행을 건너뛸지 대신 어디서부터 읽을지를 알려 준다. 앞 페이지의 마지막 행이 가진 정렬 키를 기억해 두었다가 다음 요청의 WHERE 조건에 넣는다.

SELECT id, user_id, amount, created_at
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

(created_at, id) < ($1, $2)는 created_at이 더 작거나, created_at이 같으면서 id가 더 작은 행이라는 뜻이다. 사전에서 단어 순서를 따지는 방식과 같다. PostgreSQL은 이 조건을 인덱스 탐색 조건으로 바로 쓴다.

 Limit (actual time=0.040..0.061 rows=20 loops=1)
   Buffers: shared hit=24
   ->  Index Scan using orders_created_id_idx on orders (actual time=0.039..0.058 rows=20 loops=1)
         Index Cond: (ROW(created_at, id) < ROW('2026-03-14 09:12:00+09'::timestamp with time zone, 481203))

읽은 행이 20건으로 줄었다. B-트리를 따라 기준 위치까지 내려간 다음 거기서부터 20건만 읽기 때문이다.

OFFSET 방식은 인덱스 처음부터 십만 개 항목을 읽고 버린 뒤 20건을 돌려주고, 키셋 방식은 마지막으로 본 (created_at, id) 위치로 바로 내려가 20건만 읽는 모습을 비교한 그림
OFFSET은 앞에서부터 세어 가고, 키셋은 마지막 위치로 바로 내려간다

처음 바꿨을 때는 tie-breaker인 id를 빼고 created_at < $1만 썼다. 테스트 데이터에서는 멀쩡했다. 그런데 운영에서 외부 몰 주문을 한꺼번에 들여오는 날이 있었고, 그 주문들은 같은 초에 수백 건씩 찍혀 있었다. 페이지 경계에 같은 시각이 걸리면 나머지 행이 통째로 사라졌다. 정렬 키가 행 하나를 유일하게 가리키지 못하면 키셋은 행을 빠뜨린다. 그 뒤로는 기본 키를 반드시 정렬 끝에 붙인다.

인덱스 방향 맞추기

ORDER BY 방향과 인덱스 방향은 어긋나지 않게 맞춰야 한다. (created_at DESC, id DESC) 인덱스를 쓰든, 오름차순 인덱스를 거꾸로 훑든 두 컬럼의 방향만 같으면 된다. 문제는 created_at DESC, id ASC처럼 방향이 섞일 때다. 행 값 비교는 모든 컬럼에 같은 부등호를 적용하므로 이런 순서를 표현할 수 없다. 그때는 조건을 OR로 풀어 써야 하고 인덱스도 그 방향대로 새로 만들어야 한다. 나는 섞을 이유가 없으면 방향을 하나로 통일한다.

MySQL에서 걸린 일

회원별 주문 목록을 같은 방식으로 옮기던 날이었다. MySQL 8.0에서 WHERE user_id = ? AND (created_at, id) < (?, ?)로 쓰고 EXPLAIN을 봤는데, key_len을 보니 (user_id, created_at, id) 인덱스에서 user_id 부분만 쓰고 있었다. 순간 당황했다. MySQL 문서에도 같은 사례가 나온다. 행 생성자를 AND나 OR와 섞으면 옵티마이저가 인덱스를 덜 쓰게 되니 둘 중 하나만 쓰라는 권고다. 결국 풀어 쓴 형태로 바꿨다.

SELECT id, amount, created_at
FROM orders
WHERE user_id = ?
  AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 20;

바꾸고 나서 잰 숫자

같은 테이블에서 20건씩 읽을 때 Index Scan 노드가 실제로 읽은 행 수다.

방식읽은 행실행 시간
OFFSET 0200.05ms 안팎
OFFSET 100000100,020약 148ms
키셋, 10만 번째 근처200.06ms 안팎

키셋은 몇 페이지째를 읽든 읽는 양이 같다. 엑셀 내려받기도 이 방식으로 바꿨더니 마지막 묶음까지 시간이 고르게 나왔다. 덤으로 내려받는 도중 새 주문이 들어와 같은 행이 두 번 찍히던 문제도 사라졌다. OFFSET은 몇 개를 건너뛸지만 알고 어떤 행을 이미 읽었는지는 모르기 때문에 생기던 일이다.

페이지 번호를 포기하는 대가

공짜는 아니었다. 기획 쪽에서 회원 주문 목록 아래에 있던 1, 2, 3 … 47 같은 번호를 그대로 두자고 했고, 나는 그럴 수 없다고 설명해야 했다. 키셋으로 바꾸면서 잃는 것은 이렇다.

이전 페이지는 이렇게 읽는다.

SELECT * FROM (
  SELECT id, amount, created_at
  FROM orders
  WHERE (created_at, id) > ($1, $2)
  ORDER BY created_at ASC, id ASC
  LIMIT 20
) t
ORDER BY created_at DESC, id DESC;

결국 회원 화면은 ‘더 보기’ 버튼으로 바꾸고, 관리자 화면에는 번호를 남겨 두는 선에서 합의했다. 관리자는 보통 검색 조건으로 범위를 좁힌 뒤에 목록을 넘기기 때문에 OFFSET이 커질 일이 드물다. 그래도 혹시 몰라 넘길 수 있는 페이지 수에 상한을 걸어 두었다.

지금 쓰는 방식

이제 목록 API를 새로 만들 때 처음 묻는 질문은 사람이 몇 페이지까지 넘길까다. 앞쪽 몇 페이지만 보는 화면이라면 OFFSET으로 충분하다. 코드도 짧고 페이지 번호도 쉽게 붙는다. 반대로 배치, 내보내기, 무한 스크롤, 외부에 공개하는 API처럼 끝까지 훑을 수 있는 곳은 처음부터 키셋으로 짠다. 정렬 키 끝에는 기본 키를 붙이고, 인덱스는 ORDER BY와 같은 방향으로 만든다. 그리고 바꾸고 나면 EXPLAIN ANALYZE에서 rows 숫자가 LIMIT과 같은지 꼭 확인한다. 그 숫자가 20이면 그날은 편하게 잔다.

참고한 자료