데이터 페이지네이션
문서 목록을 25개씩 보여줘도 서버가 모든 문서와 관련 데이터를 먼저 읽으면 조회 비용은 그대로 남는다. 페이지네이션은 이번에 어떤 문서를 가져올지 정하고, N+1 문제는 그 문서들의 관련 정보를 몇 번의 조회로 얻을지 다룬다. 화면을 나누는 일과 DB 호출을 줄이는 일을 함께 살펴야 하는 이유다.
순서와 가져오는 범위를 따로 정하기
관계형 조회에서 결과의 순서는 정렬 조건으로 표현한다. PostgreSQL 문서는 LIMIT·OFFSET으로 일부만 가져올 때 결과를 유일하게 정하는 ORDER BY가 필요하다고 명시한다. 같은 작성 시각을 가진 문서가 있다면 시각만 정렬해서는 그 안의 순서가 정해지지 않으므로, 고유 ID 같은 동률 해소 기준도 넣는다.1
여기서 OFFSET은 앞에서 몇 행을 건너뛸지 나타내고, 커서는 다음 조회가 시작할 위치를 재현할 정보다. 키셋(keyset) 방식은 마지막으로 본 정렬값을 기억한 뒤 그 뒤의 값을 찾는다. 적합한 인덱스가 있으면 깊은 페이지에서 많은 행을 건너뛰는 일을 줄일 수 있다.
조회 기능이 발전해 온 기록
페이지네이션 전체의 발명자나 최초 명명 시점은 여기서 다루는 자료로 확인되지 않는다. 구현의 변화를 보여주는 기록은 따로 확인할 수 있다. SQLite는 2016년 10월 14일 버전 3.15.0에 row value 비교를 추가했고, 공식 문서는 이 기능으로 마지막 성과 이름을 기억해 다음 연락처들을 찾는 scrolling-window 예제를 제공한다. 이 날짜는 SQLite 기능 추가일이지 키셋 조회의 발명일이 아니다.2
이와 별개로 ORM은 객체의 관계를 필요할 때 읽는 lazy loading과 미리 가져오는 eager loading을 제공해 왔다. 현재 SQLAlchemy의 관계 로딩 문서는 N개 객체를 얻은 뒤 각 객체의 관계 접근이 추가 SELECT를 발생시키는 전형적인 N+1 문제와, join·select-IN·raiseload 같은 선택지를 설명한다. 페이지의 크기와 관계를 읽는 방식은 서로 다른 층에서 정해진다.3
마지막 정렬값을 함께 넘기기
다음 PostgreSQL 예제는 created_at과 고유 id를 모두 내림차순으로 정렬한다. 시각은 비교가 잘 보이도록 정수로 두었고 두 정렬 필드는 모두 NULL을 허용하지 않는다.
CREATE TEMP TABLE articles (
id integer PRIMARY KEY,
created_at integer NOT NULL,
author_id integer NOT NULL
);
CREATE INDEX article_page ON articles (created_at DESC, id DESC);
INSERT INTO articles VALUES
(1, 50, 7), (2, 40, 8), (3, 40, 7), (4, 30, 8);
SELECT id, created_at FROM articles
ORDER BY created_at DESC, id DESC LIMIT 2;
INSERT INTO articles VALUES (5, 60, 7);
SELECT id FROM articles
ORDER BY created_at DESC, id DESC LIMIT 2 OFFSET 2;
SELECT id FROM articles
WHERE (created_at, id) < (40, 3)
ORDER BY created_at DESC, id DESC LIMIT 2;
첫 페이지는 ID 1, 3이고 마지막 위치는 (40, 3)이다. 맨 앞에 새 문서가 들어오면 OFFSET의 다음 페이지는 3, 2가 되어 이미 본 3이 겹친다. 복합 커서는 2, 4를 반환한다. created_at < 40만 조건으로 쓰면 같은 시각의 2가 빠지므로, 정렬을 결정한 값들을 커서에도 함께 넣는다. 행 값은 왼쪽 값부터 차례로 비교한다.4
관련 정보를 묶어서 읽기
문서 2개를 읽고 각각의 저자를 한 번씩 조회하면 모두 3번의 조회가 된다. 다음 SQLite 예제는 먼저 부모 문서의 페이지를 정하고, 필요한 저자들을 묶어서 가져온다. 이 고정 크기 예제에서는 SELECT가 2번 실행된다.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE authors(id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE articles(id INTEGER PRIMARY KEY, author_id INTEGER NOT NULL);
INSERT INTO authors VALUES(7, 'Ada'), (8, 'Grace');
INSERT INTO articles VALUES(1, 7), (2, 8), (3, 7);
""")
queries = []
db.set_trace_callback(queries.append)
page = db.execute(
"SELECT id, author_id FROM articles ORDER BY id LIMIT ?", (2,)
).fetchall()
author_ids = sorted({author_id for _, author_id in page})
authors = {}
if author_ids:
placeholders = ",".join("?" for _ in author_ids)
authors = dict(db.execute(
f"SELECT id, name FROM authors WHERE id IN ({placeholders})",
author_ids,
))
result = [(article_id, authors[author_id]) for article_id, author_id in page]
assert result == [(1, 'Ada'), (2, 'Grace')]
assert sum(sql.lstrip().upper().startswith("SELECT") for sql in queries) == 2
print(result)
db.close()
SQL에 끼워 넣는 것은 내부에서 만든 플레이스홀더뿐이고, 저자 ID는 매개변수로 전달한다. 실제 조회 계약에는 관계의 단계 수, ID 개수 상한, 사라진 저자를 어떻게 표시할지도 포함한다. select-IN이 큰 집합을 여러 배치로 나눠 읽을 수 있으므로, 모든 페이지가 반드시 2번의 조회로 끝나는 것은 아니다.3
커서가 가리키는 위치는 언제까지 유효할까
키셋 방식 자체가 고정 스냅샷은 아니다. 이미 본 행의 정렬값이 커서 뒤쪽으로 바뀌면 그 행이 다시 나타날 수 있다. 독립적인 HTTP 요청은 서로 다른 스냅샷을 읽을 수 있으므로, 정확히 고정된 집합을 끝까지 훑어야 한다면 결과 집합을 저장하거나 적절한 버전 경계를 따로 설계한다. PostgreSQL의 Read Committed에서는 같은 트랜잭션에서도 문장마다 보이는 커밋이 바뀔 수 있다.5
일대다 JOIN에 곧바로 LIMIT를 적용하면 자식 때문에 늘어난 행 수를 제한해 부모 문서가 예상한 개수에 못 미칠 수 있다. 부모의 페이지를 먼저 확정해 관련 정보를 읽거나, 부모를 제한한 서브쿼리에 JOIN하는 등 무엇을 25개로 셀지부터 정한다. 성능은 왕복 수뿐 아니라 훑은 행 수, 반환 바이트, 실행계획도 함께 측정한다.
예제 환경: SQL은 PostgreSQL 18, 관련 정보 조회는 Python 3의 표준 SQLite. 커서를 불투명한 문자열로 만들어 API에 넘겨도 검색 조건·정렬·권한 검증은 서버가 맡는다.
각주
- PostgreSQL 18, §7.6 “LIMIT and OFFSET”. 공식 문서. ↩
- SQLite, “Row Values”, §3.1 “Scrolling Window Queries”와 §4 “Backwards Compatibility”. 공식 문서. ↩
- SQLAlchemy 2.0, “Relationship Loading Techniques”, lazy loading·select-IN loading·raiseload·joined eager loading. 공식 문서. ↩
- PostgreSQL 18, §9.25.5 “Row Constructor Comparison”. 공식 문서. ↩
- PostgreSQL 18, §13.2 “Transaction Isolation”. 공식 문서. ↩