GraphQL 페이징

페이징 유형

GitLab은 두 가지 주요 페이징 유형을 사용합니다: 오프셋키킷 (때로는 커서 기반) 페이징. GraphQL API는 주로 키킷 페이징을 사용하며 필요할 때 오프셋 페이징으로 전환합니다.

성능 고려 사항

추가 정보는 일반 페이징 가이드라인 섹션을 참조하세요.

오프셋 페이징

이것은 전통적인 페이지별 페이징으로 가장 흔하며 GitLab의 대부분에서 사용됩니다. 페이지 하단에 페이지 번호 목록이 있어 선택하면 해당 페이지의 결과로 이동합니다.

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

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

이것에는 몇 가지 문제가 있습니다:

  • 성능. 페이지 100에 대한 쿼리를 실행하면 (2000의 오프셋을 제공), 데이터베이스가 해당 오프셋까지 스캔하고 다음 20개의 레코드를 선택해야 합니다. 오프셋이 증가함에 따라 성능이 빠르게 저하됩니다. 자세한 내용은 The SQL I Love <3. Efficient pagination of a table with 100M records에서 확인하세요.

  • 데이터 안정성. 페이지 100의 20개 항목(오프셋 2000)을 가져오면 GitLab은 해당 20개 항목을 표시합니다. 그러나 다른 사람이 페이지 99나 그 이전에 레코드를 삭제하거나 추가하면 오프셋 2000에 있는 항목이 다른 항목 집합이 됩니다. 목록이 계속 변화하기 때문에 때로는 페이징 중에 항목을 건너뛰게 될 수도 있습니다. 자세한 내용은 Pagination: You’re (Probably) Doing It Wrong을 확인하세요.

키킷 페이징

특정 레코드가 주어진 경우 무엇이 그 뒤에 오는지 계산하는 방법을 알고 있다면 데이터베이스에서 해당 특정 레코드를 쿼리할 수 있습니다.

예를 들어, 생성 날짜별로 정렬된 이슈 목록이 있다고 가정해보십시오. 페이지의 첫 번째 항목이 특정 날짜(예: 1월 1일)를 가지고 있다면 해당 날짜 이후에 생성된 모든 레코드를 요청하여 처음 20개를 가져올 수 있습니다. 많은 레코드가 삭제되거나 추가되더라도 해당 날짜 이후에 항상 요청하고 있기 때문에 항상 올바른 항목을 얻을 수 있습니다.

유감스럽게도 1월 1일에 생성된 이슈가 20페이지에 있는지 아니면 100페이지에 있는지 쉽게 알 수 있는 방법이 없습니다.

키킷 페이징의 이점과 희생을 기억하세요:

  • 성능이 훨씬 뛰어납니다.

  • 사용자들에게 더 많은 데이터 안정성이 있습니다. 삭제 또는 추가로 인해 목록에서 레코드가 누락되지 않습니다.

  • 무한 스크롤링에 가장 적합합니다.

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

구현

쿼리에 대한 페이징을 지원하는 경우 GitLab은 기본적으로 키킷 페이징을 사용합니다. 이 설정은 pagination/connections.rb에서 확인할 수 있습니다. 쿼리가 ActiveRecord::Relation을 반환하면 자동으로 키킷 페이징이 사용됩니다.

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

그러나 레코드 정렬의 복잡성으로 인해 이슈의 레이블 우선 순위별로 정렬해야 하는 경우와 같은 경우에는 사용이 불가능한 키킷 페이징 연결인 OffsetActiveRecordRelationConnection을 사용해야 하는 몇 가지 경우가 있습니다.

(정렬에 따른 경우 등의 이유로) 키킷 페이징에 적합하지 않은 해결사로부터 반환할 경우, 해당 관계를 올바른 연결 유형으로 래핑하기 위해 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 젬의 일부입니다. 이것은 모든 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인지 여부에 따라 올바른 쿼리 조건이 작성됩니다. 마지막 순서 지정 필드는 고유(unique)하다고 간주되기 때문에 열에는 NULL 값이 포함되지 않습니다.

쿼리 복잡성의 제한 사항

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

쿼리의 유사 코드 두 가지 예제를 제시합니다.

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

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

    아래는 relative_position: :asc로 정렬한 관계 Issue.order(relative_position: :asc).order(id: :asc)를 기준으로 한 예입니다. 후커서는 relative_position: 1500, id: 500입니다.

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

    X1이 NOT NULL이고
      (C1 > X1)
          OR
      (C1 IS NULL)
          OR
      (C1 = X1 AND C2 > X2)
          OR
      (C1 = X1
          AND
            X2이 NOT NULL이고
              ((C2 > X2)
                   OR
                 (C2 IS NULL)
                   OR
                 (C2 = X2 AND C3 > X3)
          OR
            X2이 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)

첫 번째 예제는 올바르게 정렬 정보(created_at 예시에서)를 페이지네이션 커서에 임베드하지 않으므로 잘못된 순서로 정렬됩니다.

오프셋 페이지네이션

쿼리 복잡성의 제한 사항에서 설명한 것처럼 정렬의 복잡성이 키셋 페이지네이션으로 처리하기에 너무 클 때가 있습니다.

예를 들어, 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를 페이징해야 할 수 있습니다.

예를 들어, Error Tracking 구현에서는 Sentry errors를 GitLab API를 통해 프록시하고 있습니다. 이를 위해 Sentry API를 호출하여 해당 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