페이지네이션 가이드라인
GitLab v19.1이 문서는 GitLab에서, 특히 PostgreSQL에서 데이터를 페이지네이션하기 위한 현재 기능에 대한 개요와 모범 사례를 제공합니다. 페이지네이션은 하나의 웹 요청에서 너무 많은 데이터를 로드하지 않기 위한 일반적인 기법입니다.
이 문서는 GitLab에서, 특히 PostgreSQL에서 데이터를 페이지네이션하기 위한 현재 기능에 대한 개요와 모범 사례를 제공합니다.
페이지네이션이 필요한 이유#
페이지네이션은 하나의 웹 요청에서 너무 많은 데이터를 로드하지 않기 위한 일반적인 기법입니다. 이는 보통 레코드 목록을 렌더링할 때 발생합니다. 일반적인 시나리오는 UI에서 부모-자식 관계(has many)를 시각화하는 것입니다.
예시: 프로젝트 내 이슈 목록 표시
프로젝트 내 이슈 수가 증가함에 따라 목록이 길어집니다. 목록을 렌더링하기 위해 백엔드는 다음을 수행합니다:
-
데이터베이스에서 레코드를 로드합니다. 보통 특정 순서로 로드합니다.
-
Ruby에서 레코드를 직렬화합니다. Ruby(ActiveRecord) 객체를 빌드한 다음 JSON 또는 HTML 문자열을 빌드합니다.
-
브라우저에 응답을 반환합니다.
-
브라우저가 콘텐츠를 렌더링합니다.
콘텐츠 렌더링을 위한 두 가지 옵션이 있습니다:
-
HTML: 백엔드가 렌더링을 처리합니다(HAML 템플릿).
-
JSON: 클라이언트(클라이언트 측 JavaScript)가 페이로드를 HTML로 변환합니다.
긴 목록을 렌더링하면 프론트엔드와 백엔드 성능 모두에 큰 영향을 줄 수 있습니다:
-
데이터베이스가 디스크에서 많은 데이터를 읽습니다.
-
쿼리 결과(레코드)는 결국 Ruby 객체로 변환되어 메모리 할당이 증가합니다.
-
응답이 크면 사용자 브라우저로 전송하는 데 더 많은 시간이 걸립니다.
-
긴 목록을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).
페이지네이션을 사용하면 데이터가 동일한 크기의 조각(페이지)으로 나뉩니다. 첫 방문 시 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 앞으로 페이지를 이동하여 더 많은 항목을 볼 수 있으며, 이는 새로운 HTTP 요청과 새로운 데이터베이스 쿼리를 발생시킵니다.
[
](/19.1/development/database/img/project_issues_pagination_v13_11.jpg)
페이지네이션을 위한 일반 가이드라인#
올바른 접근 방식 선택#
페이지네이션, 필터링, 데이터 조회는 데이터베이스가 처리하도록 하세요. 백엔드의 인메모리 페이지네이션(Kaminari의 paginate_array) 또는 프론트엔드(JavaScript)에서의 페이지네이션은 수백 개의 레코드에 대해서는 작동할 수 있습니다. 하지만 애플리케이션 제한이 정의되지 않으면 상황이 빠르게 통제 불능 상태가 될 수 있습니다.
복잡성 줄이기#
페이지에 레코드를 나열할 때 추가 필터와 다양한 정렬 옵션을 제공하는 경우가 많습니다. 이는 백엔드 측에서 복잡성을 크게 높일 수 있습니다.
MVC 버전의 경우 다음 사항을 고려하세요:
-
정렬 옵션을 최소한으로 줄이세요.
-
필터 수(드롭다운 목록, 검색창)를 최소한으로 줄이세요.
정렬과 페이지네이션을 효율적으로 만들기 위해 각 정렬 옵션마다 최소 두 개의 데이터베이스 인덱스(오름차순, 내림차순)가 필요합니다. 필터 옵션(상태별 또는 작성자별)을 추가하면 좋은 성능을 유지하기 위해 더 많은 인덱스가 필요할 수 있습니다. 인덱스는 공짜가 아니며 UPDATE 쿼리 시간에 큰 영향을 줄 수 있습니다.
모든 필터와 정렬 조합을 성능적으로 만들 수는 없으므로, 사용 패턴에 따라 성능을 최적화하려고 노력해야 합니다.
확장을 위한 준비#
오프셋 기반 페이지네이션은 레코드를 페이지네이션하는 가장 쉬운 방법이지만, 대용량 데이터베이스 테이블에서는 확장이 잘 되지 않습니다. 장기적인 해결책으로는 keyset 페이지네이션이 선호됩니다. 오프셋과 keyset 페이지네이션 간의 전환은 일반적으로 간단하며, 다음 조건이 충족되면 최종 사용자에게 영향을 주지 않고 수행할 수 있습니다:
- 총 개수 표시를 피하고, 제한 개수를 선호하세요.
예시: 최대 1001개의 레코드를 세고, 개수가 1001이면 UI에서 1000+를 표시하고, 그렇지 않으면 실제 숫자를 표시합니다.
-
자세한 내용은 배지 카운터 접근 방식을 참조하세요.
-
페이지 번호 사용을 피하고, 다음 및 이전 페이지 버튼을 사용하세요.
Keyset 페이지네이션은 페이지 번호를 지원하지 않습니다.
- API의 경우, 다음 페이지의 URL을 "수동으로" 구성하는 방식을 권장하지 않습니다.
백엔드에서 다음 및 이전 페이지의 URL을 제공하는 Link 헤더 사용을 권장하세요.
-
이 방식으로 URL 구조를 변경해도 이전 버전과의 호환성을 깨뜨리지 않을 수 있습니다.
무한 스크롤은 노출되는 페이지 번호가 없으므로 사용자 경험에 영향을 주지 않고 keyset 페이지네이션을 사용할 수 있습니다.
페이지네이션 옵션#
오프셋 페이지네이션#
목록을 페이지네이션하는 가장 일반적인 방법은 오프셋 기반 페이지네이션입니다(UI 및 REST API). 이는 ActiveRecord 쿼리에서 페이지네이션을 구현하는 편리한 헬퍼 메서드를 제공하는 유명한 Kaminari Ruby gem으로 구현됩니다.
오프셋 기반 페이지네이션은 LIMIT 및 OFFSET SQL 절을 활용하여 테이블에서 특정 슬라이스를 가져옵니다.
프로젝트 내 이슈의 두 번째 페이지를 조회할 때의 예시 데이터베이스 쿼리:
SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20 OFFSET 20
-
테이블 행 위에 가상의 포인터를 이동하여 20개의 행을 건너뜁니다.
-
다음 20개의 행을 가져옵니다.
쿼리가 기본 키(id)로 행을 정렬한다는 점에 주목하세요. 데이터를 페이지네이션할 때 순서를 지정하는 것은 매우 중요합니다. 순서가 없으면 반환된 행은 비결정적이며 최종 사용자를 혼란스럽게 할 수 있습니다.
페이지 번호#
예시 페이지네이션 바:
[
](/19.1/development/database/img/offset_pagination_ui_v13_11.jpg)
Kaminari gem은 UI에서 페이지 번호와 선택적으로 다음, 이전, 첫 번째, 마지막 페이지 버튼 단축키가 있는 멋진 페이지네이션 바를 렌더링합니다. 이러한 버튼을 렌더링하려면 Kaminari는 행의 수를 알아야 하며, 이를 위해 count 쿼리가 실행됩니다.
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개의 행만 읽습니다. 쿼리는 프로젝트 내 이슈 수에 관계없이 잘 수행됩니다. 따라서 이 변경으로 초기 페이지 로드(사용자가 이슈 페이지를 로드할 때)도 개선됩니다.
여기서는 b-tree 데이터베이스 인덱스의 정렬 속성을 활용하고 있습니다. 인덱스의 값이 정렬되어 있으므로 20개의 행을 읽는 데 추가 정렬이 필요하지 않습니다.
알려진 문제#
대용량 데이터셋에서의 COUNT(*)#
Kaminari는 기본적으로 페이지 링크를 렌더링하기 위해 페이지 수를 결정하는 count 쿼리를 실행합니다. Count 쿼리는 대용량 테이블에서 상당히 비용이 많이 들 수 있습니다. 불운한 시나리오에서는 쿼리가 타임아웃됩니다.
이를 해결하려면 count SQL 쿼리를 호출하지 않고 Kaminari를 실행할 수 있습니다.
Issue.where(project_id: 1).page(1).per(20).without_count
이 경우 count 쿼리는 실행되지 않으며 페이지네이션은 더 이상 페이지 번호를 렌더링하지 않습니다. 다음 및 이전 링크만 표시됩니다.
대용량 데이터셋에서의 OFFSET#
대용량 데이터셋을 페이지네이션할 때 응답 시간이 점점 느려지는 것을 알 수 있습니다. 이는 행을 탐색하고 N개의 행을 건너뛰는 OFFSET 절 때문입니다.
사용자 관점에서 이것이 항상 눈에 띄지 않을 수도 있습니다. 사용자가 앞으로 페이지를 이동할 때 이전 행이 아직 데이터베이스의 버퍼 캐시에 있을 수 있습니다. 사용자가 다른 사람과 링크를 공유하고 몇 분 또는 몇 시간 후에 열리면 응답 시간이 크게 높아지거나 타임아웃될 수도 있습니다.
큰 페이지 번호를 요청할 때 데이터베이스는 PAGE * PAGE_SIZE개의 행을 읽어야 합니다. 이로 인해 오프셋 페이지네이션은 대용량 데이터베이스 테이블에는 적합하지 않습니다. 그러나 최적화 기법을 사용하면 데이터베이스 쿼리의 전반적인 성능을 약간 향상시킬 수 있습니다.
예시: Admin 영역에서 사용자 목록 표시
매우 간단한 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 사용자는 매우 높은 페이지 번호까지 이동할 수 있습니다(스크래핑, 데이터 수집).
Keyset 페이지네이션#
Keyset 페이지네이션은 큰 페이지를 요청할 때 이전 행을 "건너뛰는" 성능 문제를 해결하지만, 오프셋 기반 페이지네이션의 드롭인 대체는 아닙니다. API 엔드포인트를 오프셋 기반 페이지네이션에서 keyset 기반 페이지네이션으로 이동할 때는 두 가지 모두 지원되어야 합니다. 하나의 페이지네이션 유형을 완전히 제거하는 것은 브레이킹 체인지입니다.
Keyset 페이지네이션은 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 파라미터를 수정할 수 있습니다. Keyset 페이지네이션은 페이지 로직이 다른 칼럼에 따라 달라질 수 있으므로 페이지 번호를 제공할 수 없습니다.
이전 예시에서 칼럼은 id이므로 URL에서 다음과 같이 볼 수 있습니다:
id_after=5
GraphQL에서는 파라미터가 JSON으로 직렬화된 다음 인코딩됩니다:
eyJpZCI6Ijk0NzMzNTk0IiwidXBkYXRlZF9hdCI6IjIwMjEtMDQtMDkgMDg6NTA6MDUuODA1ODg0MDAwIFVUQyJ9
페이지네이션 파라미터는 사용자에게 보이므로, 어떤 칼럼으로 정렬할지 주의하세요.
Keyset 페이지네이션은 다음, 이전, 첫 번째, 마지막 페이지만 제공할 수 있습니다.
복잡성#
단일 칼럼으로 정렬할 때 쿼리를 작성하는 것은 매우 쉽지만, 타이 브레이커 또는 다중 칼럼 정렬이 사용되면 더 복잡해집니다. 칼럼이 nullable인 경우 복잡성이 증가합니다.
예시: created_at이 nullable인 경우 id와 created_at으로 정렬, 두 번째 페이지를 가져오는 쿼리:
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 프로젝트 내에서 일반적인 keyset 페이지네이션 라이브러리를 사용할 수 있으며, 대부분의 경우 기존 Kaminari 기반 페이지네이션을 대체하여 대용량 데이터셋을 처리할 때 상당한 성능 향상을 제공할 수 있습니다.
예시:
# first page
paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
puts paginator.to_a # records
# next page
cursor = paginator.cursor_for_next_page
paginator = Project.order(:created_at, :id).keyset_paginate(cursor: cursor, per_page: 20)
puts paginator.to_a # records
종합적인 개요는 keyset 페이지네이션 가이드 페이지를 참조하세요.
성능#
Keyset 페이지네이션은 앞으로 이동한 페이지 수에 관계없이 안정적인 성능을 제공합니다. 이 성능을 달성하려면 오프셋 페이지네이션과 마찬가지로 페이지네이션된 쿼리에 ORDER BY 절의 모든 칼럼을 커버하는 인덱스가 필요합니다.
일반 성능 가이드라인#
페이지네이션 일반 성능 가이드라인 페이지를 참조하세요.