Claude Media
Claude CodeでDataLoaderを使いGraphQLのN+1を解消 — クエリ数をテストで固定

Claude CodeでDataLoaderを使いGraphQLのN+1を解消 — クエリ数をテストで固定

GraphQLのリゾルバごとに起きるN+1を、DB呼び出し回数を数えるテストで先に赤にし、DataLoaderのバッチ化で緑にする手順と、Stop hookで再発を止める設定です。

DataLoaderは、同じタイミングで出た個別の取得要求を1回のバッチ呼び出しにまとめるユーティリティです。GraphQLサーバーのN+1を直す定番ですが、入れただけでは「減った」と言えません。Claude Codeに任せるなら、DB呼び出し回数を数えるテストを先に置き、赤から緑への変化で確かめる順序にします。

この記事では、投稿と著者を返すGraphQLサーバー(Node.js、graphql-js、テストはVitest)を例に、次の流れを組みます。

  1. リゾルバ単位のDB呼び出しを数えるテストを書き、N+1の状態で失敗させる
  2. DataLoaderのバッチ関数に置き換えて緑にする
  3. 順序のずれやキャッシュの混線を、別のテストで検出する
  4. Stop hookで、テストが赤のままClaudeが終わらないようにする

コードは公式READMEの仕様に沿って組んだ例で、そのまま動く完成品ではなく、スキーマやDB層は手元のものに置き換えてください。

GraphQLのN+1はリゾルバ単位で起きる

GraphQLでは、フィールドごとに単機能のリゾルバを書けます。graphql.orgのパフォーマンス解説は、この作りのまま何も工夫しないと、サーバーが何度もデータソースに問い合わせる状態になると説明しています。同ページの例では、ヒーロー1件、その友人の一覧1回、友人ごとの宇宙船N回、という形でリクエストが増えます。

次のクエリで考えます。

{
  posts {
    title
    author { name }
  }
}

posts のリゾルバがDBを1回呼び、各投稿の author リゾルバが投稿ごとにDBを呼ぶ実装だと、投稿30件で31回になります。ORMの遅延読み込みと違い、GraphQLではクライアントが選んだフィールドによって呼び出し回数が変わります。コードを眺めても、どのクエリで増えるかは分かりません。

ORM側のN+1を数えて直す手順は、Claude CodeでN+1クエリを見つけて直すにあります。ここで扱うのは、リゾルバごとに独立してDBを呼ぶ構造そのものを、DataLoaderでまとめる方法です。

先にテストを置く — 件数を変えて呼び出し回数を比べる

DataLoaderを入れる前に、現状を数字で固定します。best-practicesは、Claudeに「合否が返ってくる確認手段」を渡すと、変更、実行、失敗の読み取り、再実行を自分で回せると説明しています。テストはその確認手段になります。

まず、呼び出しを記録するだけのDB層を用意します。

// test/helpers/counting-db.ts(例)
export function createCountingDb(seed: Seed) {
  const calls: string[] = [];
  return {
    calls,
    async posts() {
      calls.push("posts");
      return seed.posts;
    },
    async authorById(id: string) {
      calls.push("authorById");
      return seed.authors.find((a) => a.id === id);
    },
    async authorsByIds(ids: string[]) {
      calls.push("authorsByIds");
      return seed.authors.filter((a) => ids.includes(a.id));
    },
  };
}

次に、同じクエリを投稿3件と30件で実行し、DB呼び出しの回数を比べます。

// test/query-count.test.ts(例)
import { graphql } from "graphql";
import { schema } from "../src/schema";
import { createContext } from "../src/context";
import { createCountingDb } from "./helpers/counting-db";
 
const source = `{ posts { title author { name } } }`;
 
async function run(postCount: number) {
  const db = createCountingDb(makeSeed({ posts: postCount, authors: 5 }));
  const result = await graphql({
    schema,
    source,
    contextValue: createContext(db),
  });
  expect(result.errors).toBeUndefined();
  return db.calls;
}
 
it("posts一覧のDB呼び出しは件数に比例しない", async () => {
  const small = await run(3);
  const large = await run(30);
  expect(large.length).toBe(small.length);
  expect(large.length).toBeLessThanOrEqual(2);
});

N+1の実装では、3件で4回、30件で31回になるため、toBe の行で落ちます。上限だけでなく「件数を変えても回数が同じ」を検証するのがポイントです。上限の数字は、いつかの機能追加で緩められがちですが、件数への比例は構造の問題なので誤魔化せません。

落とし穴

著者が全員違うテストデータだけでは、重複キーの扱いを通らない

上の例は著者を5人に固定し、投稿30件を著者に割り振っています。同じ著者が何度も出るため、DataLoaderのキャッシュも効く状況になります。著者が全員違うデータだけで確かめると、バッチ化の効果は見えても、同じキーを複数回 load する経路を通っていません。投稿の著者が重なるデータと重ならないデータの両方を、シードで切り替えられるようにしておくと安心です。

DataLoaderで直す — バッチ関数の制約とリクエスト単位の生成

DataLoaderの使い方は、バッチ関数を渡して load(key) を呼ぶだけです。READMEによれば、同じ1回のイベントループのtick内に出た load は、まとめて1回のバッチ関数呼び出しになります。

バッチ関数には2つの制約があります。値の配列はキーの配列と同じ長さにすること、各インデックスのキーと値が対応していることです。DBが順番を変えて返したり、存在しないキーの行を落としたりしても、バッチ関数の側で並べ直す必要があります。

// src/loaders.ts(例)
import DataLoader from "dataloader";
 
export function createLoaders(db: Db) {
  return {
    author: new DataLoader<string, Author>(async (ids) => {
      const rows = await db.authorsByIds([...ids]);
      const byId = new Map(rows.map((r) => [r.id, r]));
      return ids.map(
        (id) => byId.get(id) ?? new Error(`author ${id} not found`),
      );
    }),
  };
}
 
// src/context.ts(例)
export function createContext(db: Db) {
  return { db, loaders: createLoaders(db) };
}

著者から投稿一覧を返す向き(1対多)でも制約は同じで、キーごとの値を配列にして、キーの数だけ返します。キーに該当する行が0件なら、そのインデックスは空配列にします。

リゾルバは db を直接呼ばず、ローダーを経由します。

// src/schema.ts のリゾルバ(例)
Post: {
  author: (post, _args, ctx) => ctx.loaders.author.load(post.authorId),
},

もう1つの要点は、ローダーをリクエストごとに作ることです。READMEは、各DataLoaderインスタンスが1つのキャッシュを持つこと、ユーザーごとに見えるものが違うWebサーバーでは、リクエスト単位で作ることを勧めています。上の createContext は、リクエストのたびに呼ぶ前提です。モジュールの最上位で1個作って使い回すと、別ユーザーのデータがキャッシュ経由で見えるおそれがあります。

直ったことを確かめる — 赤から緑へ、そして順序のずれ

先ほどのテストが緑になれば、呼び出しは posts が1回、authorsByIds が1回の計2回です。件数が3でも30でも変わりません。db.calls の中身を ["posts", "authorsByIds"] と比べておくと、どの呼び出しが残ったかまで固定できます。例のデータでは、リゾルバ単位の呼び出しが31回から2回になる計算です。

ただし、回数が減ったことは中身が正しいことを意味しません。バッチ関数の並べ直しを間違えると、回数は2回のまま、投稿と著者の組み合わせがずれます。そこで、別のテストで中身を固定します。

it("各投稿に正しい著者が結びつく", async () => {
  const db = createCountingDb(makeSeed({ posts: 6, authors: 3 }));
  const result = await graphql({
    schema,
    source,
    contextValue: createContext(db),
  });
  const pairs = result.data!.posts.map((p) => [p.title, p.author.name]);
  expect(pairs).toEqual(expectedPairs);
});
 
it("ローダーはリクエストごとに作られる", () => {
  const db = createCountingDb(makeSeed({ posts: 1, authors: 1 }));
  expect(createContext(db).loaders).not.toBe(createContext(db).loaders);
});

3本のテストが、それぞれ別の壊れ方を捕まえます。

壊れ方捕まえるテスト
リゾルバが authorById を直接呼ぶ(N+1に戻る)捕まえるテスト件数を変えて回数を比べるテスト
バッチ関数の並べ直しを間違える捕まえるテスト投稿と著者の組み合わせを確かめるテスト
ローダーをモジュールで共有する捕まえるテストローダーがリクエストごとに別物かを見るテスト

3つ目は、同じ内容の2回目のリクエストが古いキャッシュを返すバグまでは捕まえません。そこは設計の約束として押さえ、テストは構造の取り違えを検出する役目に絞っています。

Claude Codeへの頼み方 — 順番を指定する

best-practicesは、「failing testを書いて、それから直す」と順序まで書いた指示のほうが、「バグを直して」より手戻りが少ないという趣旨の例を挙げています。N+1でも同じで、依頼文に次の順序を入れます。

postsクエリのauthorリゾルバがN+1になっているので直して。
1. test/query-count.test.ts を書き、投稿3件と30件でDB呼び出し回数が
   同じになることを検証する。修正前のコードで落ちることを確認する
2. DataLoaderでauthorをバッチ化する。ローダーはcreateContextで
   リクエストごとに作る
3. 全テストを実行し、コマンドとその出力を見せて

2つの点が効きます。

  • 手順1で落ちるのを確認させると、常に通るテストを防げる
  • 手順3で出力を求めると、「通りました」だけの報告で終わらない(best-practicesも、Claudeに証拠としてコマンドと出力を求めるよう書いている)

プロジェクトの CLAUDE.md には、実行コマンドと規約を短く書いておきます。

# Commands
- テスト: pnpm vitest run
- クエリ数テストだけ: pnpm vitest run test/query-count.test.ts
 
# Conventions
- リゾルバからDBを直接呼ばず、ctx.loaders 経由にする
- DataLoaderはcreateContextでリクエストごとに作る(モジュール共有禁止)

best-practicesには、リポジトリに既にある確認手段の実行コマンドを CLAUDE.md に書くのが最も有用な1行、という趣旨の記述があります。

Stop hookでテストが赤のまま終わらせない

テストを置いても、実行されなければ守れません。Claude Codeには、Claudeが応答を終えようとするたびに走るStop hookがあります。best-practicesの例では、スクリプトがexit code 2で終わると、Claudeは終了せず作業を続けます。標準エラー出力が理由として渡されるため、テストの出力を標準エラーに回します。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "pnpm vitest run test/query-count.test.ts 1>&2 || exit 2"
          }
        ]
      }
    ]
  }
}

これを .claude/settings.json に置けば、リゾルバをいじってN+1に戻した状態では、Claudeが完了を宣言できません。公式の説明では、連続8回止めても間に何のツール呼び出しもないとき、Claude Codeが次の停止を上書きしてターンを終わらせます。上限は環境変数 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変えられます。対象のテストが重い場合は、クエリ数のテストだけに絞ると待ち時間を抑えられます。

CIでの扱いは、Claude CodeでCIのテスト失敗を自動修正の手順と組み合わせられます。

DataLoaderでは足りないケース

バッチ化が効かない、または別の注意が要る場面は次のとおりです。

  • mutationのあとの読み直し: キャッシュは同一リクエスト内で有効なので、更新の直後に同じキーを読むと古い値が返ります。READMEは loader.clear(key) で消してから読み直す例を載せています
  • 別のtickに分かれる読み込み: 既定ではtick内の load を束ねます。間に setTimeout などが挟まるとバッチが分かれるため、READMEは batchScheduleFn で収集の仕方を変えられると説明しています。ただし、既定のtickよりレイテンシが増える設計もあるので、必要な場合だけにします
  • バッチが大きすぎる: maxBatchSize でバッチ関数に渡すキーの数を制限できます。IN の要素数に上限があるDBで使う場面があります
  • キャッシュを切りたい場合: cache: false にすると、バッチ関数にキーの重複が渡ります。READMEが明記している挙動なので、バッチ関数は重複キーにも1対1で値を返す必要があります

スキーマを変えたときに互換性が壊れていないかは、graphql-inspectorで破壊的変更を止める手順の領分です。クライアント側の型生成はGraphQL Codegenの設定で扱っています。ClaudeにAPIを直接叩かせたい場合は、GraphQL MCPサーバーの使い方が参考になります。

まとめ

リゾルバ単位のN+1は、件数を変えて呼び出し回数を比べるテストで可視化できます。DataLoaderはバッチ関数の並べ直しとリクエスト単位の生成が要で、回数のテストだけでは両方を守れません。Claude Codeには「落ちるテスト、修正、出力つきの全テスト実行」の順序を渡し、Stop hookで赤のまま終わらない形にしておくと、構造の退行が早い段階で見つかります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. github.com/graphql/dataloader
  3. graphql.org/learn/performance
この記事を共有:XはてブLinkedIn