페이지네이션 가이드라인

이 문서는 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+개를 보여주며 레코드 수가 1001개이면 그렇지 않으면 실제 숫자를 표시합니다.
    • 자세한 내용은 배지 카운터 접근 방식을 참조하세요.
  • 페이지 번호 대신 다음 및 이전 페이지 버튼을 사용합니다.
    • 키셋 페이지네이션은 페이지 번호를 지원하지 않습니다.
  • API의 경우 “손으로” 다음 페이지를 위한 URL을 구축하는 것을 권장하지 않습니다.
    • 백엔드에서 다음 및 이전 페이지를 위한 URL을 제공하는 방식인 Link 헤더 사용을 장려합니다.
    • 이렇게 하면 URL 구조를 변경하고도 하위 호환성을 깨뜨리지 않고 가능합니다.
note
무한 스크롤은 노출된 페이지 번호가 없기 때문에 사용자 경험에 영향을 미치지 않고 키셋 페이지네이션을 사용할 수 있습니다.

페이지네이션 옵션

오프셋 페이지네이션

리스트를 페이지별로 나누는 가장 일반적인 방법은 오프셋 기반 페이지네이션(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개의 행을 읽습니다. 이 쿼리는 프로젝트 내에서 이슈 수에 관계없이 잘 수행됩니다. 따라서 이 변경으로 인해 초기 페이지 로드(사용자가 이슈 페이지를 로드할 때)도 개선되었습니다.

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

제한 사항

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

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

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

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

이 경우 카운트 쿼리가 실행되지 않으며 페이지네이션은 더 이상 페이지 번호가 아닌 다음 및 이전 링크만 표시됩니다.

대규모 데이터 세트에서 OFFSET

대규모 데이터 세트를 페이지별로 나눌 때 응답 시간이 점점 느려지는 것을 알 수 있을 것입니다. 이는 행을 찾아 N개의 행을 건너뛰는 OFFSET 절 때문입니다.

사용자 관점에서는 이것이 항상 눈에 띄지는 않을 수 있습니다. 사용자가 앞으로 페이지를 이동할 때 이전 행들은 여전히 데이터베이스의 버퍼 캐시에 남아 있을 수 있습니다. 사용자가 링크를 다른 사람과 공유하고, 몇 분 또는 몇 시간 후에 열면 응답 시간이 상당히 증가하거나 타임아웃될 수 있습니다.

대규모 페이지 번호를 요청할 때, 데이터베이스는 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)

실행 계획을 이해하는 방법에 대한 자세한 내용은 실행 계획 이해를 참조하세요.

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
note
페이지 매개변수는 사용자에게 노출되므로 어떤 열을 기준으로 정렬하는지 신중하게 선택해야 합니다.

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

복잡성

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

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

전체적인 내용은 키셋 페이징 가이드 페이지를 참조하세요.

성능

Keyset 페이지네이션은 우리가 전진하는 페이지 수에 관계없이 안정적인 성능을 제공합니다. 이 성능을 달성하기 위해 페이지네이션된 쿼리는 오프셋 페이지네이션과 마찬가지로 ORDER BY 절의 모든 열을 포함하는 인덱스가 필요합니다.

일반 성능 지침

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