LESSON 40分

ストーリー

高橋アーキテクト
GraphQLにはダークサイドがある

高橋アーキテクトがデータベースのクエリログを見せた。 画面に表示された SQL は100行以上に及んでいた。

高橋アーキテクト
10件のタスクを取得しただけなのに、SQLが111回も実行されている。なぜだと思う?
あなた
タスクごとに担当者やラベルを取得しているから…ですか?
高橋アーキテクト
その通り。これが N+1問題 だ。GraphQLでは特に起きやすい。でも解決策がある。DataLoader というパターンだ

N+1問題とは

問題の発生メカニズム

# このクエリを実行すると...
query {
  tasks(projectId: "proj_123", limit: 10) {
    id
    title
    assignee {    # ← タスクごとにユーザーを取得
      name
    }
    labels {      # ← タスクごとにラベルを取得
      name
    }
  }
}
// リゾルバーの実行順序

// 1回目: タスク一覧を取得
SELECT * FROM tasks WHERE project_id = 'proj_123' LIMIT 10;
// → 10件のタスクが返る

// 2〜11回目: 各タスクの担当者を取得(10回)
SELECT * FROM users WHERE id = 'usr_1';
SELECT * FROM users WHERE id = 'usr_2';
SELECT * FROM users WHERE id = 'usr_3';
// ... 以下、タスクの数だけ繰り返し(N回)

// 12〜21回目: 各タスクのラベルを取得(10回)
SELECT * FROM labels JOIN task_labels ON ... WHERE task_id = 'tsk_1';
SELECT * FROM labels JOIN task_labels ON ... WHERE task_id = 'tsk_2';
// ... 以下、タスクの数だけ繰り返し(N回)

// 合計: 1 + N + N = 1 + 10 + 10 = 21クエリ
// タスクが100件なら: 1 + 100 + 100 = 201クエリ

なぜ起きるのか

// フィールドリゾルバーは「各アイテムごとに独立して」実行される
const resolvers = {
  Task: {
    // このリゾルバーはタスクの数だけ呼ばれる
    assignee: async (task: Task) => {
      // タスクごとに1回ずつDBクエリが発行される
      return await db.users.findById(task.assigneeId);
    },
  },
};

DataLoaderによる解決

DataLoaderの仕組み

// DataLoader: 複数のリクエストをバッチ処理する
// 同一のイベントループ内で発生した複数のロード要求を
// 1回のバッチリクエストにまとめる

import DataLoader from 'dataloader';

// ユーザーを一括取得するDataLoader
const userLoader = new DataLoader<string, User>(async (userIds) => {
  // 個別のクエリではなく、IN句で一括取得
  // SELECT * FROM users WHERE id IN ('usr_1', 'usr_2', 'usr_3', ...)
  const users = await db.users.findByIds([...userIds]);

  // DataLoaderの要件: 入力の順序と同じ順序で結果を返す
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id) || new Error(`User ${id} not found`));
});

// リゾルバーでの使用
const resolvers = {
  Task: {
    assignee: async (task: Task, _args: unknown, context: Context) => {
      // load() は即座にDBクエリを発行しない
      // 同一ティック内のload()をまとめてバッチ処理する
      return context.loaders.user.load(task.assigneeId);
    },
  },
};

// 結果:
// 10件のタスクに対して assignee が呼ばれても
// DBクエリは1回だけ:
// SELECT * FROM users WHERE id IN ('usr_1', 'usr_2', ..., 'usr_10')

DataLoaderの作成パターン

// DataLoaderファクトリ
function createLoaders() {
  return {
    user: new DataLoader<string, User>(async (ids) => {
      const users = await userRepository.findByIds([...ids]);
      const map = new Map(users.map(u => [u.id, u]));
      return ids.map(id => map.get(id) || new Error(`User not found: ${id}`));
    }),

    tasksByProject: new DataLoader<string, Task[]>(async (projectIds) => {
      const tasks = await taskRepository.findByProjectIds([...projectIds]);
      const grouped = groupBy(tasks, t => t.projectId);
      return projectIds.map(id => grouped.get(id) || []);
    }),

    labelsByTask: new DataLoader<string, Label[]>(async (taskIds) => {
      const relations = await db.taskLabels.findByTaskIds([...taskIds]);
      const labelIds = [...new Set(relations.map(r => r.labelId))];
      const labels = await labelRepository.findByIds(labelIds);
      const labelMap = new Map(labels.map(l => [l.id, l]));

      const grouped = new Map<string, Label[]>();
      for (const rel of relations) {
        const taskLabels = grouped.get(rel.taskId) || [];
        const label = labelMap.get(rel.labelId);
        if (label) taskLabels.push(label);
        grouped.set(rel.taskId, taskLabels);
      }

      return taskIds.map(id => grouped.get(id) || []);
    }),
  };
}

// Apollo Serverのcontext設定
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

// リクエストごとに新しいDataLoaderを作成(キャッシュの分離)
const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    currentUser: await authenticate(req),
    loaders: createLoaders(),  // リクエストごとに新規作成
  }),
});

DataLoaderの効果

Before(N+1問題あり)

タスク10件のクエリ数:
  タスク一覧: 1
  担当者取得: 10(各タスクごと)
  ラベル取得: 10(各タスクごと)
  合計: 21クエリ

タスク100件の場合: 201クエリ

After(DataLoader使用)

タスク10件のクエリ数:
  タスク一覧: 1
  担当者取得: 1(IN句でバッチ取得)
  ラベル取得: 1(IN句でバッチ取得)
  合計: 3クエリ

タスク100件の場合: 3クエリ(同じ!)

クエリの複雑さ制限

// 悪意のあるクエリや深すぎるネストを防ぐ

import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5),  // ネストの深さを5階層まで
    createComplexityLimitRule(1000),  // クエリの複雑さを制限
  ],
});

// これにより以下のような攻撃的クエリをブロック
// query {
//   tasks {
//     assignee {
//       tasks {
//         assignee {
//           tasks { ... }  // 無限ネスト攻撃
//         }
//       }
//     }
//   }
// }

まとめ

ポイント内容
N+1問題フィールドリゾルバーがアイテムごとに独立してDBクエリを発行する問題
DataLoader複数のload()をバッチ処理し、1回のDBクエリにまとめる
キャッシュDataLoaderはリクエスト内でキャッシュし、同じIDの重複取得を防ぐ
リクエスト分離DataLoaderはリクエストごとに新規作成する
複雑さ制限depthLimit, complexityLimit で攻撃的クエリを防ぐ

チェックリスト

  • N+1問題が発生するメカニズムを説明できる
  • DataLoaderの仕組み(バッチ処理とキャッシュ)を理解した
  • DataLoaderの実装パターンを把握した
  • クエリの複雑さ制限の必要性を理解した

次のステップへ

N+1問題とDataLoaderを学びました。

次のセクションでは、RESTとGraphQLの使い分けについて学びます。 どちらが優れているかではなく、どの場面でどちらを選ぶべきかを理解しましょう。


推定読了時間: 40分