키셋 페이징

키셋 페이징 라이브러리는 HAML 기반 뷰와 GitLab 프로젝트 내 REST API에서 사용할 수 있습니다.

keyset pagination guidelines 페이지에서 키셋 페이징과 오프셋 기반 페이징을 비교하는 내용을 확인할 수 있습니다.

API 개요

시놉시스

ActiveRecord를 사용하여 레일스 컨트롤러에서 키셋 페이징:

cursor = params[:cursor] # 첫 번째 페이지를 요청할 때 이 값은 nil입니다
paginator = Project.order(:created_at).keyset_paginate(cursor: cursor, per_page: 20)

paginator.each do |project|
  puts project.name # 최대 20개의 프로젝트를 출력합니다
end

사용법

이 라이브러리는 ActiveRecord 관계에 단일 메소드를 추가합니다: #keyset_paginate.

이는 Kaminari의 paginate 메소드와 정신적으로 유사하지만 구현은 다릅니다.

키셋 페이징은 다음과 같은 간단한 ActiveRecord 쿼리에도 구성 없이 동작합니다:

  • 하나의 열에 따라 정렬합니다.
  • 마지막 열이 기본 키인 두 개의 열에 따라 정렬합니다.

이 라이브러리는 nullable 및 non-distinct 열을 감지하고, 이를 기반으로 기본 키를 사용하여 추가적인 순서를 추가합니다. 이는 키셋 페이징이 고유한 순서 값을 예상하기 때문에 필요합니다:

Project.order(:created_at).keyset_paginate.records # created_at, id를 기준으로 정렬됨

Project.order(:name).keyset_paginate.records # name, id를 기준으로 정렬됨

Project.order(:created_at, id: :desc).keyset_paginate.records # created_at, id를 기준으로 정렬됨

Project.order(created_at: :asc, id: :desc).keyset_paginate.records # created_at를 오름차순으로, id를 내림차순으로 정렬됨

keyset_paginate 메소드는 특별한 페이징 객체를 반환합니다. 이 객체에는 로드된 레코드 및 다양한 페이지를 요청하기 위한 추가 정보가 포함되어 있습니다.

이 메소드는 다음과 같은 키워드 인수를 받습니다:

  • cursor - 다음 페이지를 요청하기 위해 인코딩된 정렬 열 값 (null일 수 있음).
  • per_page - 페이지 당 로드할 레코드 수 (기본값 20).
  • keyset_order_options - UNION 쿼리에 대한 키셋으로 정렬된 데이터베이스 쿼리를 작성하는 추가 옵션, 성능 섹션에서 예제를 확인하세요 (선택 사항).

페이징 객체에는 다음과 같은 메소드가 있습니다:

  • records - 현재 페이지의 레코드를 반환합니다.
  • has_next_page? - 다음 페이지가 있는지 알려줍니다.
  • has_previous_page? - 이전 페이지가 있는지 알려줍니다.
  • cursor_for_next_page - 다음 페이지를 요청하기 위한 인코딩된 값(문자열 형식)을 반환합니다 (null일 수 있음).
  • cursor_for_previous_page - 이전 페이지를 요청하기 위한 인코딩된 값(문자열 형식)을 반환합니다 (null일 수 있음).
  • cursor_for_first_page - 첫 번째 페이지를 요청하기 위한 인코딩된 값(문자열 형식)을 반환합니다.
  • cursor_for_last_page - 마지막 페이지를 요청하기 위한 인코딩된 값(문자열 형식)을 반환합니다.
  • 페이징 객체에는 Enumerable 모듈이 포함되어 있고, 열거 기능을 records 메소드/배열에 위임합니다.

첫 번째 페이지와 두 번째 페이지를 가져오는 예제:

paginator = Project.order(:name).keyset_paginate

paginator.to_a # .records와 동일함

cursor = paginator.cursor_for_next_page # 다음 페이지를 위한 인코딩된 열 속성

paginator = Project.order(:name).keyset_paginate(cursor: cursor).records # 다음 페이지 로드

페이지 번호를 지원하지 않으므로, 다음 페이지로 이동하는 것만이 제한됩니다:

  • 다음 페이지
  • 이전 페이지
  • 마지막 페이지
  • 첫 번째 페이지

paginate_with_strategies를 사용한 REST API에서의 사용법

REST API에서는 관계에 paginate_with_strategies 도우미를 사용하여 키셋 페이징 또는 오프셋 페이징을 사용할 수 있습니다.

  desc '프로젝트와 관련된 항목 가져오기' do
    detail '이 기능은 GitLab 16.1에서 소개되었습니다'
    success code: 200, model: ::API::Entities::Thing
    failure [
      { code: 401, message: '권한이 없음' },
      { code: 403, message: '금지됨' },
      { code: 404, message: '찾을 수 없음' }
    ]
  end
  params do
    use :pagination
    requires :project_id, type: Integer, desc: '프로젝트의 ID'
    optional :cursor, type: String, desc: '기록의 다음 세트를 얻기 위한 커서'
    optional :order_by, type: String, values: %w[id name], default: 'id',
      desc: '정렬할 속성'
    optional :sort, type: String, values: %w[asc desc], default: 'desc', desc: '정렬 순서'
  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

키셋 페이징을 사용하려면, 다음 조건을 충족해야 합니다:

  1. params[:pagination]'keyset'을 반환해야 합니다.
  2. params[:order_by]params[:sort]는 모델의 supported_keyset_orderings 클래스 메소드에서 반환된 객체에 모두 나타나야 합니다. 다음 예제에서 Thing은 ID에 따라 오름차순 또는 내림차순으로 정렬할 때 키셋 페이징을 지원합니다.

    class Thing < ApplicationRecord
      def self.supported_keyset_orderings
       { id: [:asc, :desc] }
      end
    end
    

Rails에서 HAML 뷰와 사용하기

다음과 같은 컨트롤러 액션을 고려해보세요. 여기서는 이름별로 프로젝트를 나열하는 경우입니다.

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)로 정렬하는 경우, 생성된 쿼리는 효율적입니다. 왜냐하면 기본 키는 데이터베이스 인덱스로 covered되기 때문입니다.

ORDER BY 절에 두 개 이상의 열을 사용하는 경우 생성된 데이터베이스 쿼리를 확인하고 올바른 인덱스 구성이 사용되는지 확인하는 것이 좋습니다. 더 많은 정보는 페이지네이션 가이드 페이지에서 확인할 수 있습니다.

참고: 첫 번째 페이지의 쿼리 성능이 좋아 보일 수 있지만, 두 번째 페이지(커서 속성이 쿼리에 사용되는 페이지)는 성능이 나빠질 수 있습니다. 항상 첫 번째 페이지와 두 번째 페이지의 성능을 확인하는 것이 좋습니다.

id 열을 tie-breaker로 사용하는 예시 데이터베이스 쿼리:

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

PostgreSQL에서 OR 쿼리는 최적화하기 어려우며 보통 UNION 쿼리를 사용하는 것이 좋습니다. 키셋 페이징 라이브러리는 ORDER BY 절에 여러 열이 있는 경우 효율적인 UNION을 생성할 수 있습니다. 이는 Relation#keyset_paginate에 전달된 옵션에서 use_union_optimization: true 옵션을 지정할 때 트리거됩니다.

예시:

# 첫 번째 페이지에 대한 간단한 쿼리를 트리거합니다.
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

# 두 번째 페이지에 대해 UNION 쿼리를 트리거합니다.
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 쿼리

복잡한 순서 구성

일반적인 ORDER BY 구성은 keyset_paginate 메서드에서 자동으로 처리되므로 수동 구성이 필요하지 않습니다. 순서 객체 구성이 필요한 몇 가지 특이한 경우가 있습니다.

  • NULLS LAST 순서매김.
  • 함수 기반 순서매김.
  • iid와 같은 사용자 정의 tie-breaker 열을 사용한 순서매김.

이러한 순서 객체는 표준 ActiveRecord 스코프로 모델 클래스에서 정의할 수 있으며, 이러한 스코프를 다른 곳(Kaminari, 백그라운드 작업)에서 사용하는 데 별다른 특별한 동작이 없습니다.

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 메서드는 쿼리의 순서 값이 사용자 정의 SQL 문자열이기 때문에 자동으로 Arel AST 노드에서 이러한 유형의 쿼리에서 구성 값을 추론할 수 없습니다.

키셋 페이징을 작동하려면 주문 열에 대한 사용자 정의 순서 객체를 구성해야 합니다. 이를 위해 주문 열에 대한 정보를 수집해야 합니다.

  • relative_position은 고유한 인덱스가 없기 때문에 중복된 값을 가질 수 있습니다.
  • relative_position은 null 값을 가질 수 있기 때문에 열에 NOT NULL 제약 조건이 없습니다. 이를 위해 NULL 값을 어디서 볼 것인지 결정해야 합니다. 결과 집합의 시작 부분에 있는지 아니면 끝에 있는지 (NULLS LAST).
  • 키셋 페이징에는 고유한 순서 열이 필요하므로 주문을 고유하게 만들기 위해 기본 키(id)를 추가해야 합니다.
  • 마지막 페이지로 이동하고 뒤로 페이지를 나누면 실제로 ORDER BY 절이 반전됩니다. 따라서 반전된 ORDER BY 절을 제공해야 합니다.

예시:

order = Gitlab::Pagination::Keyset::Order.build([
  # 속성은 `lib/gitlab/pagination/keyset/column_order_definition.rb` 파일에 문서화되어 있습니다.
  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,
    distinct: false
  ),
  Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
    attribute_name: 'id',
    order_expression: Issue.arel_table[:id].asc,
    nullable: :not_nullable,
    distinct: true
  )
])

scope = Issue.where(project_id: 10).order(order) # or reorder()

scope.keyset_paginate.records # 작동

기능 기반 주문

다음 예에서는 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,
    distinct: true,
    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 절에 열 식을 노출하도록 지시합니다. 이것은 킷셋 페이징이 레코드에서 마지막 값을 어떤 식으로든 추출하여 다음 페이지를 요청해야 하기 때문에 필요합니다.

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,
    distinct: true
  )
])

scope = Issue.where(project_id: 10).order(order)

scope.keyset_paginate.records # 작동