GraphQL 페이지네이션
페이지네이션의 유형
GitLab은 두 가지 주요 페이지네이션 유형인 offset과 keyset(때로는 커서 기반이라 불림) 페이지네이션을 사용합니다.
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_at
및sort_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)
를 사용합니다.
다음 파일에서 예제 구현을 확인할 수 있습니다:
-
types/error__tracking/sentry_error_collection_type.rb
는field :errors
에 대한 확장을 추가합니다. -
resolvers/error_tracking/sentry_errors_resolver.rb
는 리졸버에서 데이터를 반환합니다.
테스트
페이지네이션 및 정렬을 지원하는 모든 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