데이터 페이지네이션
개요
페이지네이션(pagination)은 긴 목록을 일정한 범위로 나눠 조회하고 탐색하는 방식이다. 검색 결과를 한 번에 모두 내려보내는 대신 25개씩 보여주거나, 피드에서 다음 묶음을 가져오는 동작이 이에 해당한다. 화면의 페이지 번호와 무한 스크롤은 사용자가 범위를 탐색하는 서로 다른 인터페이스다.
데이터 조회에서는 “몇 개 가져올 것인가”와 “어디서부터 이어갈 것인가”를 함께 정해야 한다. 두 번째 질문에 답하려면 먼저 목록의 순서가 있어야 한다. 정렬 순서가 정해지지 않은 데이터에 개수 제한만 붙이면 같은 요청을 반복해도 같은 범위를 뜻한다고 보기 어렵다.
이 개념에는 사용자에게 보이는 분할과 서버의 조회 전략이 모두 관련된다. 브라우저가 25개만 표시해도 서버가 전체 결과를 읽어 잘라낸다면 화면의 분할과 저장소의 읽기 비용은 서로 다르게 움직인다. API, DB 쿼리, 화면이 어떤 범위를 한 페이지라고 부르는지 맞춰야 한다.
페이지네이션을 이해하는 핵심 축은 위치를 표현하는 방법, 순서를 고정하는 기준, 여러 요청 사이에서 데이터가 변할 때의 의미다. OFFSET과 키셋은 이 축에 대해 다른 선택을 한다.
웹 피드를 나누는 방식과 보관의 구분 (2007)
Mark Nottingham이 작성한 RFC 5005는 2007년 9월 웹 피드를 complete feed, paged feed, archived feed로 구분했다. 항목이 많은 피드를 한 응답에 모두 넣기 어렵고, 새 항목이 들어오면 현재 피드에서 오래된 항목이 밀려나는 문제가 배경에 있다.1
paged feed의 문서는 시간에 따라 내용이 달라지는 임시 묶음이다. archived feed는 과거 항목을 보존하고 전체 항목을 복원할 수 있도록 영구적인 보관 문서와 연결을 다룬다. “다음 묶음이 있다”는 탐색과 “고정된 전체 기록을 얻는다”는 보존을 구분한 셈이다.
이 RFC는 피드 형식의 연혁이다. 오늘날 모든 웹 API의 커서가 이 규약을 구현하는 것은 아니지만, 변화하는 목록 탐색과 완전한 기록 수집을 나누는 질문은 현재의 페이지네이션에도 남아 있다.
SQL의 범위 지정이 표준 구문으로 정리되다 (2008)
PostgreSQL 8.4의 당시 문서는 SQL:2008이 OFFSET … FETCH 구문을 도입했다고 설명한다. 이는 SQL 표준의 해당 구문에 관한 연혁이며, LIMIT이나 목록 분할 전체가 그때 처음 등장했다는 의미는 아니다.2
OFFSET은 정렬한 결과의 앞부분을 일정 개수 건너뛴 뒤 다음 범위를 반환한다. 페이지 번호를 행의 위치로 바꾸기 쉬워, 20개씩 보여주는 3페이지를 “40개를 건너뛰고 20개 읽기”로 표현할 수 있다.
현재 PostgreSQL 문서가 강조하듯, 정렬이 결과를 유일하게 결정해야 여러 범위를 일관되게 읽을 수 있다. 건너뛴 행도 서버가 계산해야 하므로 깊은 위치로 갈수록 비용이 커질 수 있다.3
복합 정렬값을 다음 위치로 사용하다 (2016)
SQLite는 3.15.0에서 row value 비교를 도입했고, 공식 문서는 출시일을 2016년 10월 14일로 기록한다. 같은 문서의 scrolling window 예제는 큰 OFFSET 대신 마지막 정렬값 이후의 행을 찾는 방식을 설명한다. 이 날짜는 SQLite 기능의 도입 시점이다.4
키셋 방식은 앞에서 센 개수 대신 마지막으로 본 키를 경계로 삼는다. 생성 시각이 같은 글이 여럿이라면 시각만으로는 위치를 유일하게 정할 수 없다. 고유 ID를 함께 정렬해 (created_at, id)를 다음 범위의 경계로 쓴다.
두 열이 모두 내림차순이면 다음 묶음은 직전 마지막 튜플보다 작은 범위다. PostgreSQL의 행 비교는 왼쪽 열부터 차이를 판정한다. 예제의 시각과 ID는 NULL이 없고 ID가 고유하므로 같은 시각 안에서도 순서가 정해진다.5
SQL의 위치와 API의 커서가 나뉘다
현재 GraphQL Cursor Connections 명세는 edges, pageInfo, first, after 등의 계약을 정의한다. 클라이언트는 커서의 내부 표현을 해석하지 않고 불투명한 위치 식별자로 다룬다. 이 문서는 현재 인터페이스의 근거로 사용한다.6
커서에는 복합 키를 넣을 수도 있고, 서버가 보관한 조회 위치의 식별자를 넣을 수도 있다. 문자열이 base64로 보인다는 사실만으로 저장소에서 키셋 쿼리를 실행한다고 판단할 수는 없다.
이 구분으로 인터페이스와 조회 전략을 따로 비교할 수 있다. 화면의 “더 보기”는 커서를 받아 다음 요청을 만들고, 서버는 그 커서의 의미에 따라 저장소를 탐색한다. 숫자 버튼이나 무한 스크롤이라는 외형만으로 실제 쿼리가 정해지지는 않는다.
변화하는 목록과 고정된 결과의 서로 다른 계약
앞쪽에 글이 추가되면 OFFSET의 위치가 밀려 같은 글을 다시 읽거나 건너뛸 수 있다. 키셋은 앞부분의 개수가 변해 경계가 밀리는 문제를 줄이지만, 정렬값 수정이나 필터 진입·이탈까지 고정하지는 않는다.
요청마다 다른 스냅샷을 읽는다면 사용자는 변화하는 목록을 계속 탐색하는 것이다. PostgreSQL의 Read Committed에서는 같은 트랜잭션 안에서도 명령마다 새 스냅샷을 얻을 수 있다.7
모든 항목을 한 번씩 담아야 하는 내보내기에는 적절한 스냅샷이나 저장된 결과 집합의 식별자가 필요하다. RFC 5005에서 탐색용 묶음과 보관용 문서를 나눈 질문이 데이터베이스 조회의 시간 경계로 다시 나타난다.
관계 로딩과 결합되며 드러나는 N+1
N+1은 부모 목록을 한 번 읽은 뒤 각 행의 관계 데이터를 따로 조회하는 패턴이다. 부모를 20개로 제한해도 관계 조회가 20번 추가될 수 있으므로, 범위를 나누는 일과 관계를 읽는 일은 별도의 비용을 가진다.
SQLAlchemy의 관계 로딩 문서는 지연 로딩에서 이 문제가 나타나는 과정과 select-IN 같은 일괄 로딩을 설명한다. 이는 현대 ORM의 관계 조회 동작을 확인하는 출처다.8
일대다 조인에 바로 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를 반환한다. 두 쿼리의 정렬 방향과 행 비교가 일치해 다음 범위가 이어진다. 이 예제는 고정된 데이터의 범위 선택을 보여주며 동시 수정이나 큰 데이터에서의 실행 계획을 측정하지는 않는다.
각주
- Mark Nottingham, RFC 5005, 「Feed Paging and Archiving」, 2007-09, §1–4. 피드의 임시 페이지와 영구 보관 문서를 구분한다. RFC 원문. ↩
- PostgreSQL 8.4, 「SELECT」, LIMIT/OFFSET 및 Compatibility. SQL:2008의 OFFSET … FETCH 구문을 설명하는 당시 구현 문서. 공식 문서. ↩
- PostgreSQL 18, §7.6 “LIMIT and OFFSET”. 공식 문서. ↩
- SQLite, “Row Values”, §3.1 및 §4. 공식 문서. ↩
- 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”. 공식 문서. ↩