InfoGrab DocsInfoGrab Docs

페이지네이션 가이드라인

요약

이 문서는 GitLab에서, 특히 PostgreSQL에서 데이터를 페이지네이션하기 위한 현재 기능에 대한 개요와 모범 사례를 제공합니다. 페이지네이션은 하나의 웹 요청에서 너무 많은 데이터를 로드하지 않기 위한 일반적인 기법입니다.

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

페이지네이션이 필요한 이유#

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

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

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

  • 데이터베이스에서 레코드를 로드합니다. 보통 특정 순서로 로드합니다.

  • Ruby에서 레코드를 직렬화합니다. Ruby(ActiveRecord) 객체를 빌드한 다음 JSON 또는 HTML 문자열을 빌드합니다.

  • 브라우저에 응답을 반환합니다.

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

콘텐츠 렌더링을 위한 두 가지 옵션이 있습니다:

  • HTML: 백엔드가 렌더링을 처리합니다(HAML 템플릿).

  • JSON: 클라이언트(클라이언트 측 JavaScript)가 페이로드를 HTML로 변환합니다.

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

  • 데이터베이스가 디스크에서 많은 데이터를 읽습니다.

  • 쿼리 결과(레코드)는 결국 Ruby 객체로 변환되어 메모리 할당이 증가합니다.

  • 응답이 크면 사용자 브라우저로 전송하는 데 더 많은 시간이 걸립니다.

  • 긴 목록을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).

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

[

](/19.1/development/database/img/project_issues_pagination_v13_11.jpg)

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

올바른 접근 방식 선택#

페이지네이션, 필터링, 데이터 조회는 데이터베이스가 처리하도록 하세요. 백엔드의 인메모리 페이지네이션(Kaminari의 paginate_array) 또는 프론트엔드(JavaScript)에서의 페이지네이션은 수백 개의 레코드에 대해서는 작동할 수 있습니다. 하지만 애플리케이션 제한이 정의되지 않으면 상황이 빠르게 통제 불능 상태가 될 수 있습니다.

복잡성 줄이기#

페이지에 레코드를 나열할 때 추가 필터와 다양한 정렬 옵션을 제공하는 경우가 많습니다. 이는 백엔드 측에서 복잡성을 크게 높일 수 있습니다.

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

  • 정렬 옵션을 최소한으로 줄이세요.

  • 필터 수(드롭다운 목록, 검색창)를 최소한으로 줄이세요.

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

모든 필터와 정렬 조합을 성능적으로 만들 수는 없으므로, 사용 패턴에 따라 성능을 최적화하려고 노력해야 합니다.

확장을 위한 준비#

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

  • 총 개수 표시를 피하고, 제한 개수를 선호하세요.

예시: 최대 1001개의 레코드를 세고, 개수가 1001이면 UI에서 1000+를 표시하고, 그렇지 않으면 실제 숫자를 표시합니다.

  • 자세한 내용은 배지 카운터 접근 방식을 참조하세요.

  • 페이지 번호 사용을 피하고, 다음 및 이전 페이지 버튼을 사용하세요.

Keyset 페이지네이션은 페이지 번호를 지원하지 않습니다.

  • API의 경우, 다음 페이지의 URL을 "수동으로" 구성하는 방식을 권장하지 않습니다.

백엔드에서 다음 및 이전 페이지의 URL을 제공하는 Link 헤더 사용을 권장하세요.

  • 이 방식으로 URL 구조를 변경해도 이전 버전과의 호환성을 깨뜨리지 않을 수 있습니다.

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

페이지네이션 옵션#

오프셋 페이지네이션#

목록을 페이지네이션하는 가장 일반적인 방법은 오프셋 기반 페이지네이션입니다(UI 및 REST API). 이는 ActiveRecord 쿼리에서 페이지네이션을 구현하는 편리한 헬퍼 메서드를 제공하는 유명한 Kaminari Ruby gem으로 구현됩니다.

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

프로젝트 내 이슈의 두 번째 페이지를 조회할 때의 예시 데이터베이스 쿼리:

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

  • 다음 20개의 행을 가져옵니다.

쿼리가 기본 키(id)로 행을 정렬한다는 점에 주목하세요. 데이터를 페이지네이션할 때 순서를 지정하는 것은 매우 중요합니다. 순서가 없으면 반환된 행은 비결정적이며 최종 사용자를 혼란스럽게 할 수 있습니다.

페이지 번호#

예시 페이지네이션 바:

[

](/19.1/development/database/img/offset_pagination_ui_v13_11.jpg)

Kaminari gem은 UI에서 페이지 번호와 선택적으로 다음, 이전, 첫 번째, 마지막 페이지 버튼 단축키가 있는 멋진 페이지네이션 바를 렌더링합니다. 이러한 버튼을 렌더링하려면 Kaminari는 행의 수를 알아야 하며, 이를 위해 count 쿼리가 실행됩니다.

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개의 행만 읽는다는 의미는 아닙니다.

다음과 같은 일이 발생합니다:

  • 데이터베이스는 테이블 통계와 사용 가능한 인덱스를 기반으로 가장 효율적인 방법으로 실행을 계획하려고 합니다.

  • 플래너는 project_id 칼럼을 커버하는 인덱스가 있음을 알고 있습니다.

  • 데이터베이스는 project_id의 인덱스를 사용하여 모든 행을 읽습니다.

  • 이 시점에서 행들은 정렬되지 않으므로 데이터베이스가 행을 정렬합니다.

  • 데이터베이스는 처음 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개의 행을 읽어야 합니다. 이로 인해 오프셋 페이지네이션은 대용량 데이터베이스 테이블에는 적합하지 않습니다. 그러나 최적화 기법을 사용하면 데이터베이스 쿼리의 전반적인 성능을 약간 향상시킬 수 있습니다.

예시: Admin 영역에서 사용자 목록 표시

매우 간단한 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 사용자는 매우 높은 페이지 번호까지 이동할 수 있습니다(스크래핑, 데이터 수집).

Keyset 페이지네이션#

Keyset 페이지네이션은 큰 페이지를 요청할 때 이전 행을 "건너뛰는" 성능 문제를 해결하지만, 오프셋 기반 페이지네이션의 드롭인 대체는 아닙니다. API 엔드포인트를 오프셋 기반 페이지네이션에서 keyset 기반 페이지네이션으로 이동할 때는 두 가지 모두 지원되어야 합니다. 하나의 페이지네이션 유형을 완전히 제거하는 것은 브레이킹 체인지입니다.

Keyset 페이지네이션은 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 파라미터를 수정할 수 있습니다. Keyset 페이지네이션은 페이지 로직이 다른 칼럼에 따라 달라질 수 있으므로 페이지 번호를 제공할 수 없습니다.

이전 예시에서 칼럼은 id이므로 URL에서 다음과 같이 볼 수 있습니다:

id_after=5

GraphQL에서는 파라미터가 JSON으로 직렬화된 다음 인코딩됩니다:

eyJpZCI6Ijk0NzMzNTk0IiwidXBkYXRlZF9hdCI6IjIwMjEtMDQtMDkgMDg6NTA6MDUuODA1ODg0MDAwIFVUQyJ9
페이지네이션 파라미터는 사용자에게 보이므로, 어떤 칼럼으로 정렬할지 주의하세요.

Keyset 페이지네이션은 다음, 이전, 첫 번째, 마지막 페이지만 제공할 수 있습니다.

복잡성#

단일 칼럼으로 정렬할 때 쿼리를 작성하는 것은 매우 쉽지만, 타이 브레이커 또는 다중 칼럼 정렬이 사용되면 더 복잡해집니다. 칼럼이 nullable인 경우 복잡성이 증가합니다.

예시: created_at이 nullable인 경우 idcreated_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 프로젝트 내에서 일반적인 keyset 페이지네이션 라이브러리를 사용할 수 있으며, 대부분의 경우 기존 Kaminari 기반 페이지네이션을 대체하여 대용량 데이터셋을 처리할 때 상당한 성능 향상을 제공할 수 있습니다.

예시:

# first page
paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
puts paginator.to_a # records

# next page
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 페이지네이션 가이드 페이지를 참조하세요.

성능#

Keyset 페이지네이션은 앞으로 이동한 페이지 수에 관계없이 안정적인 성능을 제공합니다. 이 성능을 달성하려면 오프셋 페이지네이션과 마찬가지로 페이지네이션된 쿼리에 ORDER BY 절의 모든 칼럼을 커버하는 인덱스가 필요합니다.

일반 성능 가이드라인#

페이지네이션 일반 성능 가이드라인 페이지를 참조하세요.

페이지네이션 가이드라인

GitLab v19.1
원문 보기
요약

이 문서는 GitLab에서, 특히 PostgreSQL에서 데이터를 페이지네이션하기 위한 현재 기능에 대한 개요와 모범 사례를 제공합니다. 페이지네이션은 하나의 웹 요청에서 너무 많은 데이터를 로드하지 않기 위한 일반적인 기법입니다.

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

페이지네이션이 필요한 이유#

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

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

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

  • 데이터베이스에서 레코드를 로드합니다. 보통 특정 순서로 로드합니다.

  • Ruby에서 레코드를 직렬화합니다. Ruby(ActiveRecord) 객체를 빌드한 다음 JSON 또는 HTML 문자열을 빌드합니다.

  • 브라우저에 응답을 반환합니다.

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

콘텐츠 렌더링을 위한 두 가지 옵션이 있습니다:

  • HTML: 백엔드가 렌더링을 처리합니다(HAML 템플릿).

  • JSON: 클라이언트(클라이언트 측 JavaScript)가 페이로드를 HTML로 변환합니다.

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

  • 데이터베이스가 디스크에서 많은 데이터를 읽습니다.

  • 쿼리 결과(레코드)는 결국 Ruby 객체로 변환되어 메모리 할당이 증가합니다.

  • 응답이 크면 사용자 브라우저로 전송하는 데 더 많은 시간이 걸립니다.

  • 긴 목록을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).

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

[

](/19.1/development/database/img/project_issues_pagination_v13_11.jpg)

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

올바른 접근 방식 선택#

페이지네이션, 필터링, 데이터 조회는 데이터베이스가 처리하도록 하세요. 백엔드의 인메모리 페이지네이션(Kaminari의 paginate_array) 또는 프론트엔드(JavaScript)에서의 페이지네이션은 수백 개의 레코드에 대해서는 작동할 수 있습니다. 하지만 애플리케이션 제한이 정의되지 않으면 상황이 빠르게 통제 불능 상태가 될 수 있습니다.

복잡성 줄이기#

페이지에 레코드를 나열할 때 추가 필터와 다양한 정렬 옵션을 제공하는 경우가 많습니다. 이는 백엔드 측에서 복잡성을 크게 높일 수 있습니다.

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

  • 정렬 옵션을 최소한으로 줄이세요.

  • 필터 수(드롭다운 목록, 검색창)를 최소한으로 줄이세요.

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

모든 필터와 정렬 조합을 성능적으로 만들 수는 없으므로, 사용 패턴에 따라 성능을 최적화하려고 노력해야 합니다.

확장을 위한 준비#

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

  • 총 개수 표시를 피하고, 제한 개수를 선호하세요.

예시: 최대 1001개의 레코드를 세고, 개수가 1001이면 UI에서 1000+를 표시하고, 그렇지 않으면 실제 숫자를 표시합니다.

  • 자세한 내용은 배지 카운터 접근 방식을 참조하세요.

  • 페이지 번호 사용을 피하고, 다음 및 이전 페이지 버튼을 사용하세요.

Keyset 페이지네이션은 페이지 번호를 지원하지 않습니다.

  • API의 경우, 다음 페이지의 URL을 "수동으로" 구성하는 방식을 권장하지 않습니다.

백엔드에서 다음 및 이전 페이지의 URL을 제공하는 Link 헤더 사용을 권장하세요.

  • 이 방식으로 URL 구조를 변경해도 이전 버전과의 호환성을 깨뜨리지 않을 수 있습니다.

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

페이지네이션 옵션#

오프셋 페이지네이션#

목록을 페이지네이션하는 가장 일반적인 방법은 오프셋 기반 페이지네이션입니다(UI 및 REST API). 이는 ActiveRecord 쿼리에서 페이지네이션을 구현하는 편리한 헬퍼 메서드를 제공하는 유명한 Kaminari Ruby gem으로 구현됩니다.

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

프로젝트 내 이슈의 두 번째 페이지를 조회할 때의 예시 데이터베이스 쿼리:

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

  • 다음 20개의 행을 가져옵니다.

쿼리가 기본 키(id)로 행을 정렬한다는 점에 주목하세요. 데이터를 페이지네이션할 때 순서를 지정하는 것은 매우 중요합니다. 순서가 없으면 반환된 행은 비결정적이며 최종 사용자를 혼란스럽게 할 수 있습니다.

페이지 번호#

예시 페이지네이션 바:

[

](/19.1/development/database/img/offset_pagination_ui_v13_11.jpg)

Kaminari gem은 UI에서 페이지 번호와 선택적으로 다음, 이전, 첫 번째, 마지막 페이지 버튼 단축키가 있는 멋진 페이지네이션 바를 렌더링합니다. 이러한 버튼을 렌더링하려면 Kaminari는 행의 수를 알아야 하며, 이를 위해 count 쿼리가 실행됩니다.

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개의 행만 읽는다는 의미는 아닙니다.

다음과 같은 일이 발생합니다:

  • 데이터베이스는 테이블 통계와 사용 가능한 인덱스를 기반으로 가장 효율적인 방법으로 실행을 계획하려고 합니다.

  • 플래너는 project_id 칼럼을 커버하는 인덱스가 있음을 알고 있습니다.

  • 데이터베이스는 project_id의 인덱스를 사용하여 모든 행을 읽습니다.

  • 이 시점에서 행들은 정렬되지 않으므로 데이터베이스가 행을 정렬합니다.

  • 데이터베이스는 처음 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개의 행을 읽어야 합니다. 이로 인해 오프셋 페이지네이션은 대용량 데이터베이스 테이블에는 적합하지 않습니다. 그러나 최적화 기법을 사용하면 데이터베이스 쿼리의 전반적인 성능을 약간 향상시킬 수 있습니다.

예시: Admin 영역에서 사용자 목록 표시

매우 간단한 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 사용자는 매우 높은 페이지 번호까지 이동할 수 있습니다(스크래핑, 데이터 수집).

Keyset 페이지네이션#

Keyset 페이지네이션은 큰 페이지를 요청할 때 이전 행을 "건너뛰는" 성능 문제를 해결하지만, 오프셋 기반 페이지네이션의 드롭인 대체는 아닙니다. API 엔드포인트를 오프셋 기반 페이지네이션에서 keyset 기반 페이지네이션으로 이동할 때는 두 가지 모두 지원되어야 합니다. 하나의 페이지네이션 유형을 완전히 제거하는 것은 브레이킹 체인지입니다.

Keyset 페이지네이션은 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 파라미터를 수정할 수 있습니다. Keyset 페이지네이션은 페이지 로직이 다른 칼럼에 따라 달라질 수 있으므로 페이지 번호를 제공할 수 없습니다.

이전 예시에서 칼럼은 id이므로 URL에서 다음과 같이 볼 수 있습니다:

id_after=5

GraphQL에서는 파라미터가 JSON으로 직렬화된 다음 인코딩됩니다:

eyJpZCI6Ijk0NzMzNTk0IiwidXBkYXRlZF9hdCI6IjIwMjEtMDQtMDkgMDg6NTA6MDUuODA1ODg0MDAwIFVUQyJ9
페이지네이션 파라미터는 사용자에게 보이므로, 어떤 칼럼으로 정렬할지 주의하세요.

Keyset 페이지네이션은 다음, 이전, 첫 번째, 마지막 페이지만 제공할 수 있습니다.

복잡성#

단일 칼럼으로 정렬할 때 쿼리를 작성하는 것은 매우 쉽지만, 타이 브레이커 또는 다중 칼럼 정렬이 사용되면 더 복잡해집니다. 칼럼이 nullable인 경우 복잡성이 증가합니다.

예시: created_at이 nullable인 경우 idcreated_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 프로젝트 내에서 일반적인 keyset 페이지네이션 라이브러리를 사용할 수 있으며, 대부분의 경우 기존 Kaminari 기반 페이지네이션을 대체하여 대용량 데이터셋을 처리할 때 상당한 성능 향상을 제공할 수 있습니다.

예시:

# first page
paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
puts paginator.to_a # records

# next page
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 페이지네이션 가이드 페이지를 참조하세요.

성능#

Keyset 페이지네이션은 앞으로 이동한 페이지 수에 관계없이 안정적인 성능을 제공합니다. 이 성능을 달성하려면 오프셋 페이지네이션과 마찬가지로 페이지네이션된 쿼리에 ORDER BY 절의 모든 칼럼을 커버하는 인덱스가 필요합니다.

일반 성능 가이드라인#

페이지네이션 일반 성능 가이드라인 페이지를 참조하세요.