페이지네이션
고친 사람 github-actions[bot]
페이지네이션은 긴 목록을 한 번에 다 주지 않고 작은 묶음으로 나눠 차례로 건네는 방법입니다. 게시판이 글을 스무 개씩 보여 주고 아래에 다음 쪽 버튼을 두는 것이 그 모습입니다. 서버는 한 번에 적은 양만 보내면 됩니다. 받는 쪽은 필요한 만큼만 받아 갑니다.
쉽고 빠른 이해
페이지네이션은 목록을 조금씩 나눠 주는 방법입니다. 주문 내역이 10만 건이어도 한 번에 50건씩만 돌려주는 식입니다.
이게 없으면 목록이 커질수록 한 번의 응답이 무거워집니다. 서버는 전부 읽어 메모리에 올려야 합니다. 받는 쪽은 다 받을 때까지 기다려야 합니다. 처음엔 괜찮던 조회가 데이터가 쌓이면서 느려지다 결국 멈춥니다.
도는 방식은 이렇습니다.
- 클라이언트가 첫 묶음을 달라고 합니다
- 서버가 한 묶음과 함께 다음 묶음을 찾을 단서를 돌려줍니다
- 클라이언트가 그 단서를 붙여 다음 묶음을 달라고 합니다
단서를 주는 방식은 크게 둘입니다. 앞에서 몇 개를 건너뛸지 세는 쪽과, 마지막으로 받은 항목을 기억하는 쪽입니다.
대가는 요청 횟수입니다. 전체를 보려면 여러 번 물어야 합니다. 건너뛸 개수를 세는 쪽은 넘기는 사이에 목록이 바뀌면 같은 항목을 두 번 보거나 하나를 놓칠 수도 있습니다.
계속 자라는 목록을 돌려주는 조회에는 처음부터 둡니다. 늘 작은 목록이나 어차피 전부 받아야 하는 경우에는 필요 없습니다.
상세
도서관에서 백과사전 전집을 한꺼번에 빌려 가는 사람은 없습니다. 오늘 볼 한 권만 들고 갑니다. 다 읽으면 다시 와서 다음 권을 빌립니다.
페이지네이션은 결과가 많은 조회를 여러 번의 요청으로 나눠 받는 방법입니다. 한 번에 돌려주는 묶음을 페이지라고 부릅니다. 우리말로 쪽이라고도 합니다. 한 페이지에 담는 항목 수는 페이지 크기라고 부릅니다. 주문 10만 건을 페이지 크기 50으로 나누면 페이지는 2,000개가 됩니다.
API(Application Programming Interface, 애플리케이션 프로그래밍 인터페이스)에서 목록을 돌려주는 조회라면 거의 다 이 방법을 씁니다. 화면에서는 쪽 번호 버튼이나 「더 보기」 버튼으로 드러납니다. 스크롤을 내리면 다음 묶음이 붙는 무한 스크롤도 같은 방법입니다.
나눠 주는 까닭
목록은 시간이 갈수록 자랍니다. 서비스를 연 날 주문이 열 건이던 테이블이 1년 뒤엔 수백만 건이 됩니다. 「전부 달라」는 조회는 첫날엔 금방 끝나다가 데이터가 쌓이면서 조용히 느려집니다.
전부 한 번에 주면 세 곳이 함께 무거워집니다. 데이터베이스는 모든 행을 읽어야 합니다. 서버는 그 행을 전부 메모리에 올려 직렬화해야 합니다. 직렬화는 객체를 전송용 문자열이나 바이트로 바꾸는 일입니다.
마지막으로 그 큰 응답이 네트워크를 지납니다. 한 번에 보낼 수 있는 데이터 양을 대역폭이라고 합니다. 응답이 크면 그만큼 대역폭을 오래 차지합니다. 사용자는 보지도 않을 999번째 쪽까지 받느라 첫 화면을 늦게 봅니다.
페이지네이션은 한 번에 보내는 양을 목록 전체가 아니라 페이지 크기에 묶어 둡니다. 목록이 열 배로 자라도 응답 하나의 크기는 그대로입니다.
데이터베이스가 그 한 페이지를 만들려고 읽는 양은 따로 봐야 합니다. 이 양은 방식에 따라 다릅니다. 뒤쪽 페이지로 갈수록 더 많이 읽는 방식도 있습니다. 아래 「오프셋 방식의 약점」에서 다룹니다.
순서가 먼저 정해져야 한다
목록을 나누려면 먼저 줄을 세워야 합니다. 「앞에서 스무 개」나 「이 항목 다음」이라는 말은 순서가 정해져 있을 때만 뜻이 있습니다.
SQL(Structured Query Language, 구조화 질의 언어)은 정렬을 따로 지정하지 않으면 행을 어떤 순서로 돌려줄지 약속하지 않습니다. 순서가 요청마다 달라지면 1쪽에 나온 행이 2쪽에 또 나오기도 합니다. 그래서 페이지네이션 조회에는 언제나 ORDER BY 로 정렬 기준을 붙입니다.
정렬 기준은 값이 겹치지 않아야 합니다. 작성 시각만으로 정렬하면 같은 시각에 쓴 글끼리는 순서가 흔들립니다. 이럴 때는 행마다 하나뿐인 값을 담는 기본 키를 두 번째 기준으로 덧붙입니다.
오프셋 방식
첫째 방식은 앞에서 몇 개를 건너뛸지 세는 것입니다. 건너뛸 개수를 오프셋이라고 합니다. 클라이언트는 요청 주소의 쿼리 스트링에 쪽 번호와 크기를 싣습니다. /orders?page=3&size=20 이 그런 요청입니다.
서버는 쪽 번호를 오프셋으로 바꿔 데이터베이스에 묻습니다. 3쪽이면 앞의 두 쪽, 곧 40개를 건너뜁니다. LIMIT 은 몇 개를 가져올지, OFFSET 은 몇 개를 건너뛸지를 뜻합니다.
SELECT id FROM orders
ORDER BY id
LIMIT 20 OFFSET 40; -- 41~60번째 행
이 방식은 만들기 쉽습니다. 원하는 쪽으로 바로 갈 수도 있습니다. 전체 개수를 따로 세어 함께 주면 화면에 「총 57쪽」 같은 쪽 번호 줄을 그릴 수 있습니다.
오프셋 방식의 약점
약점은 둘입니다. 첫째는 뒤쪽으로 갈수록 느려진다는 것입니다. 데이터베이스는 건너뛸 행을 바로 뛰어넘지 못합니다. 앞에서부터 하나씩 읽어 세면서 버립니다. 1만 쪽을 달라고 하면 앞의 행 약 20만 개를 읽고 버린 뒤에야 스무 개를 돌려줍니다.
둘째는 넘기는 사이에 목록이 바뀌면 경계가 밀린다는 것입니다. 오프셋은 「몇 번째」만 기억하지 어느 항목이었는지는 기억하지 않습니다. 아래는 최신 글이 먼저 오는 목록을 한 쪽에 스무 개씩 보는 도중, 다른 사용자가 새 글을 하나 올린 장면입니다.
sequenceDiagram
participant C as 클라이언트
participant S as 서버
participant U as 다른 사용자
C->>S: 1쪽 요청 · 오프셋 0
S-->>C: 글 1번째부터 20번째
U->>S: 새 글 작성
Note over S: 새 글이 맨 앞에 서고 나머지가 하나씩 밀린다
C->>S: 2쪽 요청 · 오프셋 20
S-->>C: 1쪽 마지막 글이 다시 온다
새 글 하나가 맨 앞에 끼면서 1쪽의 마지막 글이 21번째로 밀렸습니다. 그래서 2쪽 첫머리에 이미 본 글이 또 나옵니다. 거꾸로 글이 하나 지워지면 한 글이 두 쪽 사이로 빠져 아무 쪽에도 안 나옵니다.
커서 방식
둘째 방식은 몇 개를 건너뛸지 대신 마지막으로 받은 항목을 기억하는 것입니다. 서버는 페이지와 함께 「여기까지 줬다」는 표시를 돌려줍니다. 이 표시를 커서라고 부릅니다. 클라이언트는 다음 요청에 그 커서를 손대지 않고 되돌려 보냅니다.
커서 안에는 대개 마지막 항목의 정렬 기준 값이 들어 있습니다. 서버는 그 값보다 뒤에 오는 행만 골라 달라고 데이터베이스에 묻습니다. 앞 요청에서 id 1060번까지 받았다면 다음 조회는 이렇게 됩니다.
SELECT id FROM orders
WHERE id > 1060
ORDER BY id
LIMIT 20; -- 1061번부터 20개
정렬 기준 열에 인덱스가 있으면 이 조회는 몇 쪽째든 스무 개만 읽습니다. 인덱스는 값을 정렬해 둔 목차라서, 1060 다음 위치로 바로 찾아갈 수 있습니다. 앞의 행을 세며 버리는 일이 없습니다.
넘기는 사이에 새 항목이 끼어도 경계가 안 밀립니다. 기준이 「몇 번째」가 아니라 「이 값 다음」이기 때문입니다. 정렬 열의 값으로 끊는다고 해서 키셋 페이지네이션이라고도 부릅니다.
앞의 최신순 게시판을 id 역순으로 넘긴다고 해 봅니다. 이때 조건은 id < 커서 값 이 됩니다. 새 글은 id 가 가장 크므로 커서보다 앞에 섭니다. 그래서 2쪽 조회에는 섞여 들어오지 않습니다.
커서가 오가는 순서
커서 방식에서 클라이언트는 커서를 풀어 볼 필요가 없습니다. 받은 것을 다음 요청에 붙이기만 합니다. 더 받을 것이 없다는 답이 오면 멈춥니다. 아래는 앞의 SQL 예처럼 id 1060번까지 받은 뒤의 왕복입니다.
sequenceDiagram
participant C as 클라이언트
participant S as 서버
participant D as 데이터베이스
S-->>C: 항목 20개 · 커서(1060)
C->>S: 커서(1060)를 그대로 붙여 요청
Note over S: 커서에서 1060을 꺼낸다
S->>D: WHERE id > 1060 ORDER BY id LIMIT 20
D-->>S: 1061번부터 20개
S-->>C: 항목 20개 · 새 커서(마지막 id)
그림에서 커서에 담긴 1060은 서버 안에서 다음 조회의 조건 id > 1060 으로 바뀝니다. 클라이언트는 그 사이 아무것도 계산하지 않습니다. 이 왕복을 더 받을 것이 없다는 답이 올 때까지 되풀이합니다.
응답은 대개 항목 목록과 다음 커서, 더 있는지를 함께 담습니다. JSON(JavaScript Object Notation, 자바스크립트 객체 표기법) 응답으로 적으면 이런 모양입니다.
{
"items": [ ... ],
"next_cursor": "eyJpZCI6MTA2MH0=",
"has_more": true
}
next_cursor 는 마지막 항목의 id 1060을 담아 알아보기 어려운 문자열로 감싼 값입니다. 속을 감춰 두면 클라이언트가 커서를 직접 만들어 보내지 못합니다. 서버는 나중에 커서에 담는 내용을 바꿀 수도 있습니다.
커서는 데이터베이스가 연결 안에 열어 두고 결과를 조금씩 꺼내는 커서와 이름만 같습니다. 페이지네이션의 커서는 서버가 아무것도 기억하지 않습니다. 위치를 적은 값을 클라이언트가 들고 다닙니다.
그래서 요청마다 다른 서버가 받아도 이어서 넘길 수 있습니다. 이렇게 서버가 요청 사이에 상태를 쥐지 않는 성질을 무상태라고 합니다.
커서 방식의 대가
커서 방식은 「이 값 다음」만 알기 때문에 17쪽으로 바로 뛰지 못합니다. 16쪽까지 차례로 넘겨야 17쪽의 커서가 생깁니다. 총 몇 쪽인지도 이 방식만으로는 모릅니다.
정렬 기준을 바꾸기도 까다롭습니다. 가격순으로 넘기다 날짜순으로 바꾸면 들고 있던 커서가 뜻을 잃습니다. 여러 열로 정렬하면 커서에 그 열 값을 모두 담아야 합니다. 조건도 그만큼 복잡해집니다.
두 방식 견주기
두 방식은 「다음을 어떻게 찾나」 하나에서 갈립니다. 나머지 차이는 전부 거기서 나옵니다.
| 오프셋 방식 | 커서 방식 | |
|---|---|---|
| 다음 페이지를 찾는 단서 | 건너뛸 개수 | 마지막 항목의 정렬 값 |
| 원하는 쪽으로 바로 가기 | 된다 | 안 된다 |
| 뒤쪽 페이지 속도 | 뒤로 갈수록 느려진다 | 앞쪽과 같다 |
| 넘기는 사이에 목록이 바뀌면 | 겹치거나 빠진다 | 경계가 유지된다 |
| 쪽 번호 줄 그리기 | 전체 개수로 쪽 수를 내어 그린다 | 개수를 알아도 특정 쪽으로 못 간다 |
쪽 번호가 필요한지와 목록이 얼마나 자주 바뀌는지가 고르는 기준입니다.
페이지 크기와 전체 개수
페이지 크기는 서버가 기본값과 상한을 함께 정합니다. 클라이언트가 크기를 고를 수 있게 해도 상한이 없으면 size=1000000 한 번으로 나눠 준 의미가 사라집니다. 상한을 넘는 요청은 상한으로 줄이거나 오류로 돌려보냅니다.
전체 개수는 공짜가 아닙니다. 「총 몇 건」을 알려면 데이터베이스가 조건에 맞는 행을 모두 세야 합니다. 그 비용은 목록 크기에 비례합니다. 그래서 큰 목록에서는 전체 개수를 빼거나, 대략의 값만 주거나, 따로 요청할 때만 세기도 합니다.
언제 쓰고 언제 안 쓰나
계속 자라는 목록을 돌려주는 조회라면 처음부터 페이지네이션을 둡니다. 나중에 붙이면 전부 돌려받던 클라이언트가 깨집니다. 첫 페이지만 받고 목록이 거기서 끝난 줄 알기 때문입니다.
오프셋 방식은 관리자 화면처럼 쪽 번호로 이동해야 하고 데이터가 크지 않을 때 맞습니다. 커서 방식은 피드와 무한 스크롤, 또는 다른 시스템이 목록 전체를 차례로 훑어 가져가는 데이터 동기화에 맞습니다.
국가 코드 목록처럼 늘 작고 거의 안 바뀌는 목록에는 필요 없습니다. 목록 전체를 파일로 내려받는 일처럼 어차피 다 받아야 하는 경우라면, 여러 번 묻는 대신 한 응답을 조금씩 흘려보내는 스트리밍이 더 맞을 수 있습니다.
이름이 겹치는 다른 말
한국 실무에서는 이 방법을 흔히 「페이징」이라고도 부릅니다. 운영체제가 메모리를 고정 크기 조각으로 나눠 관리하는 페이징과는 이름만 같고 다른 것입니다.
인쇄와 조판에서 pagination 은 글을 쪽으로 나누고 쪽 번호를 매기는 작업을 가리킵니다. 목록을 나눠 전달하는 이 문서의 뜻과는 다른 분야의 말입니다.
관련 항목
페이지네이션의 방식
오프셋 페이지네이션 · 커서 페이지네이션 · 키셋 페이지네이션 · 무한 스크롤
페이지네이션을 받치는 데이터베이스 장치
인덱스 · 정렬 · 기본 키 · SQL · ORDER BY · LIMIT
페이지네이션이 요청과 응답에 싣는 값
쿼리 스트링 · JSON · 직렬화 · Link 헤더 · 불투명 토큰
페이지네이션이 덜어 주는 자원
페이지네이션이 속하는 API 설계 주제
API · API 설계 · 자원 지향 설계 · 무상태 · 속도 제한 · 하위 호환
페이지네이션 대신 쓰는 수단
스트리밍 · 데이터 동기화 · 배치 처리 · 웹훅
페이지네이션과 헷갈리는 이름
페이징 · 커서 · 페이지 테이블 · 조판
다른 이름: pagination · 페이지 나누기