GraphQL 페이지네이션
페이지네이션 유형
GitLab은 주로 offset과 keyset(때로는 커서 기반) 페이지네이션 두 가지 유형을 사용합니다. GraphQL API는 주로 keyset 페이지네이션을 사용하며 필요할 때 offset 페이지네이션으로 전환합니다.
성능 고려 사항
더 많은 정보는 일반 페이지네이션 가이드라인 섹션을 확인하세요.
Offset 페이지네이션
이는 가장 일반적인 전통적인 페이지별 페이지네이션으로, 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를 참조하세요.
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
Keyset 페이지네이션
Keyset 페이지네이션 구현은 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
값이 포함되지 않습니다.
쿼리 복잡성의 제한 사항
우리는 두 개의 정렬 필드만 지원하며, 그 중 하나는 기본 키(primary key)이어야 합니다.
다음은 쿼리를 위한 유사 코드의 두 가지 예입니다.
-
두 조건 쿼리.
X
는 커서에서의 값들을 나타냅니다.C
는 데이터베이스에서:after
커서를 사용하여 오름차순으로 정렬된 열을 나타내며,NULL
값은 마지막으로 정렬됩니다.X1이 NULL이 아님 그리고 (C1 > X1) 또는 (C1이 NULL) 또는 (C1 = X1 그리고 C2 > X2) X1이 NULL임 그리고 (C1이 NULL 그리고 C2 > X2)
아래는
relative_position: :asc
로 정렬된Issue.order(id: :asc)
관계를 기반으로 한 예시로, 커서가relative_position: 1500, id: 500
인 경우입니다.커서[relative_position]가 NULL이 아닌 경우 ("issues"."relative_position" > 1500) 또는 ( "issues"."relative_position" = 1500 그리고 "issues"."id" > 500 ) 또는 ("issues"."relative_position"이 NULL인 경우) 커서[relative_position]가 NULL인 경우 "issues"."relative_position"이 NULL 그리고 "issues"."id" > 500
-
세 가지 조건 쿼리. 아래 예시는 미완성이지만, 하나의 추가 조건을 추가하는 복잡성을 보여줍니다.
X
는 커서에서의 값들을 나타냅니다.C
는 데이터베이스에서:after
커서를 사용하여 오름차순으로 정렬된 열을 나타내며,NULL
값은 마지막으로 정렬됩니다.X1이 NULL이 아님 그리고 (C1 > X1) 또는 (C1이 NULL) 또는 (C1 = X1 그리고 C2 > X2) 또는 (C1 = X1 그리고 X2가 NULL이 아닌 경우 그리고 ((C2 > X2) 또는 (C2이 NULL) 또는 (C2 = X2 그리고 C3 > X3) 또는 X2가 NULL인 경우.....
Gitlab::Graphql::Pagination::Keyset::QueryBuilder
를 사용하여 필요한 SQL 조건을 작성하고 Active Record 관계에 적용할 수 있습니다.
복잡한 쿼리는 사용하기 어렵거나 불가능할 수 있습니다. 예를 들어, issuable.rb
에서는 order_due_date_and_labels_priority
메서드가 아주 복잡한 쿼리를 생성합니다.
이러한 유형의 쿼리는 지원되지 않습니다. 이러한 경우 오프셋(offset) 페이지네이션을 사용할 수 있습니다.
주의 사항
문자열 구문을 사용하여 컬렉션의 순서를 정의하지 마세요.
# 잘못된 예
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의 페이지네이션을 사용해야 할 수 있습니다.
이 예로는 Error Tracking 구현이 있습니다. 여기서는 Sentry errors를 GitLab API를 통해 프록시로 사용합니다. 이를 위해 Sentry API를 호출하여 자체 사용자 정의 페이지네이션을 수행할 수 없기 때문에 외부 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
에서 찾을 수 있는 정렬된 페이지네이션 쿼리 공유 예제를 사용하여 테스트해야 합니다.
이를 통해 정렬 키가 호환되는지와 커서가 올바르게 작동하는지 확인할 수 있습니다.
특히 일부 정렬 키가 지원되지 않을 수 있으므로 키셋 페이지네이션을 사용할 때 이 점이 매우 중요합니다.
다음과 같은 섹션을 request 스펙스(request specs)에 추가하세요.
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