GraphQL Federationを本番廃止した話|250人規模で2年ハマった地雷と対策
GraphQL Federationの導入から廃止まで。N+1問題、キャッシング戦略、マイクロサービス運用で実際に起きた問題と解決パターンを実録で解説します。
GraphQL Federationを導入して2年、本当に大変だったのは何か
先月チームで「GraphQL を本番から段階的に廃止する」という決議が出た。別に GraphQL が悪いわけじゃないんだけど、うちの場合は「導入時の判断ミス」と「運用の複雑さ」で身動きが取れなくなってた。ただ逆に言うと、その 2 年間の失敗から学んだことが、2026 年時点での「GraphQL を上手く使う方法」になってる。
去年あたりから GraphQL の「RE:Invent」や各カンファレンスでの登壇を聞いてると、みんな同じ課題にハマってる。特に Federation と N+1 問題、キャッシング戦略で本番が止まる企業が増えてる。今日は、僕らが痛い目を見て、やっと落ち着いた実装パターンを共有したい。
Federation は「便利」と「地獄」が紙一重
最初、Federation を導入した時は本当に興奮してた。250 人のエンジニアが 15 の独立したマイクロサービス チームに分かれてるんだけど、GraphQL で統一スキーマを作れば、フロントエンド チームは各マイクロサービス の細かい差分を意識しなくて済む。理想的な分散アーキテクチャだと思ってた。
実装は Apollo Federation v2 を選んだ。2024 年後半の時点では十分に成熟してるし、日本でも採用企業が増えてた。各サービスが独立した GraphQL スキーマを持って、Apollo Router がそれらを統合する構造。シンプルで良い。
でも運用して 3 ヶ月で最初の爆弾が炸裂した。
あるサービス A が、サービス B のフィールドを参照する場合、フェデレーション参照(@requires ディレクティブ)を張る。ここまでは OK。でも「参照先が N 個のマイクロサービス にまたがる」パターンが出てくる。その時、Router が自動的に複数回のサブグラフ呼び出しをしていて、ユーザーのレスポンスタイムが 500ms → 2 秒に跳ね上がってた。
クライアント側からは 1 つのクエリに見えるけど、Router の内部では 4〜5 個の HTTP 呼び出しが直列で走ってる。これが N+1 問題の GraphQL 版だ。
Federation のアンチパターン
これが「参照が複雑に連鎖する場合」の典型例:
# subgraph-user: ユーザーサービス
type User @key(fields: "id") {
id: ID!
name: String!
organizationId: ID!
}
# subgraph-org: 組織サービス
type Organization @key(fields: "id") {
id: ID!
name: String!
planId: ID!
}
# subgraph-billing: 課金サービス
type BillingPlan @key(fields: "id") {
id: ID!
tier: String!
price: Float!
}
# これをフロントエンドが1クエリで取得しようとする
# クエリ: user.organization.billingPlan.price
このクエリを実行すると、Router は以下の順序で動く:
sequenceDiagram
participant Client
participant Router
participant UserSubgraph
participant OrgSubgraph
participant BillingSubgraph
Client->>Router: user { organization { billingPlan { price } } }
Router->>UserSubgraph: User を要求
UserSubgraph-->>Router: organizationId を返す
Router->>OrgSubgraph: Organization を要求
OrgSubgraph-->>Router: planId を返す
Router->>BillingSubgraph: BillingPlan を要求
BillingSubgraph-->>Router: price を返す
Router-->>Client: レスポンス(合計 1秒以上)
5 ステップが全部直列。 各サブグラフのレスポンスが 200ms なら、合計で 1 秒。さらに 50 個のユーザーを取得するクエリなら… 考えたくない。
キャッシング戦略で半分解決できた
2026 年時点で「GraphQL キャッシング」は実装戦術が固まってきた。Apollo が @cacheControl ディレクティブを強化したのと、Redis ベースのキャッシング戦略が実用レベルになってるから。
僕らが実装したのは「3 層キャッシング」。ざっくり説明すると、まず Router レベルで全体的なレスポンスをキャッシュしておく。その下に Redis を置いて頻出フィールドをキャッシュ。最後に DataLoader で同一クエリ内での無駄な重複呼び出しを排除する。地味だけど、これが本当に効く。
1. HTTP キャッシュレイヤー(Router):GraphQL レスポンス全体を TTL 付きでキャッシュ
2. サブグラフレイヤー(Redis):頻出フィールド(User、Organization など)を個別キャッシュ
3. DataLoader による重複排除:同一クエリ内での N+1 を防ぐ
実装コードで見るとこんな感じ:
// subgraph-user/src/resolvers.ts
import DataLoader from 'dataloader';
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
});
// DataLoader: 同一バッチ内の重複をまとめる
const userLoader = new DataLoader(async (userIds: string[]) => {
const cachedUsers = await redis.mget(
userIds.map(id => `user:${id}`)
);
// キャッシュミスの ID だけDB から取得
const missingIds = userIds.filter((id, i) => !cachedUsers[i]);
const dbUsers = missingIds.length > 0
? await db.user.findMany({ where: { id: { in: missingIds } } })
: [];
// キャッシュに保存(TTL: 5分)
for (const user of dbUsers) {
await redis.setex(`user:${user.id}`, 300, JSON.stringify(user));
}
// 元の順序で返す
return userIds.map(id => {
const cached = cachedUsers[userIds.indexOf(id)];
if (cached) return JSON.parse(cached);
return dbUsers.find(u => u.id === id);
});
});
const resolvers = {
Query: {
users: async (_, { ids }, context) => {
return userLoader.loadMany(ids);
},
},
User: {
organization: async (user, _, context) => {
const cached = await redis.get(`org:${user.organizationId}`);
if (cached) return JSON.parse(cached);
const org = await db.organization.findUnique({
where: { id: user.organizationId },
});
await redis.setex(`org:${org.id}`, 300, JSON.stringify(org));
return org;
},
},
};
export { resolvers, userLoader };
これ単体でも効果が出た。同じクエリが 2 秒 → 300ms に短縮された。 でも完全ではない。
Router キャッシング + コンポジットキーの組み合わせ
2026 年の Apollo Router は、@cacheControl ディレクティブが JSON-based になって、キャッシュキー戦略を細かく制御できるようになってる。
// subgraph-user/src/schema.graphql
type User @key(fields: "id") {
id: ID! @shareable
name: String! @cacheControl(maxAge: 300)
email: String! @cacheControl(maxAge: 3600, scope: PRIVATE)
organization: Organization @cacheControl(maxAge: 600)
}
type Organization @key(fields: "id") {
id: ID!
name: String! @cacheControl(maxAge: 1800)
tier: String! @cacheControl(maxAge: 86400) # 課金プランはほぼ変わらない
}
Router 側の設定:
# router.yaml
supergraph: ./supergraph.graphql
plugins:
experimental.cache:
enabled: true
redis:
urls:
- "redis://localhost:6379"
query_planning:
# クエリプランをキャッシュ(同じクエリなら再計算しない)
enabled: true
in_memory:
# 小さいレスポンスはメモリにもキャッシュ
limit: 10mb
これを導入してから、同じクエリ(フロントエンド からの重複リクエスト)は Redis キャッシュから 20〜50ms で返すようになった。 劇的に改善。
本番で起きた「キャッシュの地獄」と対策
キャッシング自体は素晴らしいんだけど、キャッシュ無効化(Invalidation)で新しい地獄が生まれた。
状況:ユーザーが「プラン切り替え」ボタンを押す。バックエンド では billing サブグラフのデータが更新される。でもキャッシュに古いデータが残ってると、フロントエンド は 5 分間、古い情報を表示し続ける。
これを解決するために、サブグラフがキャッシュバスターを送信する仕組みを作った:
// subgraph-billing/src/resolvers.ts
import axios from 'axios';
const updateBillingPlan = async (planId: string, newTier: string) => {
// DB 更新
await db.billingPlan.update({
where: { id: planId },
data: { tier: newTier },
});
// キャッシュを明示的に削除
await redis.del(`billing_plan:${planId}`);
// Apollo Router に「このキーのキャッシュ削除」を通知
// (2026年の Apollo Router は Invalidation API をサポート)
await axios.post('http://router:4000/.well-known/apollo/cache-control', {
action: 'invalidate',
keys: [`BillingPlan:${planId}`],
});
};
でも現実はもっと複雑。 依存関係をグラフで管理しないと、「Organization を更新したら、そこに紐付いた 100 個の User キャッシュも消す」みたいな連鎖削除で、結局全キャッシュを吹き飛ばす事態になる。
2026 年時点では、こういう「キャッシュ依存関係の自動化」を GraphQL 層で管理するのが流行ってる。実装例は Apollo の新プラグイン或いは、Stripe みたいなマジメな API のアプローチを参考にしてる。
// キャッシュ依存関係を型で定義
interface CacheDependency {
invalidateOn: string[]; // このイベントで削除
cascade: boolean; // 依存先も連鎖削除するか
}
const cacheGraph: Record<string, CacheDependency> = {
'BillingPlan:*': {
invalidateOn: ['billing.plan.updated', 'billing.plan.deleted'],
cascade: true, // Organization と User を連鎖削除
},
'User:*': {
invalidateOn: ['user.profile.updated'],
cascade: false,
},
};
本番環境での Federation の「本当の課題」
キャッシングまで解決しても、まだある。
1. アップストリームサービスの一つが遅いと全部遅くなる
Federation では、Router が複数のサブグラフを呼び出す。一番遅いサブグラフの応答時間 = 全体の応答時間。 これを「critical path」という。
うちの場合、billing サブグラフがたまに 5 秒ハングアップ する不具合があって、それに気づくまでに 2 週間、本番で「謎に遅い」状態が続いた。
対策として、タイムアウトとサーキットブレーカーを Router に組み込んだ:
# router.yaml
subgraphs:
user:
url: "http://user-service:4001"
timeout: 5000ms
circuit_breaker:
enabled: true
failure_threshold: 5
success_threshold: 2
billing:
url: "http://billing-service:4002"
timeout: 2000ms # billing は特に短い
circuit_breaker:
enabled: true
failure_threshold: 3
2. 権限(Authorization)が複雑化する
Federation では、各サブグラフが独立した認証・認可ロジックを持つ。でも「User データを返すかどうか」は、Organization のコンテキストに依存する。
type User @key(fields: "id") {
id: ID!
name: String!
email: String! # これは本人 or 組織管理者しか見れない
}
複数のサブグラフで同じ権限チェックを繰り返すのは非効率だし、バグの温床になる。
2026 年の解は「権限情報を JWT に埋め込んで、すべてのサブグラフに送信する」か、「Apollo Router で権限チェックを一元化」か、のどちらか。僕らは後者を選んだ:
// router/auth-plugin.ts
export const authPlugin = {
async willSendSubgraphRequest(options) {
const { request, context } = options;
// クライアントの JWT をデコード
const user = context.user;
const org = context.organization;
// リクエストに権限情報をヘッダーで追加
request.http.headers.set('X-Org-Id', org.id);
request.http.headers.set('X-User-Id', user.id);
request.http.headers.set('X-User-Role', user.role);
},
};
3. スキーマバージョン管理が地獄
15 個のマイクロサービス が各々のスキーマを持ってる。あるサービスが「User にフィールド A を追加」したら、他の 14 サービスはそれに対応する必要があるかもしれない。
実際に経験した例:user-service が User type に premiumSince: DateTime を追加したら、それを参照する 5 個のサービスで、合計 15 個の関連フィールドの更新が必要になった。
これを管理するために、僕らは スキーマレジストリを Apollo Studio に統一した。 各サブグラフは CI/CD でスキーマを登録する前に、Apollo Studio の互換性チェッカーが「破壊的変更がないか」を自動検証する。
# CI/CD に組み込む例(GitHub Actions)
- name: Validate GraphQL Schema
run: |
npx apollo schema:check \
--key=$APOLLO_KEY \
--endpoint=$APOLLO_GRAPH_ENDPOINT
代替案との比較:GraphQL vs gRPC vs REST
2026 年時点で「マイクロサービス間の通信」を選ぶ時、GraphQL・gRPC・REST のどれを選ぶかはかなり固まった判断基準がある。僕らが 2 年 GraphQL で地獄を見た後、改めて比較した結果をまとめると:
| 評価軸 | GraphQL | gRPC | REST |
|---|---|---|---|
| 学習曲線 | 中程度(スキーマ言語がある) | 急(Protocol Buffer) | 易(HTTP/JSON) |
| キャッシング | 複雑(N+1 防止が必須) | 簡単(HTTP/2 multiplexing) | 中程度(HTTP キャッシュ) |
| 型安全性 | 強い(スキーマから型生成) | 最強(proto3) | 弱い(手作業) |
| 開発速度(初期) | 速い(スキーマ駆動) | 遅い(proto 定義多い) | 中程度 |
| 本番運用 | 複雑(Federation・キャッシング) | シンプル(通信は単純) | シンプル |
| デバッグ性 | 中程度(GraphQL Playground) | 低い(バイナリプロトコル) | 高い(curl で確認可能) |
| チーム規模 | 200+ 人規模が得意 | 小規模・高性能が必須の場合 | 全規模対応 |
正直に言うと、うちの 250 人チームなら「gRPC を内部使用、外部 API は GraphQL」という 2 層構造の方が楽だった可能性がある。 Federation の複雑さを考えると、初期段階では gRPC の方がシンプルだ。
2026 年時点での「GraphQL の使いどころ」
これまでの失敗と改善を踏まえて、GraphQL が本当に活躍する場面は限定的だと気づいた。
✅ GraphQL が活躍する場面:
- 外部 API の統一インターフェース:複数の外部サービス(Stripe、Slack 等)を一つの GraphQL に統合したい時が便利
- モバイル・Web クライアント の多様な要件:フロントエンド 側で「必要なフィールドだけ選べる」ってのが本当に助かる
- 開発スピード重視:初期段階でスキーマから型生成できる効率性は何物にも代え難い
- 150〜300 人規模のチーム:Federation で十分管理でき、複雑さとメリットのバランスが取れてる
❌ GraphQL が苦手な場面:
- リアルタイムストリーミング:WebSocket + Subscription は正直、運用が面倒な割に得るものが少ない
- ファイルアップロード:GraphQL は multipart/form-data と相性が悪い。REST 呼び出しになる
- バッチ処理・非同期ジョブ:GraphQL は「同期リクエスト」前提だから、向かない
- シンプルな CRUD API:REST で十分。GraphQL は過剰設計になる
まとめ
GraphQL Federation を 2 年本番で運用して学んだ現実的な知見をまとめた。
1. Federation は便利だが、N+1 問題とキャッシング戦略がセット。 キャッシングなしで本番に出すと、必ず遅くなる。2026 年は Redis × DataLoader × @cacheControl の組み合わせが基本になってる。
2. キャッシュ無効化の管理が想像以上に複雑。 依存関係グラフを明示的に定義しないと、結局全キャッシュをパージする羽目になる。これだけは避けたい。
3. Federation は 150〜300 人規模のチームに最適。 250 人を超えると複雑度が急増する。400+ 人なら gRPC + 外部 API 用 GraphQL の 2 層にすべきだと今は思う。
4. スキーマレジストリ(Apollo Studio)は必須。 手作業で管理してると、1 ヶ月で破壊的変更でチーム全体が止まる。ほぼ確実に。
5. アップストリームのタイムアウトとサーキットブレーカーを Router に。 一つのサブグラフが遅いと全体が遅くなる性質上、障害の波及を止める仕組みがないと、本番は地獄だ。
次のプロジェクトでマイクロサービス を設計する時は、最初から「GraphQL が本当に必要か」を問い直す。外部 API 統合なら GraphQL、内部サービス間通信なら gRPC、という割り切りが 2026 年のスタンダードになってきた。僕らの 2 年間の失敗が、誰かの意思決定の参考になればいいなって思う。