페이지네이션 가이드라인

이 문서는 GitLab에서 데이터를 페이지네이션하는 현재 기능 및 모범 사례를 개요로 제공합니다, 특히 PostgreSQL에 대해 포함합니다.

페이지네이션의 필요성은?

페이지네이션은 웹 요청에서 너무 많은 데이터를로드하는 것을 피하기 위한 인기있는 기술입니다. 이것은 보통 레코드 디렉터리을 렌더링할 때 발생합니다. 일반적인 시나리오는 UI에서 부모-자식 관계(다대다)를 시각화하는 것입니다.

예: 프로젝트 내의 이슈 디렉터리

프로젝트 내의 이슈 수가 늘어날수록 디렉터리이 길어집니다. 디렉터리을 렌더링하기 위해 백엔드는 다음을 수행합니다.

  1. 데이터베이스에서 레코드를로드합니다. 보통 특정한 순서로.
  2. 루비에서 레코드를 직렬화합니다. 루비(ActiveRecord) 객체를 빌드 한 다음 JSON 또는 HTML 문자열을 빌드합니다.
  3. 응답을 브라우저로 보냅니다.
  4. 브라우저가 콘텐츠를 렌더링합니다.

콘텐츠를 렌더링하는 데 두 가지 옵션이 있습니다.

  • HTML: 백엔드에서 렌더링 처리(HAML 템플릿).
  • JSON: 클라이언트(클라이언트 측 JavaScript)가 페이로드를 HTML로 변환.

긴 디렉터리은 프론트엔드 및 백엔드 성능에 상당한 영향을 미칠 수 있습니다.

  • 데이터베이스는 디스크에서 많은 데이터를 읽습니다.
  • 쿼리 결과(레코드)는 메모리 할당을 증가시키는 루비 객체로 최종적으로 변환됩니다.
  • 큰 응답은 사용자 브라우저로 보내기까지 더 많은 시간이 걸립니다.
  • 긴 디렉터리을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).

페이지네이션을 사용하면 데이터가 동일한 크기의 조각(페이지)으로 나뉩니다. 첫 방문에서 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 앞으로 페이지를 나누어 더 많은 항목을 볼 수 있으며, 이로 인해 새 HTTP 요청과 새 데이터베이스 쿼리가 발생합니다.

프로젝트 이슈 페이지와 페이지네이션

페이지네이션의 일반적인 지침

적절한 방법 선택

데이터베이스가 페이지네이션, 필터링 및 데이터 검색을 처리하도록 합니다. 백엔드(‘Kaminari’의 paginate_array)나 프론트엔드(JavaScript)에서 인메모리 페이지네이션을 구현할 수도 있지만, 레코드수가 수백 개라면 문제가 발생할 수 있습니다. 애플리케이션의 제한 사항이 정의되어 있지 않으면 문제가 급작스러워질 수 있습니다.

복잡성 줄이기

페이지에서 레코드를 나열할 때 자주 추가 필터 및 다양한 정렬 옵션을 제공합니다. 이것은 백엔드 측에서 복잡해질 수 있습니다.

MVC 버전의 경우 다음을 고려하세요.

  • 정렬 옵션의 수를 최소화하세요.
  • 필터의 수를 최소화하세요(드롭다운 디렉터리, 검색 바).

정렬 및 페이지네이션을 효율적으로 만들기 위해 각 정렬 옵션마다 적어도 두 개의 데이터베이스 인덱스가 필요합니다(오름차순, 내림차순). 필터 옵션(상태 또는 작성자별로)을 추가하면 성능을 유지하기위해 더 많은 인덱스가 필요할 수 있습니다. 인덱스는 무료가 아니며, UPDATE 쿼리 시간에 상당한 영향을 미칠 수 있습니다.

모든 필터 및 정렬 조합을 성능적으로 만드는 것은 불가능하기 때문에 사용 패턴을 이용하여 성능을 최적화하도록 노력해야 합니다.

확장을 고려하기

오프셋 기반 페이지네이션은 레코드를 나열하는 가장 쉬운 방법입니다. 그러나 대형 데이터베이스 테이블에 대해서는 잘 확장되지 않습니다. 장기적인 솔루션으로는 키셋 페이지네이션이 선호됩니다. 오프셋 및 키셋 페이지네이션 사이의 전환은 일반적으로 간단하며 다음 조건을 충족시킨다면 최종 사용자에게 영향을 주지 않고 수행할 수 있습니다.

  • 총 수를 제시하는 것을 피하고 제한 수를 선호하세요.
    • 예: 최대 1001개의 레코드를 세고, 그런 다음 UI에 1000개 이상이면 1000+로 표시하고 그 외의 경우 실제 수를 표시하세요.
    • 자세한 정보는 뱃지 카운터 접근 방식을 참조하세요.
  • 페이지 번호 대신 다음 및 이전 페이지 버튼을 사용하세요.
    • 키셋 페이지네이션은 페이지 번호를 지원하지 않습니다.
  • API의 경우 “손작업으로” 다음 페이지를 위한 URL을 만드는 것을 권장하지 마세요.
    • 다음 백엔드에서 제공하는 다음 페이지 및 이전 페이지의 URL을 Link 헤더를 사용하는 것을 장려하세요.
    • 이렇게 하면 URL 구조를 변경하여도 하위 호환성을 깨뜨리지 않고 변경할 수 있습니다.
note
무한 스크롤은 페이지 번호가 없으므로 사용자 경험에 영향을 주지 않고 키셋 페이지네이션을 사용할 수 있습니다.

페이지네이션 옵션

오프셋 페이지네이션

디렉터리을 페이지별로 구분하는 가장 일반적인 방법은 오프셋 기반 페이지네이션(UI 및 REST API)을 사용하는 것입니다. 인기 있는 Kaminari 루비 젬을 통해 활성 레코드 쿼리에서 페이지네이션을 구현하기 위한 편리한 도우미 메서드를 제공합니다.

오프셋 기반 페이지네이션은 LIMITOFFSET SQL 절을 이용하여 테이블에서 특정 슬라이스를 가져옵니다.

프로젝트 내의 이슈의 2번째 페이지를 찾을 때의 예시 데이터베이스 쿼리:

SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20 OFFSET 20
  1. 테이블 행 위를 가상의 포인터로 이동하고 20개의 행을 건너뜁니다.
  2. 다음 20개의 행을 가져옵니다.

쿼리에서 행을 순서대로 나열하는 것이 매우 중요합니다. 그렇지 않으면 반환된 행은 결정론적이 아니며 사용자를 혼란스럽게 할 수 있습니다.

페이지 번호

Kaminari 젬에 의해 렌더링 된 예시 페이지 선택기:

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개의 행 만 읽는 것을 의미하지는 않습니다.

다음이 발생콩합니다:

  1. 데이터베이스는 테이블 통계와 사용 가능한 인덱스를 기반으로 실행 계획을 가장 효율적인 방식으로 계획하려고 시도합니다.
  2. 플래너는 project_id 열을 커버하는 인덱스가 있는 것을 압니다.
  3. 데이터베이스는 project_id에 대한 인덱스를 사용하여 모든 행을 읽습니다.
  4. 이 시점에서 행은 정렬되어 있지 않기 때문에 데이터베이스가 행을 정렬합니다.
  5. 데이터베이스는 처음 20개의 행을 반환합니다.

프로젝트에 10,000개의 행이 있는 경우, 데이터베이스는 10,000개의 행을 읽고 메모리(또는 디스크)에서 정렬합니다. 이것은 장기적인 관점에서 잘 확장되지 않습니다.

이를 해결하기 위해 다음과 같은 인덱스가 필요합니다:

CREATE INDEX index_on_issues_project_id ON issues (project_id, id);

id 열을 인덱스의 일부로 만들어 데이터베이스는 이전 쿼리로 최대 20개의 행 만을 읽습니다. 이 쿼리는 프로젝트 내의 이슈 수와 관계없이 잘 수행됩니다. 따라서 이 변경으로 초기 페이지로드(사용자가 이슈 페이지를로드 할 때)도 개선되었습니다.

note
여기서 우리는 b-tree 데이터베이스 인덱스의 정렬된 속성을 활용하고 있습니다. 인덱스의 값은 정렬되어 있으므로 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):

다음과 같이 LimitIndex 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 APIREST 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으로 직렬화되어 그런 다음 인코딩됩니다.

note
페이징 매개변수는 사용자에게 노출되므로 정렬 기준에 유의하십시오.

키셋 페이징은 다음, 이전, 첫 번째, 마지막 페이지만 제공할 수 있습니다.

복잡성

단일 열을 기준으로 정렬할 때 쿼리를 작성하는 것은 매우 쉽지만, tie-breaker 또는 다중 열 정렬을 사용할 경우에는 더 복잡해집니다. 열이 nullable 한 경우 복잡성이 증가합니다.

예: idcreated_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 절의 모든 열을 포함하는 인덱스가 필요합니다.

일반적인 성능 지침

페이징 일반적인 성능 지침 페이지를 참조하십시오.