GraphQL BatchLoader
GitLab v19.1GitLab은 batch-loader Ruby gem을 사용하여 N+1 SQL 쿼리를 최적화하고 방지합니다. GraphQL 쿼리 트리의 특성상 배칭(batching) 기회가 발생합니다. GraphQL 쿼리 실행 중에는 DB 요청을 가능한 한 배칭 처리해야 합니다.
GitLab은 batch-loader Ruby gem을 사용하여 N+1 SQL 쿼리를 최적화하고 방지합니다.
GraphQL 쿼리 트리의 특성상 배칭(batching) 기회가 발생합니다. 서로 연결되지 않은 노드들이 동일한 데이터를 필요로 할 수 있지만, 각 노드가 서로의 존재를 알 수는 없기 때문입니다.
언제 사용해야 하나요?#
GraphQL 쿼리 실행 중에는 DB 요청을 가능한 한 배칭 처리해야 합니다. 뮤테이션은 직렬로 실행되므로 배칭이 필요하지 않습니다. 데이터베이스 쿼리를 실행해야 하고, 두 개의 유사한(반드시 동일하지 않아도 되는) 쿼리를 결합할 수 있다면 batch-loader 사용을 고려하세요.
새 엔드포인트를 구현할 때는 SQL 쿼리 수를 최소화하는 것을 목표로 해야 합니다. 안정성과 확장성을 위해 N+1 성능 문제가 발생하지 않도록 반드시 확인해야 합니다.
구현#
배치 로딩은 입력 Qα, Qβ, ... Qω에 대한 일련의 쿼리를 Q[α, β, ... ω]에 대한 단일 쿼리로 결합할 수 있을 때 유용합니다. ID로 조회하는 것이 대표적인 예시인데, 사용자 이름으로 두 명의 사용자를 찾는 것이 한 명을 찾는 것만큼 저렴하게 처리될 수 있습니다. 하지만 실제 사례는 더 복잡할 수 있습니다.
배치 로딩은 결과 집합에 서로 다른 정렬 순서, 그룹화, 집계 또는 기타 결합 불가능한 특성이 있는 경우에는 적합하지 않습니다.
코드에서 batch-loader를 사용하는 방법은 두 가지입니다. 간단한 ID 조회의 경우 ::Gitlab::Graphql::Loaders::BatchModelLoader.new(model, id).find를 사용하세요. 더 복잡한 경우에는 batch 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은 지연 객체(데이터를 가져오는 지연된 promise)를 반환합니다.
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)를 전달하여 loader를 호출하기만 하면 됩니다:
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
배치 로딩 사용에서는 의존성 분석이 없습니다. 대기 중인 요청 큐가 있으며, 어떤 결과가 필요해지는 순간 모든 대기 중인 요청이 평가됩니다.
resolver 코드에서 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를 일반적으로 사용하는데, 이 메서드들이 배치 로더 래퍼로 결과를 반환하기 때문입니다. 뮤테이션은 직렬로 실행되므로 배치 로딩이 필요하지 않으며 객체를 즉시 평가할 수 있습니다.
테스트#
이상적으로는 request spec을 사용하고 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가 Arel::Nodes::LeadingJoin 노드를 joins_values에 주입합니다.
이 노드들은 관계를 오염시키고, 나중에 ActiveRecord의 preloader가 join 트리를 탐색할 때 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가 스코프를 빌드할 때 Arel::Nodes::LeadingJoin 노드를 joins_values에 주입합니다.
그 노드들이 includes()가 반환하는 관계를 오염시킵니다.
나중에 preloader가 해당 관계에서 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의 preloader와 호환성을 유지합니다.
자세한 내용은 머지 리퀘스트 231854를 참조하세요.