GraphQL BatchLoader
GitLab은 batch-loader Ruby gem을 사용하여 최적화하고 N+1 SQL 쿼리를 피합니다.
GraphQL 쿼리 트리의 특성 때문에 이렇게 배치하는 기회가 생깁니다. 연결되지 않은 노드들은 동일한 데이터가 필요하지만 자신을 알지 못합니다.
언제 사용해야 하나요?
GraphQL 쿼리 실행 중에 DB 요청을 최대한 배치해야 합니다. 돌이킬 수 없는 수정(mutation)에서는 배치 로딩이 필요하지 않습니다. 만약 두 가지 유사한(필수는 아님) 쿼리를 결합할 수 있는 데이터베이스 쿼리가 필요하다면 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
은 결과를 입력 키(여기서는 사용자를 사용자 이름으로 매핑)로 매핑하는 데 사용됩니다. -
BatchLoader::GraphQL
은 데이터를 가져오기 위한 보류 중인 프라미스(lazy object)를 반환합니다.
이 예시에서는 우리의 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
정확히 어떻게 작동합니까?
각 보류 중인 객체는 로드해야 할 데이터와 쿼리를 배치하는 방법을 알고 있습니다. 보류 중인 객체를 사용해야 할 때(우리가 #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
배치 로더는 블록의 소스 코드 위치를 사용하여 동일한 대기열에 속하는 요청을 결정하지만 각 배치에 대해 블록의 인스턴스 상태를 참조하는 것이 아닌, 모든 데이터를 for(data)
호출을 통해 블록으로 전달하는 것이 가장 좋은 방법입니다.
이 블록은 특정한 종류의 배치된 데이터에 대해 구체적이어야 합니다. 일반적인 로더(예: BatchModelLoader
)를 구현하는 것은 가능하지만 이를 위해서는 단사적(key
)인자의 사용이 필요합니다.
배치는 블록을 참조하지 않는 한 공유되지 않으며, 두 개의 동일한 동작, 매개변수 및 키를 가진 동일한 블록이 공유되지 않습니다. 이러한 이유로 배치된 ID 조회를 직접 구현하는 대신 최대한 공유하려면 BatchModelLoader
를 사용하세요. 두 필드가 동일한 배치 로딩을 정의하는 것을 보면, 새로운 Loader
로 추출하여 공유할 수 있도록하는 것을 고려해보세요.
보류 중(lazy)가 무엇을 의미합니까?
너무 이릅으로 배치를 동기화(평가를 강제)하면 안 됩니다. 아래 예시에서 보여주는 것처럼 너무 이릅에 sync를 호출하는 것은 배치하는 기회를 제거할 수 있습니다.
다음 예제는 x
에 너무 빨리 sync를 호출합니다:
x = find_lazy(1)
y = find_lazy(2)
# .sync를 호출하면 현재 배치를 플러시하고 최대한 늦게 평가됩니다
x.sync
z = find_lazy(3)
y.sync
z.sync
# => 2개의 쿼리가 실행됩니다
그러나 다음 예제는 모든 요청이 대기되고 추가 쿼리를 제거합니다:
x = find_lazy(1)
y = find_lazy(2)
z = find_lazy(3)
x.sync
y.sync
z.sync
# => 1개의 쿼리가 실행됩니다
리졸버 코드에서는 절대로 batch.sync
를 호출하거나 Lazy.force
를 사용해서는 안 됩니다. 게으른(lazy) 값에 의존해야 하는 경우 대신 Lazy.with_value
를 사용하세요:
def publisher
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Publisher, object.publisher_id).find
end
# 여기서는 출판사를 사용하여 카달로그 URL을 생성해야 합니다
def catalog_url
::Gitlab::Graphql::Lazy.with_value(publisher) do |p|
UrlHelpers.book_catalog_url(publisher, object.isbn)
end
end
테스트
이상적으로, 모든 테스트를 요청 스펙을 사용하여, 그리고 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