GraphQL 페이지네이션

페이지네이션의 유형

GitLab은 두 가지 주요 페이지네이션 유형인 offsetkeyset(때로는 커서 기반이라 불림) 페이지네이션을 사용합니다.

GraphQL API는 주로 keyset 페이지네이션을 사용하며, 필요할 때 offset 페이지네이션으로 대체합니다.

성능 고려 사항

자세한 내용은 일반 페이지네이션 가이드라인 섹션을 참조하세요.

Offset 페이지네이션

이것은 전통적인 페이지별 페이지네이션으로, 가장 일반적이며 GitLab의 여러 곳에서 사용됩니다. 페이지의 하단 근처에 페이지 번호 목록이 있는 것으로 인식할 수 있으며, 선택하면 해당 결과 페이지로 이동합니다.

예를 들어, 페이지 100을 선택하면 100을 백엔드로 전송합니다. 예를 들어, 각 페이지에 20개의 항목이 있다고 가정하면, 백엔드는 20 * 100 = 2000을 계산하고, 데이터베이스에 처음 2000개의 레코드를 건너뛰고 다음 20개를 가져옵니다.

페이지 번호 * 페이지 크기 = 내 레코드를 찾을 위치

몇 가지 문제가 있습니다:

  • 성능. 페이지 100(오프셋이 2000인 경우)에 대한 쿼리를 실행하면, 데이터베이스는 해당 특정 오프셋까지 테이블을 스캔한 다음 다음 20개의 레코드를 선택해야 합니다. 오프셋이 증가함에 따라 성능이 빠르게 저하됩니다. 자세한 내용은 제가 사랑하는 SQL <3. 1억 개의 레코드가 있는 테이블의 효율적인 페이지네이션을 읽어보세요.

  • 데이터 안정성. 페이지 100의 20개 항목(오프셋 2000)을 가져오면 GitLab은 해당 20개 항목을 표시합니다. 만약 누군가 페이지 99 또는 그 이전에서 레코드를 삭제하거나 추가한다면, 오프셋 2000의 항목은 다른 항목 집합이 됩니다. 페이지를 이동할 때 항목을 건너뛰는 상황에 처할 수 있으며, 이는 목록이 계속 변경되기 때문입니다. 자세한 내용은 페이지네이션: 당신은 (아마도) 잘못하고 있습니다를 읽어보세요.

Keyset 페이지네이션

특정 레코드가 주어지면, 이후에 오는 것을 계산할 수 있는 방법을 아는 경우, 해당 특정 레코드에 대해 데이터베이스에 쿼리할 수 있습니다.

예를 들어, 생성 날짜별로 정렬된 이슈 리스트가 있다고 가정해 보겠습니다. 페이지의 첫 항목이 특정 날짜(예: 1월 1일)를 가지면, 해당 날짜 이후에 생성된 모든 레코드를 요청하고 첫 20개를 가져올 수 있습니다. 많은 항목이 삭제되거나 추가되었는지는 더 이상 중요하지 않으며, 항상 해당 날짜 이후의 항목을 요청하므로 올바른 항목을 가져옵니다.

불행히도 1월 1일에 생성된 이슈가 페이지 20에 있는지 페이지 100에 있는지를 아는 쉬운 방법은 없습니다.

Keyset 페이지네이션의 몇 가지 장점과 단점은 다음과 같습니다:

  • 성능이 훨씬 더 좋습니다.

  • 삭제나 삽입으로 인해 목록에서 레코드가 누락되지 않기 때문에 최종 사용자에게 데이터 안정성이 더 높습니다.

  • 무한 스크롤을 구현하는 가장 좋은 방법입니다.

  • 프로그래밍 및 유지 관리가 더 어렵습니다. updated_atsort_order에 대해서는 쉽지만, 복잡한 정렬 시나리오에는 복잡하거나 불가능합니다.

구현

쿼리에서 페이지네이션을 지원하는 경우, GitLab은 기본적으로 keyset 페이지네이션을 사용합니다. 이는 pagination/connections.rb에서 구성된 것을 확인할 수 있습니다.

쿼리가 ActiveRecord::Relation을 반환하면, keyset 페이지네이션이 자동으로 사용됩니다.

이는 성능 및 데이터 안정성을 지원하기 위한 의식적인 결정이었습니다.

그러나 이슈의 레이블 우선순위로 정렬할 때와 같이 정렬의 복잡성 때문에 offset 페이지네이션 연결 OffsetActiveRecordRelationConnection을 사용해야 하는 경우가 있습니다.

keyset 페이지네이션에 적합하지 않은 관계를 반환하는 리졸버가 있을 경우 (예를 들어 정렬 순서 때문), BaseResolver#offset_pagination 메서드를 사용하여 관계를 올바른 연결 유형으로 래핑할 수 있습니다. 예를 들어:

def resolve(**args)
  result = Finder.new(object, current_user, args).execute
  result = offset_pagination(result) if needs_offset?(args[:sort])

  result
end

키셋 페이지네이션

키셋 페이지네이션 구현은 GraphQL::Pagination::ActiveRecordRelationConnection의 하위 클래스이며, 이는 graphql gem의 일부입니다. 이는 모든 ActiveRecord::Relation에 대해 기본값으로 설치됩니다.

그러나 GitLab은 기본값인 오프셋 기반 커서를 사용하는 대신, 더 특화된 커서를 사용합니다.

커서는 관련 정렬 필드를 포함하는 JSON 객체를 인코딩하여 생성됩니다. 예를 들어:

ordering = {"id"=>"72410125", "created_at"=>"2020-10-08 18:05:21.953398000 UTC"}
json = ordering.to_json
cursor = Base64.urlsafe_encode64(json, padding: false)

"eyJpZCI6IjcyNDEwMTI1IiwiY3JlYXRlZF9hdCI6IjIwMjAtMTAtMDggMTg6MDU6MjEuOTUzMzk4MDAwIFVUQyJ9"

json = Base64.urlsafe_decode64(cursor)
Gitlab::Json.parse(json)

{"id"=>"72410125", "created_at"=>"2020-10-08 18:05:21.953398000 UTC"}

커서에 주문 특성 값들을 저장하는 이점:

  • 객체의 ID만 저장된다면, 객체와 그 속성을 질의할 수 있습니다. 이는 추가 질의를 요구하며, 만약 객체가 더 이상 존재하지 않는다면 필요한 속성을 찾을 수 없습니다.

  • 특정 속성이 NULL인 경우 하나의 SQL 질의를 사용할 수 있습니다. NULL이 아닌 경우, 다른 SQL 질의를 사용할 수 있습니다.

주요 속성 필드가 커서에서 NULL인지 여부에 따라 적절한 질의 조건이 생성됩니다. 마지막 정렬 필드는 고유한 값(기본 키)으로 간주되며, 즉 이 열은 결코 NULL 값을 포함하지 않습니다.

질의 복잡성의 제한

두 개의 정렬 필드만 지원되며, 그 중 하나는 기본 키여야 합니다.

다음은 질의에 대한 두 가지 의사 코드 예시입니다:

  • 두 조건 질의. X는 커서에서 가져온 값을 나타냅니다. C는 데이터베이스의 열을 나타내며, 오름차순으로 정렬되고, :after 커서를 사용하며 NULL 값은 마지막에 정렬됩니다.

    X1 IS NOT NULL
      AND
        (C1 > X1)
          OR
        (C1 IS NULL)
          OR
        (C1 = X1
          AND
         C2 > X2)
    
    X1 IS NULL
      AND
        (C1 IS NULL
          AND
         C2 > X2)
    

    아래는 Issue.order(relative_position: :asc).order(id: :asc) 관계에 따라, relative_position: 1500, id: 500에 대한 after 커서의 예시입니다:

    when cursor[relative_position] is not NULL
    
        ("issues"."relative_position" > 1500)
        OR (
          "issues"."relative_position" = 1500
          AND
          "issues"."id" > 500
        )
        OR ("issues"."relative_position" IS NULL)
    
    when cursor[relative_position] is NULL
    
        "issues"."relative_position" IS NULL
        AND
        "issues"."id" > 500
    
  • 세 조건 질의. 아래 예시는 완전하지 않지만, 한 조건을 추가했을 때의 복잡성을 보여줍니다. X는 커서에서 가져온 값을 나타냅니다. C는 데이터베이스의 열을 나타내며, 오름차순으로 정렬되고, :after 커서를 사용하며 NULL 값은 마지막에 정렬됩니다.

    X1 IS NOT NULL
      AND
        (C1 > X1)
          OR
        (C1 IS NULL)
          OR
        (C1 = X1 AND C2 > X2)
          OR
        (C1 = X1
          AND
            X2 IS NOT NULL
              AND
                ((C2 > X2)
                   OR
                 (C2 IS NULL)
                   OR
                 (C2 = X2 AND C3 > X3)
          OR
            X2 IS NULL.....
    

Gitlab::Graphql::Pagination::Keyset::QueryBuilder를 사용하여 필요한 SQL 조건을 구축하고 이를 Active Record 관계에 적용할 수 있습니다.

복잡한 질의는 사용하기 어려울 수 있습니다. 예를 들어, issuable.rb에서 order_due_date_and_labels_priority 메서드는 매우 복잡한 질의를 생성합니다.

이러한 유형의 질의는 지원되지 않습니다. 이러한 경우, 오프셋 페이지네이션을 사용할 수 있습니다.

주의사항

문자열 구문을 사용하여 컬렉션의 순서를 정의하지 마세요:

# 나쁨
items.order('created_at DESC')

대신, 해시 구문을 사용하세요:

# 좋음
items.order(created_at: :desc)

첫 번째 예시는 정렬 정보를 올바르게 페이지네이션 커서에 포함하지 않으며, 이로 인해 잘못된 정렬 순서가 발생합니다.

오프셋 페이지네이션

페이지네이션 키셋이 처리할 수 있는 것보다 정렬의 복잡성이 더 높은 경우가 있습니다.

예를 들어 ProjectIssuesResolver에서 priority_asc로 정렬할 때, 정렬이 너무 복잡하기 때문에 키셋 페이지네이션을 사용할 수 없습니다. 더 많은 정보는 issuable.rb를 참조하세요.

이러한 경우, ActiveRecord::Relation 대신 Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection를 반환하여 일반 오프셋 페이지네이션으로 돌아갈 수 있습니다:

    def resolve(parent, finder, **args)
      issues = apply_lookahead(Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all)

      if non_stable_cursor_sort?(args[:sort])
        # 특정 복잡한 정렬은 안정적인 커서 페이지네이션에서 지원되지 않습니다.
        # 이러한 경우, 올바른 연결을 반환하기 위해 오프셋 페이지네이션을 사용합니다.
        offset_pagination(issues)
      else
        issues
      end
    end

외부 페이지네이션

다른 시스템에 저장된 데이터를 GitLab API를 통해 반환해야 할 때가 있습니다. 이러한 경우, 서드 파티 API를 페이지네이션해야 할 수 있습니다.

예를 들어, 우리의 오류 추적 구현에서는 GitLab API를 통해 Sentry 오류를 프로시합니다. 이는 Sentry API를 호출하여 자체 페이지네이션 규칙을 적용하기 때문입니다. 이로 인해 GitLab 내에서 컬렉션에 접근하여 사용자 정의 페이지네이션을 수행할 수 없습니다.

일관성을 위해, 외부 API에서 반환된 값을 기반으로 페이지네이션 커서를 수동으로 설정합니다. 이때 Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *items)를 사용합니다.

다음 파일에서 예제 구현을 확인할 수 있습니다:

테스트

페이지네이션 및 정렬을 지원하는 모든 GraphQL 필드는 graphql/sorted_paginated_query_shared_examples.rb에서 공유되는 정렬 페이지네이션 쿼리의 예제를 사용하여 테스트해야 합니다.

이 예제는 정렬 키가 호환되는지 확인하고 커서가 올바르게 작동하는지 검증하는 데 도움을 줍니다.

특히 키셋 페이지네이션을 사용할 때 중요합니다. 일부 정렬 키는 지원되지 않을 수 있습니다.

요청 사양에 다음 섹션을 추가하세요:

describe 'sorting and pagination' do
  ...
end

그 후 issues_spec.rb를 사용하여 테스트를 구성할 수 있습니다.

graphql/sorted_paginated_query_shared_examples.rb에는 공유 예제를 사용하는 방법에 대한 문서도 포함되어 있습니다.

공유 예제는 특정 let 변수와 메서드가 설정되어야 합니다:

describe 'sorting and pagination' do
  let_it_be(:sort_project) { create(:project, :public) }
  let(:data_path)    { [:project, :issues] }

  def pagination_query(params)
    graphql_query_for( :project, { full_path: sort_project.full_path },
      query_nodes(:issues, :id, include_pagination_info: true, args: params))
    )
  end

  def pagination_results_data(nodes)
    nodes.map { |issue| issue['iid'].to_i }
  end

  context 'when sorting by weight' do
    let_it_be(:issues) { make_some_issues_with_weights }

    context 'when ascending' do
      let(:ordered_issues) { issues.sort_by(&:weight) }

      it_behaves_like 'sorted paginated query' do
        let(:sort_param) { :WEIGHT_ASC }
        let(:first_param) { 2 }
        let(:all_records) { ordered_issues.map(&:iid) }
      end
    end
  end