GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド

GraphQL DataLoader Apollo Server パフォーマンス バックエンド N+1
結論
  • GraphQL の N+1 問題は、一覧 resolver が 1 回・各要素のフィールド resolver が N 回 DB に行くことでクエリが爆発する現象。
  • DataLoader は同一イベントループ内のキーをまとめて 1 回の IN クエリにし、リクエストスコープのキャッシュで重複取得を防ぐ。
  • Apollo Server では context ごとに DataLoader を new する。
  • あわせて depth limitquery complexity で悪意ある深いクエリを拒否し、一覧は カーソル型ページネーション で件数を cap する。
  • 修正前後を EXPLAIN ANALYZE と SQL ログで比較し、101 回 → 2 回に減ったことを確認してから本番へ。

GraphQL N+1 問題とは

GraphQL では、クライアントが必要なフィールドだけを指定して取得できる。これは over-fetch を減らす強みだが、サーバー側の フィールド resolver の実行モデル と組み合わさると、データベースへの問い合わせが 1 + N 回 に膨らむ典型パターンが生まれる。これを N+1 問題(N+1 query problem)と呼ぶ。

具体例を考える。ブログの posts 一覧(20件)と、各投稿の author 名前を返す API がある。

query PostsWithAuthors {
  posts(limit: 20) {
    id
    title
    author {
      id
      name
    }
  }
}

理想的には DB へは次の 2 回で済むはずだ。

  1. 投稿一覧 SELECT id, title, author_id FROM posts ORDER BY id DESC LIMIT 20
  2. 著者一覧 SELECT id, name FROM users WHERE id IN (1, 5, 8, 12, 15)

ところが、各フィールドに独立した resolver を書くと、次のような実装になりがちだ。

// 親: 投稿一覧 — 1 回のクエリ
async function posts(parent, args, ctx) {
  return ctx.db.query('SELECT id, title, author_id FROM posts LIMIT $1', [args.limit]);
}

// 子: 各投稿の author — 投稿ごとに 1 回ずつ実行される
async function postAuthor(post, args, ctx) {
  return ctx.db.query('SELECT id, name FROM users WHERE id = $1', [post.author_id]);
}

GraphQL エンジンは posts の結果 20 件それぞれについて author resolver を呼ぶ。結果、SQL は 1 + 20 = 21 回。件数が 100 なら 101 回、1000 なら 1001 回 になる。REST で JOIN 済み JSON を返す設計なら 1 クエリで済む場面で、GraphQL が 意図せず DB を圧迫 する。

REST との違い — なぜ GraphQL で目立つのか

REST の一覧エンドポイントは、サーバー側で JOIN した DTO を返す設計が多い。GraphQL は スキーマ上の型ごとに resolver が分離 され、クライアントが author { organization { name } } のように深くネストした要求をその場で組み立てられる。resolver 作者がバッチ処理を書かない限り、木構造の各辺ごとに DB 往復 が発生する。

観点REST(JOIN 済み DTO)GraphQL(素朴な resolver)
クエリ回数通常 1 回1 + N(+ ネスト分)
フィールド選択サーバー固定クライアント可変
性能劣化の発見比較的早いステージングで見逃しやすい
対策JOIN 設計DataLoader / JOIN / SQL バッチ

N+1 は GraphQL 固有のバグではなく、リレーション解決をループ内クエリで書いたときに起きる古典的問題 だ。GraphQL の実行モデルがそれを露呈しやすいだけである。

問題の見つけ方

本番前に次の方法で検出できる。

  1. SQL ログ — 1 リクエストあたりの SELECT 回数。21 以上なら疑う
  2. APM / トレース — span が投稿件数と比例しているか
  3. DataLoader 未使用の resolver レビューparent.id で都度 SELECT していないか
  4. 負荷試験 — 件数を 10 → 100 → 1000 と増やし、レイテンシが線形増加するか
ステージングの落とし穴

開発 DB が数百行しかないと N+1 でも体感 50ms 程度で済み、問題が見えない。本番相当の件数(数千〜数万 parent)で EXPLAIN ANALYZE と SQL 回数を必ず計測する。

再現用スキーマと Before 実装

以降の例では PostgreSQLNode.jsApollo Server 4pg プールを使う。ドメインは「投稿(Post)」と「著者(User)」の多対一。

GraphQL スキーマ

type Query {
  posts(limit: Int = 20): [Post!]!
  post(id: ID!): Post
}

type Post {
  id: ID!
  title: String!
  body: String
  authorId: ID!
  author: User!
  commentCount: Int!
}

type User {
  id: ID!
  name: String!
  email: String!
}

テーブル定義(抜粋)

CREATE TABLE users (
  id         BIGSERIAL PRIMARY KEY,
  name       TEXT NOT NULL,
  email      TEXT NOT NULL UNIQUE
);

CREATE TABLE posts (
  id         BIGSERIAL PRIMARY KEY,
  title      TEXT NOT NULL,
  body       TEXT,
  author_id  BIGINT NOT NULL REFERENCES users(id)
);

CREATE INDEX idx_posts_author_id ON posts(author_id);

Before: N+1 が発生する resolver

// resolvers/before.js — 問題のある実装(そのまま使わないこと)

const resolvers = {
  Query: {
    async posts(_, { limit }, { db }) {
      const { rows } = await db.query(
        `SELECT id, title, body, author_id AS "authorId"
         FROM posts
         ORDER BY id DESC
         LIMIT $1`,
        [limit]
      );
      return rows;
    },
  },

  Post: {
    // 各 Post ごとに 1 回 SELECT → N+1 の本体
    async author(post, _, { db }) {
      const { rows } = await db.query(
        'SELECT id, name, email FROM users WHERE id = $1',
        [post.authorId]
      );
      return rows[0];
    },

    // コメント数も件数分クエリ → さらに N 回
    async commentCount(post, _, { db }) {
      const { rows } = await db.query(
        'SELECT COUNT(*)::int AS count FROM comments WHERE post_id = $1',
        [post.id]
      );
      return rows[0].count;
    },
  },
};

posts(limit: 20)authorcommentCount を付けると、概算 1 + 20 + 20 = 41 回 の SQL になる。limit を 100 にすれば 201 回 だ。

Before 実行時の SQL ログ(イメージ)

[query] SELECT id, title, body, author_id FROM posts ORDER BY id DESC LIMIT 20
[query] SELECT id, name, email FROM users WHERE id = 3
[query] SELECT id, name, email FROM users WHERE id = 3   -- 同じ author が重複
[query] SELECT id, name, email FROM users WHERE id = 7
[query] SELECT id, name, email FROM users WHERE id = 12
[query] SELECT id, name, email FROM users WHERE id = 3   -- 再び重複
[query] SELECT COUNT(*)::int FROM comments WHERE post_id = 101
[query] SELECT COUNT(*)::int FROM comments WHERE post_id = 100
[query] SELECT COUNT(*)::int FROM comments WHERE post_id = 99
-- 以降、author 用に残り16回 + commentCount 用に残り17回 → 合計41回

同じ authorId が複数投稿に現れても、都度 SELECT される点も無駄である。

EXPLAIN ANALYZE で Before を計測する

PostgreSQL では EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) で、プランナが選んだ実行計画と 実測時間 を確認できる。N+1 の 1 クエリあたりは軽くても、回数 × 往復レイテンシ で全体が遅くなる。

単体クエリの EXPLAIN

著者 1 件取得:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, email FROM users WHERE id = 42;

典型的な出力(インデックスあり):

Index Scan using users_pkey on users  (cost=0.29..8.30 rows=1 width=...) (actual time=0.025..0.026 rows=1 loops=1)
  Index Cond: (id = 42)
Planning Time: 0.12 ms
Execution Time: 0.05 ms

1 回 0.05ms でも、100 回で 5ms + ネットワーク往復 になる。アプリから DB まで RTT が 1ms なら、クエリ本体 5ms + 往復 100ms ≒ 105ms が author 解決だけのコストだ。コメント数も同様なら 200ms 超 になりやすい。

バッチ相当の EXPLAIN(After の目標)

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, email FROM users WHERE id IN (3, 7, 12, 15, 42);
Bitmap Heap Scan on users  (cost=... rows=5 width=...) (actual time=0.05..0.08 rows=5 loops=1)
  Recheck Cond: (id = ANY ('{3,7,12,15,42}'::bigint[]))
  ->  Bitmap Index Scan on users_pkey  (cost=0.00..12.50 rows=5 width=0) (actual time=0.03..0.03 rows=5 loops=1)
Planning Time: 0.15 ms
Execution Time: 0.12 ms

5 件まとめて 0.12ms — ループ 5 回(合計 ~0.25ms + 5 RTT)より圧倒的に効率的。

コメント数のバッチ集計

EXPLAIN (ANALYZE, BUFFERS)
SELECT post_id, COUNT(*)::int AS count
FROM comments
WHERE post_id IN (101, 100, 99, 98, 97)
GROUP BY post_id;

HashAggregate または Index Scan + 集約が 1 回で済む。Before の COUNT(*) × N ループと比べ、Seq Scan が post_id インデックスで Index Only Scan になる ことも EXPLAIN で確認できる。

計測チェックリスト

項目Before(N+1)After(DataLoader)
SQL 回数(posts 20 + author)212
author 解決の Execution Time 合計~1ms + 20 RTT~0.1ms + 1 RTT
同一 authorId の重複 SELECTありキャッシュで 0
プランナの起動コスト× N 回× 1 回

アプリ層では pglog_statement = 'all'Apollo Server プラグイン でクエリ数を数えると Before/After の差が一目瞭然だ。

DataLoader の仕組み

DataLoader は Facebook が GraphQL 向けに公開した小さなライブラリで、次の 2 機能を提供する。

  1. Batching — 短い時間窓(同一イベントループ tick)に load() されたキーを配列にまとめ、batch 関数を 1 回だけ 呼ぶ
  2. Caching — 同一 DataLoader インスタンス内で同じキーの load()メモリ上の Promise を再利用
import DataLoader from 'dataloader';

const userLoader = new DataLoader(async (userIds) => {
  // userIds = [3, 7, 3, 12] のように重複含む場合あり
  const uniqueIds = [...new Set(userIds)];
  const rows = await db.query(
    'SELECT id, name, email FROM users WHERE id = ANY($1::bigint[])',
    [uniqueIds]
  );
  const map = new Map(rows.map((r) => [String(r.id), r]));
  // 重要: 返却配列は userIds と同じ長さ・同じ順序
  return userIds.map((id) => map.get(String(id)) ?? new Error(`User ${id} not found`));
});

load(3) を複数 resolver から呼んでも、1 イベントループ内では batch 関数が 1 回 だけ実行される。2 回目以降の load(3) はキャッシュから返る。

batch 関数の契約

ルール理由
返却配列の長さ = keys.lengthDataLoader が各 load() に結果を対応付ける
順序 = keys の順序インデックスでマッピング
見つからないキーは Error または null仕様に合わせる(GraphQL なら null 許容フィールドか Error)
batch 内でループ SELECT 禁止それでは N+1 が batch 内に移るだけ

スケジューリング — いつバッチが flush されるか

DataLoader はデフォルトで Promise の microtask キュー に batch 実行を載せる。同一 resolver ツリー内の sibling フィールドが同じ tick で load() すれば 1 バッチにまとまる。await で途中にイベントループを挟むと、複数バッチに分割 されることがある。通常の GraphQL フィールド解決では問題になりにくいが、意図的に setTimeout между load するとバッチが割れる点は知っておく。

DataLoader 完全実装

プロジェクト構成例:

src/
  db/
    pool.js
  loaders/
    createLoaders.js
    userLoader.js
    commentCountLoader.js
  resolvers/
    index.js
  index.js          # Apollo Server 起動

db/pool.js

import pg from 'pg';

const { Pool } = pg;

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // インスタンス数 × max が PostgreSQL 上限を超えないよう設計([接続プール枯渇の対処](/blog/postgres-connection-pool-exhaustion-対処/))
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

export async function query(text, params) {
  const start = Date.now();
  const result = await pool.query(text, params);
  if (process.env.LOG_SQL === '1') {
    console.log('[sql]', { ms: Date.now() - start, text, rows: result.rowCount });
  }
  return result;
}

loaders/userLoader.js

import DataLoader from 'dataloader';
import { query } from '../db/pool.js';

export function createUserLoader() {
  return new DataLoader(async (userIds) => {
    const ids = [...new Set(userIds.map(Number))];
    if (ids.length === 0) return userIds.map(() => null);

    const { rows } = await query(
      `SELECT id, name, email FROM users WHERE id = ANY($1::bigint[])`,
      [ids]
    );

    const byId = new Map(rows.map((row) => [String(row.id), row]));
    return userIds.map((id) => {
      const row = byId.get(String(id));
      return row ?? null;
    });
  });
}

loaders/commentCountLoader.js

import DataLoader from 'dataloader';
import { query } from '../db/pool.js';

export function createCommentCountLoader() {
  return new DataLoader(async (postIds) => {
    const ids = [...new Set(postIds.map(Number))];
    if (ids.length === 0) return postIds.map(() => 0);

    const { rows } = await query(
      `SELECT post_id, COUNT(*)::int AS count
       FROM comments
       WHERE post_id = ANY($1::bigint[])
       GROUP BY post_id`,
      [ids]
    );

    const countByPostId = new Map(rows.map((r) => [String(r.post_id), r.count]));
    return postIds.map((id) => countByPostId.get(String(id)) ?? 0);
  });
}

loaders/createLoaders.js — ファクトリ

import { createUserLoader } from './userLoader.js';
import { createCommentCountLoader } from './commentCountLoader.js';

/** リクエストごとに 1 回呼ぶ */
export function createLoaders() {
  return {
    userLoader: createUserLoader(),
    commentCountLoader: createCommentCountLoader(),
  };
}

リクエストスコープ が最重要。グローバル singleton の DataLoader は、キャッシュがリクエストをまたいで汚染される。

ネストした User → Organization 用 loader(深い N+1 対策)

// loaders/organizationLoader.js
import DataLoader from 'dataloader';
import { query } from '../db/pool.js';

export function createOrganizationLoader() {
  return new DataLoader(async (orgIds) => {
    const ids = [...new Set(orgIds.map(Number))];
    const { rows } = await query(
      `SELECT id, name FROM organizations WHERE id = ANY($1::bigint[])`,
      [ids]
    );
    const byId = new Map(rows.map((r) => [String(r.id), r]));
    return orgIds.map((id) => byId.get(String(id)) ?? null);
  });
}
// User.organization resolver
User: {
  async organization(user, _, { loaders }) {
    if (!user.organization_id) return null;
    return loaders.organizationLoader.load(user.organization_id);
  },
},

木の深さが 3 でも、各エッジに DataLoader があれば各レベルでバッチが効く。

After: DataLoader を使った resolver

// resolvers/after.js

const resolvers = {
  Query: {
    async posts(_, { limit }, { db }) {
      const { rows } = await db.query(
        `SELECT id, title, body, author_id AS "authorId"
         FROM posts ORDER BY id DESC LIMIT $1`,
        [limit]
      );
      return rows;
    },
  },

  Post: {
    async author(post, _, { loaders }) {
      return loaders.userLoader.load(post.authorId);
    },

    async commentCount(post, _, { loaders }) {
      return loaders.commentCountLoader.load(post.id);
    },
  },
};

author resolver は 1 行も SQL を書かないload() はバッチキューに載るだけ。20 投稿なら userLoader の batch が 1 回、commentCountLoader の batch が 1 回。posts の Query と合わせて 合計 3 回(以前は 41 回)。

Before / After 比較

// ❌ Before — フィールドごとに DB
async author(post, _, { db }) {
  const { rows } = await db.query(
    'SELECT id, name, email FROM users WHERE id = $1',
    [post.authorId]
  );
  return rows[0];
}

// ✅ After — DataLoader に委譲
async author(post, _, { loaders }) {
  return loaders.userLoader.load(post.authorId);
}
posts limitBefore SQL 回数After SQL 回数
20(author のみ)212
20(author + commentCount)413
100(author + commentCount)2013

件数 N に比例していたクエリ数が、フィールド種類 + 1 程度の定数 に抑えられる。

Apollo Server への組み込み

Apollo Server 4 の最小構成例。contextcreateLoaders() を呼び、全 resolver が同じ loader インスタンスを共有する。

index.js — サーバー起動

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'node:fs';
import { resolvers } from './resolvers/index.js';
import { pool, query } from './db/pool.js';
import { createLoaders } from './loaders/createLoaders.js';

const typeDefs = readFileSync('./schema.graphql', 'utf8');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    {
      async requestDidStart() {
        let sqlCount = 0;
        return {
          async executionDidStart() {
            return {
              willResolveField({ info }) {
                // デバッグ用: フィールド解決のトレース
                if (process.env.LOG_FIELDS === '1') {
                  console.log('[field]', info.parentType.name, info.fieldName);
                }
              },
            };
          },
          async willSendResponse(ctx) {
            if (process.env.LOG_SQL === '1') {
              console.log('[request] sqlCount approx from logs');
            }
          },
        };
      },
    },
  ],
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => {
    // 認証ユーザー等もここで
    const userId = req.headers['x-user-id'] ?? null;
    return {
      db: { query },
      loaders: createLoaders(), // ★ リクエストごとに new
      userId,
    };
  },
});

console.log(`GraphQL ready at ${url}`);

Express ミドルウェアと併用する場合

import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import cors from 'cors';
import { createLoaders } from './loaders/createLoaders.js';

const app = express();
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use(
  '/graphql',
  cors(),
  express.json(),
  expressMiddleware(server, {
    context: async ({ req }) => ({
      db: { query },
      loaders: createLoaders(),
      userId: req.user?.id ?? null,
    }),
  })
);

認可と DataLoader

loader の batch 関数内で テナント ID や ACL を WHERE に含める。

export function createUserLoader(tenantId) {
  return new DataLoader(async (userIds) => {
    const ids = [...new Set(userIds.map(Number))];
    const { rows } = await query(
      `SELECT id, name, email FROM users
       WHERE tenant_id = $1 AND id = ANY($2::bigint[])`,
      [tenantId, ids]
    );
    const byId = new Map(rows.map((row) => [String(row.id), row]));
    return userIds.map((id) => byId.get(String(id)) ?? null);
  });
}

// context
loaders: createLoaders(tenantIdFromAuth(req)),

バッチしても 他テナントの行は返さない 設計にする。

サンプルクエリとレスポンス

リクエスト

query PostsWithAuthors {
  posts(limit: 3) {
    id
    title
    author {
      id
      name
    }
    commentCount
  }
}

レスポンス

{
  "data": {
    "posts": [
      {
        "id": "103",
        "title": "DataLoader 入門",
        "author": { "id": "7", "name": "Alice" },
        "commentCount": 12
      },
      {
        "id": "102",
        "title": "GraphQL N+1",
        "author": { "id": "3", "name": "Bob" },
        "commentCount": 0
      },
      {
        "id": "101",
        "title": "EXPLAIN ANALYZE",
        "author": { "id": "7", "name": "Alice" },
        "commentCount": 5
      }
    ]
  }
}

authorId 7 は 2 回登場するが、After では userLoader のキャッシュ により DB には 1 行分としてバッチに含まれ、結果も 1 回の SELECT で取得される。

発行 SQL(After)

SELECT id, title, body, author_id FROM posts ORDER BY id DESC LIMIT 3
SELECT id, name, email FROM users WHERE id = ANY('{7,3}')
SELECT post_id, COUNT(*)::int AS count FROM comments WHERE post_id = ANY('{103,102,101}') GROUP BY post_id

DataLoader 以外の選択肢

DataLoader が万能ではない。ユースケースに応じて次も検討する。

1. SQL JOIN / DataLoader 併用

一覧が 常に author 付き なら、Query.posts 内で JOIN して author を埋め込む方が速い場合がある。

async posts(_, { limit }, { db }) {
  const { rows } = await db.query(
    `SELECT p.id, p.title, p.body, p.author_id AS "authorId",
            u.id AS "author_id", u.name AS "author_name", u.email AS "author_email"
     FROM posts p
     JOIN users u ON u.id = p.author_id
     ORDER BY p.id DESC
     LIMIT $1`,
    [limit]
  );
  return rows.map((r) => ({
    id: r.id,
    title: r.title,
    body: r.body,
    authorId: r.authorId,
    author: { id: r.author_id, name: r.author_name, email: r.author_email },
  }));
},

Post: {
  author(post) {
    return post.author; // すでに JOIN 済み
  },
},

欠点: クライアントが author を要求しない場合も JOIN コストがかかる。GraphQL の柔軟性とトレードオフ。

2. Prisma / Drizzle の include

ORM の include: { author: true } は JOIN または IN クエリに変換される。ただし GraphQL フィールドと ORM の include が 1:1 でない と over-fetch や N+1 が再発する。Prisma では [Relation] loader パターンや @prisma/extension-optimize、dataloader プラグインの利用を検討。

3. PostgreSQL の LATERAL / サブクエリ

レポート用途で固定形状なら、1 本の SQL に集約する。

SELECT p.*, u.name AS author_name,
       (SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id) AS comment_count
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.id DESC
LIMIT 20;

EXPLAIN ANALYZE で SubPlan が N 回実行されていないか(InitPlan / 集約サブクエリ)を必ず確認する。

4. 永続キャッシュ(Redis)

読み取りが多く更新が少ない User マスタなどは、loader 内で Redis を見る。

return new DataLoader(async (ids) => {
  const cached = await redis.mget(ids.map((id) => `user:${id}`));
  const missing = [];
  // cache miss だけ DB IN クエリ
});

リクエストスコープの DataLoader キャッシュRedis はレイヤーが違う。Redis はプロセス横断、DataLoader は同一リクエスト内。

A B
素朴な resolver 実装最速 / N+1 リスク最大
DataLoader GraphQL 向き / フィールド単位バッチ
JOIN 一覧 固定形状一覧が最速 / 柔軟性低
ORM include 小規模向き / 深いネストで要注意

よくある落とし穴

batch 内ループ SELECT

// ❌ バッチのふりをした N+1
async (ids) => {
  const results = [];
  for (const id of ids) {
    const { rows } = await query(
      'SELECT id, name, email FROM users WHERE id = $1',
      [id]
    );
    results.push(rows[0] ?? null);
  }
  return results;
}

返却順序の不一致

WHERE id IN (...) の結果順は保証されない。必ず Map で keys 順に並べ替える

グローバル singleton DataLoader

マルチテナント SaaS で致命傷。必ず createLoaders() を context 内で呼ぶ。

Error と null の混同

GraphQL で non-null フィールド User! に null を返すと親ごと null になる。存在しない ID はビジネスルールに合わせて Error または nullable に。

DataLoader クリアタイミング

同一リクエスト内で「古いデータを再取得したい」 rare case では loader.clear(id)loader.clearAll() を使う。mutation 直後に同リクエストで再 query する場合など。

テスト戦略

SQL 回数アサーション

import { describe, it, expect } from 'vitest';
import { createLoaders } from '../loaders/createLoaders.js';

describe('userLoader', () => {
  it('batches duplicate ids into one query', async () => {
    let queryCount = 0;
    const mockQuery = async () => {
      queryCount++;
      return { rows: [{ id: 1, name: 'A', email: '[email protected]' }] };
    };
    const loader = createUserLoader({ query: mockQuery });
    await Promise.all([loader.load(1), loader.load(1), loader.load(1)]);
    expect(queryCount).toBe(1);
  });
});

結合テスト — GraphQL リクエスト

const res = await server.executeOperation({
  query: `query { posts(limit: 10) { author { name } } }`,
});
expect(res.body.singleResult.errors).toBeUndefined();
// ログまたは mock で SQL が 2 回であることを確認

EXPLAIN ANALYZE を CI に入れるか

本番 DB 負荷を避け、マイグレーション後の代表クエリ だけステージングで EXPLAIN を取り、Seq Scan 化していないかレビューするのが現実的。完全自動化は postgres の統計情報依存のため、閾値(Execution Time < 50ms)でアラートする程度がよい。

Query Complexity 制限 — DataLoader だけでは足りない防御線

DataLoader は 同一リクエスト内の DB 往復 を減らすが、GraphQL には別の DoS ベクトルがある。クライアントが次のようなクエリを送ると、DB は効率化されても resolver 呼び出し回数・CPU・メモリ は爆発する。

query EvilNested {
  posts(limit: 100) {
    author {
      organization {
        parent {
          parent {
            parent {
              name
            }
          }
        }
      }
    }
  }
}
query EvilAliases {
  a1: posts(limit: 50) { id title }
  a2: posts(limit: 50) { id title }
  a3: posts(limit: 50) { id title }
  a4: posts(limit: 50) { id title }
  a5: posts(limit: 50) { id title }
  a6: posts(limit: 50) { id title }
  a7: posts(limit: 50) { id title }
  a8: posts(limit: 50) { id title }
}

DataLoader があっても posts resolver はエイリアス数 × 1 回、author は 50 × エイリアス数回 load() される。DB クエリはバッチ化 されても、GraphQL エンジンのフィールド解決コストは線形に増える。対策は depth limit(深さ)query complexity(複雑度スコア) の二段構えだ。

depth limit — ネストの深さを cap する

graphql-depth-limit は validation 段階で AST の深さを数え、閾値超過なら 実行前に 400 を返す。

import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(7)],
});

深さ 7 なら posts → author → organization → parent × 4 程度まで許容、それ以上は拒否。スキーマの最大ネストに合わせて調整する。本番では 6〜8 がよく使われる。

query complexity — フィールドごとにコストを付与

graphql-query-complexity は、各フィールドに重みを付け、クエリ全体の合計スコアが上限を超えたら拒否する。

import {
  createComplexityLimitRule,
  simpleEstimator,
  fieldExtensionsEstimator,
} from 'graphql-query-complexity';
import { ApolloServer } from '@apollo/server';

const MAX_COMPLEXITY = 300;

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    createComplexityLimitRule(MAX_COMPLEXITY, {
      estimators: [
        fieldExtensionsEstimator(),
        simpleEstimator({ defaultComplexity: 1 }),
      ],
      onComplete: (complexity) => {
        if (process.env.LOG_COMPLEXITY === '1') {
          console.log('[complexity]', complexity);
        }
      },
    }),
  ],
});

スキーマ側で重いフィールドに @complexity を付ける例:

type Query {
  posts(limit: Int = 20): [Post!]!
    @complexity(value: 10, multipliers: ["limit"])
}

type Post {
  id: ID!
  title: String!
  author: User! @complexity(value: 2)
  commentCount: Int! @complexity(value: 3)
}

posts(limit: 100) なら 10 × 100 = 1000 点になり、上限 300 なら 実行前に拒否 される。limit 引数を multipliers に含めるのが GraphQL 一覧 API では重要だ。

Apollo Server 4 — depth + complexity をまとめて適用

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import depthLimit from 'graphql-depth-limit';
import {
  createComplexityLimitRule,
  simpleEstimator,
  fieldExtensionsEstimator,
} from 'graphql-query-complexity';
import { readFileSync } from 'node:fs';
import { resolvers } from './resolvers/index.js';
import { query } from './db/pool.js';
import { createLoaders } from './loaders/createLoaders.js';

const typeDefs = readFileSync('./schema.graphql', 'utf8');

const complexityRule = createComplexityLimitRule(300, {
  estimators: [
    fieldExtensionsEstimator(),
    simpleEstimator({ defaultComplexity: 1 }),
  ],
});

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(7), complexityRule],
  formatError: (formattedError) => {
    if (
      formattedError.message.includes('exceeds maximum operation depth') ||
      formattedError.message.includes('exceeds maximum query complexity')
    ) {
      return {
        message: 'Query too expensive. Reduce depth, limit, or field selection.',
        extensions: { code: 'QUERY_REJECTED' },
      };
    }
    return formattedError;
  },
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async () => ({
    db: { query },
    loaders: createLoaders(),
  }),
});

console.log(`GraphQL ready at ${url}`);

拒否されたクエリは DB に到達しないため、接続プールを守る第一防衛 になる。プール枯渇の調査手順は PostgreSQL 接続プール枯渇の対処 を参照。

limit 引数のサーバー側バリデーション

complexity だけに頼らず、resolver 入口でも limit を clamp する。

const MAX_PAGE_SIZE = 50;

function clampLimit(limit) {
  const n = Number(limit ?? 20);
  if (!Number.isFinite(n) || n < 1) return 20;
  return Math.min(n, MAX_PAGE_SIZE);
}

const resolvers = {
  Query: {
    async posts(_, { limit, cursor }, { db }) {
      const safeLimit = clampLimit(limit);
      const { rows } = await db.query(
        `SELECT id, title, body, author_id AS "authorId"
         FROM posts
         WHERE ($2::bigint IS NULL OR id < $2)
         ORDER BY id DESC
         LIMIT $1`,
        [safeLimit, cursor ? Number(cursor) : null]
      );
      return rows;
    },
  },
};

深いオフセットではなく カーソル型 で次ページを取る設計は、件数上限と相性が良い。方式の選定は API ページネーション比較(オフセット vs カーソル) を参照。

DataLoader と complexity は役割が違う

DataLoader は 正しいクエリの DB 効率 を上げる。Query complexity / depth limit は 許可するクエリの形状 を制限する。両方入れて初めて、性能と可用性のバランスが取れる。

A B
depth limit ネスト深さの上限 / 実装が軽い / エイリアスは別問題
query complexity フィールド重み × 引数で総コスト制御 / 一覧 limit に必須
maxBatchSize 1 バッチの IN 句サイズ上限 / DB プランナ保護
clampLimit resolver 入口の件数 cap / 最後の砦

拒否クエリの監視

// plugins/metricsPlugin.js
export const metricsPlugin = {
  async requestDidStart() {
    return {
      async didEncounterErrors(ctx) {
        for (const err of ctx.errors) {
          if (err.extensions?.code === 'QUERY_REJECTED') {
            console.warn('[graphql-rejected]', {
              query: ctx.request.query?.slice(0, 200),
              operation: ctx.operationName,
            });
          }
        }
      },
    };
  },
};

拒否率が急増したら、正当なクライアントの complexity 設定ミスか、攻撃かを切り分ける。正当利用なら @complexity の重みを見直す。

パフォーマンスチューニングの追加施策

DataLoader 導入後も、次を検討する。

施策効果
author_id にインデックスIN クエリの Bitmap Index Scan
comments(post_id) インデックスGROUP BY 集約の高速化
maxBatchSize オプション超大 IN を分割(例: 500 ID ずつ)
Query complexity 制限悪意ある深いクエリを拒否
Persisted Queries本番は許可リストのみ
new DataLoader(batchFn, { maxBatchSize: 500 });

IN 句が数千 ID になるとプランナも負荷が上がる。parent 件数自体を抑える のが根本解で、GraphQL 一覧は カーソル型ページネーションlimit の二重 cap(resolver + complexity)が定番だ。同時リクエストが増えて pg.Pool が待ち行列に入る場合は PostgreSQL 接続プール枯渇の対処 も合わせて確認する。

GraphQL N+1 と API 設計の原則

  1. リレーションは loader 経由 — フィールド resolver で db.query 直書きしない
  2. リクエストスコープ — loader は context で生成
  3. 計測してから最適化 — EXPLAIN ANALYZE と SQL 回数
  4. 一覧専用の高速 path — 必要なら JOIN 一覧 resolver を別途用意
  5. 深いネストも同じパターン — エッジごとに DataLoader
  6. depth + complexity — 悪意あるクエリは DB 到達前に拒否
1

スキーマと N+1 になる Before resolver を書く

2

EXPLAIN ANALYZE / SQL ログで 1+N を確認

3

batch 関数(IN クエリ + Map 並べ替え)を実装

4

createLoaders を context に載せ After resolver へ差し替え

5

Apollo Server で E2E テスト、SQL 回数が定数化されたことを確認

6

depth limit と query complexity を validationRules に追加

7

ネスト・mutation 後のキャッシュ・認可をレビュー

関連記事

この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
APIキャッシュ設計ガイドAPIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務
Web APIのエラーハンドリング設計Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け
Web APIのべき等性(Idempotency)設計Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド
APIのレート制限(Rate Limiting)実装ガイドAPIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング
APIレート制限と429対応APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
WebHook設計のベストプラクティスWebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
ZodでAPI境界のランタイム検証を設計するZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
Circuit Breaker + Retry パターン実装ガイドCircuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
Sagaパターン完全ガイドSagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js)
Event SourcingとCQRSの基礎Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド
Redisキャッシュスタンピード防止ガイドRedisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効

まとめ

GraphQL N+1 問題は、親一覧 1 回に対し子フィールドが N 回の SELECT を発行する性能障害である。原因は GraphQL 自体ではなく、リレーション解決を per-item クエリで書く実装 にある。

DataLoader は、同一リクエスト内の load(key)1 回のバッチクエリ にまとめ、同じ key の 重複取得をキャッシュ する。Apollo Server では context: () => ({ loaders: createLoaders() }) が定番。

Before では posts(limit: 100) + author + commentCount201 SQL、After では 3 SQL まで減らせる。PostgreSQL の EXPLAIN ANALYZE で、ループ Index Scan × N から Bitmap Index Scan × 1 への改善を確認しよう。

JOIN 一括取得や ORM include も選択肢だが、GraphQL の フィールド選択の柔軟性 を保ちたいなら DataLoader がバランス良い。depth limitquery complexity でクエリ形状を制限し、一覧件数は カーソル型ページネーション で抑える。DB 同時接続が逼迫する場合は 接続プール枯渇の対処 もセットで見る。本番投入前に、代表クエリの SQL 回数と Execution Time を必ず記録しておくことが、長期的な SLA 維持につながる。