GraphQL BatchLoader

GitLab은 batch-loader Ruby gem을 사용하여 최적화하고 N+1 SQL 질의를 피합니다.

이것은 GraphQL 쿼리 트리의 속성으로 인해 이처럼 배치 처리할 수 있는 기회를 만들어냅니다 - 연결되지 않은 노드는 동일한 데이터가 필요할 수 있지만 자신을 알지 못할 수 있습니다.

언제 사용해야 하나요?

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를 직접 사용할 수 있습니다.

예를 들어, Userusername으로 로드하려면 다음과 같이 배치로딩을 추가할 수 있습니다:

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)를 반환합니다.

여기에서 예제 MR(Merge Request)를 통해 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)를 전달하여 로더를 호출하기만 하면 됩니다:

BatchLoader::GraphQL.for(username).batch do |usernames, loader|
  User.by_username(usernames).each do |user|
    loader.call(user.username, user)
  end
end

batch-loader는 블록의 소스 코드 위치를 사용하여 동일한 큐에 속하는 요청을 결정하지만 각 배치에 대해 블록의 인스턴스 상태를 참조하거나(클로저를 포함) 참조하지 않도록 하는 것이 중요합니다. 블록은 배치된 데이터 종류에 특화되어야 합니다. 일반적인 로더(예: BatchModelLoader)를 구현하는 것은 가능하지만 사상 함수 key 인자의 사용이 필요합니다. 배치는 동일한 블록을 참조하지 않는 한 공유되지 않습니다 - 동일한 동작, 매개변수 및 키를 가진 두 개의 동일한 블록은 공유되지 않습니다. 따라서 배치된 ID 조회를 직접 구현하지 말고 최대한 공유하기 위해 BatchModelLoader를 사용해야 합니다. 두 필드에서 동일한 배치로딩을 볼 경우, 새로운 Loader로 추출하고 공유할 수 있도록 활성화해야 합니다.

Lazy는 무엇을 의미하나요?

너무 일찍 배치를 동기화(평가 강제)하지 않는 것이 중요합니다. 아래 예제는 너무 일찍 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개의 쿼리를 실행합니다
note
배치로딩 사용에는 의존성 분석이 없습니다. 대기 중인 요청의 대기열이 있으며 결과가 필요할 때 대기 중인 모든 요청이 평가됩니다.

리졸버 코드에서 batch.sync를 호출하거나 Lazy.force를 사용해서는 절대로 안 됩니다. 게으른 값을 의존하는 경우에는 대신 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) 값을 직접 처리할 필요가 없고 정확한 결과를 얻을 수 있습니다.

게으른(lazy) 값을 반환하는 GraphQL 필드는 테스트에서 이러한 값을 강제로 처리해야 할 수 있습니다. 여기서 강제 처리란 일반적으로 프레임워크에 의해 처리되는 평가에 대한 명시적인 요구를 의미합니다.

GraphQLHelpers에서 사용 가능한 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