본문으로 건너뛰기
뒤로
Database

섹션8 실습 2: PostgreSQL keyset 페이지네이션

섹션8: PostgreSQL 페이지네이션에서 본 커서는 페이지 번호가 아니라 마지막으로 전달한 정렬 키다. 작은 재현 테이블에서 페이지 경계와 실행 계획을 확인한다. 아래 결과는 PostgreSQL 17.11에서 직접 실행했다.

준비

docker run --rm --name section8-pagination-pg -e POSTGRES_PASSWORD=pw -d postgres:17.11
until docker exec section8-pagination-pg pg_isready -U postgres; do sleep 1; done
docker exec -it section8-pagination-pg psql -U postgres
CREATE SCHEMA section8;
CREATE TABLE section8.posts (
  id bigint PRIMARY KEY,
  created_at timestamptz NOT NULL,
  title text NOT NULL
);

INSERT INTO section8.posts
SELECT g,
       timestamptz '2026-01-01 00:00:00+00' + ((g / 3) * interval '1 second'),
       'post-' || g
FROM generate_series(1, 30) AS g;

CREATE INDEX posts_created_id_idx
  ON section8.posts (created_at DESC, id DESC) INCLUDE (title);
VACUUM (ANALYZE) section8.posts;

첫 페이지

마지막 행의 키 created_at, id

다음 쿼리의 경계 조건

다음 LIMIT 행

복합 커서가 같은 timestamp 안에서 이어진다

확인할 내용 — created_at 값이 같은 행들을 id 타이브레이커로 빠짐없이 페이지에 나누는지. (정리 글)

쿼리 — 첫 페이지

SELECT id, created_at, title
FROM section8.posts
ORDER BY created_at DESC, id DESC
LIMIT 5;

실제 결과

 id |       created_at       |  title
----+------------------------+--------
 30 | 2026-01-01 00:00:10+00 | post-30
 29 | 2026-01-01 00:00:09+00 | post-29
 28 | 2026-01-01 00:00:09+00 | post-28
 27 | 2026-01-01 00:00:09+00 | post-27
 26 | 2026-01-01 00:00:08+00 | post-26

마지막 행의 키 (2026-01-01 00:00:08+00, 26)이 다음 페이지 커서다.

쿼리 — 다음 페이지

SELECT id, created_at, title
FROM section8.posts
WHERE (created_at, id) < (timestamptz '2026-01-01 00:00:08+00', 26)
ORDER BY created_at DESC, id DESC
LIMIT 5;

실제 결과

 id |       created_at       |  title
----+------------------------+--------
 25 | 2026-01-01 00:00:08+00 | post-25
 24 | 2026-01-01 00:00:08+00 | post-24
 23 | 2026-01-01 00:00:07+00 | post-23
 22 | 2026-01-01 00:00:07+00 | post-22
 21 | 2026-01-01 00:00:07+00 | post-21

같은 timestamp의 나머지 id 25, 24, 23이 빠지지 않고 이어진다. 다음 요청에 넣을 커서는 이번 페이지 마지막 행의 (2026-01-01 00:00:07+00, 21)이다.

복합 인덱스 계획을 확인한다

확인할 내용 — 복합 행 비교가 B-tree 범위 조건으로 들어가고, 인덱스만으로 결과를 만들 수 있는지.

계획을 보기 전에 3000행으로 표를 키운다. 첫 두 절의 페이지 결과는 앞에서 만든 30행 표본에서 얻었다.

TRUNCATE section8.posts;
INSERT INTO section8.posts
SELECT g,
       timestamptz '2026-01-01 00:00:00+00' + ((g / 3) * interval '1 second'),
       'post-' || g
FROM generate_series(1, 3000) AS g;
VACUUM (ANALYZE) section8.posts;

쿼리

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT created_at, id
FROM section8.posts
WHERE (created_at, id) < (timestamptz '2026-01-01 00:00:01+00', 5)
ORDER BY created_at DESC, id DESC
LIMIT 20;

실제 결과 — PostgreSQL 17.11, 3000행 표본

Limit (actual time=0.002..0.003 rows=4 loops=1)
  Buffers: shared hit=3
  ->  Index Only Scan using posts_created_id_idx on posts (actual time=0.002..0.002 rows=4 loops=1)
        Index Cond: (ROW(created_at, id) < ROW('2026-01-01 00:00:01+00'::timestamp with time zone, '5'::bigint))
        Heap Fetches: 0
        Buffers: shared hit=3
Planning:
  Buffers: shared hit=27
Planning Time: 0.051 ms
Execution Time: 0.008 ms

실행 시간·버퍼 수는 컨테이너와 실행 시점에 따라 달라진다. 이 결과는 3000행 표본과 vacuum 직후 visibility map 조건에서 관찰했다. 같은 조건의 30행 표본에서는 다음처럼 Seq Scan과 Sort를 선택했다.

Limit (actual time=0.008..0.009 rows=4 loops=1)
  ->  Sort (actual time=0.007..0.008 rows=4 loops=1)
        Sort Key: created_at DESC, id DESC
        Sort Method: quicksort  Memory: 25kB
        ->  Seq Scan on posts (actual time=0.003..0.005 rows=4 loops=1)
              Filter: (ROW(created_at, id) < ROW('2026-01-01 00:00:01+00'::timestamp with time zone, '5'::bigint))
              Rows Removed by Filter: 26

즉 인덱스를 만들어도 작은 표본에서는 planner가 순차 스캔 후 정렬이 더 싸다고 판단할 수 있다. 실행 계획은 데이터 크기와 통계에 따라 달라진다.

OFFSET 계획과 비교한다

확인할 내용 — OFFSET은 페이지가 깊어질수록 앞선 인덱스 엔트리를 소비하는지.

쿼리

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT created_at, id
FROM section8.posts
ORDER BY created_at DESC, id DESC
LIMIT 5 OFFSET 20;

실제 결과 — PostgreSQL 17.11, 3000행 표본

Limit (actual time=0.008..0.009 rows=5 loops=1)
  Buffers: shared hit=3
  ->  Index Only Scan using posts_created_id_idx on posts (actual time=0.006..0.007 rows=25 loops=1)
        Heap Fetches: 0
Planning Time: 0.017 ms
Execution Time: 0.012 ms

노드가 25행을 내보내고 Limit이 그중 OFFSET 20개를 건너뛴 뒤 5개를 반환했다. 실행 시간과 버퍼는 실행 환경에 따라 달라진다.

정리 — 무엇을 확인했나

정리 글의 주장확인 방법결과
복합 커서는 동률 정렬 키를 이어서 읽는다첫 페이지 마지막 (created_at, id)로 다음 쿼리실제 결과에서 id 25부터 연결됨
복합 인덱스는 keyset 탐색에 쓰일 수 있다EXPLAIN ANALYZEIndex Only Scan, Heap Fetches 0
Index Only Scan은 힙 fetch가 없을 수 있다VACUUM 뒤 계획 확인해당 실험에서 0
OFFSET은 건너뛸 행까지 소비한다OFFSET 20 계획 측정Index Only Scan 25행, Limit 5행

정리하기

DROP SCHEMA section8 CASCADE;
docker stop section8-pagination-pg

참고


조회 —



댓글