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개의 쿼리가 실행됩니다
note
배치 로딩 사용 시 의존성 분석이 없습니다. 대기 중인 요청의 대기열이 있고, 필요한 결과가 하나라도 즉시 필요하면 모든 대기 중인 요청이 평가됩니다.

리졸버 코드에서는 절대로 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