페이지네이션 가이드라인
이 문서는 현재 기능에 대한 개요를 제공하고 GitLab에서 데이터에 대한 페이지네이션을 처리하기 위한 모범 사례를 제시합니다. 특히 PostgreSQL에 대해 다룹니다.
왜 페이지네이션이 필요할까요?
페이지네이션은 한 웹 요청에서 너무 많은 데이터를 로드하지 않기 위해 사용하는 인기 있는 기법입니다. 이는 일반적으로 기록 목록을 렌더링할 때 발생합니다. 일반적인 시나리오는 UI에서 부모-자식 관계(다수의 관계)를 시각화하는 것입니다.
예: 프로젝트 내 이슈 나열
프로젝트 내 이슈의 수가 증가함에 따라 목록이 길어집니다. 목록을 렌더링하기 위해 백엔드는 다음과 같은 작업을 수행합니다:
-
보통 특정 순서로 데이터베이스에서 기록을 로드합니다.
-
루비에서 기록을 직렬화합니다. 루비(ActiveRecord) 객체를 구축하고 JSON 또는 HTML 문자열을 생성합니다.
-
응답을 브라우저로 다시 보냅니다.
-
브라우저가 콘텐츠를 렌더링합니다.
콘텐츠를 렌더링하는 두 가지 옵션이 있습니다:
- HTML: 백엔드가 렌더링을 처리합니다(HAML 템플릿).
- JSON: 클라이언트(클라이언트 측 JavaScript)가 페이로드를 HTML로 변환합니다.
긴 목록을 렌더링하면 프론트엔드와 백엔드 성능 모두에 상당한 영향을 미칠 수 있습니다:
- 데이터베이스는 디스크에서 많은 데이터를 읽습니다.
- 쿼리 결과(기록)는 결국 루비 객체로 변환되어 메모리 할당이 증가합니다.
- 큰 응답은 사용자 브라우저로 전송하는 데 더 많은 시간이 걸립니다.
- 긴 목록을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).
페이지네이션을 사용하면 데이터가 동일한 조각(페이지)으로 나뉘어집니다. 처음 방문할 때 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 페이지를 앞으로 넘김으로써 더 많은 항목을 볼 수 있으며, 이는 새로운 HTTP 요청과 새로운 데이터베이스 쿼리를 초래합니다.
페이지네이션을 위한 일반적인 가이드라인
올바른 접근 방식 선택
데이터베이스가 페이지네이션, 필터링 및 데이터 검색을 처리하도록 하세요. 백엔드에서 메모리 내 페이지네이션(paginate_array
from Kaminari)이나 프론트엔드(JavaScript)에서 구현하는 것은 수백 개의 기록에 대해서는 작동할 수 있습니다. 애플리케이션 제한이 정의되지 않으면 상황이 금세 통제 불능 상태가 될 수 있습니다.
복잡성 줄이기
페이지에 기록을 나열할 때 종종 추가 필터와 다양한 정렬 옵션을 제공합니다. 이는 백엔드에서 상황을 크게 복잡하게 만들 수 있습니다.
MVC 버전의 경우 다음을 고려하세요:
- 정렬 옵션의 수를 최소화합니다.
- 필터(드롭다운 목록, 검색바)의 수를 최소화합니다.
정렬 및 페이지네이션을 효율적으로 만들기 위해 각 정렬 옵션에 대해 최소한 두 개의 데이터베이스 인덱스(오름차순, 내림차순 순서)가 필요합니다. 필터 옵션(상태별 또는 작성자별)을 추가하면 양호한 성능을 유지하기 위해 더 많은 인덱스가 필요할 수 있습니다. 인덱스는 무료가 아니므로 UPDATE
쿼리 시간에 상당한 영향을 미칠 수 있습니다.
모든 필터 및 정렬 조합을 성능 좋게 만드는 것은 불가능하므로, 사용 패턴에 따라 성능 최적화를 시도해야 합니다.
확장성을 위한 준비
오프셋 기반 페이지네이션은 기록을 페이지하는 가장 쉬운 방법이지만, 큰 데이터베이스 테이블에서는 확장성이 좋지 않습니다. 장기적인 솔루션으로는 키셋 페이지네이션이 선호됩니다. 오프셋과 키셋 페이지네이션 간의 전환은 일반적으로 간단하며, 다음 조건이 충족되면 최종 사용자에게 영향을 주지 않고 수행할 수 있습니다:
- 총 카운트를 제시하지 않고, 제한 카운트를 선호합니다.
- 예: 최대 1001개의 기록이 있는 경우, UI에 1000+을 표시하고 카운트가 1001인 경우 실제 숫자를 표시합니다.
- 자세한 내용은 배지 카운터 접근 방식을 참조하세요.
- 페이지 번호를 사용하지 않고, 다음 페이지 및 이전 페이지 버튼을 사용하세요.
- 키셋 페이지네이션은 페이지 번호를 지원하지 않습니다.
- API의 경우 “손으로” 다음 페이지의 URL을 만드는 것을 피하도록 권장합니다.
- 백엔드에서 제공하는 다음 및 이전 페이지에 대한 URL이 포함된
Link
헤더의 사용을 권장합니다. - 이렇게 하면 URL 구조를 변경할 수 있으며, 이전 호환성을 깨지 않고 유지할 수 있습니다.
- 백엔드에서 제공하는 다음 및 이전 페이지에 대한 URL이 포함된
참고:
무한 스크롤은 노출된 페이지 번호가 없으므로 사용자 경험에 영향을 주지 않고 키셋 페이지네이션을 사용할 수 있습니다.
페이지 매김 옵션
오프셋 페이지 매김
목록을 페이지 매김하는 가장 일반적인 방법은 오프셋 기반 페이지 매김(UI 및 REST API)을 사용하는 것입니다. 이는 ActiveRecord 쿼리에서 페이지 매김을 구현하기 위한 편리한 헬퍼 메서드를 제공하는 인기 있는 Kaminari 루비 젬에 의해 지원됩니다.
오프셋 기반 페이지 매김은 LIMIT
및 OFFSET
SQL 절을 사용하여 테이블에서 특정 슬라이스를 가져옵니다.
프로젝트 내에서 이슈의 두 번째 페이지를 찾을 때의 예시 데이터베이스 쿼리:
SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20 OFFSET 20
- 테이블 행을 가리키는 가상 포인터를 이동하고 20개의 행을 건너뜁니다.
- 다음 20개의 행을 가져옵니다.
쿼리는 또한 기본 키(id
)로 행을 정렬한다는 점에 유의하세요. 데이터를 페이지 매김할 때, 정렬을 지정하는 것은 매우 중요합니다. 그렇지 않으면 반환된 행이 비결정적이며 최종 사용자를 혼란스럽게 할 수 있습니다.
페이지 번호
예시 페이지 매김 바:
Kaminari 젬은 페이지 번호와 선택적으로 다음, 이전, 처음 및 마지막 페이지 버튼의 빠른 바로 가기를 포함한 멋진 페이지 매김 바를 UI에 렌더링합니다. 이러한 버튼을 렌더링하기 위해 Kaminari는 행의 수를 알아야 하며, 이를 위해 카운트 쿼리가 실행됩니다.
SELECT COUNT(*) FROM issues WHERE project_id = 1
성능
인덱스 커버리지
좋은 성능을 달성하기 위해서는 ORDER BY
절이 인덱스에 의해 커버되어야 합니다.
다음과 같은 인덱스가 있다고 가정해 보겠습니다:
CREATE INDEX index_on_issues_project_id ON issues (project_id);
첫 번째 페이지를 요청해 보겠습니다:
SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20;
Rails에서 동일한 쿼리를 생성할 수 있습니다:
Issue.where(project_id: 1).page(1).per(20)
SQL 쿼리는 데이터베이스에서 최대 20개의 행을 반환합니다. 그러나 이는 데이터베이스가 결과를 생성하기 위해 디스크에서 20개의 행만 읽는 것을 의미하지는 않습니다.
다음과 같은 일이 발생합니다:
- 데이터베이스는 테이블 통계 및 사용 가능한 인덱스를 기반으로 가장 효율적인 실행 계획을 수립하려고 시도합니다.
- 플래너는
project_id
열을 커버하는 인덱스가 있음을 알고 있습니다. - 데이터베이스는
project_id
에 대한 인덱스를 사용하여 모든 행을 읽습니다. - 이 시점에서 행은 정렬되지 않으므로 데이터베이스는 행을 정렬합니다.
- 데이터베이스는 첫 20개의 행을 반환합니다.
프로젝트에 10,000개의 행이 있는 경우, 데이터베이스는 10,000개의 행을 읽고 이를 메모리(또는 디스크)에서 정렬합니다. 이는 장기적으로 잘 확장되지 않습니다.
이를 해결하기 위해 다음과 같은 인덱스가 필요합니다:
CREATE INDEX index_on_issues_project_id ON issues (project_id, id);
id
열을 인덱스의 일부로 만듦으로써, 이전 쿼리는 최대 20개의 행을 읽습니다. 쿼리는 프로젝트 내 이슈의 수에 관계없이 잘 수행됩니다. 따라서 이 변경으로 사용자가 이슈 페이지를 로드할 때 초기 페이지 로드 성능도 개선되었습니다.
제한 사항
대규모 데이터 세트에서의 COUNT(*)
Kaminari는 기본적으로 페이지 링크를 렌더링하기 위해 페이지 수를 결정하기 위해 카운트 쿼리를 실행합니다. 카운트 쿼리는 대규모 테이블에 대해 꽤 비쌀 수 있습니다. 불행한 상황에서는 쿼리가 타임아웃될 수도 있습니다.
이를 우회하기 위해, 카운트 SQL 쿼리를 호출하지 않고 Kaminari를 실행할 수 있습니다.
Issue.where(project_id: 1).page(1).per(20).without_count
이 경우, 카운트 쿼리가 실행되지 않으며 페이지 매김은 더 이상 페이지 번호를 렌더링하지 않습니다. 다음 및 이전 링크만 보입니다.
대규모 데이터셋에서의 OFFSET
대규모 데이터셋을 페이지네이팅할 때, 응답 시간이 점점 느려지는 것을 알 수 있습니다. 이는 OFFSET
절이 행을 탐색하고 N개의 행을 건너뛰기 때문입니다.
사용자 관점에서 이는 항상 눈에 띄지 않을 수 있습니다. 사용자가 앞으로 페이지를 넘길 때, 이전 행들이 여전히 데이터베이스의 버퍼 캐시에 있을 수 있습니다. 사용자가 다른 사람과 링크를 공유하고 몇 분 또는 몇 시간 후에 열면 응답 시간이 크게 증가하거나 타임아웃이 발생할 수 있습니다.
대규모 페이지 번호를 요청할 때, 데이터베이스는 PAGE * PAGE_SIZE
행을 읽어야 합니다. 이로 인해 오프셋 기반 페이지네이팅은 대규모 데이터베이스 테이블에 부적합합니다. 그러나 최적화 기법을 사용하면 데이터베이스 쿼리의 전반적인 성능을 약간 개선할 수 있습니다.
예제: 관리 영역에서 사용자 나열하기
매우 간단한 SQL 쿼리를 사용하여 사용자를 나열합니다:
SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 0
쿼리 실행 계획은 이 쿼리가 효율적임을 보여줍니다. 데이터베이스는 데이터베이스에서 20개의 행만 읽습니다(rows=20
):
Limit (cost=0.43..3.19 rows=20 width=1309) (actual time=0.098..2.093 rows=20 loops=1)
Buffers: shared hit=103
-> Index Scan Backward using users_pkey on users (cost=0.43..X rows=X width=1309) (actual time=0.097..2.087 rows=20 loops=1)
Buffers: shared hit=103
Planning Time: 0.333 ms
Execution Time: 2.145 ms
(6 rows)
쿼리 실행 계획을 읽는 방법에 대한 더 많은 정보는 EXPLAIN 계획 이해하기를 참조하세요.
50,000번째 페이지를 방문해 봅시다:
SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 999980;
계획에 따르면 데이터베이스는 20개의 행을 반환하기 위해 1,000,000개의 행을 읽으며 실행 시간이 매우 높습니다(5.5초):
Limit (cost=137878.89..137881.65 rows=20 width=1309) (actual time=5523.588..5523.667 rows=20 loops=1)
Buffers: shared hit=1007901 read=14774 written=609
I/O Timings: read=420.591 write=57.344
-> Index Scan Backward using users_pkey on users (cost=0.43..X rows=X width=1309) (actual time=0.060..5459.353 rows=1000000 loops=1)
Buffers: shared hit=1007901 read=14774 written=609
I/O Timings: read=420.591 write=57.344
Planning Time: 0.821 ms
Execution Time: 5523.745 ms
(8 rows)
일반 사용자가 이러한 페이지를 방문하지 않는다고 주장할 수 있습니다. 그러나 API 사용자는 매우 높은 페이지 번호에 이를 수 있습니다(스크래핑, 데이터 수집).
키셋 페이지네이팅
키셋 페이지네이팅은 대규모 페이지를 요청할 때 “이전 행을 건너뛰는” 성능 문제를 해결합니다. 그러나 오프셋 기반 페이지네이팅에 대한 즉각적인 대체품은 아닙니다. API 엔드포인트를 오프셋 기반 페이지네이팅에서 키셋 기반 페이지네이팅으로 전환할 때, 둘 다 지원해야 합니다. 한 유형의 페이지네이팅을 완전히 제거하는 것은 브레이킹 변화입니다.
키셋 페이지네이팅은 GraphQL API와 REST API 모두에서 사용됩니다.
다음 issues
테이블을 고려하십시오:
id |
project_id |
---|---|
1 | 1 |
2 | 1 |
3 | 2 |
4 | 1 |
5 | 1 |
6 | 2 |
7 | 2 |
8 | 1 |
9 | 1 |
10 | 2 |
기본 키(id
)로 정렬된 전체 테이블을 페이지네이팅합니다. 첫 페이지에 대한 쿼리는 오프셋 페이지네이팅 쿼리와 동일하며, 단순히 페이지 크기로 5를 사용합니다:
SELECT "issues".* FROM "issues" ORDER BY "issues"."id" ASC LIMIT 5
OFFSET
절을 추가하지 않았음을 유의하십시오.
다음 페이지로 가려면, 마지막 행에서 ORDER BY
절의 일부인 값을 추출해야 합니다. 이 경우, id
값 5만 필요합니다. 이제 다음 페이지에 대한 쿼리를 구성합니다:
SELECT "issues".* FROM "issues" WHERE "issues"."id" > 5 ORDER BY "issues"."id" ASC LIMIT 5
쿼리 실행 계획을 살펴보면 이 쿼리는 5개의 행만 읽었습니다(오프셋 기반 페이지네이팅은 10개의 행을 읽었을 것입니다):
Limit (cost=0.56..2.08 rows=5 width=1301) (actual time=0.093..0.137 rows=5 loops=1)
-> Index Scan using issues_pkey on issues (cost=0.56..X rows=X width=1301) (actual time=0.092..0.136 rows=5 loops=1)
Index Cond: (id > 5)
Planning Time: 7.710 ms
Execution Time: 0.224 ms
(5 rows)
제한사항
페이지 번호 없음
오프셋 페이지 매김은 특정 페이지를 요청하는 간편한 방법을 제공합니다. 우리는 URL을 수정하고 page=
URL 매개변수를 변경할 수 있습니다. 키셋 페이지 매김은 페이지 번호를 제공할 수 없습니다. 왜냐하면 페이지 매김 로직이 서로 다른 열에 따라 달라질 수 있기 때문입니다.
이전 예제에서 열은 id
이므로, URL에서 다음과 같은 내용을 볼 수 있습니다:
id_after=5
GraphQL에서는 매개변수가 JSON으로 직렬화된 후 인코딩됩니다:
eyJpZCI6Ijk0NzMzNTk0IiwidXBkYXRlZF9hdCI6IjIwMjEtMDQtMDkgMDg6NTA6MDUuODA1ODg0MDAwIFVUQyJ9
참고:
페이지 매김 매개변수는 사용자에게 보이므로, 어떤 열에 따라 정렬할지 주의해야 합니다.
키셋 페이지 매김은 다음, 이전, 첫 번째 및 마지막 페이지만 제공할 수 있습니다.
복잡성
단일 열로 정렬할 때 쿼리를 구성하는 것은 매우 쉽지만, 타이브레이커 또는 다중 열 정렬이 사용되면 복잡성이 증가합니다. 열이 nullable인 경우 복잡성이 더해집니다.
예: id
및 created_at
로 정렬하는 경우, 여기서 created_at
은 nullable입니다. 두 번째 페이지를 가져오는 쿼리:
SELECT "issues".*
FROM "issues"
WHERE (("issues"."id" > 99
AND "issues"."created_at" = '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" > '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" IS NULL))
ORDER BY "issues"."created_at" DESC NULLS LAST, "issues"."id" DESC
LIMIT 20
도구
GitLab 프로젝트 내에 일반적인 키셋 페이지 매김 라이브러리가 제공되어, 대량의 데이터셋을 처리할 때 기존의 Kaminari 기반 페이지 매김을 상당한 성능 향상과 함께 쉽게 대체할 수 있습니다.
예:
# 첫 번째 페이지
paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
puts paginator.to_a # 레코드
# 다음 페이지
cursor = paginator.cursor_for_next_page
paginator = Project.order(:created_at, :id).keyset_paginate(cursor: cursor, per_page: 20)
puts paginator.to_a # 레코드
포괄적인 개요를 보려면 키셋 페이지 매김 가이드 페이지를 참조하세요.
성능
키셋 페이지 매김은 우리가 이동한 페이지 수에 관계없이 안정적인 성능을 제공합니다. 이 성능을 달성하기 위해서는 페이지 매김 쿼리가 ORDER BY
절의 모든 열을 포함하는 인덱스가 필요합니다. 이는 오프셋 페이지 매김과 유사합니다.
일반 성능 지침
페이지 매김 일반 성능 지침 페이지를 참조하세요.