NestJSとDataLoaderでGraphQLのN+1問題を解決する

に公開

はじめに

GraphQLを使うと柔軟なAPIを構築できますが、適切に実装しないとパフォーマンスの問題に直面することがあります。特に有名なのが「N+1問題」です。この記事では、NestJSとDataLoaderを使ってこの問題をエレガントに解決する方法を解説します。

N+1問題とは?

N+1問題は以下のような状況で発生します:

  1. 親エンティティのリストを取得する(1回のクエリ)
  2. そのリスト内の各エンティティについて、関連するデータを個別に取得する(N回のクエリ)

例えば、10人のユーザーを取得し、各ユーザーの国情報を取得する場合、データベースへのクエリが合計11回発行されてしまいます。

DataLoaderでの解決策

DataLoaderはFacebookが開発したユーティリティで、以下の機能でN+1問題を解決します:

  1. バッチ処理: 複数のIDに対するリクエストをまとめて一度に実行
  2. キャッシュ: 同じIDに対するリクエストをキャッシュ
  3. 重複排除: 同一リクエスト内で重複するIDをまとめる

実装例

1. DataLoaderの実装

@Injectable({ scope: Scope.REQUEST })
export class CountryLoader {
  readonly #repository: ICountryRepository

  constructor(@Inject(ICountryRepository) repository: ICountryRepository) {
    this.#repository = repository
  }

  public readonly batchCountries = new DataLoader(async (keys: number[]) => {
    const countries = await this.#repository.findByIds({ ids: keys })
    const countriesMap = new Map(countries.map((country) => [country.id, country]))
    return keys.map((key) => countriesMap.get(key))
  })
}

2. UseCaseでの利用

export class CountryGetUseCase {
  readonly #loader: CountryLoader

  constructor(@Inject(CountryLoader) loader: CountryLoader) {
    this.#loader = loader
  }

  async handle(input: CountryGetInput): Promise<CountryGetOutput | undefined> {
    const country = await this.#loader.batchCountries.load(input.countryId)
    return country
  }
}

詳細解説

なぜkeysが配列なのか?

DataLoaderはバッチ処理のために設計されています。load(id)が呼ばれると:

  1. IDはキューに追加される
  2. JavaScriptのイベントループの次のティックまでデータ取得は遅延される
  3. その間に他のload()呼び出しがあれば、それらのIDも同じキューに追加される
  4. 最終的に全てのIDをまとめて一度に処理する

つまり、単一のcountryIdでも、フレームワーク内で複数のリクエストが発生すれば自動的にバッチ処理されるのです。

batchCountriesの処理の流れ

public readonly batchCountries = new DataLoader(async (keys: number[]) => {
  // 1. リポジトリから複数IDのデータを一度に取得
  const countries = await this.#repository.findByIds({ ids: keys })
  
  // 2. 高速検索のためのマップを作成
  const countriesMap = new Map(countries.map((country) => [country.id, country]))
  
  // 3. 元のkeysと同じ順序で結果を返す
  return keys.map((key) => countriesMap.get(key))
})

処理を詳しく見ていくと:

  1. バッチ取得: keys配列内の全IDを一度のクエリで取得
  2. マッピング: ID→オブジェクトのMapを作成(O(1)の検索のため)
  3. 整列: 元のリクエスト順に合わせてデータを並べ替え
  4. 欠損処理: データベースに存在しないIDはundefinedとして返される

例えばkeys = [5, 10, 3]だった場合:

  • findByIds({ ids: [5, 10, 3] })で一度にデータ取得
  • 結果は[国データ(5), 国データ(10), 国データ(3)]の順で返される

スコープの重要性

@Injectable({ scope: Scope.REQUEST })

DataLoaderはリクエストスコープで作成されています。これにより:

  1. リクエスト内でキャッシュが共有される
  2. リクエスト間でキャッシュが混ざらない
  3. 各リクエストで独立したバッチ処理が行われる

まとめ

DataLoaderを使うことで、GraphQLのN+1問題を効率的に解決できます。このパターンは:

  1. パフォーマンスを大幅に改善
  2. コードの可読性を維持
  3. データ取得ロジックをカプセル化

特にユーザー数が多いアプリケーションでは、この最適化が大きな違いを生み出します。NestJSとDDDパターンを使った設計と組み合わせることで、保守性の高いGraphQLアプリケーションを構築できるでしょう。

スペースマーケット Engineer Blog

Discussion