InfoGrab DocsInfoGrab Docs

GraphQL BatchLoader

요약

GitLab은 N+1 SQL 쿼리를 최적화하고 피하기 위해 batch-loader Ruby 젬을 사용합니다. 이러한 배치 처리의 기회를 만드는 것은 GraphQL 쿼리 트리의 특성입니다. GraphQL 쿼리를 실행하는 동안에는 데이터베이스 요청을 최대한 배치로 묶는 것이 좋습니다.

GitLab은 N+1 SQL 쿼리를 최적화하고 피하기 위해 batch-loader Ruby 젬을 사용합니다.

이러한 배치 처리의 기회를 만드는 것은 GraphQL 쿼리 트리의 특성입니다. 서로 연결되지 않은 노드가 같은 데이터를 필요로 할 수 있지만, 서로의 존재를 알 수는 없습니다.

사용 시점#

GraphQL 쿼리를 실행하는 동안에는 데이터베이스 요청을 최대한 배치로 묶는 것이 좋습니다. 뮤테이션은 직렬로 실행되므로 배치 로딩이 필요하지 않습니다. 데이터베이스 쿼리를 수행해야 하고, 비슷한(반드시 동일하지는 않은) 두 쿼리를 하나로 합칠 수 있다면 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은 결과를 입력 키에 다시 매핑하는 데 사용합니다(여기서는 user를 해당 username에 매핑합니다).
  • BatchLoader::GraphQL은 지연 객체(데이터를 가져오기 위해 보류된 프로미스)를 반환합니다.

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는 블록의 소스 코드 위치를 기준으로 어떤 요청이 같은 큐에 속하는지 판단하지만, 배치마다 블록 인스턴스는 하나만 평가됩니다. 어느 인스턴스가 평가될지는 직접 제어할 수 없습니다.

따라서 다음 사항이 중요합니다.

  • 블록은 객체의 인스턴스 상태를 참조(클로저로 캡처)해서는 안 됩니다. 블록에 필요한 데이터는 모두 for(data) 호출로 전달하는 것이 가장 좋습니다.
  • 블록은 특정 종류의 배치 데이터에 한정되어야 합니다. BatchModelLoader처럼 범용 로더를 구현할 수도 있지만, 그러려면 단사(injective) key 인수를 사용해야 합니다.
  • 같은 블록을 참조하지 않으면 배치는 공유되지 않습니다. 동작과 파라미터, 키가 같은 동일한 블록이 두 개 있어도 공유되지 않습니다. 그러므로 배치 ID 조회를 직접 구현하지 말고 공유가 최대한 이루어지도록 BatchModelLoader를 사용합니다. 두 필드가 같은 배치 로딩을 정의하고 있다면 이를 새 Loader로 추출해 서로 공유하도록 하는 방안을 검토합니다.

lazy의 의미#

배치를 너무 일찍 동기화(강제 평가)하지 않는 것이 중요합니다. 다음 예시는 sync를 너무 일찍 호출하면 배치 처리 기회가 사라진다는 점을 보여 줍니다.

다음 예시는 x에 sync를 너무 일찍 호출합니다.

x = find_lazy(1)
y = find_lazy(2)

# calling .sync will flush the current batch and will inhibit maximum laziness
x.sync

z = find_lazy(3)

y.sync
z.sync

# => will run 2 queries

반면 다음 예시는 모든 요청이 큐에 쌓일 때까지 기다려 추가 쿼리를 없앱니다.

x = find_lazy(1)
y = find_lazy(2)
z = find_lazy(3)

x.sync
y.sync
z.sync

# => will run 1 query
Note

배치 로딩에는 의존성 분석이 없습니다. 보류 중인 요청 큐가 있고, 결과가 하나라도 필요해지는 즉시 보류 중인 모든 요청이 평가됩니다.

리졸버 코드에서는 batch.sync를 호출하거나 Lazy.force를 사용해서는 안 됩니다. 지연 값에 의존해야 한다면 대신 Lazy.with_value를 사용합니다.

def publisher
  ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Publisher, object.publisher_id).find
end

# Here we need the publisher to generate the catalog URL
def catalog_url
  ::Gitlab::Graphql::Lazy.with_value(publisher) do |p|
    UrlHelpers.book_catalog_url(publisher, object.isbn)
  end
end

뮤테이션에서 GitlabSchema.find_by_gid 나 .object_from_id로 레코드를 찾은 뒤에는 #sync를 자주 사용합니다. 이 메서드들이 결과를 배치 로더 래퍼에 담아 반환하기 때문입니다. 뮤테이션은 직렬로 실행되므로 배치 로딩이 필요하지 않고 객체를 즉시 평가할 수 있습니다.

테스트#

테스트는 요청 스펙과 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

쿼리 빌드 시 Arel::Nodes::LeadingJoin 오류 방지#

연관 관계 프록시를 통해 includes()와 함께 쿼리를 빌드하면 Rails가 joins_values에 Arel::Nodes::LeadingJoin 노드를 주입합니다. 이 노드는 릴레이션을 오염시키고, 이후 ActiveRecord의 프리로더가 조인 트리를 순회할 때 ActiveRecord::ConfigurationError를 일으킵니다. 이 문제는 Rails 7.2.3에서 발생하며, 이전 버전에도 영향이 있을 수 있습니다.

문제가 되는 패턴#

includes()를 적용하기 전에 연관 관계 프록시를 통해 쿼리를 빌드하는 경우입니다.

children = work_item.work_item_children.where.not(work_item_type_id: epic_type_id)
keyset_order.apply_cursor_conditions(children.includes(:parent_link)).reorder(keyset_order)

work_item.work_item_children는 has_many :through 연관 관계 프록시입니다. ActiveRecord가 스코프를 빌드할 때 joins_values에 Arel::Nodes::LeadingJoin 노드를 주입합니다. 그러면 이 노드가 includes()가 반환한 릴레이션을 오염시킵니다. 이후 프리로더가 해당 릴레이션에서 has_many :through 연관 관계를 처리할 때 ActiveRecord::ConfigurationError: Arel::Nodes::LeadingJoin is not supported for visitor_for를 발생시킵니다.

해결책#

연관 관계 프록시가 아니라 클래스 수준에서 쿼리를 빌드합니다.

epic_type_id = ::WorkItems::TypesFramework::Provider.new(work_item.namespace).find_by_base_type(:epic).id
keyset_order = ::WorkItem.work_item_children_keyset_order_config

keyset_order.apply_cursor_conditions(
  WorkItem.joins(:parent_link).where.not(work_item_type_id: epic_type_id)
).reorder(keyset_order)

연관 관계 프록시가 아닌 클래스에서 joins(:parent_link)를 사용하면 Arel::Nodes::LeadingJoin 노드가 들어가지 않습니다. 이 쿼리는 ActiveRecord 프리로더와 계속 호환됩니다.

자세한 내용은 머지 리퀘스트 231854를 참고합니다.

GraphQL BatchLoader

GitLab v19.4
원문 보기

요약

GitLab은 N+1 SQL 쿼리를 최적화하고 피하기 위해 batch-loader Ruby 젬을 사용합니다. 이러한 배치 처리의 기회를 만드는 것은 GraphQL 쿼리 트리의 특성입니다. GraphQL 쿼리를 실행하는 동안에는 데이터베이스 요청을 최대한 배치로 묶는 것이 좋습니다.

GitLab은 N+1 SQL 쿼리를 최적화하고 피하기 위해 batch-loader Ruby 젬을 사용합니다.

이러한 배치 처리의 기회를 만드는 것은 GraphQL 쿼리 트리의 특성입니다. 서로 연결되지 않은 노드가 같은 데이터를 필요로 할 수 있지만, 서로의 존재를 알 수는 없습니다.

사용 시점#

GraphQL 쿼리를 실행하는 동안에는 데이터베이스 요청을 최대한 배치로 묶는 것이 좋습니다. 뮤테이션은 직렬로 실행되므로 배치 로딩이 필요하지 않습니다. 데이터베이스 쿼리를 수행해야 하고, 비슷한(반드시 동일하지는 않은) 두 쿼리를 하나로 합칠 수 있다면 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은 결과를 입력 키에 다시 매핑하는 데 사용합니다(여기서는 user를 해당 username에 매핑합니다).
  • BatchLoader::GraphQL은 지연 객체(데이터를 가져오기 위해 보류된 프로미스)를 반환합니다.

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는 블록의 소스 코드 위치를 기준으로 어떤 요청이 같은 큐에 속하는지 판단하지만, 배치마다 블록 인스턴스는 하나만 평가됩니다. 어느 인스턴스가 평가될지는 직접 제어할 수 없습니다.

따라서 다음 사항이 중요합니다.

  • 블록은 객체의 인스턴스 상태를 참조(클로저로 캡처)해서는 안 됩니다. 블록에 필요한 데이터는 모두 for(data) 호출로 전달하는 것이 가장 좋습니다.
  • 블록은 특정 종류의 배치 데이터에 한정되어야 합니다. BatchModelLoader처럼 범용 로더를 구현할 수도 있지만, 그러려면 단사(injective) key 인수를 사용해야 합니다.
  • 같은 블록을 참조하지 않으면 배치는 공유되지 않습니다. 동작과 파라미터, 키가 같은 동일한 블록이 두 개 있어도 공유되지 않습니다. 그러므로 배치 ID 조회를 직접 구현하지 말고 공유가 최대한 이루어지도록 BatchModelLoader를 사용합니다. 두 필드가 같은 배치 로딩을 정의하고 있다면 이를 새 Loader로 추출해 서로 공유하도록 하는 방안을 검토합니다.

lazy의 의미#

배치를 너무 일찍 동기화(강제 평가)하지 않는 것이 중요합니다. 다음 예시는 sync를 너무 일찍 호출하면 배치 처리 기회가 사라진다는 점을 보여 줍니다.

다음 예시는 x에 sync를 너무 일찍 호출합니다.

x = find_lazy(1)
y = find_lazy(2)

# calling .sync will flush the current batch and will inhibit maximum laziness
x.sync

z = find_lazy(3)

y.sync
z.sync

# => will run 2 queries

반면 다음 예시는 모든 요청이 큐에 쌓일 때까지 기다려 추가 쿼리를 없앱니다.

x = find_lazy(1)
y = find_lazy(2)
z = find_lazy(3)

x.sync
y.sync
z.sync

# => will run 1 query
Note

배치 로딩에는 의존성 분석이 없습니다. 보류 중인 요청 큐가 있고, 결과가 하나라도 필요해지는 즉시 보류 중인 모든 요청이 평가됩니다.

리졸버 코드에서는 batch.sync를 호출하거나 Lazy.force를 사용해서는 안 됩니다. 지연 값에 의존해야 한다면 대신 Lazy.with_value를 사용합니다.

def publisher
  ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Publisher, object.publisher_id).find
end

# Here we need the publisher to generate the catalog URL
def catalog_url
  ::Gitlab::Graphql::Lazy.with_value(publisher) do |p|
    UrlHelpers.book_catalog_url(publisher, object.isbn)
  end
end

뮤테이션에서 GitlabSchema.find_by_gid 나 .object_from_id로 레코드를 찾은 뒤에는 #sync를 자주 사용합니다. 이 메서드들이 결과를 배치 로더 래퍼에 담아 반환하기 때문입니다. 뮤테이션은 직렬로 실행되므로 배치 로딩이 필요하지 않고 객체를 즉시 평가할 수 있습니다.

테스트#

테스트는 요청 스펙과 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

쿼리 빌드 시 Arel::Nodes::LeadingJoin 오류 방지#

연관 관계 프록시를 통해 includes()와 함께 쿼리를 빌드하면 Rails가 joins_values에 Arel::Nodes::LeadingJoin 노드를 주입합니다. 이 노드는 릴레이션을 오염시키고, 이후 ActiveRecord의 프리로더가 조인 트리를 순회할 때 ActiveRecord::ConfigurationError를 일으킵니다. 이 문제는 Rails 7.2.3에서 발생하며, 이전 버전에도 영향이 있을 수 있습니다.

문제가 되는 패턴#

includes()를 적용하기 전에 연관 관계 프록시를 통해 쿼리를 빌드하는 경우입니다.

children = work_item.work_item_children.where.not(work_item_type_id: epic_type_id)
keyset_order.apply_cursor_conditions(children.includes(:parent_link)).reorder(keyset_order)

work_item.work_item_children는 has_many :through 연관 관계 프록시입니다. ActiveRecord가 스코프를 빌드할 때 joins_values에 Arel::Nodes::LeadingJoin 노드를 주입합니다. 그러면 이 노드가 includes()가 반환한 릴레이션을 오염시킵니다. 이후 프리로더가 해당 릴레이션에서 has_many :through 연관 관계를 처리할 때 ActiveRecord::ConfigurationError: Arel::Nodes::LeadingJoin is not supported for visitor_for를 발생시킵니다.

해결책#

연관 관계 프록시가 아니라 클래스 수준에서 쿼리를 빌드합니다.

epic_type_id = ::WorkItems::TypesFramework::Provider.new(work_item.namespace).find_by_base_type(:epic).id
keyset_order = ::WorkItem.work_item_children_keyset_order_config

keyset_order.apply_cursor_conditions(
  WorkItem.joins(:parent_link).where.not(work_item_type_id: epic_type_id)
).reorder(keyset_order)

연관 관계 프록시가 아닌 클래스에서 joins(:parent_link)를 사용하면 Arel::Nodes::LeadingJoin 노드가 들어가지 않습니다. 이 쿼리는 ActiveRecord 프리로더와 계속 호환됩니다.

자세한 내용은 머지 리퀘스트 231854를 참고합니다.