페이지네이션 가이드라인
이 문서는 GitLab에서 데이터를 페이지네이션하는 현재 기능 및 모범 사례를 개요로 제공합니다, 특히 PostgreSQL에 대해 포함합니다.
페이지네이션의 필요성은?
페이지네이션은 웹 요청에서 너무 많은 데이터를로드하는 것을 피하기 위한 인기있는 기술입니다. 이것은 보통 레코드 디렉터리을 렌더링할 때 발생합니다. 일반적인 시나리오는 UI에서 부모-자식 관계(다대다)를 시각화하는 것입니다.
예: 프로젝트 내의 이슈 디렉터리
프로젝트 내의 이슈 수가 늘어날수록 디렉터리이 길어집니다. 디렉터리을 렌더링하기 위해 백엔드는 다음을 수행합니다.
- 데이터베이스에서 레코드를로드합니다. 보통 특정한 순서로.
- 루비에서 레코드를 직렬화합니다. 루비(ActiveRecord) 객체를 빌드 한 다음 JSON 또는 HTML 문자열을 빌드합니다.
- 응답을 브라우저로 보냅니다.
- 브라우저가 콘텐츠를 렌더링합니다.
콘텐츠를 렌더링하는 데 두 가지 옵션이 있습니다.
- HTML: 백엔드에서 렌더링 처리(HAML 템플릿).
- JSON: 클라이언트(클라이언트 측 JavaScript)가 페이로드를 HTML로 변환.
긴 디렉터리은 프론트엔드 및 백엔드 성능에 상당한 영향을 미칠 수 있습니다.
- 데이터베이스는 디스크에서 많은 데이터를 읽습니다.
- 쿼리 결과(레코드)는 메모리 할당을 증가시키는 루비 객체로 최종적으로 변환됩니다.
- 큰 응답은 사용자 브라우저로 보내기까지 더 많은 시간이 걸립니다.
- 긴 디렉터리을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).
페이지네이션을 사용하면 데이터가 동일한 크기의 조각(페이지)으로 나뉩니다. 첫 방문에서 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 앞으로 페이지를 나누어 더 많은 항목을 볼 수 있으며, 이로 인해 새 HTTP 요청과 새 데이터베이스 쿼리가 발생합니다.
페이지네이션의 일반적인 지침
적절한 방법 선택
데이터베이스가 페이지네이션, 필터링 및 데이터 검색을 처리하도록 합니다. 백엔드(‘Kaminari’의 paginate_array
)나 프론트엔드(JavaScript)에서 인메모리 페이지네이션을 구현할 수도 있지만, 레코드수가 수백 개라면 문제가 발생할 수 있습니다. 애플리케이션의 제한 사항이 정의되어 있지 않으면 문제가 급작스러워질 수 있습니다.
복잡성 줄이기
페이지에서 레코드를 나열할 때 자주 추가 필터 및 다양한 정렬 옵션을 제공합니다. 이것은 백엔드 측에서 복잡해질 수 있습니다.
MVC 버전의 경우 다음을 고려하세요.
- 정렬 옵션의 수를 최소화하세요.
- 필터의 수를 최소화하세요(드롭다운 디렉터리, 검색 바).
정렬 및 페이지네이션을 효율적으로 만들기 위해 각 정렬 옵션마다 적어도 두 개의 데이터베이스 인덱스가 필요합니다(오름차순, 내림차순). 필터 옵션(상태 또는 작성자별로)을 추가하면 성능을 유지하기위해 더 많은 인덱스가 필요할 수 있습니다. 인덱스는 무료가 아니며, UPDATE
쿼리 시간에 상당한 영향을 미칠 수 있습니다.
모든 필터 및 정렬 조합을 성능적으로 만드는 것은 불가능하기 때문에 사용 패턴을 이용하여 성능을 최적화하도록 노력해야 합니다.
확장을 고려하기
오프셋 기반 페이지네이션은 레코드를 나열하는 가장 쉬운 방법입니다. 그러나 대형 데이터베이스 테이블에 대해서는 잘 확장되지 않습니다. 장기적인 솔루션으로는 키셋 페이지네이션이 선호됩니다. 오프셋 및 키셋 페이지네이션 사이의 전환은 일반적으로 간단하며 다음 조건을 충족시킨다면 최종 사용자에게 영향을 주지 않고 수행할 수 있습니다.
- 총 수를 제시하는 것을 피하고 제한 수를 선호하세요.
- 예: 최대 1001개의 레코드를 세고, 그런 다음 UI에 1000개 이상이면 1000+로 표시하고 그 외의 경우 실제 수를 표시하세요.
- 자세한 정보는 뱃지 카운터 접근 방식을 참조하세요.
- 페이지 번호 대신 다음 및 이전 페이지 버튼을 사용하세요.
- 키셋 페이지네이션은 페이지 번호를 지원하지 않습니다.
- API의 경우 “손작업으로” 다음 페이지를 위한 URL을 만드는 것을 권장하지 마세요.
- 다음 백엔드에서 제공하는 다음 페이지 및 이전 페이지의 URL을
Link
헤더를 사용하는 것을 장려하세요. - 이렇게 하면 URL 구조를 변경하여도 하위 호환성을 깨뜨리지 않고 변경할 수 있습니다.
- 다음 백엔드에서 제공하는 다음 페이지 및 이전 페이지의 URL을
페이지네이션 옵션
오프셋 페이지네이션
디렉터리을 페이지별로 구분하는 가장 일반적인 방법은 오프셋 기반 페이지네이션(UI 및 REST API)을 사용하는 것입니다. 인기 있는 Kaminari 루비 젬을 통해 활성 레코드 쿼리에서 페이지네이션을 구현하기 위한 편리한 도우미 메서드를 제공합니다.
오프셋 기반 페이지네이션은 LIMIT
및 OFFSET
SQL 절을 이용하여 테이블에서 특정 슬라이스를 가져옵니다.
프로젝트 내의 이슈의 2번째 페이지를 찾을 때의 예시 데이터베이스 쿼리:
SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20 OFFSET 20
- 테이블 행 위를 가상의 포인터로 이동하고 20개의 행을 건너뜁니다.
- 다음 20개의 행을 가져옵니다.
쿼리에서 행을 순서대로 나열하는 것이 매우 중요합니다. 그렇지 않으면 반환된 행은 결정론적이 아니며 사용자를 혼란스럽게 할 수 있습니다.
페이지 번호
Kaminari 젬에 의해 렌더링 된 예시 페이지 선택기:
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
대규모 데이터셋에 페이징을 적용할 때 응답 시간이 점점 느려질 수 있는데, 이는 행을 검색하고 N개의 행을 건너뛰는 OFFSET
절 때문입니다.
사용자 관점에서 이는 항상 눈에 띄지 않을 수 있습니다. 사용자가 다음 페이지로 넘어갈 때 이전 행은 여전히 데이터베이스의 버퍼 캐시에 남아 있을 수 있습니다. 사용자가 누군가에게 링크를 공유하고, 몇 분 또는 몇 시간 후에 열리면 응답 시간이 상당히 높아지거나 시간이 초과될 수 있습니다.
큰 페이지 번호를 요청할 때, 데이터베이스는 페이지 * 페이지 크기
의 행을 읽어야 합니다. 이로 인해 offset 페이징은 대규모 데이터베이스 테이블에 적합하지 않습니다.
예: 관리자 영역에서 사용자 나열
아주 간단한 SQL 쿼리를 사용하여 사용자를 나열합니다.
SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 0
쿼리 실행 계획을 보면 이 쿼리가 효율적이며 데이터베이스에서 20개의 행만 읽었음을 보여줍니다 (rows=20
):
다음과 같이 Limit
및 Index Scan
이 나타납니다.
이것이 통계 옵티마이저를 참조하세요. 읽기에 대한 더 많은 정보를 얻을 수 있습니다.
5만번째 페이지를 방문해 봅시다:
SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 999980;
계획에서는 데이터베이스가 1,000,000개의 행을 읽어 20개의 행을 반환하고 매우 오랜 실행 시간(5.5초)이 걸린다는 것을 보여줍니다.
우리는 전형적인 사용자가 이러한 페이지를 방문하지 않을 수 있다고 주장할 수 있습니다. 그러나 API 사용자는 매우 높은 페이지 번호로 쉽게 이동할 수 있습니다 (스크래핑, 데이터 수집).
키셋 페이징
키셋 페이징은 대량 페이지를 요청할 때 이전 행을 “건너 뛰는” 성능 문제에 대한 대응책입니다. 그러나 이는 offset 기반 페이징의 대체품이 아닙니다. API 엔드포인트를 offset 기반 페이징에서 키셋 기반 페이징으로 변경할 때는 둘 다 지원되어야 합니다. 한 유형의 페이징을 완전히 제거하는 것은 중단 변경입니다.
키셋 페이징은 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
를 기본 키로 정렬하여 전체 테이블에 대해 페이징하면, 첫 번째 페이지에 대한 쿼리는 offset 페이징 쿼리와 동일하지만, 여기서는 페이지 크기로 5를 사용합니다:
정렬 순서를 기준으로 마지막 행에서 값을 추출해야만 다음 페이지로 이동할 수 있습니다. 이 경우 id
만 필요합니다. 이제 다음 페이지를 위한 쿼리를 작성합니다:
다음과 같은 쿼리 실행 계획을 보면, 이 쿼리가 5개의 행만 읽었음을 보여줍니다 (offset 기반 페이징은 10개의 행을 읽었을 것입니다):
제한 사항
페이지 번호 없음
Offset 페이징은 특정 페이지를 요청하는 간편한 방법을 제공합니다. URL을 편집하고 page=
URL 매개변수를 수정할 수 있습니다. 키셋 페이징은 페이지 번호를 제공할 수 없기 때문에 페이지 넘버가 없습니다.
비슷한 과정이 URL에 반영됩니다.
GraphQL의 경우, 매개변수가 JSON으로 직렬화되어 그런 다음 인코딩됩니다.
키셋 페이징은 다음, 이전, 첫 번째, 마지막 페이지만 제공할 수 있습니다.
복잡성
단일 열을 기준으로 정렬할 때 쿼리를 작성하는 것은 매우 쉽지만, tie-breaker 또는 다중 열 정렬을 사용할 경우에는 더 복잡해집니다. 열이 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 # 레코드
좀 더 포괄적인 개요를 보려면 키셋 페이징 가이드 페이지를 확인하세요.
성능
키셋 페이징은 앞으로 이동한 페이지 수에 관계없이 안정적인 성능을 제공합니다. 이 성능을 달성하기 위해 페이징된 쿼리는 offset 페이징과 유사하게 ORDER BY
절의 모든 열을 포함하는 인덱스가 필요합니다.
일반적인 성능 지침
페이징 일반적인 성능 지침 페이지를 참조하십시오.