🧠

NestJS × GraphQL × gRPCにおけるN+1問題とDataloaderの活用

に公開

NestJS × GraphQLで開発していると、N+1問題に直面する場面が多くあります。
特に、ユーザー情報などを別のマイクロサービスからgRPCで取得する場合、ネットワーク越えが発生するため、この問題がさらに深刻になります。

本記事では、以下の内容を解説します。

  • Dataloaderとは何か
  • N+1問題の仕組みと例
  • GraphQLでのリゾルバ構造
  • gRPCを使う場合に注意すべきこと
  • Dataloaderを使った解決方法(実装例あり)

🔍 Dataloaderとは?

Dataloader は、Facebookが開発したバッチ処理ユーティリティライブラリです。
GraphQLでよく発生する「同じフィールドに対しての繰り返しアクセス」をバッチ化し、リクエストの最適化・パフォーマンス向上が可能です。

✅ 特徴

  • 同一リクエスト内での重複読み込みをバッチ化
  • 順序を保持して戻す
  • .load()で個別に呼び出しても、内部でまとめて処理される

❗ N+1問題とは?

「N+1問題」とは、1回のリクエストに対して1つの親データ取得に加えて、各親に対してN回の子データ取得が行われる問題です。

例:投稿と著者

query {
  posts {
    id
    title
    author {
      id
      name
    }
  }
}

このGraphQLクエリに対して以下のようなResolverを組んでしまうと…

@ResolveField(() => User)
async author(@Parent() post: Post) {
  return this.userService.findById(post.authorId); // N回実行される
}

投稿が10件あれば、userService.findByIdが10回実行されてしまいます。
DBアクセスやAPIコールが多発し、パフォーマンスが大きく劣化します。

🏗NestJS × GraphQLの構成とデコレーター

デコレーター 説明
@Query(() => [Post]) GraphQLのpostsクエリを定義
@ResolveField(() => User) Post型の中のauthorフィールドを解決するリゾルバ
@Parent() 親オブジェクト(ここではPost)を取得
GraphQLは「必要なデータだけを取得する」ことがメリットですが、それにより裏側では複数のデータ取得処理が走る構造になりがちです。

💬SQLならJOINで解決できるけど…

例えばRDBであれば以下のようにJOINで一発取得が可能です。

SELECT posts.*, users.*
FROM posts
JOIN users ON posts.author_id = users.id;
ORMでも以下のようにincludeを使えば解決できます。
prisma.post.findMany({
  include: {
    author: true
  }
});

しかし、GraphQLのようにリゾルバがフィールドごとに独立して定義されている場合、このJOIN的なアプローチが使いづらくなり、N+1が発生しやすくなります。

⚡gRPCを使っているときのN+1のヤバさ

今回の構成では、投稿の著者情報を別マイクロサービスからgRPCで取得しています。

❌悪い例:投稿ごとにgRPC呼び出し

@ResolveField(() => User)
async author(@Parent() post: Post) {
  return this.userClient.getUserById(post.authorId); // N回 gRPC 呼び出し
}

投稿が10件あれば、gRPCで10回サービス間通信が発生します。

gRPCはDBアクセスより通信コストが高い

サービス間通信が直列に走ると遅延が増大

スケーラビリティや安定性にも悪影響

✅DataloaderでgRPCをバッチ化する

gRPCでもDataloaderを使えば、リクエストをバッチ化して1回のgRPC通信にまとめることができます。

📦UserLoaderの実装(gRPC用)

@Injectable({ scope: Scope.REQUEST })
export class UserLoader {
  constructor(private readonly userClient: UserGrpcClient) {}

  readonly batchUsers = new DataLoader(async (userIds: readonly string[]) => {
    const users = await this.userClient.getUsersByIds(userIds as string[]); // gRPCで一括取得
    const userMap = new Map(users.map(u => [u.id, u]));
    return userIds.map(id => userMap.get(id));
  });
}

📍Resolver側での利用

@ResolveField(() => User)
async author(@Parent() post: Post) {
  return this.userLoader.batchUsers.load(post.authorId);
}

これにより、投稿者情報が一括で取得されるようになります。

✅まとめ

観点 内容
GraphQLの構造 フィールド単位のリゾルバでN+1が起きやすい
gRPC連携 DBよりも高コスト。N回のサービス間通信は致命的
解決策 Dataloaderを導入してバッチ処理にする
メリット パフォーマンス改善、レイテンシ削減、ネットワーク負荷軽減
🔧 補足リンク
Dataloader GitHub

NestJS公式 GraphQLドキュメント

gRPC in NestJS

📝おわりに

GraphQL × Microservice構成では、N+1問題は避けて通れない壁です。
NestJSとgRPCを組み合わせる場合は特に、Dataloaderを用いたバッチ処理による最適化をぜひ導入してみてください。

質問や補足などあれば、コメントで気軽にどうぞ!## 🧩 Dataloaderとは?

Dataloader は、GraphQLのリゾルバが引き起こすN+1問題を解決するためのユーティリティライブラリです。

  • 同じフィールドへの複数アクセスをバッチ化
  • データ取得をまとめて実行してパフォーマンスを最適化
// 投稿ごとにauthor情報を取得(悪い例)
@ResolveField(() => User)
async author(@Parent() post: Post) {
  return this.userService.findById(post.authorId); // N回実行される
}

Discussion