GraphQL BatchLoader
GitLab v19.4요약
GitLab은 N+1 SQL 쿼리를 최적화하고 피하기 위해 batch-loader Ruby 젬을 사용합니다. 이러한 배치 처리의 기회를 만드는 것은 GraphQL 쿼리 트리의 특성입니다. GraphQL 쿼리를 실행하는 동안에는 데이터베이스 요청을 최대한 배치로 묶는 것이 좋습니다.
GitLab은 N+1 SQL 쿼리를 최적화하고 피하기 위해 batch-loader Ruby 젬을 사용합니다.
이러한 배치 처리의 기회를 만드는 것은 GraphQL 쿼리 트리의 특성입니다. 서로 연결되지 않은 노드가 같은 데이터를 필요로 할 수 있지만, 서로의 존재를 알 수는 없습니다.
사용 시점#
GraphQL 쿼리를 실행하는 동안에는 데이터베이스 요청을 최대한 배치로 묶는 것이 좋습니다. 뮤테이션은 직렬로 실행되므로 배치 로딩이 필요하지 않습니다. 데이터베이스 쿼리를 수행해야 하고, 비슷한(반드시 동일하지는 않은) 두 쿼리를 하나로 합칠 수 있다면 batch-loader 사용을 검토합니다.
새 엔드포인트를 구현할 때는 SQL 쿼리 수를 최소화하는 것을 목표로 삼습니다. 안정성과 확장성을 위해 쿼리가 N+1 성능 문제를 겪지 않도록 해야 합니다.
구현#
배치 로딩은 입력 Qα, Qβ, ... Qω에 대한 일련의 쿼리를 Q[α, β, ... ω]라는 단일 쿼리로 합칠 수 있을 때 유용합니다. ID 조회가 그 예로, 사용자 이름으로 사용자 두 명을 찾는 비용이 한 명을 찾는 비용과 같습니다. 다만 실제 사례는 더 복잡할 수 있습니다.
결과 집합의 정렬 순서, 그룹화, 집계 등 합칠 수 없는 요소가 서로 다른 경우에는 배치 로딩이 적합하지 않습니다.
코드에서 batch-loader를 사용하는 방법은 두 가지입니다. 단순 ID 조회에는 ::Gitlab::Graphql::Loaders::BatchModelLoader.new(model, id).find를 사용합니다. 더 복잡한 경우에는 배치 API를 직접 사용할 수 있습니다.
예를 들어 username으로 User를 로드하려면 다음과 같이 배치 처리를 추가할 수 있습니다.
class UserResolver < BaseResolver
type UserType, null: true
argument :username, ::GraphQL::Types::String, required: true
def resolve(**args)
BatchLoader::GraphQL.for(username).batch do |usernames, loader|
User.by_username(usernames).each do |user|
loader.call(user.username, user)
end
end
end
end
username은 조회하려는 사용자 이름입니다. 이름은 하나일 수도 있고 여러 개일 수도 있습니다.loader.call은 결과를 입력 키에 다시 매핑하는 데 사용합니다(여기서는 user를 해당 username에 매핑합니다).BatchLoader::GraphQL은 지연 객체(데이터를 가져오기 위해 보류된 프로미스)를 반환합니다.
BatchLoading 메커니즘을 사용하는 방법은 예시 머지 리퀘스트에서 확인할 수 있습니다.
BatchModelLoader#
ID 조회에는 BatchModelLoader를 사용하기를 권장합니다.
def project
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, object.project_id).find
end
연관 관계를 미리 로드하려면 연관 관계 배열을 전달할 수 있습니다.
def issue(lookahead:)
preloads = [:author] if lookahead.selects?(:author)
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, object.issue_id, preloads).find
end
동작 방식#
각 지연 객체는 어떤 데이터를 로드해야 하는지, 쿼리를 어떻게 배치로 묶을지 알고 있습니다. 지연 객체를 실제로 사용해야 할 때(#sync를 호출해 알립니다) 현재 배치에 있는 다른 유사 객체와 함께 로드됩니다.
블록 안에서는 대상 항목(User)에 대한 배치 쿼리를 실행합니다. 그다음에는 BatchLoader::GraphQL.for 메서드에서 사용한 항목(usernames)과 로드된 객체 자체(user)를 전달해 로더를 호출하기만 하면 됩니다.
BatchLoader::GraphQL.for(username).batch do |usernames, loader|
User.by_username(usernames).each do |user|
loader.call(user.username, user)
end
end
batch-loader는 블록의 소스 코드 위치를 기준으로 어떤 요청이 같은 큐에 속하는지 판단하지만, 배치마다 블록 인스턴스는 하나만 평가됩니다. 어느 인스턴스가 평가될지는 직접 제어할 수 없습니다.
따라서 다음 사항이 중요합니다.
- 블록은 객체의 인스턴스 상태를 참조(클로저로 캡처)해서는 안 됩니다. 블록에 필요한 데이터는
모두
for(data)호출로 전달하는 것이 가장 좋습니다. - 블록은 특정 종류의 배치 데이터에 한정되어야 합니다.
BatchModelLoader처럼 범용 로더를 구현할 수도 있지만, 그러려면 단사(injective)key인수를 사용해야 합니다. - 같은 블록을 참조하지 않으면 배치는 공유되지 않습니다. 동작과 파라미터, 키가 같은
동일한 블록이 두 개 있어도 공유되지 않습니다. 그러므로 배치 ID 조회를 직접 구현하지 말고
공유가 최대한 이루어지도록
BatchModelLoader를 사용합니다. 두 필드가 같은 배치 로딩을 정의하고 있다면 이를 새Loader로 추출해 서로 공유하도록 하는 방안을 검토합니다.
lazy의 의미#
배치를 너무 일찍 동기화(강제 평가)하지 않는 것이 중요합니다. 다음 예시는 sync를 너무 일찍 호출하면 배치 처리 기회가 사라진다는 점을 보여 줍니다.
다음 예시는 x에 sync를 너무 일찍 호출합니다.
x = find_lazy(1)
y = find_lazy(2)
# calling .sync will flush the current batch and will inhibit maximum laziness
x.sync
z = find_lazy(3)
y.sync
z.sync
# => will run 2 queries
반면 다음 예시는 모든 요청이 큐에 쌓일 때까지 기다려 추가 쿼리를 없앱니다.
x = find_lazy(1)
y = find_lazy(2)
z = find_lazy(3)
x.sync
y.sync
z.sync
# => will run 1 query
배치 로딩에는 의존성 분석이 없습니다. 보류 중인 요청 큐가 있고, 결과가 하나라도 필요해지는 즉시 보류 중인 모든 요청이 평가됩니다.
리졸버 코드에서는 batch.sync를 호출하거나 Lazy.force를 사용해서는 안 됩니다.
지연 값에 의존해야 한다면 대신 Lazy.with_value를 사용합니다.
def publisher
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Publisher, object.publisher_id).find
end
# Here we need the publisher to generate the catalog URL
def catalog_url
::Gitlab::Graphql::Lazy.with_value(publisher) do |p|
UrlHelpers.book_catalog_url(publisher, object.isbn)
end
end
뮤테이션에서 GitlabSchema.find_by_gid 나 .object_from_id로 레코드를 찾은 뒤에는 #sync를 자주 사용합니다. 이 메서드들이 결과를 배치 로더 래퍼에 담아 반환하기 때문입니다. 뮤테이션은 직렬로 실행되므로 배치 로딩이 필요하지 않고 객체를 즉시 평가할 수 있습니다.
테스트#
테스트는 요청 스펙과 Schema.execute를 사용해 수행하는 것이 가장 좋습니다. 이렇게 하면
지연 값의 수명 주기를 직접 관리할 필요가 없고, 정확한 결과를
보장받을 수 있습니다.
지연 값을 반환하는 GraphQL 필드는 테스트에서 값을 강제로 평가해야 할 수 있습니다. 강제 평가란 평소에는 프레임워크가 처리하는 평가를 명시적으로 요구하는 것을 말합니다.
지연 값은 GraphQLHelpers의 GraphqlHelpers#batch_sync 메서드나 Gitlab::Graphql::Lazy.force로 강제 평가할 수 있습니다. 예를 들면 다음과 같습니다.
it 'returns data as a batch' do
results = batch_sync(max_queries: 1) do
[{ id: 1 }, { id: 2 }].map { |args| resolve(args) }
end
expect(results).to eq(expected_results)
end
def resolve(args = {}, context = { current_user: current_user })
resolve(described_class, obj: obj, args: args, ctx: context)
end
QueryRecorder를 사용해 호출당 SQL 쿼리가 하나만 실행되는지 확인할 수도 있습니다.
it 'executes only 1 SQL query' do
query_count = ActiveRecord::QueryRecorder.new { subject }
expect(query_count).not_to exceed_query_limit(1)
end
쿼리 빌드 시 Arel::Nodes::LeadingJoin 오류 방지#
연관 관계 프록시를 통해 includes()와 함께 쿼리를 빌드하면
Rails가 joins_values에 Arel::Nodes::LeadingJoin 노드를 주입합니다.
이 노드는 릴레이션을 오염시키고, 이후 ActiveRecord의 프리로더가 조인 트리를 순회할 때
ActiveRecord::ConfigurationError를 일으킵니다.
이 문제는 Rails 7.2.3에서 발생하며, 이전 버전에도 영향이 있을 수 있습니다.
문제가 되는 패턴#
includes()를 적용하기 전에 연관 관계 프록시를 통해 쿼리를 빌드하는 경우입니다.
children = work_item.work_item_children.where.not(work_item_type_id: epic_type_id)
keyset_order.apply_cursor_conditions(children.includes(:parent_link)).reorder(keyset_order)
work_item.work_item_children는 has_many :through 연관 관계 프록시입니다.
ActiveRecord가 스코프를 빌드할 때 joins_values에 Arel::Nodes::LeadingJoin 노드를 주입합니다.
그러면 이 노드가 includes()가 반환한 릴레이션을 오염시킵니다.
이후 프리로더가 해당 릴레이션에서 has_many :through 연관 관계를 처리할 때
ActiveRecord::ConfigurationError: Arel::Nodes::LeadingJoin is not supported for visitor_for를 발생시킵니다.
해결책#
연관 관계 프록시가 아니라 클래스 수준에서 쿼리를 빌드합니다.
epic_type_id = ::WorkItems::TypesFramework::Provider.new(work_item.namespace).find_by_base_type(:epic).id
keyset_order = ::WorkItem.work_item_children_keyset_order_config
keyset_order.apply_cursor_conditions(
WorkItem.joins(:parent_link).where.not(work_item_type_id: epic_type_id)
).reorder(keyset_order)
연관 관계 프록시가 아닌 클래스에서 joins(:parent_link)를 사용하면
Arel::Nodes::LeadingJoin 노드가 들어가지 않습니다. 이 쿼리는 ActiveRecord 프리로더와 계속 호환됩니다.
자세한 내용은 머지 리퀘스트 231854를 참고합니다.