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