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 페이지의 항목(오프셋 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인지 여부에 따라 올바른 쿼리 조건이 작성됩니다. 마지막 정렬 필드는 고유하게(PK) 여겨지며 해당 칼럼에는 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의 후 커서로 예시입니다:

    커서 내용이 '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 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)

첫 번째 예는 정렬 정보(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를 통해 다른 시스템에 저장된 데이터를 반환해야 하는 경우가 있습니다. 이러한 경우에는 제3자 API의 페이지네이션을 해야 할 수 있습니다.

이 경우의 예로는 에러 추적 구현이 있습니다. 여기서 Sentry 에러를 GitLab API를 통해 프록시하고 있습니다. 이를 위해 Sentry API를 호출하며, 이는 각자의 페이지네이션 규칙을 강제합니다. 이는 GitLab 내의 컬렉션에 액세스하여 사용자 정의 페이지네이션을 수행할 수 없음을 뜻합니다.

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

다음 파일에서 구현 예제를 볼 수 있습니다:

테스트

페이징 및 정렬을 지원하는 모든 GraphQL 필드는 graphql/sorted_paginated_query_shared_examples.rb에서 찾을 수 있는(sorted paginated query 공유 예시)를 사용하여 테스트해야 합니다. 이를 통해 정렬 키가 호환되는지, 커서가 올바르게 작동하는지 확인할 수 있습니다.

특히 일부 정렬 키가 지원되지 않을 수 있기 때문에, 키셋 페이징을 사용할 때 이것이 매우 중요합니다.

다음과 같이 요청 스펙에 섹션을 추가하세요:

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