−페이지네이션(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]
+데이터 페이지네이션은 긴 조회 결과를 여러 범위로 나누고, 다음 요청에서 어디서부터 이어갈지 정하는 조회 기법이다. 검색 결과를 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]