키셋 페이지네이션
GitLab v19.4요약
키셋 페이지네이션 라이브러리는 GitLab 프로젝트 안의 HAML 기반 뷰와 REST API에서 사용할 수 있습니다. 키셋 페이지네이션과 오프셋 기반 페이지네이션의 비교는 페이지네이션 지침 페이지에서 확인할 수 있습니다.
키셋 페이지네이션 라이브러리는 GitLab 프로젝트 안의 HAML 기반 뷰와 REST API에서 사용할 수 있습니다.
키셋 페이지네이션과 오프셋 기반 페이지네이션의 비교는 페이지네이션 지침 페이지에서 확인할 수 있습니다.
API 개요#
요약#
Rails 컨트롤러에서 ActiveRecord와 함께 사용하는 키셋 페이지네이션입니다.
cursor = params[:cursor] # this is nil when the first page is requested
paginator = Project.order(:created_at).keyset_paginate(cursor: cursor, per_page: 20)
paginator.each do |project|
puts project.name # prints maximum 20 projects
end
사용법#
이 라이브러리는 ActiveRecord 릴레이션에 #keyset_paginate 메서드 하나를 추가합니다.
이는 Kaminari의 paginate 메서드와 취지는 비슷하지만 구현은 다릅니다.
키셋 페이지네이션은 단순한 ActiveRecord 쿼리에서는 별도 설정 없이 동작합니다.
- 칼럼 하나로 정렬하는 경우.
- 칼럼 두 개로 정렬하고 마지막 칼럼이 기본 키인 경우.
이 라이브러리는 null 허용 칼럼과 값이 중복될 수 있는 칼럼을 감지하고, 이를 근거로 기본 키를 사용한 정렬을 추가합니다. 키셋 페이지네이션은 정렬 값이 서로 구별되기를 요구하므로 이 과정이 필요합니다.
Project.order(:created_at).keyset_paginate.records # ORDER BY created_at, id
Project.order(:name).keyset_paginate.records # ORDER BY name, id
Project.order(:created_at, id: :desc).keyset_paginate.records # ORDER BY created_at, id
Project.order(created_at: :asc, id: :desc).keyset_paginate.records # ORDER BY created_at, id DESC
keyset_paginate 메서드는 로드된 레코드와 여러 페이지를 요청하는 데 필요한 추가 정보를 담은 특수 paginator 객체를 반환합니다.
이 메서드는 다음 키워드 인수를 받습니다.
cursor- 다음 페이지를 요청하기 위해 인코딩된 정렬 기준 칼럼 값(nil일 수 있습니다).per_page- 페이지당 로드할 레코드 수(기본값 20).keyset_order_options- 키셋 페이지네이션 데이터베이스 쿼리를 만들 때 쓰는 추가 옵션이며,UNION쿼리 예시는 성능 섹션을 참고합니다(선택 사항).
paginator 객체에는 다음 메서드가 있습니다.
records- 현재 페이지의 레코드를 반환합니다.has_next_page?- 다음 페이지가 있는지 알려 줍니다.has_previous_page?- 이전 페이지가 있는지 알려 줍니다.cursor_for_next_page- 다음 페이지를 요청하기 위해 인코딩된String값입니다(nil일 수 있습니다).cursor_for_previous_page- 이전 페이지를 요청하기 위해 인코딩된String값입니다(nil일 수 있습니다).cursor_for_first_page- 첫 페이지를 요청하기 위해 인코딩된String값입니다.cursor_for_last_page- 마지막 페이지를 요청하기 위해 인코딩된String값입니다.- paginator 객체는
Enumerable모듈을 포함하며 enumerable 기능을records메서드와 배열에 위임합니다.
첫 페이지와 두 번째 페이지를 가져오는 예시입니다.
paginator = Project.order(:name).keyset_paginate
paginator.to_a # same as .records
cursor = paginator.cursor_for_next_page # encoded column attributes for the next page
paginator = Project.order(:name).keyset_paginate(cursor: cursor).records # loading the next page
키셋 페이지네이션은 페이지 번호를 지원하지 않으므로 이동할 수 있는 페이지는 다음으로 제한됩니다.
- 다음 페이지
- 이전 페이지
- 마지막 페이지
- 첫 페이지
paginate_with_strategies를 사용한 REST API 에서의 사용법#
REST API에서는 릴레이션에 paginate_with_strategies 헬퍼를 사용해 키셋 페이지네이션이나 오프셋 페이지네이션을 쓸 수 있습니다.
desc 'Get the things related to a project' do
detail 'This feature was introduced in GitLab 16.1'
success code: 200, model: ::API::Entities::Thing
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
end
params do
use :pagination
requires :project_id, type: Integer, desc: 'The ID of the project'
optional :cursor, type: String, desc: 'Cursor for obtaining the next set of records'
optional :order_by, type: String, values: %w[id name], default: 'id',
desc: 'Attribute to sort by'
optional :sort, type: String, values: %w[asc desc], default: 'desc', desc: 'Order of sorting'
end
route_setting :authentication
get ':project_id/things' do
project = Project.find_by_id(params[:project_id])
not_found! if project.blank?
things = project.things
present paginate_with_strategies(things), with: ::API::Entities::Thing
end
키셋 페이지네이션이 사용되려면 다음 조건을 충족해야 합니다.
-
params[:pagination]이'keyset'을 반환해야 합니다 -
params[:order_by]와params[:sort]가 모두 모델의supported_keyset_orderings클래스 메서드가 반환하는 객체에 있어야 합니다. 다음 예시에서Thing은 ID를 기준으로 오름차순 또는 내림차순으로 정렬할 때 키셋 페이지네이션을 지원합니다.class Thing < ApplicationRecord def self.supported_keyset_orderings { id: [:asc, :desc] } end end
HAML 뷰를 사용하는 Rails 에서의 사용법#
프로젝트를 이름순으로 나열하는 다음 컨트롤러 액션을 살펴봅니다.
def index
@projects = Project.order(:name).keyset_paginate(cursor: params[:cursor])
end
HAML 파일에서는 레코드를 다음과 같이 렌더링할 수 있습니다.
- if @projects.any?
- @projects.each do |project|
.project-container
= project.name
= keyset_paginate @projects
성능#
키셋 페이지네이션의 성능은 데이터베이스 인덱스 구성과 ORDER BY 절에 사용하는 칼럼 수에 따라 달라집니다.
기본 키(id)로 정렬하는 경우에는 기본 키가 데이터베이스 인덱스로 커버되므로
생성되는 쿼리가 효율적입니다.
ORDER BY 절에 칼럼을 두 개 이상 사용할 때는 생성되는 데이터베이스 쿼리를 확인해 올바른 인덱스 구성이 사용되는지 점검하는 것이 좋습니다. 자세한 내용은 페이지네이션 지침 페이지에서 확인할 수 있습니다.
첫 페이지의 쿼리 성능은 좋아 보여도, 커서 속성이 쿼리에 사용되는 두 번째 페이지의 성능은 나쁠 수 있습니다. 첫 페이지와 두 번째 페이지 두 쿼리의 성능을 항상 확인하는 것이 좋습니다.
타이브레이커(id) 칼럼이 포함된 데이터베이스 쿼리 예시입니다.
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
OR 쿼리는 PostgreSQL에서 최적화하기 어렵습니다. 일반적으로 UNION 쿼리 사용을 권장합니다. 키셋 페이지네이션 라이브러리는 ORDER BY 절에 칼럼이 여러 개 있을 때 효율적인 UNION을 생성할 수 있습니다. Relation#keyset_paginate에 전달하는 옵션에 use_union_optimization: true를 지정하면 이 동작이 실행됩니다.
예시입니다.
# Triggers a simple query for the first page.
paginator1 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, keyset_order_options: { use_union_optimization: true })
cursor = paginator1.cursor_for_next_page
# Triggers UNION query for the second page
paginator2 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, cursor: cursor, keyset_order_options: { use_union_optimization: true })
puts paginator2.records.to_a # UNION query
복잡한 정렬 구성#
일반적인 ORDER BY 구성은 keyset_paginate 메서드가 자동으로 처리하므로
수동 설정이 필요하지 않습니다. 다만 정렬 객체 구성이 필요한 예외적인 경우가
몇 가지 있습니다.
NULLS LAST정렬.- 함수 기반 정렬.
iid처럼 사용자 지정 타이브레이커 칼럼을 쓰는 정렬.
이러한 정렬 객체는 모델 클래스에 일반 ActiveRecord 스코프로 정의할 수 있습니다. 이 스코프를 다른 곳(Kaminari, 백그라운드 job)에서 쓰지 못하게 막는 동작은 없습니다.
NULLS LAST 정렬#
다음 스코프를 살펴봅니다.
scope = Issue.where(project_id: 10).order(Issue.arel_table[:relative_position].desc.nulls_last)
# SELECT "issues".* FROM "issues" WHERE "issues"."project_id" = 10 ORDER BY relative_position DESC NULLS LAST
scope.keyset_paginate # raises: Gitlab::Pagination::Keyset::UnsupportedScopeOrder: The order on the scope does not support keyset pagination
keyset_paginate 메서드는 쿼리의 정렬 값이 Arel AST 노드가 아니라 사용자 지정 SQL 문자열이기 때문에 오류를 발생시킵니다. 키셋 라이브러리는 이런 종류의 쿼리에서 구성 값을 자동으로 추론할 수 없습니다.
키셋 페이지네이션이 동작하게 하려면 사용자 지정 정렬 객체를 구성해야 합니다. 그러려면 정렬 칼럼에 대한 정보를 모아야 합니다.
relative_position은 고유 인덱스가 없으므로 값이 중복될 수 있습니다.relative_position은 칼럼에 not null 제약이 없으므로 null 값을 가질 수 있습니다. 따라서NULL값이 결과 집합의 앞에 오는지 뒤에 오는지(NULLS LAST)를 정해야 합니다.- 키셋 페이지네이션은 서로 구별되는 정렬 칼럼을 요구하므로, 정렬을 구별되게 만들기 위해 기본 키(
id)를 추가해야 합니다. - 마지막 페이지로 이동해 역방향으로 페이지를 넘기면 실제로는
ORDER BY절이 뒤집힙니다. 따라서 뒤집힌ORDER BY절도 제공해야 합니다.
예시입니다.
order = Gitlab::Pagination::Keyset::Order.build([
# The attributes are documented in the `lib/gitlab/pagination/keyset/column_order_definition.rb` file
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'relative_position',
column_expression: Issue.arel_table[:relative_position],
order_expression: Issue.arel_table[:relative_position].desc.nulls_last,
reversed_order_expression: Issue.arel_table[:relative_position].asc.nulls_first,
nullable: :nulls_last,
order_direction: :desc
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
order_expression: Issue.arel_table[:id].asc,
nullable: :not_nullable
)
])
scope = Issue.where(project_id: 10).order(order) # or reorder()
scope.keyset_paginate.records # works
함수 기반 정렬#
다음 예시에서는 id에 10을 곱한 값으로 정렬합니다. id 칼럼은 고유하므로
칼럼을 하나만 정의합니다.
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id_times_ten',
order_expression: Arel.sql('id * 10').asc,
nullable: :not_nullable,
order_direction: :asc,
add_to_projections: true
)
])
paginator = Issue.where(project_id: 10).order(order).keyset_paginate(per_page: 5)
puts paginator.records.map(&:id_times_ten)
cursor = paginator.cursor_for_next_page
paginator = Issue.where(project_id: 10).order(order).keyset_paginate(cursor: cursor, per_page: 5)
puts paginator.records.map(&:id_times_ten)
add_to_projections 플래그는 해당 칼럼 표현식을 SELECT 절에 노출하도록 paginator에 알립니다. 키셋 페이지네이션이 다음 페이지를 요청하기 위해 레코드에서 마지막 값을 추출해야 하므로 이 설정이 필요합니다.
iid 기반 정렬#
이슈를 정렬할 때 데이터베이스는 프로젝트 안에서 iid 값이 서로 구별되도록 보장합니다.
project_id 필터가 있다면 칼럼 하나로 정렬하는 것만으로 페이지네이션이 동작합니다.
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'iid',
order_expression: Issue.arel_table[:iid].asc,
nullable: :not_nullable
)
])
scope = Issue.where(project_id: 10).order(order)
scope.keyset_paginate.records # works