GraphQL BatchLoader
GitLab은 batch-loader Ruby gem을 사용하여 최적화하고 N+1 SQL 쿼리를 피합니다.
GraphQL 쿼리 트리의 속성은 이와 같은 배치 처리의 기회를 만듭니다. 연결되지 않은 노드들은 동일한 데이터가 필요하지만 자신을 알지 못할 수 있습니다.
언제 사용해야 하나요?
GraphQL query 실행 중에 최대한 많은 DB 요청을 배치로 처리해야 합니다. Mutations 도중에는 배치로 처리할 필요가 없습니다. 데이터베이스 쿼리를 수행해야 하고 두 개의 유사한 (하지만 반드시 동일한 필요는 없는) 쿼리를 결합할 수 있다면, batch-loader를 사용하는 것을 고려해 보세요.
새 엔드포인트를 구현할 때는 SQL 쿼리의 수를 최소화해야 합니다. 안정성과 확장성을 위해 쿼리가 N+1 성능 문제에 영향을 받지 않도록 해야 합니다.
구현
일련의 입력 Qα, Qβ, ... Qω
에 대한 일련의 쿼리를 Q[α, β, ... ω]
로 결합할 수 있는 경우, batch loading은 유용합니다. 이는 ID에 의한 조회의 예로, 여기서 우리는 한 명의 사용자를 저렴하게 찾을 수 있을 뿐만 아니라 두 명의 사용자도 찾을 수 있습니다. 하지만 실제 사례는 더 복잡할 수 있습니다.
결과 집합에 서로 다른 정렬 순서, 그룹화, 집계 또는 다른 비-합성 기능이 있는 경우에는 batch loading을 사용할 수 없습니다.
코드에서 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
은 결과를 입력 키(여기서 사용자는 사용자 이름에 매핑됨)로 매핑하는 데 사용됩니다. -
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
)을 전달하여 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
로 추출하고 공유할 수 있도록하여 새 Loader
로 추출하고 공유할 수 있도록 고려하세요.
“게으름”이란 무엇을 의미합니까?
일괄처리된 데이터를 너무 일찍 동기화(평가를 강제)하면 안 됩니다. 다음 예시는 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개의 쿼리가 실행됩니다
참고: 일괄로드 사용 시 의존성 분석은 없습니다. 대기 중인 요청들이 있고, 그 중 하나의 결과가 필요할 때 즉시 모든 대기 중인 요청이 평가됩니다.
리졸버(resolver) 코드에서는 절대로 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
테스트
이상적으로는 모든 테스트를 요청 사양(request specs)을 사용하고 Schema.execute
를 사용하여 수행하십시오. 그렇게 하면 게으른(lazy) 값을 직접 관리할 필요가 없으며 정확한 결과가 보장됩니다.
게으른 값을 반환하는 GraphQL 필드는 테스트에서 해당 값을 강제(force)할 수 있을 수 있습니다. “강제”는 일반적으로 프레임워크에서 배열되는 평가에 대한 명시적인 요구를 의미합니다.
GraphqlHelpers#batch_sync
메서드나 Gitlab::Graphql::Lazy.force
를 사용하여 게으른 값을 강제할 수 있습니다. 예를 들어:
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