https://zenn.dev/alea12/articles/15d73282c3aacc

TLDR

GraphQL における N+1 問題

GraphQL で 1:N のデータ構造をクエリすると、すぐに N+1 問題に行き当たります。UserPost が 1:N の関係となる、以下の例を見てみましょう。

type User {
  id: Int!
  name: String!
  posts: [Post!]!
}

type Post {
  id: Int!
  title: String!
  user: User!
}

type Query {
  users: [User!]!
}

Apollo Server と TypeORM を使って素朴に Resolver を実装すると、以下のようになります。

export const resolvers: Resolvers = {
  User: {
    posts: async (parent: User) => await postsOfUser(parent),
  },

  Query: {
    users: async () => await users(),
  },
}

const postsOfUser = async (user: User): Promise<Post[]> => {
  const postRepository = getConnection().getRepository(Post)
  return await postRepository.find({ where: { user } })
}

const users = async (): Promise<User[]> => {
  const userRepository = getConnection().getRepository(User)
  return await userRepository.find()
}

この実装に対し、以下のようなクエリを実行することを考えます。

query ExampleQuery {
  users {
    id
    name
    posts {
       id
       title
    }
  }
}

すると、Resolver はまず users() を実行して User を全件取得します。その後、それぞれの user に対して postsOfUser(user) を実行します。その結果、データベースへのアクセスは以下のようになります。

本例では User が 5 件登録されていましたが、ユーザーを全件取得するために 1 回、それぞれの User に対して posts を取得するために各 1 回、合計 6 回の SELECT が発生しました。これが N+1 問題です[1]

データ量が少ないうちは問題に気付きにくいですが、データ量が増えた場合や、1:N が多重階層となった場合 (例えば上記に加え Post:Comment = 1:N となる Comment を考える場合) などでは、パフォーマンスに与える影響が爆発的に大きくなります。

幸い、この N+1 問題を回避する方法はいくつかあります。上記の例を ベースケース として、それぞれの解決方法を紹介します。

方法1: DataLoader

Facebook により提供されている DataLoader というライブラリを使う方法です。DataLoader には Batching と呼ばれる機能があり、同一テーブルに対する複数の SELECT を 1 本にまとめてくれます。これを使うと、 Resolver の実装は以下のようになります。

export const resolvers: Resolvers = {
  User: {
    posts: async (parent: User) => await postsLoader.load(parent.id),
  },

  Query: {
    users: async () => await users(),
  },
}

const users = async (): Promise<User[]> => {
  const userRepository = getConnection().getRepository(User)
  return await userRepository.find()
}

const postsLoader = new DataLoader(async (keys): Promise<Post[][]> => {
  const postRepository = getConnection().getRepository(Post)
  const posts = await postRepository.find({ user: { id: In(keys as number[]) } })
  return keys.map((userId) => posts.filter((post) => post.userId! === userId))
})