페이지네이션 가이드라인

이 문서는 GitLab에서 데이터를 페이지네이션하는 현재 기능과 최선의 방법을 개요로 제시합니다. 특히 PostgreSQL에 대한 것입니다.

페이지네이션 필요성은 무엇인가요?

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

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

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

  1. 데이터베이스에서 레코드를 로드합니다. 일반적으로 특정한 순서로 로드합니다.
  2. 레코드를 Ruby로 직렬화합니다. Ruby(ActiveRecord) 객체를 빌드하고 그 후에 JSON 또는 HTML 문자열을 빌드합니다.
  3. 브라우저로 응답을 전송합니다.
  4. 브라우저에서 콘텐츠를 렌더링합니다.

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

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

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

  • 데이터베이스는 디스크에서 많은 데이터를 읽습니다.
  • 쿼리 결과(레코드)는 메모리 할당을 증가시키는 Ruby 객체로 전환됩니다.
  • 큰 응답은 사용자의 브라우저로 전송하는 데 더 많은 시간이 걸립니다.
  • 긴 목록을 렌더링하는 것은 브라우저를 멈출 수 있습니다 (나쁜 사용자 경험).

페이지네이션을 사용하면 데이터가 동일한 크기의 조각(페이지)으로 나뉩니다. 최초 방문 시 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 페이지네이션을 통해 더 많은 항목을 볼 수 있고, 이는 새로운 HTTP 요청과 새로운 데이터베이스 쿼리로 이어집니다.

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

페이지네이션에 대한 일반적인 지침

올바른 접근 방식 선택

데이터베이스에게 페이지네이션, 필터링 및 데이터 검색을 처리하도록 합시다. 백엔드에서 인메모리 페이지네이션(Kaminari의 paginate_array)이나 프론트엔드(JavaScript)에서 구현하는 것은 몇 백 개의 레코드에 대해서만 작동할 수 있습니다. 응용 프로그램 제한이 정의되지 않은 경우 상황이 금방 다소 불가능해질 수 있습니다.

복잡성 줄이기

페이지에 레코드를 나열할 때 추가적인 필터 및 정렬 옵션을 제공하는 경우, 백엔드 측에서는 일이 크게 복잡해질 수 있습니다.

MVC 버전의 경우 다음 사항을 고려해 보세요:

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

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

모든 필터 및 정렬 조합을 효율적으로 만드는 것은 불가능하므로 사용 패턴을 통해 성능을 최적화해야 합니다.

확장을 위한 준비

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

  • 총 개수를 표시하는 대신, 제한된 수를 선호합니다.
    • 예: 최대 1001개의 레코드를 세면, UI에서는 그 개수가 1001개인 경우에는 1000+로 보여주고 그렇지 않은 경우에는 실제 번호를 표시합니다.
    • 자세한 내용은 배지 카운터 접근 방식을 참조하세요.
  • 페이지 번호 대신 다음과 이전 페이지 버튼을 사용하세요.
    • 키편집 페이지네이션은 페이지 번호를 지원하지 않습니다.
  • API의 경우, 다음 페이지를 “수동”으로 위한 URL을 만들지 않도록 권장합니다.
    • 백엔드에서 다음 및 이전 페이지에 대한 URL을 제공하는 Link 헤더의 사용을 장려하세요.
    • 이렇게 함으로써 URL 구조를 변경할 수 있고 역호환성을 깨지 않고 가능합니다.

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

페이지네이션 옵션

오프셋 페이지네이션

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

오프셋 기반 페이지네이션은 테이블에서 특정한 조각을 제거하는 데 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;

레일스에서도 동일한 쿼리를 생성할 수 있습니다:

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는 페이지 링크를 렌더링하기 위해 수를 결정하기 위해 count 쿼리를 실행합니다. 큰 테이블에 대해서 count 쿼리는 비용이 많이 들 수 있습니다. 불행히도 쿼리가 시간 초과 될 수 있습니다.

이를 해결하기 위해, 우리는 count SQL 쿼리를 호출하지 않고 Kaminari를 실행할 수 있습니다.

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

이 경우, count 쿼리는 실행되지 않으며 페이지네이션은 더 이상 페이지 번호를 렌더링하지 않습니다. 다음 및 이전 링크만 표시됩니다.

대규모 데이터세트에서의 OFFSET

대규모 데이터세트를 페이징하는 경우, 응답 시간이 점점 더 느려지는 것을 관찰할 수 있습니다. 이는 행을 찾고 N개의 행을 건너뛰는 OFFSET 절 때문입니다.

사용자 관점에서 이는 항상 눈에 띄지는 않을 수 있습니다. 사용자가 앞으로 페이징을 할 때, 이전 행은 데이터베이스의 버퍼 캐시에 여전히 남아있을 수 있습니다. 사용자가 링크를 다른 사람과 공유하고 며칠 또는 몇 시간 후에 열면 응답 시간이 크게 느려질 수 있거나 시간이 초과될 수도 있습니다.

큰 페이지 번호를 요청할 때, 데이터베이스는 PAGE * PAGE_SIZE개의 행을 읽어야 합니다. 이로써 offset pagination은 대규모 데이터베이스 테이블에 적합하지 않지만, 최적화 기술을 통해 데이터베이스 쿼리의 전반적인 성능을 약간 향상시킬 수 있습니다.

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

매우 간단한 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;

계획에 따르면, 데이터베이스는 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 사용자들은 매우 높은 페이지 숫자로 이동할 수 있습니다 (스크래핑, 데이터 수집).

키셋 페이지네이션

키셋 페이지네이션은 “이전” 행을 건너뛸 때의 성능 문제에 대응하지만, offset 기반 페이지네이션을 대체할 수는 없습니다. API endpoint를 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를 기준으로 정렬된 전체 테이블에서 페이지네이션을 적용해 봅시다. 간단한 예로, 페이지 크기를 5로 설정합니다:

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

OFFSET 절을 추가하지 않았음을 주목하세요.

다음 페이지로 이동하려면, 마지막 행에서 ORDER BY 절의 값들을 추출해야 합니다. 이 경우에는 5인 id만 필요합니다. 이제 다음 페이지를 위한 쿼리를 만들어 봅시다:

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

쿼리 실행 계획을 확인해 보면, 이 쿼리가 5개의 행만 읽었음을 알 수 있습니다 (offset 기반 페이지네이션은 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)

한계

페이지 번호 없음

offset 페이지네이션은 특정 페이지를 요청하는 간단한 방법을 제공합니다. 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 기반 페이징을 쉽게 대체하고, 대용량 데이터셋을 다룰 때 signficant performance improvements를 제공합니다.

예:

# 첫 페이지
paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
puts paginator.to_a # records

# 다음 페이지
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 pagination guide 페이지를 참조하십시오.

성능

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

일반 성능 가이드라인

pagination general performance guidelines 페이지를 참조하십시오.