페이지 네이션 가이드라인

이 문서는 현재의 기능에 대한 개요를 제공하고 GitLab에서 데이터를 페이징하는 최상의 방법을 제시합니다. 특히 PostgreSQL에 대한 정보를 다룹니다.

페이지 네이션의 필요성

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

예: 프로젝트 내의 이슈 목록 표시

프로젝트 내의 이슈 수가 증가함에 따라 목록도 길어집니다. 목록을 렌더링하기 위해 백엔드는 다음을 수행합니다.

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

콘텐트를 렌더링하기 위해 다음과 같은 옵션이 있습니다.

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

긴 목록을 렌더링하는 것은 프론트엔드와 백엔드 성능에 상당한 영향을 미칠 수 있습니다.

  • 데이터베이스는 디스크에서 많은 데이터를 읽어들입니다.
  • 쿼리 결과(레코드)는 최종적으로 루비 객체로 변환되어 메모리 할당량이 증가합니다.
  • 큰 응답은 사용자 브라우저로 보내는 데 더 많은 시간이 소요됩니다.
  • 긴 목록을 렌더링하는 것은 브라우저를 멈출 수 있습니다 (나쁜 사용자 경험).

페이지 네이션을 사용하면 데이터가 동등한 조각(페이지)로 분할됩니다. 처음 방문시 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 페이지를 전환함으로써 더 많은 항목을 볼 수 있으며, 이로 인해 새로운 HTTP 요청과 데이터베이스 질의가 발생합니다.

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

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

적절한 방식 선택

데이터베이스에게 페이지 네이션, 필터링, 데이터 검색을 처리하도록 합니다. 백엔드에서 메모리 안 페이지네이션(paginate_array from Kaminari) 또는 프론트엔드(JavaScript)에서 구현하는 것은 몇 백 개의 레코드에 대해 작동할 수 있습니다. 응용 프로그램 제한이 정의되지 않으면, 상황이 금방 손을 잡을 수 있습니다.

복잡성 줄이기

페이지에서 레코드를 나열할 때, 종종 추가 필터 및 다른 정렬 옵션을 제공합니다. 이것은 백엔드에서 일을 상당히 복잡하게 만들 수 있습니다.

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

  • 정렬 옵션의 수를 최소로 줄입니다.
  • 필터의 수를 최소로 줄입니다(드롭다운 목록, 검색 바 등).

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

모든 필터 및 정렬 조합을 성능 좋게 만드는 것은 불가능하기 때문에 사용 패턴을 통해 성능을 최적화하려고 노력해야 합니다.

확장을 위한 준비

옵셋 기반 페이지네이션은 레코드를 페이지 별로 나누는 가장 쉬운 방법이지만, 대규모 데이터베이스 테이블에 대해서는 잘 확장되지 않습니다. 장기적인 해결책으로는 키셋 페이지네이션이 선호됩니다. 또한 옵셋과 키셋 페이지네이션 간의 전환은 일반적으로 직관적이며, 다음 조건을 충족하는 경우에는 최종 사용자에게 영향을 미치지 않고 수행할 수 있습니다.

  • 전체 카운트를 표시하는 것은 피하고, 한정된 카운트를 추천합니다.
    • 예: 최대 1001개의 레코드를 카운트한 후, UI에는 1000개 이상의 경우 “1000+”을 표시하고, 그렇지 않으면 실제 번호를 표시합니다.
    • 자세한 내용은 뱃지 카운터 접근 방식을 참조하세요.
  • 페이지 번호 대신 다음과 이전 페이지 버튼을 사용하는 것을 권장합니다.
    • 키셋 페이지네이션은 페이지 번호를 지원하지 않습니다.
  • API의 경우, “손으로” 다음 페이지를 위한 URL을 구축하는 것을 권장하지 않습니다.
    • 백엔드에서 다음 페이지 및 이전 페이지의 URL을 제공하는 Link 헤더의 사용을 장려합니다.
    • 이렇게 하면 URL 구조를 변경할 수 있고, 역호환성이 깨지지 않습니다.

참고: 무한 스크롤은 노출된 페이지 번호가 없기 때문에 사용자 경험에 영향을 미치지 않고 키셋 페이지네이션을 사용할 수 있습니다.

페이지 네이션 옵션

옵셋 페이지네이션

목록을 페이징하는 가장 일반적인 방법은 옵셋 기반 페이지네이션(UI와 REST API)을 사용하는 것입니다. 이는 인기 있는 Kaminari 루비 젬을 기반으로 하며, ActiveRecord 쿼리에 대해 페이지네이션을 구현하기 위한 편리한 도우미 메서드를 제공합니다.

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

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

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

쿼리가 또한 행을 주요 키(id)로 정렬합니다. 데이터를 페이지네이션할 때, 순서를 명시하는 것이 매우 중요합니다. 그렇지 않으면 반환된 행은 결정론적이지 않으며 사용자를 혼란스럽게 할 수 있습니다.

페이지 번호

페이지 선택기 예시:

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행을 읽습니다. 이 쿼리는 프로젝트 내의 이슈 수에 관계없이 잘 수행됩니다. 따라서 이 변경으로 초기 페이지 로드(사용자가 이슈 페이지를로드 할 때)도 개선되었습니다.

참고: 여기서는 b-tree 데이터베이스 인덱스의 순서화된 속성을 활용합니다. 인덱스의 값은 정렬되어 있기 때문에 20개의 행을 읽는 데 추가로 정렬이 필요하지 않습니다.

제한 사항

대규모 데이터 집합에서의 COUNT(*)

기본적으로 Kaminari는 페이지 링크를 렌더링하기 위해 카운트 쿼리를 실행합니다. 대규모 테이블에 대해 카운트 쿼리는 상당히 비용이 많이 들 수 있습니다. 불행히도 쿼리가 시간 초과 될 수 있습니다.

이를 해결하기 위해 Kaminari를 카운트 SQL 쿼리를 실행하지 않도록 실행할 수 있습니다.

Issue.where(project_id: 1).page(1).per(20).without_count

이 경우 카운트 쿼리는 실행되지 않으며 페이징은 페이지 번호를 더 이상 렌더링하지 않습니다. 다음 및 이전 링크만 볼 수 있습니다.

대규모 데이터 집합에서의 OFFSET

대규모 데이터 집합을 페이징 할 때 응답 시간이 점점 느려지는 것을 관찰할 수 있습니다. 이는 OFFSET 절로 인해 발생하는 것입니다. OFFSET 절은 행을 찾고 N개의 행을 건너뛸 수 있습니다.

사용자 관점에서 이는 항상 알아차릴 수 없을 수 있습니다. 사용자가 다음 페이지로 이동하면 이전 행은 데이터베이스의 버퍼 캐시에 여전히 남아 있을 수 있습니다. 사용자가 링크를 다른 사람과 공유하고 몇 분 또는 몇 시간 후에 열면 응답 시간이 상당히 높아질 수 있거나 시간이 초과될 수 있습니다.

대규모 페이지 번호를 요청할 때 데이터베이스는 페이지 * 페이지 크기의 행을 읽어야 합니다. 이로 인해 offset 페이징은 대형 데이터베이스 테이블에 적합하지 않습니다.

예: 관리자 영역에서 사용자 나열

아주 간단한 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)

50,000페이지를 방문해 봅시다.

SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 999980;

계획에 따르면 데이터베이스는 1,000,000개의 행을 읽어 20개의 행만 반환하며, 매우 높은 실행 시간(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 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)로 정렬하여 페이지를 나누기로 합시다. 첫 번째 페이지에 대한 쿼리는 간단히 하기 위해 페이지 크기를 5로 하여 오프셋 페이징 쿼리와 동일합니다:

SELECT "issues".* FROM "issues" ORDER BY "issues"."id" ASC LIMIT 5

OFFSET 절을 추가하지 않았음에 유의하십시오.

다음 페이지로 이동하려면 ORDER BY 절의 일부인 값들을 마지막 행에서 추출해야 합니다. 이 경우 id만 필요하므로, 다음 페이지에 대한 쿼리를 작성합니다:

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

참고: 페이징 매개변수는 사용자에게 보이므로 어떤 열을 기준으로 정렬하는지에 대해 신중해야 합니다.

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

복잡성

단일 열을 기준으로 정렬할 때는 쿼리를 작성하는 것이 매우 쉽지만, 타이브레이커나 다중 열 정렬이 사용되는 경우 복잡해집니다. 열이 널 가능하다면 복잡성이 증가합니다.

예: idcreated_at로 정렬하고 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 프로젝트 내에는 대부분의 경우에 기존의 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 절의 모든 열을 포함하는 인덱스가 필요합니다.

일반 성능 가이드라인

pagination general performance guidelines page를 참조하세요.