데이터 페이지네이션
개요
페이지네이션(pagination)은 긴 목록을 일정한 범위로 나눠 조회하고 탐색하는 방식이다. 검색 결과를 한 번에 다 내려보내는 대신 25개씩 보여주거나, 피드에서 다음 묶음을 가져오는 동작이 여기에 해당한다. 화면의 페이지 번호와 무한 스크롤은 사용자가 범위를 탐색하는 서로 다른 인터페이스다.
데이터 조회에서는 “몇 개 가져올 것인가”와 “어디서부터 이어갈 것인가”를 함께 정해야 한다. 두 번째 질문에 답하려면 먼저 목록의 순서가 있어야 한다. 정렬 순서가 정해지지 않은 데이터에 개수 제한만 붙이면, 같은 요청을 반복해도 같은 범위를 뜻한다고 보기 어렵다.
페이지네이션에는 화면의 분할과 서버의 조회 전략이 함께 걸린다. 브라우저가 25개만 표시해도 서버가 전체 결과를 읽어 잘라낸다면, 화면의 분할과 저장소의 읽기 비용은 서로 다르게 움직인다. API와 DB 쿼리, 화면이 어떤 범위를 한 페이지라고 부르는지 맞춰야 한다.
페이지네이션 방식은 위치를 어떻게 표현하는지, 순서를 무엇으로 고정하는지, 요청과 요청 사이에 데이터가 바뀌면 결과가 무엇을 뜻하는지에 따라 갈린다. OFFSET과 키셋은 이 세 가지에서 서로 다른 선택을 한다.
연혁
2006년 12월 5일: PostgreSQL 8.2가 출시됐다. 이 버전에서 행 비교의
<,<=,>,>=가 SQL 표준대로 첫 열부터 차례로 비교하도록 바뀌었고, 열 구성이 행 값과 같은 다중 열 인덱스의 인덱스 조건으로 쓸 수 있게 됐다.12007년 9월: Mark Nottingham이 RFC 5005 「Feed Paging and Archiving」에서 웹 피드를 complete feed, paged feed, archived feed로 구분했다.2
2008년: SQL:2008이
OFFSET … FETCH구문을 도입했다고 PostgreSQL 8.4의 당시 문서가 전한다. 표준 구문이 정리된 시점이며,LIMIT이나 목록 분할이 이때 처음 생긴 것은 아니다.32016년 10월 14일: SQLite 3.15.0이 출시되며 row value 기능이 들어왔다. 이 날짜는 SQLite에 기능이 들어온 시점이며, 행 비교로 다음 범위를 찾는 방식은 2006년 PostgreSQL 8.2에서 이미 가능했다.4
OFFSET과 행의 위치
OFFSET은 정렬한 결과의 앞부분을 일정 개수 건너뛴 뒤 다음 범위를 돌려준다. 페이지 번호를 행의 위치로 옮기기 쉬워서, 20개씩 보여주는 3페이지는 “40개를 건너뛰고 20개 읽기”가 된다.
현재 PostgreSQL 문서가 강조하듯 여러 범위를 일관되게 읽으려면 정렬이 결과를 유일하게 결정해야 한다. 건너뛴 행도 서버가 계산해야 하므로 깊은 위치로 갈수록 비용이 커질 수 있다.5
키셋과 행 비교
키셋은 앞에서 센 개수 대신 마지막으로 본 키를 경계로 삼는다. SQLite 문서의 scrolling window 예제도 큰 OFFSET 대신 row value 비교로 마지막 정렬값 이후의 행을 찾는 방식을 설명한다.4
같은 시각의 글이 여럿이면 시각만으로는 위치가 정해지지 않으므로, 고유 ID를 함께 정렬해 (created_at, id)를 다음 범위의 경계로 쓴다. 두 열이 모두 내림차순이면 다음 묶음은 직전 마지막 튜플보다 작은 범위에서 찾는다. PostgreSQL의 행 비교는 왼쪽 열부터 차이를 판정하고, 예제의 시각과 ID에는 NULL이 없으며 ID가 고유하므로 같은 시각 안에서도 순서가 정해진다.6
SQL의 위치와 API의 커서
현재 GraphQL Cursor Connections 명세는 edges, pageInfo, first, after 등의 계약을 정의하고, 클라이언트는 커서의 내부 표현을 해석하지 않고 불투명한 위치 식별자로 다룬다. 이 글은 현재 커서 인터페이스를 설명할 때 이 명세를 근거로 삼는다.7
커서에는 복합 키를 넣을 수도 있고 서버가 보관한 조회 위치의 식별자를 넣을 수도 있다. 그래서 문자열이 base64로 보인다는 사실만으로 저장소가 키셋 쿼리를 실행한다고 판단할 수는 없다.
이렇게 나눠 두면 인터페이스와 조회 전략을 따로 비교할 수 있다. 화면의 “더 보기”는 커서를 받아 다음 요청을 만들고, 서버는 그 커서의 의미에 따라 저장소를 탐색한다. 화면에 숫자 버튼을 놓든 무한 스크롤을 쓰든, 실제 쿼리는 그 외형과 별개로 정해진다.
탐색용 묶음과 보관용 문서
RFC 5005는 피드를 나누는 이유로 대역폭 절약과 “sliding window” 방식의 접근을 든다. paged feed는 내용이 그대로 유지된다는 보장이 없는 임시 묶음이고, archived feed는 과거 항목을 보존하고 전체 항목을 복원할 수 있도록 영구 보관 문서와 연결을 다룬다.2
“다음 묶음이 있다”는 탐색과 “고정된 전체 기록을 얻는다”는 보존을 서로 다른 요구로 나눈 셈이다. 피드 형식에서 출발한 이 구분을 오늘날 모든 웹 API의 커서가 그대로 따르지는 않지만, 탐색과 보존을 가르는 질문은 지금의 페이지네이션에도 남아 있다.
변화하는 목록과 고정된 결과
앞쪽에 글이 추가되면 OFFSET의 위치가 밀려 같은 글을 다시 읽거나 건너뛸 수 있다. 키셋은 이 밀림을 줄이지만, 정렬값이 수정되거나 항목이 필터를 드나드는 변화는 그대로 남는다.
요청마다 다른 스냅샷을 읽는다면 사용자는 변화하는 목록을 계속 탐색하는 것이다. PostgreSQL의 Read Committed에서는 같은 트랜잭션 안에서도 명령마다 새 스냅샷을 얻을 수 있다.8
모든 항목을 한 번씩 담아야 하는 내보내기에는 적절한 스냅샷이나 저장된 결과 집합의 식별자가 필요하다. RFC 5005에서 탐색용 묶음과 보관용 문서를 나눈 질문이 데이터베이스 조회의 시간 경계로 다시 나타난다.
관계 로딩과 N+1
N+1은 부모 목록을 한 번 읽은 뒤 각 행의 관계 데이터를 따로 조회하는 패턴이다. 부모를 20개로 제한해도 관계 조회가 20번 추가될 수 있으므로, 범위를 나누는 일과 관계를 읽는 일은 비용이 따로 든다.
SQLAlchemy의 관계 로딩 문서는 지연 로딩에서 이 문제가 나타나는 과정과 select-IN 같은 일괄 로딩을 설명하며, 이 글에서는 현대 ORM의 관계 조회 동작을 확인하는 출처로 쓴다.9
일대다 조인에 바로 LIMIT을 걸면 부모 개수 대신 늘어난 자식 행을 셀 수도 있다. 부모 페이지를 먼저 정하고 나서 관계를 읽으면 항목의 단위가 분명해진다. 페이지네이션은 항목의 단위와 순서, 시간 경계를 함께 정하는 설계다.
복합 키로 다음 범위 찾기
아래 PostgreSQL 예제는 같은 시각의 글 A와 B를 고유 ID로 구별한다. 첫 묶음의 마지막 값 (100, 1)을 다음 조회의 경계로 사용한다.
CREATE TEMP TABLE posts (
id integer PRIMARY KEY,
created_at integer NOT NULL,
title text NOT NULL
);
INSERT INTO posts VALUES
(1, 100, 'A'), (2, 100, 'B'),
(3, 90, 'C'), (4, 80, 'D');
SELECT id, title FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 2;
SELECT id, title FROM posts
WHERE (created_at, id) < (100, 1)
ORDER BY created_at DESC, id DESC
LIMIT 2;
첫 조회는 B·A, 다음 조회는 C·D를 반환한다. 두 쿼리의 정렬 방향과 행 비교가 일치해 다음 범위가 이어진다. 이 예제가 보여주는 것은 고정된 데이터에서 어느 범위를 고를지 정하는 과정이다. 동시 수정과 큰 데이터의 실행 계획은 이 예제의 범위 밖이다.
각주
- PostgreSQL 8.2 Release Notes, “Migration to Version 8.2” 및 “Query Changes”. 출시일 2006-12-05. 공식 문서. ↩
- Mark Nottingham, RFC 5005, 「Feed Paging and Archiving」, 2007-09, §1–4. 피드의 임시 페이지와 영구 보관 문서를 구분한다. RFC 원문. ↩
- PostgreSQL 8.4, 「SELECT」, LIMIT/OFFSET 및 Compatibility. SQL:2008의 OFFSET … FETCH 구문을 설명하는 당시 구현 문서. 공식 문서. ↩
- SQLite, “Row Values”, §3.1 및 §4. 공식 문서. ↩
- PostgreSQL 18, §7.6 “LIMIT and OFFSET”. 공식 문서. ↩
- PostgreSQL 18, §9.25.5 “Row Constructor Comparison”. 공식 문서. ↩
- Relay, 「GraphQL Cursor Connections Specification」, §2–5. 현재 커서 인터페이스의 정의에 사용했으며 특정 최초 도입 연도를 부여하지 않았다. 명세. ↩
- PostgreSQL 18, §13.2 “Transaction Isolation”. 공식 문서. ↩
- SQLAlchemy 2.0, “Relationship Loading Techniques”. 공식 문서. ↩