GraphQL 페이지네이션
GitLab v19.4요약
GitLab은 두 가지 페이지네이션 방식을 사용합니다. 자세한 내용은 페이지네이션 일반 가이드라인을 참고합니다. 가장 흔하게 쓰이는 전통적인 페이지 단위 페이지네이션이며 GitLab 전반에서 사용합니다. 예를 들어 Page 100을 선택하면 백엔드로 100이 전달됩니다.
페이지네이션 유형#
GitLab은 두 가지 페이지네이션 방식을 사용합니다. 오프셋 페이지네이션과 keyset 페이지네이션(커서 기반이라고도 합니다)입니다. GraphQL API는 주로 keyset 페이지네이션을 사용하며 필요할 때 오프셋 페이지네이션으로 폴백합니다.
성능 고려 사항#
자세한 내용은 페이지네이션 일반 가이드라인을 참고합니다.
오프셋 페이지네이션#
가장 흔하게 쓰이는 전통적인 페이지 단위 페이지네이션이며 GitLab 전반에서 사용합니다. 페이지 하단의 페이지 번호 목록으로 알아볼 수 있으며, 번호를 선택하면 해당 페이지의 결과로 이동합니다.
예를 들어 Page 100을 선택하면 백엔드로 100이 전달됩니다.
한 페이지에 항목이 20개라면 백엔드는
20 * 100 = 2000을 계산한 다음,
처음 2000개 레코드를 건너뛰어(오프셋) 데이터베이스를 조회하고
그다음 20개를 가져옵니다.
page number * page size = where to find my records
이 방식에는 두 가지 문제가 있습니다.
- 성능. 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에서 확인할 수 있습니다.
Keyset 페이지네이션#
특정 레코드가 주어졌을 때 그다음에 오는 레코드를 계산하는 방법을 알고 있다면, 데이터베이스에서 바로 그 레코드들을 조회할 수 있습니다.
예를 들어 생성일 기준으로 정렬된 이슈 목록이 있다고 가정합니다. 페이지의 첫 항목이 특정 날짜(예: 1월 1일)라는 것을 알고 있다면, 그 날짜 이후에 생성된 모든 레코드를 요청해 앞의 20개를 가져올 수 있습니다. 항상 그 날짜 이후의 레코드를 요청하므로 다수가 삭제되거나 추가되어도 문제가 없고 올바른 항목을 얻습니다.
다만 1월 1일에 생성된 이슈가 20페이지에 있는지 100페이지에 있는지는 쉽게 알 수 없습니다.
keyset 페이지네이션의 장점과 절충점은 다음과 같습니다.
- 성능이 훨씬 좋습니다.
- 삭제나 삽입 때문에 목록에서 레코드가 누락되지 않으므로 최종 사용자에게 더 안정적인 데이터를 제공합니다.
- 무한 스크롤을 구현하는 최선의 방법입니다.
- 구현과 유지 보수가 더 어렵습니다.
updated_at과sort_order에는 쉽지만 복잡한 정렬 시나리오에는 복잡하거나 불가능합니다.
구현#
쿼리에서 페이지네이션이 지원되면 GitLab은 기본적으로 keyset
페이지네이션을 사용합니다. 설정 위치는
pagination/connections.rb에서 확인할 수 있습니다.
쿼리가 ActiveRecord::Relation을 반환하면 keyset 페이지네이션이 자동으로 사용됩니다.
성능과 데이터 안정성을 확보하기 위해 내린 의도적인 결정입니다.
다만 이슈에서 레이블 우선순위로 정렬할 때처럼 정렬이 복잡한 경우에는
오프셋 페이지네이션 커넥션인 OffsetActiveRecordRelationConnection을
사용해야 합니다.
정렬 순서 등의 이유로 keyset 페이지네이션에 적합하지 않은 relation을
리졸버에서 반환한다면, BaseResolver#offset_pagination 메서드로
해당 relation을 올바른 커넥션 타입으로 감쌀 수 있습니다.
예를 들면 다음과 같습니다.
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 gem에 포함된 GraphQL::Pagination::ActiveRecordRelationConnection의
서브클래스입니다. 모든 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)아래는
relative_position: 1500, id: 500인 after 커서와 함께 relationIssue.order(relative_position: :asc).order(id: :asc)를 사용한 예시입니다.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 relation에 적용할 수 있습니다.
복잡한 쿼리는 사용하기 어렵거나 불가능합니다. 예를 들어
issuable.rb의
order_due_date_and_labels_priority 메서드는 매우 복잡한 쿼리를 만듭니다.
이런 유형의 쿼리는 지원하지 않습니다. 이 경우에는 오프셋 페이지네이션을 사용할 수 있습니다.
주의할 점#
컬렉션의 정렬을 문자열 구문으로 정의하지 않습니다.
# Bad
items.order('created_at DESC')
대신 해시 구문을 사용합니다.
# Good
items.order(created_at: :desc)
첫 번째 예시는 정렬 정보(위 예시에서는 created_at)를
페이지네이션 커서에 올바르게 담지 못하므로
정렬 순서가 잘못됩니다.
오프셋 페이지네이션#
정렬 복잡도가 keyset 페이지네이션으로 감당할 수 있는 수준을 넘어서는 경우가 있습니다.
예를 들어 ProjectIssuesResolver에서
priority_asc로 정렬할 때는 정렬이 지나치게 복잡해 keyset 페이지네이션을
사용할 수 없습니다. 자세한 내용은 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])
# Certain complex sorts are not supported by the stable cursor pagination yet.
# In these cases, we use offset pagination, so we return the correct connection.
offset_pagination(issues)
else
issues
end
end
외부 페이지네이션#
다른 시스템에 저장된 데이터를 GitLab API로 반환해야 할 때가 있습니다. 이런 경우에는 서드파티 API를 페이지네이션해야 할 수 있습니다.
오류 추적 구현이 그 예시로, GitLab API를 통해 Sentry 오류를 프록시합니다. 이때 자체 페이지네이션 규칙을 적용하는 Sentry API를 호출합니다. 따라서 GitLab 내부에서 컬렉션에 접근해 자체 페이지네이션을 수행할 수 없습니다.
일관성을 위해 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에 있는
정렬·페이지네이션 공유 예제로 테스트해야 합니다.
이 예제는 정렬 키가 호환되는지, 커서가 제대로 동작하는지
확인하는 데 도움이 됩니다.
지원되지 않는 정렬 키가 있을 수 있으므로 keyset 페이지네이션을 사용할 때 특히 중요합니다.
요청 스펙에 다음과 같은 섹션을 추가합니다.
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)
let(:first_param) { 2 }
let(:all_records) { ordered_issues.map(&:iid) }
end
end
end