GraphQL本番2年で地雷を踏んだ話──N+1問題からセキュリティまで

GraphQLを本番運用して2年。N+1地獄、キャッシュ設計、認可の落とし穴…実際に踏んだ地雷とDataLoaderやセキュリティの実装コードを体験ベースで解説

GraphQL本番運用2年で気づいた、キャッシング戦略とセキュリティの地雷

先日チーム内で「GraphQLの運用、実際どうしてます?」って話になったんですよ。うちは2年前にREST APIからGraphQLに移行して、正直かなりハマりました。最初は「クライアント側で自由にフィールド選べて便利だ」って思ってたんですけど、本番運用に入った瞬間にいろいろ見えてくる。N+1問題、キャッシュ戦略、セキュリティ周り……教科書的な解説だけじゃ絶対に足りないやつばかりです。

2026年の今、うちのチームがどうやって乗り越えてきたのか、素直に話します。完璧な設計じゃないですし、まだ試行錯誤中の部分もありますが、「これは本当に効いた」ってやつだけ厳選しました。

最初のN+1地獄——DataLoaderなしで100ユーザー取得が500クエリになった話

うちが最初にやらかしたのはこれです。ユーザーと投稿(Post)の関連データを取得するGraphQLスキーマを書いてみました。

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

type Post {
  id: ID!
  title: String!
  author: User!
}

type Query {
  users: [User!]!
}

クライアント側から「100ユーザーのリストと、それぞれの投稿一覧を取ってほしい」ってクエリが来たんです。シンプルですよね。

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

実装側を見てみましょう。当時のリゾルバーはこんな感じ:

const resolvers = {
  Query: {
    users: async () => {
      // 100ユーザー取得:1クエリ
      return db.query('SELECT * FROM users LIMIT 100');
    }
  },
  User: {
    posts: async (user) => {
      // ユーザーごとに投稿を取得:100ユーザー × 1クエリ = 100クエリ
      return db.query('SELECT * FROM posts WHERE user_id = ?', [user.id]);
    }
  }
};

これ、データベース上では1 + 100 = 101クエリになるんですよ。最初の1つはユーザーリスト、あとの100個は各ユーザーの投稿。本番で複数リクエストが重なるとデータベースが悲鳴を上げます。実際にうちも午前中のアクセスラッシュで接続プールが枯渇して、「GraphQL遅い」ってフロントエンド側から報告を受けました。

解決したのがDataLoaderです。

import DataLoader from 'dataloader';

// バッチ処理用のキー関数を定義
const postLoader = new DataLoader(async (userIds) => {
  // userIdsはユーザーIDの配列。例:[1, 2, 3, ...]
  const results = await db.query(
    'SELECT * FROM posts WHERE user_id IN (?)',
    [userIds]
  );
  
  // userIdごとに投稿をグループ化して返す
  const postsMap = new Map();
  results.forEach(post => {
    if (!postsMap.has(post.user_id)) {
      postsMap.set(post.user_id, []);
    }
    postsMap.get(post.user_id).push(post);
  });
  
  return userIds.map(id => postsMap.get(id) || []);
});

const resolvers = {
  Query: {
    users: async () => {
      return db.query('SELECT * FROM users LIMIT 100');
    }
  },
  User: {
    posts: async (user, _, context) => {
      // DataLoaderを通すことで、複数のresolverの呼び出しを
      // 自動的にバッチ処理に変換してくれる
      return context.loaders.postLoader.load(user.id);
    }
  }
};

これで101クエリが2クエリに削減されます。本当にこれだけで劇的に改善しました。

ただ、DataLoaderも一つ注意がある。GraphQL実行の1リクエスト内でしか機能しないんです。別リクエストが来るとキャッシュがリセットされます。だから同じユーザーIDを複数回取得する場合は、別の仕組みが必要。それについては後で話します。

キャッシング戦略の失敗——クライアント側の「staleWhileRevalidate」で本番が地獄になった話

GraphQLはHTTP POSTで通信するのが一般的ですよね。REST APIと違って、キャッシングの仕組みが複雑です。

うちが最初にやったのは、単純にHTTPヘッダーでCache-Control設定してみるやつ:

app.post('/graphql', async (req, res) => {
  const result = await graphql({ schema, rootValue, source: req.body });
  
  // クエリの種類に応じてキャッシュを判定
  if (req.body.includes('query GetUserPosts')) {
    res.set('Cache-Control', 'public, max-age=3600');
  }
  
  res.json(result);
});

これ、単純すぎて失敗します。理由は2つあります。

1. POSTリクエストはブラウザでキャッシュされない

HTTPキャッシュはGETリクエストを想定してます。POSTはセッションやstate変更を意図してるから、ブラウザがデフォルトでキャッシュしないんです。

2. クエリが動的

クライアントが「今月の売上とユーザー情報」って時と「今年の売上とユーザー情報」って時で、クエリ構造は同じでもレスポンスは違う。単純にクエリ名でキャッシュ判定できない。

うちの場合、2026年時点の推奨パターンはApollo Clientのようなキャッシュライブラリと、バックエンド側のキャッシュレイヤーの組み合わせです。

import Redis from 'ioredis';
import { createHash } from 'crypto';

const redis = new Redis();

// リクエスト全体(query + variables)をハッシュ化してキャッシュキーにする
function getCacheKey(query: string, variables: any): string {
  const key = JSON.stringify({ query, variables });
  return 'gql:' + createHash('sha256').update(key).digest('hex');
}

app.post('/graphql', async (req, res) => {
  const { query, variables } = req.body;
  const cacheKey = getCacheKey(query, variables);
  
  // キャッシュから取得試行
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log('Cache HIT');
    return res.json(JSON.parse(cached));
  }
  
  // キャッシュミス時は実行
  const result = await graphql({ schema, rootValue, source: query, variableValues: variables });
  
  // クエリ名で有効期限を判定
  let ttl = 0; // デフォルトはキャッシュしない
  if (query.includes('GetUserPosts')) {
    ttl = 3600; // 1時間
  } else if (query.includes('GetPublicData')) {
    ttl = 86400; // 24時間
  }
  
  if (ttl > 0) {
    await redis.setex(cacheKey, ttl, JSON.stringify(result));
  }
  
  res.json(result);
});

これでも足りない場合がありました。特に「ユーザーが投稿を作成したら、キャッシュも無効化したい」ってパターン。

そこで導入したのがキャッシュ無効化タグ(Cache Tagging)です:

// ユーザーID単位でキャッシュをタグ付け
const cacheTagsForUser = (userId: string) => [`user:${userId}`];
const cacheTagsForPost = (postId: string) => [`post:${postId}`];

app.post('/graphql', async (req, res) => {
  const { query, variables } = req.body;
  const cacheKey = getCacheKey(query, variables);
  const cached = await redis.get(cacheKey);
  
  if (cached) {
    return res.json(JSON.parse(cached));
  }
  
  const result = await graphql(...);
  
  // 実行結果に基づいてタグをつける
  let tags: string[] = [];
  if (result.data?.user) {
    tags.push(cacheTagsForUser(result.data.user.id)[0]);
  }
  
  // タグを保存
  if (tags.length > 0) {
    await redis.sadd(cacheKey + ':tags', ...tags);
  }
  
  await redis.setex(cacheKey, 3600, JSON.stringify(result));
  res.json(result);
});

// Mutation実行後のキャッシュ無効化
app.post('/graphql', async (req, res) => {
  const { query } = req.body;
  
  if (query.includes('mutation CreatePost')) {
    // 投稿作成時は、そのユーザーの全キャッシュを無効化
    const userId = req.user.id;
    const tag = cacheTagsForUser(userId)[0];
    
    const keysToInvalidate = await redis.smembers(tag);
    if (keysToInvalidate.length > 0) {
      await redis.del(...keysToInvalidate);
    }
  }
  
  // ... クエリ実行
});

これで「ユーザーが新しい投稿を作成したら、そのユーザーのキャッシュだけ無効化」っていう細かい制御ができるようになりました。実際に本番で動かすと、「あ、キャッシュ古いな」って不具合が格段に減ります。

セキュリティの落とし穴——認可チェックを忘れたリゾルバーが全ユーザーのメールを垂れ流していた話

本当にこれです。REST APIなら「GET /users/:id の認可をチェック」って一箇所で済みます。でもGraphQLはすべてのフィールドがリゾルバーだから、各フィールドでチェックしないと詰みます。

当時のコード:

const resolvers = {
  User: {
    email: async (user) => {
      // 認可チェックなし!
      return user.email;
    }
  }
};

これだと、ログインユーザーが「他のユーザーのメール取得」ってクエリを送ったとき、何もチェックせず返すんです。セキュリティ監査で指摘されました。

修正パターンがこれ:

const resolvers = {
  User: {
    email: async (user, _, context) => {
      // 認可チェック:自分のデータ、または管理者のみ
      if (context.currentUser.id !== user.id && !context.currentUser.isAdmin) {
        throw new GraphQLError('You do not have permission to view this email', {
          extensions: { code: 'FORBIDDEN' }
        });
      }
      return user.email;
    }
  }
};

でも毎フィールド書くのは面倒ですよね。ミドルウェアパターンで一元化するほうがいい:

import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

const typeDefs = gql`
  directive @auth(requires: String) on FIELD_DEFINITION
  
  type User {
    id: ID!
    name: String!
    email: String! @auth(requires: "OWNER_OR_ADMIN")
    phone: String! @auth(requires: "ADMIN")
  }
`;

const authDirectiveTransformer = (schema) => {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth');
      
      if (authDirective) {
        const requires = authDirective[0].requires;
        const originalResolver = fieldConfig.resolve;
        
        fieldConfig.resolve = async (obj, args, context, info) => {
          // 認可ロジック
          if (requires === 'OWNER_OR_ADMIN') {
            if (context.currentUser.id !== obj.id && !context.currentUser.isAdmin) {
              throw new GraphQLError('Unauthorized', {
                extensions: { code: 'FORBIDDEN' }
              });
            }
          } else if (requires === 'ADMIN') {
            if (!context.currentUser.isAdmin) {
              throw new GraphQLError('Admin access required', {
                extensions: { code: 'FORBIDDEN' }
              });
            }
          }
          
          // 認可OKなら元のリゾルバーを実行
          return originalResolver ? originalResolver(obj, args, context, info) : obj[info.fieldName];
        };
      }
      
      return fieldConfig;
    }
  });
};

const schema = authDirectiveTransformer(buildSchema(typeDefs, resolvers));

@auth ディレクティブでフィールドの認可レベルを宣言すると、自動的にチェックが入ります。React Server Componentsのデータフェッチ設計と同じで、「どのデータを誰が見られるか」を明示的に定義する。これ本当に大事です。

N+1以外の性能問題——クライアント側のキャッシュ戦略も見直さないと意味がない

前のセクションで「DataLoaderで2クエリに削減」って話をしました。でも本当の問題は、同じクエリが何度も走ることなんです。

うちのアプリ、「ユーザー情報」をいろんなページで表示するんですよ。ダッシュボードでもプロフィールページでも。最初は毎ページリロードするたびにGraphQL呼ぶってアホなことをやってました。

そこで入れたのがApollo Client のキャッシング

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

const client = new ApolloClient({
  link: new HttpLink({ uri: '/graphql' }),
  cache: new InMemoryCache({
    // typePolicy でキャッシュ戦略を定義
    typePolicies: {
      User: {
        keyFields: ['id'],  // userの一意キーはid
        fields: {
          email: {
            // email フィールドは常に同じ値を返す(変更されない)
            merge: (existing, incoming) => incoming,
            read: (existing) => existing
          },
          posts: {
            // postsは相対的に頻繁に変わる可能性がある
            keyArgs: false,
            merge(existing = [], incoming, { args }) {
              return incoming;
            }
          }
        }
      }
    }
  })
});

設定後は、同じクエリなら自動的にキャッシュから取得します:

const { data, loading } = useQuery(GET_USER, {
  variables: { userId: '123' },
  fetchPolicy: 'cache-first'  // キャッシュを優先、なければサーバー問い合わせ
});

この組み合わせで「バックエンド DataLoader + Apollo Client のキャッシュ」で、ほぼ同じユーザー情報を2回呼ぶことはなくなりました。

ただ、ここで気をつけるポイントが2つあります。

ポイント説明
キャッシュの有効期限Apollo Clientはメモリ上のキャッシュだから、ページリロードで消える。だからAPIサーバー側のRedisも必要
キャッシュ無効化タイミングユーザーが自分のプロフィール更新したら、そのuseQueryは「キャッシュを信頼するな」ってシグナルを受け取る必要がある

特に無効化タイミングはこんな感じで実装します:

const [updateUser] = useMutation(UPDATE_USER, {
  onCompleted: (data) => {
    // Mutation後、キャッシュを更新
    client.cache.modify({
      fields: {
        user(existing, { DELETE }) {
          // userフィールドのキャッシュを削除して再取得させる
          return DELETE;
        }
      }
    });
  }
});

2026年時点で、これが実務的にはちょうどいい塩梅だと感じます。

Federationを導入したら依存関係で地獄を見た話

うちのチームが途中から導入したのが GraphQL Federation。複数のGraphQLサーバーを1つのAPIのように見せるやつです。

理由は、マイクロサービス化に伴ってチームが分かれて、「各チームが独立したGraphQLエンドポイントを持ちたい」ってニーズが出てきたから。

// Team A: User Service
import { buildSubgraphSchema } from '@apollo/subgraph';

const userTypeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.0")
  
  type Query {
    user(id: ID!): User
  }
  
  type User @key(fields: "id") {
    id: ID!
    name: String!
    email: String!
  }
`;

const userSchema = buildSubgraphSchema([{
  typeDefs: userTypeDefs,
  resolvers: userResolvers
}]);

// Team B: Post Service
const postTypeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.0")
  
  type Query {
    posts(limit: Int): [Post!]!
  }
  
  type Post @key(fields: "id") {
    id: ID!
    title: String!
    content: String!
    author: User!
  }
  
  type User @key(fields: "id") {
    id: ID!
  }
`;

この@keyディレクティブで、各サービスが「このタイプの主キーは何か」を宣言します。Apollo Gateway(またはRouter)がそれをもとにクエリを分散実行します。

ただ、うちで苦しんだのは参照の解決です。PostServiceが「User型」を参照してるけど、Userの詳細データはUserServiceに持ってるんですよ。

// PostService のresolvers
const postResolvers = {
  Post: {
    author: async (post) => {
      // ここで UserService を呼ぶ必要がある
      // でも同期的には呼べない(Federation内で自動的に解決される)
      return { __typename: 'User', id: post.user_id };
    }
  },
  // Federation 参照型の解決
  User: {
    __resolveReference: async (user) => {
      // userService から実際のUser データを取得
      return await userServiceClient.user({ id: user.id });
    }
  }
};

これ、複雑じゃないですか。特に「複数のPostを返すQuery」の場合、各Postのauthorが異なるユーザーだと、UserServiceへのリクエストが複数飛ぶんです。これもやっぱりN+1です。

Federation環境でもDataLoaderは使えます:

const createUserLoader = () => {
  return new DataLoader(async (userIds) => {
    const users = await userServiceClient.batchUsers({ ids: userIds });
    return userIds.map(id => users.find(u => u.id === id));
  });
};

const context = {
  loaders: {
    userLoader: createUserLoader()
  }
};

const postResolvers = {
  Post: {
    author: (post, _, context) => {
      return context.loaders.userLoader.load(post.user_id);
    }
  }
};

ただ正直、Federation は構成がシンプルなうちはいいんですけど、サービス数が5個を超えたあたりから運用が重くなります。「あ、このバグ、どっちのサービスの責任?」「キャッシュが古くて…」みたいなの増えます。2026年時点で、うちはまだFederation使ってますが、「シンプルな構成で十分ならやめたい」ってのが本音です。

実装時のちょっとした工夫——本番で助かった地味なやつら

いくつか小ネタを話します。

その1: Depth Limit——無制限のネストクエリで爆発するのを防ぐ

import { depthLimitRule } from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimitRule(7)]  // ネストが7段以上は拒否
});

攻撃者が「User → posts → author → posts → author → …」って無限ネストさせると、データベースが死にます。これで防げます。

その2: Query Timeout

const server = new ApolloServer({
  schema,
  plugins: {
    didResolveOperation(context) {
      const queryTimeout = setTimeout(() => {
        context.res.locals.queryTimeout = true;
      }, 5000);  // 5秒以上は強制終了
      
      context.res.on('finish', () => clearTimeout(queryTimeout));
    }
  }
});

複雑なクエリが走ってる時に、別の大量リクエストが来たら接続プールが枯渇します。タイムアウトで早期に諦めるようにします。

その3: Error Handling——本番環境では詳細なエラーは隠す

const server = new ApolloServer({
  schema,
  formatError: (error) => {
    if (process.env.NODE_ENV === 'production') {
      // 本番環境では詳細を隠す
      return {
        message: 'An error occurred',
        extensions: { code: 'INTERNAL_ERROR' }
      };
    }
    
    // 開発環境では詳細情報
    return error;
  }
});

SQLインジェクション由来のエラーメッセージが出ると、攻撃者に情報を与えちゃいます。

まとめ

GraphQL、確かに便利です。でも本番で動かすなら、これだけは抑えておいて:

  1. DataLoader は必須——N+1問題の80%はこれで解決。毎リクエストで新しいDataLoaderインスタンスを作る

  2. キャッシング戦略は二層構造——バックエンド Redis + フロントエンド Apollo Client。単純ではなく細かい制御が必要

  3. 認可は各フィールドで——REST APIと違ってリゾルバーが個別なので、忘れると情報漏洩。ディレクティブで一元化するのが吉

  4. Federation は慎重に——マイクロサービスが小規模なうちはシンプルな単一スキーマのほうが楽。複雑さと得られるメリットのバランスを見極めて

  5. セキュリティは初期段階から——Depth Limit、Query Timeout、Error Handling。後付けするなら手作業量が膨大

2026年、GraphQLの生態系も随分成熟しました。でも「教科書通りにやったら大丈夫」ではなく、実務での試行錯誤がまだ必要です。うちのチームで「GraphQL運用のコツ」として蓄積してきた知見が、誰かの本番障害を減らせたら幸いです。

皆さんの環境ではどうしてます?特にキャッシュ戦略は環境によって最適解が違うはずなんで、もし工夫があれば聞かせてください。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事