[DB] Offset vs Cursor — 페이지네이션은 어디서 무너지는가

관리자 목록에서 1페이지는 바로 떴는데 뒤쪽 페이지는 타임아웃이 났다. OFFSET 숫자만 다를 뿐인데 이런 현상이 나타났다.

페이지네이션은 초반에 20줄쯤 짜두고 다시 열어볼 일 없는 코드처럼 보이지만, 데이터가 쌓이기 시작하면 이 20줄에서 성격이 전혀 다른 문제가 두 가지 나온다. 하나는 느려지는 문제고, 다른 하나는 조회 결과 자체가 틀리는 문제다. 같은 글이 목록에 두 번 나오거나, 분명히 있는 글이 어느 페이지에도 안 나온다. 그런데 이쪽은 에러가 나지 않아서 한참 뒤에야 발견된다.

▍겪었던 세 가지 문제

이 글은 시기가 제각각인 세 가지 문제에서 출발한다. 겪을 때는 서로 아무 상관 없는 줄 알았다.

첫 번째, 관리자 목록 페이지. 목록이 느리다는 얘기가 나왔을 때 제일 먼저 본 건 당연히 쿼리와 인덱스였는데, 정작 1페이지는 멀쩡했고 뒤쪽 페이지만, 그것도 페이지 번호에 정확히 비례해서 느려졌다. 확인해보니 쿼리는 인덱스를 제대로 타고 있었다. 알고 보니 인덱스가 문제가 아니라, 인덱스로 행을 읽어온 다음 그 대부분을 그냥 버리는 게 문제였다.

두 번째, 무한 스크롤 피드. 같은 글이 두 번 보인다거나, 분명 봤는데 다시 찾으면 없다는 제보가 들어왔다. 앞의 것과는 성격이 아예 달라서 에러 로그도 안 남고 재현도 안 됐고, 로컬에서 아무리 스크롤해봐도 멀쩡하기만 했다. 조건을 알고 나서야 겨우 재현이 됐다. 페이지 1과 페이지 2 요청 사이에 글이 하나 등록돼야 한다는 조건이었다. 개발 환경에는 그 몇 초 사이에 글을 쓰고 있는 사람이 없으니 나올 리가 없었다.

세 번째, Elasticsearch 검색. 페이지를 넘기다 보면 어느 지점부터 500이 떨어지면서 Result window is too large가 찍혔다. 딱 봐도 설정값 문제처럼 보이고 실제로 max_result_window를 올리면 에러가 없어지기 때문에 그렇게 넘어가기 쉬운데, 이건 버그가 아니라 Elasticsearch가 미리 막아준 것에 가깝다. 왜 하드 리밋으로 걸어뒀는지는 뒤에서 다시 본다.

셋은 각각 성능, 정확성, 시스템 제약으로 성격이 달라 보이지만 사실 원인은 하나다. OFFSET은 위치를 “몇 번째”로 표현한다는 것이다.

▍OFFSET이 실제로 하는 일

LIMIT 20 OFFSET 100000을 보면 “100,000번째부터 20개”로 읽히지만, DB 입장에서는 100,000번째로 바로 점프할 방법이 없다. 인덱스는 정렬된 값을 늘어놓은 것이지 첨자로 바로 접근하는 배열이 아니기 때문에, 몇 번째인지 알아내려면 결국 처음부터 세는 수밖에 없다.

그래서 실제로는 이런 일이 벌어진다.

OFFSET 100000 LIMIT 20
1. 인덱스를 정렬 순서대로 스캔한다
2. 행을 하나씩 상위 노드로 올려보낸다
3. Limit 노드가 앞의 100,000개를 세면서 버린다
4. 그 다음 20개를 반환한다

읽은 행 : 100,020
버린 행 : 99,999 + 1
반환한 행: 20

그래서 비용은 O(offset + limit)이 되고, 페이지 번호에 선형으로 비례한다. 1페이지가 빠른 건 인덱스가 잘 잡혀 있어서지만, 5,000페이지가 느린 건 인덱스와 아무 상관이 없다.

▍직접 측정한 결과

체감 말고 숫자로 보고 싶어서 로컬에 PostgreSQL 18.6을 띄우고 100만 행을 넣어봤다. 재현 스크립트는 아래와 같다.

CREATE TABLE posts (
  id         bigint PRIMARY KEY,
  created_at timestamptz NOT NULL,
  title      text NOT NULL,
  body       text NOT NULL
);

-- 5개 행이 같은 created_at을 공유하도록 넣는다.
-- 정렬키가 unique하지 않은 상황을 일부러 만드는 것이다. (뒤에서 중요해진다)
INSERT INTO posts (id, created_at, title, body)
SELECT i,
       timestamptz '2024-01-01 00:00:00+09' + ((i / 5) * interval '1 minute'),
       'post title ' || i,
       repeat('x', 200)
FROM generate_series(1, 1000000) AS s(i);

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

테이블 크기는 인덱스까지 합쳐 312MB이고, 측정한 쿼리는 세 가지다.

-- A. 평범한 offset
SELECT id, created_at, title, body FROM posts
ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET :o;

-- B. deferred join (late row lookup) — offset의 대표적 완화책
SELECT p.id, p.created_at, p.title, p.body FROM posts p
JOIN (SELECT id, created_at FROM posts
      ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET :o) k
  ON k.id = p.id
ORDER BY k.created_at DESC, k.id DESC;

-- C. keyset (cursor)
SELECT id, created_at, title, body FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC LIMIT 20;

지점마다 워밍업을 한 번 돌린 뒤 5회씩 실행해서 EXPLAIN (ANALYZE, BUFFERS)의 Execution Time 중간값을 적었고, 전부 warm cache 기준이다.

OFFSET 위치A. offsetB. deferred joinC. keyset
00.019 ms0.039 ms0.022 ms
10,0000.947 ms0.603 ms0.022 ms
100,0009.133 ms5.765 ms0.024 ms
999,980141.140 ms56.136 ms0.017 ms

첫 페이지에서는 셋 다 비슷하고, 오히려 deferred join이 조인 오버헤드 때문에 두 배쯤 느리다. 그런데 마지막 페이지까지 가면 offset과 keyset의 차이가 8,300배까지 벌어진다.

같은 20행을 얻는 두 가지 방법 · 100만 행의 마지막 페이지OFFSET999980읽고 버린 999,980행반환 20141.140 ms · 37,168 buffers · 1,000,000 rows scannedKEYSETcursorB-tree seek 1회 · 커서 값으로 바로 찾아간다건드리지 않음읽은 200.017 ms · 4 buffers · 20 rows scanned

실행 계획을 나란히 놓으면 이유가 한 줄로 보인다.

-- A. offset 999980
Limit  (actual time=141.014..141.016 rows=20.00)
  Buffers: shared read=37168
  ->  Index Scan using idx_posts_created_id on posts
        (actual time=0.012..123.143 rows=1000000.00)   <-- 100만 행을 올려보냈다

-- C. keyset
Limit  (actual time=0.003..0.004 rows=20.00)
  Buffers: shared hit=4
  ->  Index Scan using idx_posts_created_id on posts
        (actual time=0.002..0.003 rows=20.00)          <-- 20행만 읽었다
        Index Cond: (ROW(created_at, id) < ROW('2024-01-01 00:04:00+09', 21))

눈여겨볼 건 시간보다 버퍼 쪽인데, offset이 37,168 블록(약 290MB)을 디스크에서 읽는 동안 keyset은 4 블록(32KB)을 캐시에서 읽고 끝났다. I/O 차이가 9,000배다. 그리고 이 피해는 요청 하나로 끝나지 않는다. 한 번에 캐시를 통째로 밀어내기 때문에 깊은 페이지 요청 몇 개가 아무 상관 없는 다른 쿼리까지 같이 느리게 만든다.

▍deferred join이 해결책이 아닌 이유

offset을 당장 걷어낼 수 없는 상황에서 흔히 꺼내는 카드가 deferred join인데, 실측에서도 141ms에서 56ms로 2.5배 빨라졌다. 그런데 실행 계획을 열어보면 뭐가 좋아졌는지보다 뭐가 그대로인지가 먼저 눈에 들어온다.

-- B. deferred join 999980
Nested Loop  (actual time=55.777..55.789 rows=20.00)
  ->  Limit  (actual time=55.766..55.768 rows=20.00)
        ->  Index Only Scan using idx_posts_created_id on posts
              (actual time=0.005..38.148 rows=1000000.00)   <-- 여전히 100만 행
              Heap Fetches: 0
  ->  Index Scan using posts_pkey on posts p  (loops=20)    <-- 힙 접근은 20번만

rows=1000000은 그대로고, 줄어든 것은 행 하나당 비용이다. 서브쿼리가 인덱스에 들어 있는 컬럼만 읽으니 index-only scan이 되고(Heap Fetches: 0), 본문 200바이트를 들고 오는 힙 접근은 최종 20건에만 일어나기 때문이다. 실제로 스캔한 행 수를 표로 정리하면 아래와 같다.

OFFSETA가 스캔한 행B가 스캔한 행C가 스캔한 행
10,00010,02010,02020
100,000100,020100,02020
999,9801,000,0001,000,00020

deferred join은 버리는 행을 싸게 버리는 기법이다. 버리는 일 자체를 없애지는 못한다.

O(offset + limit)은 상수만 작아진 채 그대로 남아 있기 때문에, 데이터가 열 배로 늘어나면 결국 같은 자리로 돌아온다.

▍더 조용한 문제, 중복과 누락

사실 두 번째가 진짜 문제였다. 성능은 그래프만 봐도 드러나지만, 이쪽은 로그에 아무것도 안 남기 때문이다.

OFFSET 5는 “6번째 행부터”라는 뜻인데, 문제는 그 6번째가 요청할 때마다 다른 행이라는 점이다. 페이지 1을 내려주고 페이지 2 요청이 들어오기까지의 몇 초 사이에 글이 하나만 등록돼도 뒤쪽 순번이 전부 한 칸씩 밀려버린다.

실제로 페이지 크기를 5로 두고 재현해봤다. 첫 페이지를 받은 다음 새 글을 1건 넣고 두 번째 페이지를 요청하면 이렇게 된다.

페이지 1과 페이지 2 사이에 글 1건이 등록되면t1 · page 1LIMIT 51000000999999999998999997999996↑ 마지막 행 = 커서t2 · INSERT새 글 1건 → 모든 행의 순번이 한 칸씩 밀린다t3 · OFFSET 5page 2999996999995999994999993999992page 1에서 이미 본 행t3 · KEYSETcursor = 999996999995999994999993999992999991중복 없음 · 커서는 순번이 아니라 값을 기준으로 한다

삭제는 방향만 반대일 뿐 똑같다. 페이지 1에 있던 행이 하나 지워지면 뒤의 행들이 한 칸씩 당겨지는데, OFFSET 5는 그 당겨진 행을 그대로 건너뛰어 버린다.

페이지 1에 있던 행이 삭제되면t1 · page 1LIMIT 51000000999999999998999997999996↑ 곧 삭제될 행t2 · DELETE999998 삭제 → 뒤의 행이 한 칸씩 당겨진다t3 · OFFSET 5page 2999995OFFSET이 건너뛴 행999994999993999992999991999990t3 · KEYSETcursor = 999996999995999994999993999992999991그대로 나온다999995는 page 1에도 page 2에도 없다 · 에러 없이 사라진다

왜 재현이 안 됐는지도 여기서 풀린다. 개발 환경에서는 페이지와 페이지 사이에 데이터가 들어올 일이 없으니, 이 버그는 트래픽이 있는 운영에서만, 그것도 타이밍이 맞아떨어져야 터진다. 그리고 하필 이게 가장 자주 터지는 화면이 최신순 무한 스크롤 피드다. 최신순이니까 새 글은 목록 맨 앞에 꽂히는데, 무한 스크롤은 바로 그 맨 앞에서부터 읽어 내려간다. 글이 하나 등록될 때마다 아직 안 읽은 구간이 통째로 한 칸씩 밀리는 셈이다.

OFFSET은 “데이터가 멈춰 있다”는 가정 위에서만 정확하다.

전체를 훑는 작업(export, 배치 마이그레이션)이라면 상황이 더 나쁘다. 빠진 행은 조용히 없는 데이터가 되고 중복된 행은 조용히 두 번 처리되는데, 멱등하지 않은 처리라면 이게 그대로 사고로 이어진다.

▍세지 않고 바로 찾아가는 커서

커서 방식(keyset pagination)은 위치를 “몇 번째”가 아니라 “어떤 값 다음”으로 표현한다. 순번은 데이터가 변할 때마다 흔들리지만 값 자체는 흔들리지 않기 때문이다.

-- 첫 페이지
SELECT id, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 21;                              -- +1로 다음 페이지 존재 여부 판정

-- 다음 페이지
SELECT id, created_at FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 21;

그런데 여기서 많이들 틀린다. 보통 “유일한 정렬 키가 필요하다”고만 알고 넘어가는데, 왜 필요한지까지 알아야 제대로 쓸 수 있다.

WHERE created_at < :cursor로만 쓰면 커서와 같은 시각인 행이 전부 < 조건에 걸려서 통째로 날아간다. 아까 데이터를 만들 때 5개 행이 같은 타임스탬프를 갖도록 넣은 것도 이걸 보기 위해서였다. 그렇다고 <=로 바꾸면 이번에는 커서 행이 매번 다시 나오면서 무한 루프에 빠진다. 답은 둘 중 하나를 고르는 게 아니라 정렬키를 unique해질 때까지 늘리는 것이다.

같은 목적, 네 가지 조건문WHERE created_at < :c커서와 같은 시각인 행이 통째로 누락된다WHERE created_at <= :c커서 행이 다시 나와 무한 루프에 빠진다WHERE created_at < :c OR (created_at = :c AND id < :cid)결과는 맞지만 옵티마이저가 필터로 처리할 수 있다WHERE (created_at, id) < (:c, :cid)ORDER BY created_at DESC, id DESCINDEX (created_at DESC, id DESC)row 비교 → Index Cond로 내려가 B-tree seek 한 번이게 정답

굳이 row 비교((a, b) < (x, y)) 문법을 쓰는 이유는 실행 계획에 그대로 드러나는데, PostgreSQL이 이 조건을 Index Cond까지 밀어넣어서 B-tree를 딱 한 번만 seek하기 때문이다. 같은 뜻이라도 OR로 풀어 쓰면 옵티마이저가 이걸 못 알아보고 필터로 처리해버리는 경우가 생긴다.

그리고 ORDER BY와 인덱스의 컬럼, 방향이 정확히 맞아떨어져야 한다. 하나만 어긋나도 정렬 단계가 따로 붙는데, 정렬은 어차피 전체를 읽고 시작하니 커서로 바꾼 의미가 통째로 사라진다.

▍성능 기법이 아니라 캡슐화

여기까지는 커서가 빠르다는 얘기였는데, 실제로 쓰이고 있는 API들을 열어보면 조금 다른 그림이 나온다.

Slack의 users.list 커서 dXNlcjpXMDdRQ1JQQTQ=를 디코딩하면 user:W07QCRPA4가 나온다. 마지막 사용자 ID를 담은 평범한 keyset 커서다. 그런데 Slack 엔지니어링 블로그를 보면 커서 안에 offset 값을 넣어도 된다고 대놓고 써 있는데, 기존 offset 엔드포인트를 커서 인터페이스로 감싸기 위해서다. 샤딩된 데이터에서는 샤드별 커서를 하나로 묶어서 인코딩한다는 얘기까지 나온다.

GitHub도 비슷하다. GraphQL API의 커서 Y3Vyc29yOjEwMDA=을 디코딩하면 cursor:1000, 사실상 순번이 그대로 들어 있다. 그리고 검색 결과는 REST든 GraphQL이든 1,000건에서 잘린다.

정리하면 커서는 그 자체로 성능 기법이라기보다 위치를 어떻게 표현할지를 서버 안쪽으로 숨기는 캡슐화에 가깝고, 실무에서는 오히려 이쪽이 성능보다 더 중요할 때가 많다.

  • 클라이언트가 page=5를 알고 있으면, 그 API는 영원히 “5번째 페이지”라는 개념을 지원해야 한다.
  • 클라이언트가 불투명한 문자열만 알고 있으면, 서버는 내부를 offset에서 keyset으로, 다시 샤드별 커서 묶음으로 바꿔도 약속이 깨지지 않는다.

그래서 커서를 도입할 때는 base64 같은 걸로 한 번 감싸고 안에 버전 필드를 넣어둔다. created_atid를 날것으로 노출해버리면 그 두 컬럼이 그대로 API 스펙이 되어서, 나중에 정렬 기준을 바꾸려는 순간 손발이 묶이기 때문이다.

// 클라이언트가 보는 것
{ "next_cursor": "eyJ2IjoxLCJ0IjoiMjAyNC0wNS0xOFQyMToxOTowMCswOSIsImkiOjk5OTk5Nn0=" }

// 서버가 디코딩한 것 — v는 커서 포맷 버전
{ "v": 1, "t": "2024-05-18T21:19:00+09", "i": 999996 }

▍커서가 못 하는 것

그렇다고 커서가 늘 정답인 건 아니다. 커서로 넘어가는 순간 분명히 포기해야 하는 것들이 생긴다.

1. 아무 페이지로나 바로 건너뛸 수 없다. (created_at, id) < (:c, :cid) 조건을 만들려면 그 지점의 값을 이미 알고 있어야 하는데, 37페이지 첫 행이 무슨 값인지는 세어보기 전에 알 방법이 없다. 다만 이게 페이지 번호 UI를 못 쓴다는 뜻은 아니라서, 이 얘기는 바로 다음 절에서 다시 다룬다.

2. 전체 개수와 전체 페이지 수를 주기 어렵다. 커서라서 못 주는 게 아니라 COUNT(*) 자체가 원래 비싸기 때문인데, 같은 100만 행 테이블에서 재보면 이 정도다.

SELECT count(*) FROM posts;
Finalize Aggregate  (actual time=26.569..27.664 rows=1.00)
  Buffers: shared hit=10 read=2731
Execution Time: 27.696 ms

리더까지 병렬 프로세스를 3개나 동원하고도 27.7ms가 걸렸다. keyset 조회 본체(0.017ms)의 1,600배다. 페이지네이션만 커서로 바꿔놓고 총 개수를 그대로 내려주면 결국 비용의 대부분을 COUNT가 먹는다. 그래서 실제 서비스들은 총 개수를 아예 빼거나, 근사치로 주거나, “1,000+“처럼 뭉뚱그려 표기한다.

3. 값이 변하는 정렬에 약하다. created_at처럼 한 번 정해지면 안 바뀌는 값은 커서로 삼기 쉽지만, 좋아요 수나 인기 점수처럼 계속 바뀌는 값은 커서가 가리키는 위치 자체가 움직여버린다. (score, id)로 묶어놓아도 score가 바뀌는 순간 기준점이 흔들리기 때문에, 이럴 때는 정렬 스냅샷(Elasticsearch의 PIT 같은 것)이 따로 있어야 한다.

4. 정렬 기준을 바꾸면 이미 발급한 커서가 전부 무효가 된다. 사용자가 정렬을 바꿀 때마다 첫 페이지로 되돌려야 하고, 커서 안에 정렬 키를 같이 넣어두고 요청마다 검증하는 편이 안전하다.

5. 이전 페이지 구현과 프론트 수정 비용. “이전 페이지”를 만들려면 조건과 정렬을 뒤집어서 조회한 다음 결과를 다시 뒤집어야 하는데, Slack이 초기에 역방향 탐색을 아예 안 만든 것도 이 번거로움 때문이다. 여기에 프론트엔드가 페이지 번호 기준으로 짜여 있다면 그쪽 코드까지 다 손봐야 한다.

▍OFFSET 없이 만드는 페이지 번호 UI

성능 얘기를 꺼내면 대화가 자주 여기서 끝난다.

“커서로 바꾸면 빨라질 텐데요.” “안 됩니다. 저희는 페이지 번호가 있어서요.”

대화가 여기서 멈추는 이유는 페이지 번호와 OFFSET을 한 덩어리로 보기 때문이다. 페이지 번호는 화면의 모양이고 OFFSET은 데이터를 가져오는 방법이라, 둘은 그동안 늘 붙어다녔을 뿐 원래부터 한 몸이었던 건 아니다.

앞에서 페이지 점프가 안 되는 이유가 37페이지 첫 행의 값을 모르기 때문이라고 했는데, 그렇다면 그 값을 미리 구해두면 된다.

-- 20행마다 한 번씩, 각 페이지의 첫 행 값을 앵커로 저장한다
CREATE TABLE posts_page_anchor (
  page       int PRIMARY KEY,
  created_at timestamptz NOT NULL,
  id         bigint NOT NULL
);

INSERT INTO posts_page_anchor (page, created_at, id)
SELECT (rn / 20) + 1, created_at, id
FROM (
  SELECT created_at, id,
         row_number() OVER (ORDER BY created_at DESC, id DESC) - 1 AS rn
  FROM posts
) t
WHERE rn % 20 = 0;

같은 100만 행 테이블에서 앵커 5만 개를 만드는 데 170ms가 걸렸고, 앵커 테이블 크기는 원본 312MB의 1.2%인 3.6MB다. 게다가 이 비용은 요청할 때마다가 아니라 갱신 주기마다 한 번만 들면 된다.

읽을 때는 앵커를 그대로 커서로 쓴다.

SELECT p.id, p.created_at, p.title, p.body FROM posts p
WHERE (p.created_at, p.id) <= (SELECT a.created_at, a.id
                               FROM posts_page_anchor a WHERE a.page = 50000)
ORDER BY p.created_at DESC, p.id DESC
LIMIT 20;

앞 절에서는 <=가 틀렸다고 했는데 여기서는 <=가 맞다. 일반 커서는 직전 페이지의 마지막 행을 가리키니까 그 행을 빼야 하고, 앵커는 그 페이지의 첫 행을 가리키니까 그 행부터 포함해야 하기 때문이다.

서브쿼리를 껴넣어도 실행 계획은 안 망가진다.

Limit  (actual time=0.032..0.035 rows=20.00)
  Buffers: shared hit=2 read=5
  InitPlan 1
    ->  Index Scan using posts_page_anchor_pkey on posts_page_anchor a
          (actual time=0.014..0.015 rows=1.00)
          Index Cond: (page = 50000)
  ->  Index Scan using idx_posts_created_id on posts p
        (actual time=0.032..0.033 rows=20.00)
        Index Cond: (ROW(created_at, id) <= ROW((InitPlan 1).col1, (InitPlan 1).col2))

앵커 조회는 InitPlan으로 한 번만 풀리고 row 비교는 그대로 Index Cond로 내려가니, 결국 B-tree seek 한 번으로 끝난다. 5만 페이지(OFFSET 999980과 같은 지점) 조회 중간값은 0.027ms로, 같은 지점 offset의 141ms와 비교하면 5,200배 차이다. 그런데도 화면에는 [1] [2] … [50000] 버튼이 그대로 남아 있다. 링크가 ?page=37에서 ?cursor=…로 바뀔 뿐이다.

대신 대가는 있다.

  • 앵커는 시간이 지나면 어긋난다. 앞쪽에 행이 들어오면 실제 경계가 밀리니까, 얼마나 자주 갱신하느냐가 곧 오차의 크기다. 그래서 이렇게 만든 페이지 번호는 정확한 절대 순번이 아니라 어느 정도 안정적인 근사 위치에 가깝다. 다만 offset의 순번도 애초에 흔들리고 있었으니, 여기서 정확도가 더 나빠지지는 않는다.
  • 정렬과 필터 조합마다 앵커를 따로 만들어야 한다. 정렬 3가지에 필터 4가지면 앵커도 12벌이 필요하니, 조합이 많은 화면에서는 사실상 못 쓰는 방법이다.
  • 총 개수는 여전히 따로 풀어야 한다. 마지막 페이지 번호를 보여주려면 결국 전체 개수를 알아야 하는데, 그나마 앵커 테이블의 max(page)를 근사치로 쓸 수 있다는 게 덤으로 따라온다.

그래서 화면을 놓고 먼저 물어야 할 질문은 “페이지 번호가 필요한가”가 아니라 “실제로 아무 페이지로나 건너뛰는 일이 일어나는가”다. 취향으로 정할 문제가 아니라 로그만 보면 답이 나오는 문제다.

▍실제 서비스들의 선택

서비스방식특징
Stripestarting_after / ending_before (객체 ID)처음부터 커서. limit 최대 100
Slackcursor + limit, response_metadata.next_cursoroffset에서 전환. 일부 메서드는 커서 필수
GitHub GraphQLRelay 커넥션 (after, pageInfo.endCursor)커서를 스펙 레벨로 강제. 단 검색은 1,000건 상한
Elasticsearchfrom+size는 10,000 하드 리밋, 이후 search_after (+ PIT)깊은 오프셋을 아예 금지
Jira CloudstartAt 제거 → nextPageTokenoffset API가 410 Gone. total도 함께 삭제
Google 검색페이지 수 상한깊은 페이지를 제품 차원에서 포기

이 중 두 개는 따로 볼 만하다.

Elasticsearch의 10,000은 왜 하드 리밋인가. 여기서는 offset 비용에 샤드 수가 그대로 곱해지기 때문이다.

문서가 여러 샤드에 흩어져 있으면 코디네이터 노드는 전역 9,981번째 문서가 어느 샤드에 있는지 미리 알 수 없다. 스무 건이 전부 한 샤드에 몰려 있을 수도 있으니, 각 샤드는 자기 기준 상위 from + size건을 통째로 올려보내는 수밖에 없다. 코디네이터는 그걸 다 모아 다시 정렬한 다음에야 진짜 구간을 잘라낼 수 있다.

샤드 10개 · from=9980 & size=20
각 샤드가 상위 10,000건을 올려보낸다   (from + size)
코디네이터가 10 × 10,000 = 100,000건을 모은다
모은 100,000건을 다시 정렬해 20건만 남긴다

버린 문서 99,980 · 반환한 문서 20

OFFSET이 읽고 버리는 것과 구조가 같은데, 그 비용이 샤드 수만큼 복제된다. 그래서 max_result_window를 설정으로 올릴 수는 있어도 권장하지 않는다. 앞에서 본 500 에러도 결국 시스템이 미리 막아준 것이었다.

Jira는 offset에서 커서로 넘어간 사례로 볼 만하다. 기존 검색 API는 아예 없애서 410을 반환하게 만들었고, 새 API는 nextPageToken만 받는다. 여기서 눈여겨볼 건 응답에서 total까지 같이 없앴다는 점인데, 총 개수가 필요하면 근사 카운트를 따로 쓰라고 안내하고 있다. 커서로 옮긴다는 게 사실상 총 개수를 포기한다는 뜻이기도 하다는 걸 그대로 보여준다.

여기까지 놓고 보면 패턴이 하나 보인다. 뒤로 갈수록 가치가 떨어지는 데이터는 커서로 넘어가고, 표처럼 특정 위치를 짚어야 하는 데이터는 offset을 그대로 둔다. 그리고 커서로 갈 수 없는 경우에는 깊이 자체를 제품 차원에서 잘라버리는데, 구글이 검색 결과 페이지 수를 제한하는 것도 같은 선택이다.

▍offset과 커서의 선택 기준

실제로 쓰는 판단 순서는 이렇다.

위에서부터 내려가며, 처음 "예"가 나오는 곳에서 멈춘다임의의 페이지로 곧장 점프해야 하는가?페이지 번호 UI가 있다는 것 자체는 이유가 아니다OFFSET 유지상한을 두거나, 앵커 커서로아니오요청 사이에 데이터가 계속 삽입 / 삭제되는가?실시간 피드, 알림, 로그CURSOR중복과 누락이 사라진다아니오도달 깊이가 수만 행을 넘어가는가?무한 스크롤, 전체 순회, 데이터 내보내기CURSOR깊이와 무관하게 일정아니오정렬키가 unique한가? tie-breaker를 붙일 수 있는가?created_at 단독은 부족하다 · (created_at, id)정렬키 설계부터CURSOR의 전제 조건아니오OFFSET으로 시작하되, 커서 인터페이스로 감싸둔다내부를 keyset으로 바꿔도 API 계약이 깨지지 않는다

첫 번째 분기는 로그를 보고 정하는 게 낫다. 액세스 로그에서 page 값 분포만 뽑아봐도 답이 거의 나와 있는데, 요청은 거의 다 1~3페이지에 몰려 있고, 뒤쪽 꼬리는 사람이 아니라 크롤러나 무한 스크롤 컴포넌트가 자동으로 부른 것이 많다. “5,000페이지로 건너뛰는 사용자”는 보통 요구사항 문서에만 존재한다. 숫자 없이 상한 얘기를 꺼내면 회의가 길어지지만, “요청의 99.9%가 40페이지 안쪽”이라는 한 줄만 들고 가면 그 자리에서 정리된다.

세 번째 항목의 기준선이 생각보다 낮다는 것도 짚어두고 싶다. 실측에서 offset 10,000은 0.947ms였는데, 그냥 보면 아무 문제 없어 보이는 숫자다. 하지만 이건 100만 행에서 나온 값이고, 초당 수십 번씩 불리는 API라면 이 0.947ms가 쉬지 않고 캐시를 밀어낸다. 문제가 터진 다음에 바꾸는 것보다, 데이터가 열 배 늘었을 때를 가정하고 미리 정해두는 편이 낫다.

사실 이 판단에 들어가기 전에 확인할 게 하나 더 있는데, 범위를 먼저 좁혔는가다. offset이든 커서든 수천만 건을 필터 없이 훑는 화면은 방식을 바꿔도 느리다. 기간이나 상태, 담당자 같은 조건을 기본값으로 걸어 대상을 먼저 줄이면 애초에 깊은 페이지가 생기지 않는다. 실제로 관리자 화면 성능 이슈는 페이지네이션 방식보다 기본 필터가 없어서 생기는 경우가 더 많으니, 페이지네이션을 손대기 전에 이것부터 확인하는 게 순서다.

▍한 번에 갈아타지 않는 마이그레이션

이미 offset으로 돌아가고 있는 API가 있다면 순서는 이렇다.

  1. 정렬 인덱스를 먼저 만든다. (sort_key DESC, id DESC). CONCURRENTLY로.
  2. 응답에 next_cursor를 추가한다. 요청은 아직 page를 그대로 받으니 클라이언트는 아무것도 안 바꿔도 된다.
  3. 커서 파라미터를 같이 받는다. cursor가 오면 keyset으로, 없으면 기존 offset으로 처리하고, 두 경로가 같은 결과를 내는지 테스트로 묶어둔다.
  4. 클라이언트를 전환한다. 새 화면부터, 그리고 무한 스크롤 화면부터.
  5. 깊은 offset을 먼저 막는다. 다 걷어내기 전에 page > N 요청에 상한을 두면, 어떤 클라이언트가 얼마나 깊이 들어오는지가 로그로 드러난다.
  6. 총 개수 응답을 정리한다. 근사치로 바꾸든 상한 표기(1,000+)로 바꾸든 아예 빼든, 이 단계에서는 프론트엔드와 얘기가 필요하다.
  7. offset 경로를 제거한다.

핵심은 2번을 먼저 하는 것이다. 커서 인터페이스만 열어두면 그 안에서 offset을 keyset으로 갈아끼우는 일은 클라이언트가 알 수도 없는 서버 내부 변경으로 끝난다. Slack이 커서 안에 offset을 넣어도 된다고 한 이유도 이것이다.

▍마무리

정리하면 이렇다.

  1. offset 비용은 O(offset + limit)이라 페이지 번호에 선형으로 비례하고, 인덱스로는 해결되지 않는다.
  2. deferred join은 행 하나당 비용을 줄여줄 뿐, 버리는 행 수 자체는 그대로다.
  3. 커서는 순번이 아니라 값으로 위치를 잡기 때문에 중복과 누락이 생기지 않는다.
  4. 커서를 쓰려면 unique한 정렬키가 있어야 하고, 없으면 (created_at, id)처럼 tie-breaker를 붙여서 만들어야 한다.
  5. 커서로 옮기는 건 총 개수를 포기하는 결정에 가깝지만, 페이지 점프는 경계 커서를 미리 만들어두면 페이지 번호 UI째로 살릴 수 있다.
  6. 커서의 진짜 이점은 성능보다 내부 구현을 언제든 바꿀 수 있다는 쪽에 있다.

그렇다고 offset이 틀린 방식이라는 얘기는 아니다. offset은 데이터가 멈춰 있고 페이지가 얕다는 두 가지를 전제로 깔고 있을 뿐이다. 관리자 화면처럼 그 전제가 실제로 맞는 곳에서는 지금도 제일 단순하고 좋은 선택이다.

문제는 그 전제가 깨지는 순간인데, 그때부터 offset은 느려지는 데서 그치지 않고 틀리기 시작한다. 141ms는 그래프에 남으니 언젠가는 잡히지만, 빠져버린 한 행은 아무 데도 남지 않는다.


측정 환경: PostgreSQL 18.6 (Homebrew, Apple Silicon), 100만 행 / 312MB, 기본 설정, warm cache, 각 지점 워밍업 1회 후 5회 실행 중간값.

참고