GraphQL BatchLoader
GitLab은 batch-loader Ruby gem을 사용하여 최적화하고 N+1 SQL 쿼리를 피합니다.
BatchLoader는 GraphQL 쿼리 트리의 속성으로 인해이러한 배치 작업의 기회를 만듭니다. 연결되지 않은 노드들은 같은 데이터가 필요하지만 그들 자신에 대해 알지 못할 수 있습니다.
언제 사용해야 하나요?
GraphQL 쿼리 실행 동안 DB 요청을 최대한 많이 배치 처리해야 합니다. 뮤테이션의 경우 배치 처리가 필요하지 않습니다. 데이터베이스 쿼리를 수행해야하며 두 가지 유사한 (그러나 반드시 동일하지는 않은) 쿼리를 결합할 수 있다면 배치 로더를 사용하는 것을 고려해보십시오.
새로운 엔드포인트를 구현할 때 SQL 쿼리의 수를 최소화해야 합니다. 안정성과 확장 가능성을 위해 쿼리가 N+1 성능 문제에 영향을받지 않도록해야합니다.
구현
일련의 입력 Qα, Qβ, ... Qω
에 대한 쿼리를 Q[α, β, ... ω]
로 결합할 수있는 경우 배치로드는 유용합니다. 이 예로는 ID로 조회하는 경우가 있으며, 실제 세계의 예제는 더 복잡할 수 있습니다.
결과 세트가 서로 다른 정렬 순서, 그룹화, 집계 또는 다른 비합성 기능을 포함하는 경우 배치로드는 적합하지 않습니다.
코드에서 배치로드를 사용하는 두 가지 방법이 있습니다. 간단한 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
은 결과를 입력 키(here user is mapped to its username)로 매핑하는데 사용됩니다. -
BatchLoader::GraphQL
은 데이터를 가져 오기 위해 중단된 프라미스를 반환합니다.
여기에는 우리의 BatchLoading
메커니즘을 사용하는 방법을 보여주는 예제 MR이 있습니다.
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
정확히 어떻게 작동합니까?
각 lazy
객체는 로드해야 할 데이터와 쿼리를 배치하는 방법을 알고 있습니다. lazy
객체를 사용해야하는 경우 (#sync
를 호출하여 알릴 때), 현재 배치에 있는 모든 유사한 객체와 함께 로드됩니다.
하나의 배치를 실행하고 나면 이후에 모든 작업이 완료되는데, 우리는 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)
호출을 통해 블록에 전달하는 것입니다.
배치로드 사용시 주의사항
- 일반적인 모든 요청에서
batch.sync
를 호출하지 마십시오. 대신Lazy.with_value
를 사용하십시오.
또한, 배치 로드를 사용할 때 의존성 분석을 하지 않아야합니다. 보류 중인 요청 대기열이 있으며 그 중 하나의 결과가 필요한 즉시 모든 보류 중인 요청이 평가됩니다.
테스트
이상적으로는 요청 스펙 및 Schema.execute
를 사용하여 모든 테스트를 수행해야하며, 이렇게 함으로써 게으른(lazy) 값의 수명주기를 직접 관리할 필요가 없으며 정확한 결과를 보장받을 수 있습니다.
게으른(lazy) 값을 반환하는 GraphQL 필드의 경우, 테스트에서 이러한 값을 강제로 실행해야 할 수 있습니다. 여기서 강제 실행이란 일반적으로 프레임워크에서 안정적으로 처리되는 평가의 명시적 요구를 의미합니다.
GraphQLHelpers의 GraphqlHelpers#batch_sync
메서드를 사용하거나 Gitlab::Graphql::Lazy.force
를 사용하여 게으른(lazy) 값을 강제로 실행할 수 있습니다. 예를 들어:
it '데이터를 일괄 처리로 반환합니다' 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 '단 하나의 SQL 쿼리만을 실행합니다' do
query_count = ActiveRecord::QueryRecorder.new { subject }
expect(query_count).not_to exceed_query_limit(1)
end