👐

NestJSのGraphQLで"ローダーって何?"をちゃんと理解する

に公開

本記事のサマリ

正直、DataLoaderなんてなくても問題ないし、かえって実装をややこしくするだけじゃないか、と思っていました。でも実際に検証してみると、既存のServiceクラスをラップするだけで11回のクエリが2回に削減される様子を目の当たりにして、考えが変わりました。部分的にでも使ってみる価値はあるかもしれない、と。この記事では、通常実装とDataLoader実装の違いを明確にしながら、その実態を確認していきます。

今回検証したリポジトリは下記になります。
https://github.com/toto-inu/lab-202511-nestjs-dataloader

N+1問題って結局何なの?

まず、N+1問題について整理しておきたいと思います。これは特にGraphQLでよく発生する問題で、「データを取得するたびに余計なクエリが走ってしまう」現象です。

具体例で見てみましょう。ユーザー10人のプロフィール情報を取得するGraphQLクエリを考えてみます:

query {
  users {
    id
    name
    profile {
      bio
      avatar
    }
  }
}

このクエリを実行すると、内部的には以下のような流れでデータが取得されます:

  1. まず users を取得(1回のクエリ)
  2. 各ユーザーの profile を個別に取得(10回のクエリ)

つまり、合計で11回のデータベースクエリが実行されることになります。これがN+1問題の典型例で、Nは取得したいレコードの数(この場合は10)、+1は最初の一覧取得クエリを指しています。

GraphQLのフィールドリゾルバーは基本的に「必要になったときに実行される」仕組みなので、このような問題が起こりやすいのが特徴です。

N+1問題の実害はどの程度なのか

「まあ、多少クエリが多くても動くんじゃない?」と思われるかもしれませんが、これが実際の運用で与える影響は想像以上に大きいです。

パフォーマンス面での影響

データベースへの接続には一定のオーバーヘッドがあります。1回のクエリで済む処理が11回に分かれることで:

  • ネットワーク遅延が11倍
  • データベースの接続プールが圧迫
  • CPUの処理負荷が増大

特に、ユーザー数が100人、1000人と増えていくと、この影響は指数関数的に大きくなります。レスポンス時間が数秒かかるようになり、UXが著しく悪化します。

運用面での課題

開発環境では気づきにくいのも、この問題の厄介な点です。少量のテストデータでは問題が表面化せず、本番環境でいきなりパフォーマンス問題として顕在化することがよくあります...

また、GraphQLの柔軟性が裏目に出ることもあります。フロントエンド側でクエリを変更するだけで、意図せずN+1問題を引き起こしてしまうケースも少なくありません。

DataLoaderによる解決アプローチ

ここで登場するのがDataLoaderです。FacebookがGraphQLと一緒に開発したライブラリで、N+1問題を解決するためのパターンを実装したものです。

DataLoaderの基本的な仕組みはシンプルで、「個別のリクエストを一時的に溜め込んで、まとめて処理する」(要するにバッチ処理)というものです。

先ほどの例で言うと:

  1. 各ユーザーのプロフィール取得リクエストを一旦保留
  2. すべてのリクエストが揃ったタイミングで、一括取得用のクエリを実行
  3. 結果を各リクエストに振り分けて返す

これにより、11回のクエリが2回(ユーザー一覧 + プロフィール一括取得)に削減されます。

通常実装とDataLoader実装の違い

ここが理解のポイントなんですが、DataLoaderは「Serviceの代わり」ではありません。ServiceをラップしてバッチDB操作を可能にする、という位置づけです。

通常実装の動き

通常の実装では、リゾルバーからServiceを直接呼び出します:

@ResolveField(() => Profile)
async profile(@Parent() user: User) {
  // 各ユーザーごとに即座に実行される
  return this.profileService.findByUserId(user.id);
}

GraphQLのフィールドリゾルバーは「必要になったら即座に実行」される仕組みなので、10人のユーザーがいれば、このメソッドが10回呼ばれ、その都度 findByUserId() が実行されます。つまり:

// ProfileService(通常実装)
async findByUserId(userId: number): Promise<Profile> {
  // 個別にクエリを実行
  return this.prisma.profile.findUnique({
    where: { userId }
  });
}

この実装だと、「呼ばれたらすぐDB問い合わせ」なので、10回呼ばれたら10回DBアクセスが発生します。

DataLoader実装の動き

DataLoaderを使う場合、リゾルバーは少し変わります:

@ResolveField(() => Profile)
async profile(@Parent() user: User) {
  // 即座には実行されず、一旦「保留」される
  return this.profileDataLoader.load(user.id);
}

loader.load(id) を呼んでも、この時点ではまだDBアクセスは発生しません。DataLoaderは内部的にリクエストを溜め込んでいるだけです。

そして、JavaScriptのイベントループが次のタイミングに進む際に、溜め込んだリクエストをまとめて処理します:

// ProfileDataLoader(Serviceをinjectして利用)
@Injectable({ scope: Scope.REQUEST })
export class ProfileDataLoader {
  private readonly loader: DataLoader<number, Profile>;

  constructor(private profileService: ProfileService) {
    this.loader = new DataLoader<number, Profile>(
      (userIds) => this.batchLoad(userIds)
    );
  }

  async load(userId: number): Promise<Profile> {
    return this.loader.load(userId);
  }

  private async batchLoad(userIds: readonly number[]): Promise<Profile[]> {
    // 溜め込まれた複数のIDをまとめて処理
    // ここでServiceの一括取得メソッドを呼ぶ
    return this.profileService.findByUserIds([...userIds]);
  }
}

このとき呼ばれるServiceのメソッドは、個別取得ではなく一括取得用のメソッドです:

// ProfileService(DataLoader実装用)
@Injectable()
export class ProfileService {
  constructor(private prisma: PrismaService) {}

  // 通常実装用:個別取得
  async findByUserId(userId: number): Promise<Profile> {
    return this.prisma.profile.findUnique({
      where: { userId }
    });
  }

  // DataLoader用:IN句で一括取得
  async findByUserIds(userIds: number[]): Promise<Profile[]> {
    return this.prisma.profile.findMany({
      where: { userId: { in: userIds } }
    });
  }
}

つまり、DataLoaderの実装では:

  • リゾルバー:Serviceを直接呼ばず、loader.load() を使う
  • Service:個別取得メソッド(findById)に加えて、一括取得メソッド(findByIds)を用意する
  • DataLoader:Serviceをinjectして、その一括取得メソッドをラップし、タイミングを制御する

実行タイミングの違いが鍵

通常実装とDataLoader実装の決定的な違いは、実行タイミングです。

通常実装:

User1のprofileフィールド解決 → 即座にDB問い合わせ
User2のprofileフィールド解決 → 即座にDB問い合わせ
User3のprofileフィールド解決 → 即座にDB問い合わせ
...

DataLoader実装:

User1のprofileフィールド解決 → load(1) → 保留
User2のprofileフィールド解決 → load(2) → 保留
User3のprofileフィールド解決 → load(3) → 保留
...
(イベントループの次のタイミング)
→ まとめてDB問い合わせ(WHERE user_id IN (1,2,3,...))

GraphQLの「フィールドを逐次解決する」性質はそのままに、その解決処理の実行タイミングだけを遅延させて束ねるのがDataLoaderの仕組みです

実装で押さえるべきポイント

通常実装からDataLoader実装に移行する際、具体的に何を変更・追加する必要があるのかを整理します。

1. Serviceに一括取得メソッドを追加

通常実装では個別取得メソッドだけで済んでいましたが、DataLoader用に一括取得メソッドを追加します:

// ProfileService
async findByUserId(userId: number): Promise<Profile> {
  // 通常実装用:個別取得
  return this.prisma.profile.findUnique({ where: { userId } });
}

async findByUserIds(userIds: number[]): Promise<Profile[]> {
  // DataLoader用:IN句で一括取得
  return this.prisma.profile.findMany({
    where: { userId: { in: userIds } }
  });
}

既存のメソッドはそのまま残しておけるので、段階的な移行も可能です。

2. DataLoaderクラスを作成

DataLoaderクラスは、Serviceをラップしてバッチ処理を実現します。重要なのは Scope.REQUEST で管理することです:

@Injectable({ scope: Scope.REQUEST })
export class ProfileDataLoader {
  private readonly loader: DataLoader<number, Profile>;

  constructor(private profileService: ProfileService) {
    // DataLoaderインスタンスを初期化
    this.loader = new DataLoader<number, Profile>(
      (userIds) => this.batchLoad(userIds)
    );
  }

  async load(userId: number): Promise<Profile> {
    return this.loader.load(userId);
  }

  private async batchLoad(userIds: readonly number[]): Promise<Profile[]> {
    const profiles = await this.profileService.findByUserIds([...userIds]);
    // ⚠️ 順序の保証が必須
    return userIds.map(id => profiles.find(p => p.userId === id));
  }
}

リクエストスコープにすることで、リクエストごとに新しいインスタンスが作られ、キャッシュが適切に管理されます。

また、batchLoad メソッドでは、結果を元のリクエスト順序に合わせて返す必要があります。DataLoaderは内部で複数のリクエストを追跡しているため、順序が狂うと間違ったデータが返ってしまいます。

3. リゾルバーを修正

リゾルバーでは、Serviceの直接呼び出しをDataLoaderに置き換えます:

@ResolveField(() => Profile)
async profile(@Parent() user: User) {
  // 通常: return this.profileService.findByUserId(user.id);
  // DataLoader: loader経由で呼び出す
  return this.profileDataLoader.load(user.id);
}

GraphQLクエリ自体は変わりませんし、返すデータの形式も同じです。変わるのは「どうやってデータを取得するか」だけです。

検証結果:実装の違いによる効果を確認

実際に検証ラボを構築して、通常実装とDataLoader実装の動作を比較してみました。面白いのは、まったく同じGraphQLクエリを実行しているのに、バックエンドの実装だけで結果が変わる点です。

通常実装の場合(DataLoaderなし)

📋 UserService.findAll() called
🔍 Database Query: SELECT * FROM users LIMIT 10

❌ ProfileService.findByUserId(1) - N+1 Query!
🔍 Database Query: SELECT * FROM profiles WHERE user_id = 1

❌ ProfileService.findByUserId(2) - N+1 Query!
🔍 Database Query: SELECT * FROM profiles WHERE user_id = 2

... (これが10回繰り返される)

合計: 11回のデータベースクエリ

DataLoader実装の場合

📋 UserService.findAll() called
🔍 Database Query: SELECT * FROM users LIMIT 10

🚀 DataLoader batch function called
   Batching 10 profile requests into 1 query

✅ ProfileService.findByUserIds([1,2,3,4,5,6,7,8,9,10]) - Optimized!
🔍 Database Query: SELECT * FROM profiles WHERE user_id IN (1,2,3,4,5,6,7,8,9,10)

合計: 2回のデータベースクエリ(82%削減)

何が変わって、何が変わらないのか

改めて整理すると:

変わらないもの:

  • GraphQLクエリ(フロントエンド側のコード)
  • 返されるデータの形式
  • リゾルバーの基本構造

変わるもの:

  • Serviceに一括取得メソッドを追加
  • DataLoaderクラスを新規作成
  • リゾルバーでの呼び出し方(Service直接 → loader.load())
  • 実行タイミング(即座実行 → 一旦保留してまとめて実行)

つまり、「GraphQLの使い方」自体は何も変えずに、バックエンドの実装だけでパフォーマンスが大幅に改善されるわけです。これがDataLoaderの優れた点だと思います。

運用時の考慮点

実際にDataLoaderを導入する際に気をつけておきたい点もいくつかあります。

メモリ使用量については、リクエストスコープで管理されているため、通常は大きな問題にはなりません。ただし、非常に大量のデータを一度に処理する場合は、バッチサイズの調整を検討する必要があるかもしれません。

また、DataLoaderはキャッシュ機能も持っているため、同一リクエスト内で同じデータが複数回要求されても、実際のデータベースアクセスは1回だけに抑えられます。

エラーハンドリングについても考慮が必要です。バッチ処理の中で一部のデータ取得に失敗した場合の処理を、適切に設計しておく必要があります。

まとめ

DataLoaderは、GraphQLでのN+1問題を解決する実用的なソリューションです。今回の検証では、11回のクエリを2回に削減できることを確認しました。

実装自体はそれほど複雑ではありませんが、リクエストスコープでの管理やバッチ処理の順序保証など、抑えるべきポイントはいくつかあります。特に、NestJSの依存性注入システムとうまく統合することで、既存のコードを大幅に変更することなく導入できるのは大きなメリットだと感じました。

GraphQLを使ったAPIを運用している方は、一度DataLoaderの導入を検討してみることをお勧めします。パフォーマンスの改善はもちろん、データベースへの負荷軽減という観点からも、システムの健全性向上に寄与するはずです。

詳細な実装コードや動作検証の手順についてはリポジトリで公開していますので、実際に手を動かして確認してみてください。

株式会社StellarCreate | Tech blog📚

Discussion