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 で地獄を見た後、改めて比較した結果をまとめると:

評価軸GraphQLgRPCREST
学習曲線中程度(スキーマ言語がある)急(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 年間の失敗が、誰かの意思決定の参考になればいいなって思う。

U

Untanbaby

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

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

関連記事