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