GraphQL 페이지네이션
페이지네이션 유형
GitLab은 주로 옵셋(offset) 및 키셋(keyset)(때로는 커서 기반) 두 가지 주요 유형의 페이지네이션을 사용합니다. GraphQL API는 주로 키셋 페이지네이션을 사용하며 필요할 때 옵셋 페이지네이션으로 되돌아갑니다.
성능 고려 사항
자세한 정보는 일반 페이지네이션 가이드라인 섹션을 참조하십시오.
옵셋 페이지네이션
이것은 전통적인 페이지별 페이지네이션으로 가장 일반적이고 GitLab의 많은 부분에서 사용됩니다. 당신이 그 페이지번호 옆에 있는 리스트에서 인식할 수 있습니다. 선택하면 해당 결과 페이지로 이동합니다.
예를 들어, Page 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에 해당하는 옵셋)의 20개 항목을 가져오면, GitLab은 해당 20개 항목을 표시합니다. 그 후에 누군가 페이지 99나 그 이전에 레코드를 삭제하거나 추가한다면, 옵셋 2000에 있는 항목은 다른 항목 집합이 됩니다. 심지어 목록이 계속 변경되기 때문에 페이지네이션하는 동안 항목을 건너뛰게 될 수도 있습니다. 더 많은 정보는 Pagination: You’re (Probably) Doing It Wrong에서 확인할 수 있습니다.
키셋 페이지네이션
특정 레코드가 주어지면 그 후에 무엇이 오는지 계산하는 방법을 알고 있다면, 데이터베이스에 해당 특정 레코드에 대한 쿼리를 할 수 있습니다.
예를 들어, 생성 날짜별로 정렬된 이슈 목록이 있다고 가정해봅시다. 페이지의 첫 번째 항목이 특정 날짜(예: 1월 1일)라는 것을 알고 있다면, 해당 날짜 이후에 생성된 모든 레코드를 요청하고 처음 20개를 가져올 수 있습니다. 많은 항목이 삭제되거나 추가되더라도 상관없으며 항상 해당 날짜 이후의 항목을 요청하므로 올바른 항목을 가져올 수 있습니다.
불행히도, 1월 1일에 생성된 이슈가 20페이지에 있는지 아니면 100페이지에 있는지 알기 쉬운 방법은 없습니다.
키셋 페이지네이션의 일부 이점 및 교역은 다음과 같습니다.
-
성능이 훨씬 좋습니다.
-
삭제 또는 삽입으로 인해 리스트에서 레코드가 누락되지 않으므로 최종 사용자에게 더 안정적인 데이터 제공.
-
무한 스크롤링하는 데 가장 적합합니다.
-
프로그래밍 및 유지 보수가 더 어렵습니다.
updated_at
및sort_order
에는 간단하지만 복잡한 정렬 시나리오에 대해서는 복잡하거나 불가능합니다.
구현
쿼리를 위한 페이지네이션을 지원하는 경우, GitLab은 기본적으로 키셋 페이지네이션을 사용합니다. 이는
pagination/connections.rb
에서 구성된 위치를 확인할 수 있습니다.
쿼리가 ActiveRecord::Relation
을 반환하면 자동으로 키셋 페이지네이션이 사용됩니다.
이것은 성능과 데이터 안정성을 지원하기 위한 의도적인 결정이었습니다.
그러나 레코드의 정렬 복잡성으로 인해(예: issue에서 레이블 우선 순위에 따른 정렬) 옵셋 페이지네이션 커넥션 OffsetActiveRecordRelationConnection
을 사용해야 하는 경우와 같이 어떤 경우에는 옵셋 페이지네이션 커넥션을 사용해야 하는 경우가 있습니다.
(예: updated_at
및 sort_order
에 대해서는 간단하지만 복잡한 정렬 시나리오에 대해서는 복잡하거나 불가능합니다.)
키셋 페이지네이션에 적합하지 않은 리졸버로부터의 관계를 반환하는 경우, 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
인지 여부에 따라 적절한 쿼리 조건이 작성됩니다. 마지막으로 정렬 필드는 고유하게(기본 키)으로 고려되므로 해당 열은 결코 NULL
값을 포함하지 않습니다.
쿼리 복잡성의 제한 사항
두 개의 정렬 필드만 지원하며 이 중 하나는 기본 키여야 합니다.
쿼리에 대한 유사 코드의 두 가지 예는 다음과 같습니다.
-
두 조건 쿼리.
X
는 커서에서 값들을 나타내며,C
는 데이터베이스의 열로,:after
커서를 사용하여 오름차순으로 정렬되고NULL
값은 마지막에 정렬됩니다.X1이 NOT NULL이고 (C1 > X1) 또는 (C1 IS NULL) 또는 (C1 = X1이고 C2 > X2) 인 경우 X1이 NULL이고 (C1 IS NULL이고 C2 > X2) 인 경우
아래 예는
relative_position: :asc
로 정렬된Issue.order(relative_position: :asc).order(id: :asc)
을 기준으로 한relative_position: 1500, id: 500
의 이후 커서에 기반한 것입니다.커서[relative_position]가 NOT NULL인 경우 "issues"."relative_position" > 1500 또는 "issues"."relative_position" = 1500 이고 "issues"."id" > 500 또는 "issues"."relative_position" IS NULL인 경우 커서[relative_position]가 NULL인 경우 "issues"."relative_position" IS NULL이고 "issues"."id" > 500인 경우
-
세 가지 조건 쿼리. 아래 예제는 완전하지는 않지만 하나의 조건을 추가하는 복잡성을 보여줍니다.
X
는 커서에서 값들을 나타내며,C
는 데이터베이스의 열로,:after
커서를 사용하여 오름차순으로 정렬되고NULL
값은 마지막에 정렬됩니다.X1이 NOT NULL이고 (C1 > X1) 또는 (C1 IS NULL) 또는 (C1 = X1이고 C2 > X2) 또는 (C1 = X1이고 X2가 NOT NULL이고 ((C2 > X2) 또는 (C2 IS NULL) 또는 (C2 = X2이고 C3 > X3) 또는 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 에러들을 GitLab API를 통해 프록시로 전달합니다. 이를 위해 Sentry API를 호출하여 자체 사용자 정의 페이지네이션을 수행할 수 없습니다.
일관성을 유지하기 위해 Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *items)
를 사용하여 외부 API에서 반환된 값을 기반으로 페이지네이션 커서를 수동으로 설정합니다.
다음 파일에서 예제 구현을 볼 수 있습니다:
-
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