데이터 페이지네이션
개요
페이지네이션(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가 고유하다는 전제 아래, 시각이 같은 행 사이에서도 빠짐없이 이어가는 원리를 보여준다.
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를 반환한다. 마지막 행 A의 키가 (100, 1)이므로 이를 다음 조회의 경계로 사용하면 C, D가 나온다. 이 코드는 정렬과 경계 비교를 보이는 최소 예제이며, 실제 성능에는 조건에 맞는 인덱스와 실행계획이 영향을 준다.3
4. 커서는 키셋보다 넓은 인터페이스다
커서(cursor)는 다음 조회의 위치나 연속 정보를 담는 API 표현이다. 정렬 키를 인코딩할 수도 있고 서버가 보관한 검색 세션의 식별자를 가리킬 수도 있다. 따라서 커서라는 이름만으로 내부 쿼리가 키셋인지, 결과가 고정됐는지 알 수는 없다.
커서는 검색 조건과 정렬 방식이 달라져도 같은 의미를 유지하는지 정해야 한다. 정렬의 버전이나 조회 범위를 포함해 해석할 수도 있다. 커서가 불투명한 문자열이어도 서버는 접근 권한과 입력의 유효성을 검사해야 한다.
5. 변하는 목록과 고정된 결과 집합
키셋은 목록 앞쪽의 삽입으로 OFFSET이 밀리는 문제를 줄인다. 하지만 행의 정렬값이 바뀌거나 조회 조건에 새로 들어오는 행이 있으면 페이지 사이의 전체 집합은 달라질 수 있다.
여러 HTTP 요청이 서로 다른 스냅샷을 읽는다면 “현재 목록을 계속 탐색”하는 의미가 된다. 시점이 고정된 내보내기처럼 정확한 집합이 필요할 때는 스냅샷을 유지하거나 결과 식별자를 저장하는 등 그 요구를 따로 설계한다. PostgreSQL의 Read Committed는 같은 트랜잭션 안에서도 문장마다 스냅샷이 달라질 수 있다.4
무엇을 보장할지는 화면의 용도에 따라 정한다. 계속 새 글이 올라오는 피드와, 같은 보고서의 모든 행을 한 번씩 가져가는 작업은 같은 계약을 요구하지 않는다.
6. N+1은 무엇이 다른가
N+1 쿼리는 부모 목록을 한 번 읽은 뒤 각 부모의 관련 데이터를 별도 조회해, 부모 N개에 대해 추가 조회가 N번 생기는 형태다. 페이지네이션은 부모 목록의 범위를 나누고, 관계 로딩 전략은 그 범위의 관련 데이터를 어떻게 가져올지 정한다.
따라서 페이지 크기를 제한해도 N+1은 남을 수 있다. SQLAlchemy의 관계 로딩 문서는 지연 로딩에서 이 문제가 생기는 조건과 select-IN 같은 일괄 로딩 전략을 설명한다.5
일대다 JOIN에 바로 LIMIT를 붙이는 경우에는 자식 때문에 늘어난 행을 세고 있는지 확인해야 한다. 부모를 먼저 제한한 서브쿼리에 JOIN하거나, 부모 페이지를 정한 뒤 관련 데이터를 묶어 읽는 방법으로 무엇을 한 페이지로 셀지 표현할 수 있다.
7. 탐색 방식과 사용자 인터페이스
페이지 번호는 특정 구간으로 이동하는 데 편하고, 키셋의 연속 탐색은 마지막 위치에서 이어가기 좋다. 무한 스크롤과 더 보기 버튼은 같은 조회 방식 위에서도 구현할 수 있다. 화면 모양만으로 저장소의 접근 방식을 결정하지는 않는다.
성능을 비교할 때는 왕복 횟수뿐 아니라 훑은 행 수, 정렬·인덱스 사용, 반환 크기와 사용자가 이동하는 경로를 함께 본다. 페이지네이션의 목적은 단순히 작은 배열을 만드는 데서 끝나지 않는다. 긴 목록의 일부를 어떤 순서와 시점의 의미로 다룰지를 정하는 일이다.