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、確かに便利です。でも本番で動かすなら、これだけは抑えておいて:
-
DataLoader は必須——N+1問題の80%はこれで解決。毎リクエストで新しいDataLoaderインスタンスを作る
-
キャッシング戦略は二層構造——バックエンド Redis + フロントエンド Apollo Client。単純ではなく細かい制御が必要
-
認可は各フィールドで——REST APIと違ってリゾルバーが個別なので、忘れると情報漏洩。ディレクティブで一元化するのが吉
-
Federation は慎重に——マイクロサービスが小規模なうちはシンプルな単一スキーマのほうが楽。複雑さと得られるメリットのバランスを見極めて
-
セキュリティは初期段階から——Depth Limit、Query Timeout、Error Handling。後付けするなら手作業量が膨大
2026年、GraphQLの生態系も随分成熟しました。でも「教科書通りにやったら大丈夫」ではなく、実務での試行錯誤がまだ必要です。うちのチームで「GraphQL運用のコツ」として蓄積してきた知見が、誰かの本番障害を減らせたら幸いです。
皆さんの環境ではどうしてます?特にキャッシュ戦略は環境によって最適解が違うはずなんで、もし工夫があれば聞かせてください。