페이지네이션 가이드라인

이 문서는 현재 기능에 대한 개요를 제공하고 GitLab에서 데이터에 대한 페이지네이션을 처리하기 위한 모범 사례를 제시합니다. 특히 PostgreSQL에 대해 다룹니다.

왜 페이지네이션이 필요할까요?

페이지네이션은 한 웹 요청에서 너무 많은 데이터를 로드하지 않기 위해 사용하는 인기 있는 기법입니다. 이는 일반적으로 기록 목록을 렌더링할 때 발생합니다. 일반적인 시나리오는 UI에서 부모-자식 관계(다수의 관계)를 시각화하는 것입니다.

예: 프로젝트 내 이슈 나열

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

  1. 보통 특정 순서로 데이터베이스에서 기록을 로드합니다.

  2. 루비에서 기록을 직렬화합니다. 루비(ActiveRecord) 객체를 구축하고 JSON 또는 HTML 문자열을 생성합니다.

  3. 응답을 브라우저로 다시 보냅니다.

  4. 브라우저가 콘텐츠를 렌더링합니다.

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

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

긴 목록을 렌더링하면 프론트엔드와 백엔드 성능 모두에 상당한 영향을 미칠 수 있습니다:

  • 데이터베이스는 디스크에서 많은 데이터를 읽습니다.
  • 쿼리 결과(기록)는 결국 루비 객체로 변환되어 메모리 할당이 증가합니다.
  • 큰 응답은 사용자 브라우저로 전송하는 데 더 많은 시간이 걸립니다.
  • 긴 목록을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).

페이지네이션을 사용하면 데이터가 동일한 조각(페이지)으로 나뉘어집니다. 처음 방문할 때 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 페이지를 앞으로 넘김으로써 더 많은 항목을 볼 수 있으며, 이는 새로운 HTTP 요청과 새로운 데이터베이스 쿼리를 초래합니다.

페이지네이션이 있는 프로젝트 이슈 페이지

페이지네이션을 위한 일반적인 가이드라인

올바른 접근 방식 선택

데이터베이스가 페이지네이션, 필터링 및 데이터 검색을 처리하도록 하세요. 백엔드에서 메모리 내 페이지네이션(paginate_array from Kaminari)이나 프론트엔드(JavaScript)에서 구현하는 것은 수백 개의 기록에 대해서는 작동할 수 있습니다. 애플리케이션 제한이 정의되지 않으면 상황이 금세 통제 불능 상태가 될 수 있습니다.

복잡성 줄이기

페이지에 기록을 나열할 때 종종 추가 필터와 다양한 정렬 옵션을 제공합니다. 이는 백엔드에서 상황을 크게 복잡하게 만들 수 있습니다.

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

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

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

모든 필터 및 정렬 조합을 성능 좋게 만드는 것은 불가능하므로, 사용 패턴에 따라 성능 최적화를 시도해야 합니다.

확장성을 위한 준비

오프셋 기반 페이지네이션은 기록을 페이지하는 가장 쉬운 방법이지만, 큰 데이터베이스 테이블에서는 확장성이 좋지 않습니다. 장기적인 솔루션으로는 키셋 페이지네이션이 선호됩니다. 오프셋과 키셋 페이지네이션 간의 전환은 일반적으로 간단하며, 다음 조건이 충족되면 최종 사용자에게 영향을 주지 않고 수행할 수 있습니다:

  • 총 카운트를 제시하지 않고, 제한 카운트를 선호합니다.
    • 예: 최대 1001개의 기록이 있는 경우, UI에 1000+을 표시하고 카운트가 1001인 경우 실제 숫자를 표시합니다.
    • 자세한 내용은 배지 카운터 접근 방식을 참조하세요.
  • 페이지 번호를 사용하지 않고, 다음 페이지 및 이전 페이지 버튼을 사용하세요.
    • 키셋 페이지네이션은 페이지 번호를 지원하지 않습니다.
  • API의 경우 “손으로” 다음 페이지의 URL을 만드는 것을 피하도록 권장합니다.
    • 백엔드에서 제공하는 다음 및 이전 페이지에 대한 URL이 포함된 Link 헤더의 사용을 권장합니다.
    • 이렇게 하면 URL 구조를 변경할 수 있으며, 이전 호환성을 깨지 않고 유지할 수 있습니다.

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

페이지 매김 옵션

오프셋 페이지 매김

목록을 페이지 매김하는 가장 일반적인 방법은 오프셋 기반 페이지 매김(UI 및 REST API)을 사용하는 것입니다. 이는 ActiveRecord 쿼리에서 페이지 매김을 구현하기 위한 편리한 헬퍼 메서드를 제공하는 인기 있는 Kaminari 루비 젬에 의해 지원됩니다.

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

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

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

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

예: 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 # 레코드

포괄적인 개요를 보려면 키셋 페이지 매김 가이드 페이지를 참조하세요.

성능

키셋 페이지 매김은 우리가 이동한 페이지 수에 관계없이 안정적인 성능을 제공합니다. 이 성능을 달성하기 위해서는 페이지 매김 쿼리가 ORDER BY 절의 모든 열을 포함하는 인덱스가 필요합니다. 이는 오프셋 페이지 매김과 유사합니다.

일반 성능 지침

페이지 매김 일반 성능 지침 페이지를 참조하세요.