Skip to content
Published on

APIゲートウェイパターンとBFF設計:Kong・Envoy・GraphQL Federationの実践実装

Authors
API Gateway BFF

はじめに

マイクロサービスアーキテクチャが産業全般に普及する中、サービス間通信の複雑さをどのように管理するかは、すべてのエンジニアリング組織の中核的な課題となっています。グローバルAPI管理市場は2026年時点で約51億ドル規模に成長し、CAGR 32.3%の爆発的な拡大を見せています。2025年時点で組織の31%が複数のAPIゲートウェイを運用しており、そのうち11%は3つ以上のゲートウェイを並行運用しています。

APIゲートウェイパターンは、クライアントとバックエンドサービスの間に単一のエントリーポイント(Single Entry Point)を配置し、ルーティング、認証、レートリミッティング、プロトコル変換などの横断的関心事(Cross-Cutting Concerns)を集約するアーキテクチャパターンです。ここにBFF(Backend for Frontend)パターンを組み合わせることで、Web、モバイル、IoTなど各フロントエンドタイプに最適化された専用バックエンドを提供できます。

本記事では、APIゲートウェイパターンとBFFパターンのコア原理を検討し、Kong Gateway、Envoy Proxy、GraphQL Federationという3つの主要ツールの実践的な構成方法を扱います。認証・認可の統合、レートリミッティング、サーキットブレーカー、オブザーバビリティ(Observability)の設定まで、本番運用に必要なすべての内容を実践コードとともに提供します。

APIゲートウェイパターンの理解

ゲートウェイの役割

APIゲートウェイは、マイクロサービスアーキテクチャにおけるすべてのクライアントリクエストの単一エントリーポイントの役割を果たします。内部サービストポロジーの複雑さを抽象化し、クライアントにクリーンで一貫したインターフェースを提供します。主な責務は以下の通りです。

  • リクエストルーティング: URLパス、ヘッダー、メソッドに基づいて適切なバックエンドサービスにトラフィックを転送
  • プロトコル変換: REST-to-gRPC、HTTP-to-WebSocketなどプロトコル間の変換
  • レスポンス集約: 複数のサービスのレスポンスを1つにまとめてクライアントに返却(API Composition)
  • 横断的関心事の処理: 認証、認可、レートリミッティング、ロギング、キャッシング、CORSなどを一元管理
  • サービスディスカバリ: 動的にサービスインスタンスを探索しロードバランシングを実行

ゲートウェイトポロジーアーキテクチャ

                          API 게이트웨이 토폴로지

    ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
Web App │  │Mobile App│Partner  │  │IoT Device│
     (React) (Swift)  (B2B) (MQTT)    └────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬─────┘
         │             │             │              │
         └──────┬──────┴──────┬──────┘              │
                │             │                     │
         ┌──────▼──────┐ ┌───▼──────────┐   ┌──────▼──────┐
Edge LB   │ │  Edge LB     │   │  Edge LB           (L4/L7)  (L4/L7)  (L4/L7)         └──────┬──────┘ └──────┬───────┘   └──────┬──────┘
                │               │                  │
         ┌──────▼───────────────▼──────────────────▼──────┐
API Gateway Cluster         │  ┌──────────────────────────────────────────┐   │
         │  │  AuthRate LimitLoggingTransform │   │
         │  └──────────────────────────────────────────┘   │
         │  ┌─────────────────────────────────────────┐    │
         │  │        Route Matching & Dispatch         │    │
         │  └─────────────────────────────────────────┘    │
         └───────┬──────────┬──────────┬──────────┬───────┘
                 │          │          │          │
          ┌──────▼───┐┌────▼────┐┌────▼────┐┌───▼──────┐
User Svc ││Order Svc││Pay Svc  ││Notify Svc│
           (gRPC)   ││ (REST)  ││ (REST)  ││(WebSocket│
          └──────────┘└─────────┘└─────────┘└──────────┘

Edgeゲートウェイ vs Internalゲートウェイ

実務では、ゲートウェイを階層的に配置します。Edgeゲートウェイは外部トラフィックのエントリーポイントとしてDDoS防御、TLS終端、積極的なレートリミッティングを実行します。Internalゲートウェイはサービス間通信を管理し、サービスディスカバリ、mTLS、きめ細かなアクセス制御を担当します。

    외부 트래픽
    ┌───▼────────────────────────┐
Edge Gateway (Kong)      │  ← TLS 종료, WAF, 레이트 리미팅
- DDoS 방어              │
- JWT 검증               │
- 글로벌 레이트 리미팅    │
    └───────────┬────────────────┘
    ┌───────────▼────────────────┐
Internal Gateway (Envoy)  │  ← mTLS, 서비스 라우팅
- mTLS 상호 인증         │
- 서비스 디스커버리       │
- 세밀한 RBAC    └──┬────────┬────────┬───────┘
       │        │        │
    ┌──▼──┐  ┌──▼──┐  ┌──▼──┐
    │Svc A│  │Svc B│  │Svc C    └─────┘  └─────┘  └─────┘

BFF(Backend for Frontend)パターン

BFFパターンが必要な理由

単一のAPIゲートウェイですべてのクライアントにサービスを提供していると、必然的に「一つのサイズがすべてに合わない」問題に直面します。Webアプリケーションは豊富なデータを一度に取得したいのに対し、モバイルアプリは帯域幅節約のため最小限のフィールドのみ必要です。IoTデバイスはバイナリプロトコルを、サードパーティパートナーは安定したREST APIを期待します。

BFFパターンはSam Newmanが提案したアーキテクチャパターンで、各フロントエンドタイプに合った専用バックエンドを配置してこの問題を解決します。BFFは特定のユーザー体験に密接に結合され、そのUIを担当するチームがBFFも合わせて管理します。

BFFアーキテクチャ

    ┌──────────┐     ┌──────────┐     ┌──────────┐
Web App │     │Mobile App│Admin App│
     (React) (Flutter) (Vue.js)    └────┬─────┘     └────┬─────┘     └────┬─────┘
         │                │                │
    ┌────▼─────┐     ┌────▼─────┐     ┌────▼─────┐
Web BFF  │     │Mobile BFF│     │Admin BFF    (Node.js)  (Go)(Node.js)    │          │     │          │     │          │
- 풍부한  │     │- 경량    │     │- 대시보드│
    │  데이터  │     │  페이로드│     │  집계    │
- SSR 지원│     │- 오프라인│     │- 벌크    │
- SEO     │     │  캐싱    │     │  연산    │
    └──┬───┬───┘     └──┬───┬───┘     └──┬───┬───┘
       │   │            │   │            │   │
       │   └────────┬───┘   └────────┬───┘   │
       │            │                │       │
    ┌──▼──┐     ┌───▼──┐         ┌───▼──┐   │
    │User │     │Order │         │Admin │◄──┘
Svc │     │ Svc  │         │ Svc    └─────┘     └──────┘         └──────┘

BFF実装例:Node.js Web BFF

// web-bff/src/server.ts
import express from 'express'
import axios from 'axios'

const app = express()

// Web BFF: 대시보드를 위한 데이터 집계 엔드포인트
app.get('/api/dashboard', async (req, res) => {
  const userId = req.headers['x-user-id'] as string

  try {
    // 여러 마이크로서비스에서 병렬로 데이터 수집
    const [userProfile, recentOrders, notifications, analytics] = await Promise.all([
      axios.get(`${process.env.USER_SERVICE_URL}/users/${userId}`),
      axios.get(`${process.env.ORDER_SERVICE_URL}/orders?userId=${userId}&limit=10`),
      axios.get(`${process.env.NOTIFICATION_SERVICE_URL}/notifications/${userId}?unread=true`),
      axios.get(`${process.env.ANALYTICS_SERVICE_URL}/users/${userId}/summary`),
    ])

    // Web 프론트엔드에 최적화된 응답 구조
    res.json({
      user: {
        name: userProfile.data.name,
        email: userProfile.data.email,
        avatar: userProfile.data.avatarUrl,
        memberSince: userProfile.data.createdAt,
      },
      orders: {
        recent: recentOrders.data.items.map((order: any) => ({
          id: order.id,
          status: order.status,
          total: order.totalAmount,
          date: order.createdAt,
          itemCount: order.items.length,
          // 웹에서만 필요한 상세 정보 포함
          trackingUrl: order.trackingUrl,
          invoice: order.invoiceUrl,
        })),
        totalCount: recentOrders.data.totalCount,
      },
      notifications: {
        unreadCount: notifications.data.count,
        items: notifications.data.items.slice(0, 5),
      },
      analytics: {
        totalSpent: analytics.data.totalSpent,
        orderFrequency: analytics.data.orderFrequency,
        favoriteCategories: analytics.data.topCategories,
      },
    })
  } catch (error) {
    // 부분 실패 시 가용한 데이터만 반환 (Graceful Degradation)
    res.status(207).json({
      partial: true,
      error: 'Some services are unavailable',
      available: {},
    })
  }
})

// Mobile BFF와의 차이점: 웹은 SSR을 위한 전체 데이터를 반환
// Mobile BFF는 동일 엔드포인트에서 경량 필드만 반환
app.listen(3001, () => {
  console.log('Web BFF running on port 3001')
})

BFFコア設計原則

  1. 1つのBFFは1つのフロントエンドに対応: Web BFFがモバイルの要件を処理し始めると「多目的BFF」となり、元の問題に逆戻りします。
  2. BFFはビジネスロジックを持たない: データ集約、変換、フォーマッティングのみ実行し、ビジネスルールは必ず下位サービスに配置します。
  3. BFFチームとフロントエンドチームは同一チーム: フロントエンド開発者が自分のBFFを直接管理する時が最も効率的です。
  4. セキュリティ境界としてのBFF: 特にSPA環境でOIDC/OAuth 2.0フローのトークンネゴシエーションをBFFがConfidential Clientとして処理し、パブリッククライアントのセキュリティリスクを排除します。

ゲートウェイツール比較

主要なAPIゲートウェイソリューションを本番運用の観点から比較します。

項目Kong GatewayEnvoy ProxyAWS API GatewayGraphQL Federation
基盤技術NGINX + LuaC++ (自社開発)AWS 관리형Apollo Router (Rust)
デプロイモデルセルフホスティング / Konnectセルフホスティング / サイドカーフルマネージドセルフホスティング / GraphOS
プロトコルHTTP, gRPC, WebSocketHTTP/1.1, HTTP/2, gRPC, TCPREST, WebSocket, HTTPGraphQL
性能~50,000 TPS/노드~100,000+ TPS/노드AWS 내부 최적화サブグラフ数に比例
拡張性플러그인 (Lua, Go)필터 (C++, Wasm, Lua)Lambda 연동서브그래프 서비스
K8s統合KIC (Gateway API 준수)Envoy Gateway / IstioEKS 통합Helm Chart
オブザーバビリティPrometheus, DatadogOpenTelemetry 네이티브CloudWatchApollo Studio
認証플러그인 (JWT, OAuth)필터 (JWT, ext_authz)Cognito, Lambda Auth서브그래프 위임
レートリミッティング플러그인 (ローカル/グローバル)필터 (ローカル/グローバル)スロットリング内蔵カスタム実装が必要
ライセンスApache 2.0 / EnterpriseApache 2.0従量制Elastic License
学習曲線中程度高い低い高い
適合シナリオ汎用API管理サービスメッシュ / L7制御AWS 네이티브マルチチームグラフ統合

選択ガイド

  • Kong: プラグインエコシステムが豊富でGUI管理ツールが必要な場合。60以上の公式プラグインを提供しています。ただし、OIDC、高度な分析などのコア機能がEnterpriseライセンスに含まれる点に注意が必要です。
  • Envoy: 高性能L7プロキシが必要な場合やサービスメッシュ(Istio、Consul)との統合が必要な場合。xDS APIによる動的設定が強みですが、設定の複雑さが高いです。
  • AWS API Gateway: AWSネイティブ環境で管理負担を最小化したい場合。Lambda統合が強力ですが、AWSロックインとコールドスタート遅延が欠点です。
  • GraphQL Federation: 多数のチームが独立してグラフを提供し、フロントエンドが柔軟なクエリを必要とする場合。スキーマ設計の初期投資は大きいですが、長期的にフロントエンドの生産性が最大化されます。

Kong Gateway本番構成

Kongのインストールと基本構成(Kubernetes)

Kong Ingress Controller(KIC)はKubernetes Gateway APIをファーストクラスシチズンとしてサポートし、Gateway APIコア適合性テストを100%パスした最初のゲートウェイです。

# kong-values.yaml - Helm Chart 설정
# helm install kong kong/ingress -f kong-values.yaml -n kong-system

gateway:
  image:
    repository: kong/kong-gateway
    tag: '3.9'
  env:
    database: 'off' # DB-less 모드 (선언적 설정)
    proxy_access_log: /dev/stdout
    admin_access_log: /dev/stdout
    proxy_error_log: /dev/stderr
    admin_error_log: /dev/stderr
    plugins: bundled,oidc,prometheus
  proxy:
    type: LoadBalancer
    annotations:
      service.beta.kubernetes.io/aws-load-balancer-type: nlb
      service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
    tls:
      enabled: true
  admin:
    enabled: true
    type: ClusterIP # 클러스터 내부에서만 접근
  resources:
    requests:
      cpu: 500m
      memory: 512Mi
    limits:
      cpu: 2000m
      memory: 2Gi

controller:
  image:
    repository: kong/kubernetes-ingress-controller
    tag: '3.4'
  ingressClass: kong
  resources:
    requests:
      cpu: 250m
      memory: 256Mi

Kongサービスとルーティング構成

# kong-gateway-api.yaml
# Kubernetes Gateway API를 사용한 선언적 라우팅 구성
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: kong-gateway
  namespace: kong-system
  annotations:
    konghq.com/gateway-operator: 'true'
spec:
  gatewayClassName: kong
  listeners:
    - name: http
      protocol: HTTP
      port: 80
    - name: https
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - name: api-tls-cert
            kind: Secret
---
# HTTPRoute로 서비스별 라우팅 정의
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: user-service-route
  namespace: default
  annotations:
    konghq.com/plugins: rate-limiting-global,jwt-auth,cors
spec:
  parentRefs:
    - name: kong-gateway
      namespace: kong-system
  hostnames:
    - 'api.example.com'
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /api/v1/users
      backendRefs:
        - name: user-service
          port: 8080
          weight: 100
    - matches:
        - path:
            type: PathPrefix
            value: /api/v1/orders
      backendRefs:
        - name: order-service
          port: 8080
          weight: 90
        - name: order-service-canary
          port: 8080
          weight: 10 # 카나리 배포: 10% 트래픽
---
# Kong 플러그인: JWT 인증
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: jwt-auth
  namespace: default
config:
  key_claim_name: iss
  claims_to_verify:
    - exp
  header_names:
    - Authorization
plugin: jwt
---
# Kong 플러그인: 레이트 리미팅
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: rate-limiting-global
  namespace: default
config:
  minute: 100
  hour: 5000
  policy: redis
  redis:
    host: redis-master.redis-system.svc.cluster.local
    port: 6379
    database: 0
    timeout: 2000
  fault_tolerant: true # Redis 장애 시에도 요청 통과
  hide_client_headers: false
plugin: rate-limiting
---
# Kong 플러그인: CORS
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: cors
  namespace: default
config:
  origins:
    - 'https://app.example.com'
    - 'https://admin.example.com'
  methods:
    - GET
    - POST
    - PUT
    - DELETE
    - OPTIONS
  headers:
    - Authorization
    - Content-Type
    - X-Request-ID
  exposed_headers:
    - X-RateLimit-Remaining
  max_age: 3600
  credentials: true
plugin: cors

Envoy Proxy構成

Envoy基本構成

Envoyは高性能なC++ベースのプロキシで、L7層でHTTP/2、gRPCをネイティブにサポートします。xDS APIによる動的設定更新がコアの強みです。

# envoy-config.yaml
# Envoy 프록시 정적 설정 (프로덕션에서는 xDS 동적 설정 사용 권장)
admin:
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901

static_resources:
  listeners:
    - name: api_listener
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 8443
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress_http
                codec_type: AUTO
                # 액세스 로그 설정
                access_log:
                  - name: envoy.access_loggers.file
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
                      path: /dev/stdout
                      log_format:
                        json_format:
                          timestamp: '%START_TIME%'
                          method: '%REQ(:METHOD)%'
                          path: '%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%'
                          protocol: '%PROTOCOL%'
                          response_code: '%RESPONSE_CODE%'
                          duration_ms: '%DURATION%'
                          upstream_host: '%UPSTREAM_HOST%'
                          request_id: '%REQ(X-REQUEST-ID)%'
                # HTTP 필터 체인
                http_filters:
                  # JWT 인증 필터
                  - name: envoy.filters.http.jwt_authn
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
                      providers:
                        auth0_provider:
                          issuer: 'https://your-tenant.auth0.com/'
                          audiences:
                            - 'https://api.example.com'
                          remote_jwks:
                            http_uri:
                              uri: 'https://your-tenant.auth0.com/.well-known/jwks.json'
                              cluster: auth0_jwks
                              timeout: 5s
                            cache_duration: 600s
                          forward: true
                          payload_in_metadata: jwt_payload
                      rules:
                        - match:
                            prefix: /api/
                          requires:
                            provider_name: auth0_provider
                        - match:
                            prefix: /health
                  # 레이트 리미팅 필터
                  - name: envoy.filters.http.local_ratelimit
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
                      stat_prefix: http_local_rate_limiter
                      token_bucket:
                        max_tokens: 1000
                        tokens_per_fill: 100
                        fill_interval: 1s
                      filter_enabled:
                        runtime_key: local_rate_limit_enabled
                        default_value:
                          numerator: 100
                          denominator: HUNDRED
                      filter_enforced:
                        runtime_key: local_rate_limit_enforced
                        default_value:
                          numerator: 100
                          denominator: HUNDRED
                      response_headers_to_add:
                        - append_action: OVERWRITE_IF_EXISTS_OR_ADD
                          header:
                            key: x-local-rate-limit
                            value: 'true'
                  # 라우터 필터 (반드시 마지막)
                  - name: envoy.filters.http.router
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                # 라우트 설정
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: api_service
                      domains: ['api.example.com']
                      routes:
                        - match:
                            prefix: '/api/v1/users'
                          route:
                            cluster: user_service
                            timeout: 10s
                            retry_policy:
                              retry_on: '5xx,connect-failure,reset'
                              num_retries: 3
                              per_try_timeout: 3s
                              retry_back_off:
                                base_interval: 0.1s
                                max_interval: 1s
                        - match:
                            prefix: '/api/v1/orders'
                          route:
                            cluster: order_service
                            timeout: 15s
                            retry_policy:
                              retry_on: '5xx,connect-failure'
                              num_retries: 2
          transport_socket:
            name: envoy.transport_sockets.tls
            typed_config:
              '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
              common_tls_context:
                tls_certificates:
                  - certificate_chain:
                      filename: /etc/envoy/certs/server.crt
                    private_key:
                      filename: /etc/envoy/certs/server.key

  clusters:
    - name: user_service
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: user_service
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: user-service.default.svc.cluster.local
                      port_value: 8080
      # 서킷 브레이커 설정
      circuit_breakers:
        thresholds:
          - priority: DEFAULT
            max_connections: 1024
            max_pending_requests: 1024
            max_requests: 1024
            max_retries: 3
      # 헬스 체크
      health_checks:
        - timeout: 5s
          interval: 10s
          unhealthy_threshold: 3
          healthy_threshold: 2
          http_health_check:
            path: /health
      # Outlier Detection (이상 탐지)
      outlier_detection:
        consecutive_5xx: 5
        interval: 10s
        base_ejection_time: 30s
        max_ejection_percent: 50

    - name: order_service
      type: STRICT_DNS
      lb_policy: LEAST_REQUEST
      load_assignment:
        cluster_name: order_service
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: order-service.default.svc.cluster.local
                      port_value: 8080
      circuit_breakers:
        thresholds:
          - priority: DEFAULT
            max_connections: 512
            max_pending_requests: 512

    - name: auth0_jwks
      type: LOGICAL_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: auth0_jwks
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: your-tenant.auth0.com
                      port_value: 443
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
          sni: your-tenant.auth0.com

GraphQL Federation

Apollo Federation v2の概要

GraphQL Federationは、複数のチームが独立して管理するサブグラフ(Subgraph)を1つの統合されたスーパーグラフ(Supergraph)に合成するアーキテクチャパターンです。Apollo Federation v2は改善された共有オーナーシップモデル、向上したタイプマージ、よりクリーンな構文を提供します。

    ┌──────────────────────────────────────────────────┐
    │                  클라이언트                        │
                  (Web / Mobile App)    └────────────────────┬─────────────────────────────┘
GraphQL 쿼리
    ┌────────────────────▼─────────────────────────────┐
Apollo Router             (슈퍼그래프 실행 엔진)    │                                                  │
    │  ┌────────────────────────────────────────────┐   │
    │  │       Query Plan (쿼리 실행 계획)           │   │
    │  │  1. users 서브그래프에서 User 조회          │   │
    │  │  2. orders 서브그래프에서 Order 조회        │   │
    │  │  3. reviews 서브그래프에서 Review 조회      │   │
    │  │  4. 결과 병합 후 클라이언트에 반환          │   │
    │  └────────────────────────────────────────────┘   │
    └───────┬───────────────┬───────────────┬──────────┘
            │               │               │
    ┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
Users        │ │ Orders      │ │ ReviewsSubgraph     │ │ Subgraph    │ │ Subgraph     (Team A) (Team B) (Team C)    │              │ │             │ │             │
- User       │ │ - Order     │ │ - Review- Profile    │ │ - LineItem  │ │ - Rating    │              │ │ - extends   │ │ - extends    │              │ │   User      │ │   User    └──────────────┘ └─────────────┘ └─────────────┘

Federationサブグラフの実装

// subgraphs/users/src/index.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { buildSubgraphSchema } from '@apollo/subgraph'
import { gql } from 'graphql-tag'

const typeDefs = gql`
  extend schema
    @link(
      url: "https://specs.apollo.dev/federation/v2.11"
      import: ["@key", "@shareable", "@external", "@provides"]
    )

  type Query {
    user(id: ID!): User
    users(limit: Int = 10, offset: Int = 0): UserConnection!
    me: User
  }

  type User @key(fields: "id") {
    id: ID!
    email: String!
    name: String!
    avatarUrl: String
    role: UserRole!
    createdAt: String!
    updatedAt: String!
  }

  type UserConnection {
    nodes: [User!]!
    totalCount: Int!
    pageInfo: PageInfo!
  }

  type PageInfo @shareable {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
  }

  enum UserRole {
    ADMIN
    USER
    MODERATOR
  }
`

const resolvers = {
  Query: {
    user: async (_: any, { id }: { id: string }, context: any) => {
      return context.dataSources.usersAPI.getUser(id)
    },
    users: async (_: any, args: any, context: any) => {
      return context.dataSources.usersAPI.getUsers(args)
    },
    me: async (_: any, __: any, context: any) => {
      if (!context.userId) throw new Error('Not authenticated')
      return context.dataSources.usersAPI.getUser(context.userId)
    },
  },
  User: {
    __resolveReference: async (ref: { id: string }, context: any) => {
      // Federation이 다른 서브그래프에서 User를 참조할 때 호출
      return context.dataSources.usersAPI.getUser(ref.id)
    },
  },
}

const server = new ApolloServer({
  schema: buildSubgraphSchema({ typeDefs, resolvers }),
})

const { url } = await startStandaloneServer(server, {
  listen: { port: 4001 },
  context: async ({ req }) => ({
    userId: req.headers['x-user-id'],
    dataSources: {
      usersAPI: new UsersDataSource(),
    },
  }),
})

console.log(`Users subgraph ready at ${url}`)
// subgraphs/orders/src/index.ts
import { ApolloServer } from '@apollo/server'
import { buildSubgraphSchema } from '@apollo/subgraph'
import { gql } from 'graphql-tag'

const typeDefs = gql`
  extend schema
    @link(
      url: "https://specs.apollo.dev/federation/v2.11"
      import: ["@key", "@external", "@requires"]
    )

  type Query {
    order(id: ID!): Order
    ordersByUser(userId: ID!, status: OrderStatus): [Order!]!
  }

  # User 타입을 확장하여 orders 필드 추가
  type User @key(fields: "id") {
    id: ID! @external
    orders(status: OrderStatus, limit: Int = 10): [Order!]!
    totalSpent: Float! @requires(fields: "id")
  }

  type Order @key(fields: "id") {
    id: ID!
    userId: ID!
    user: User!
    items: [OrderItem!]!
    status: OrderStatus!
    totalAmount: Float!
    currency: String!
    createdAt: String!
    shippingAddress: Address
  }

  type OrderItem {
    productId: ID!
    productName: String!
    quantity: Int!
    unitPrice: Float!
  }

  type Address {
    street: String!
    city: String!
    country: String!
    zipCode: String!
  }

  enum OrderStatus {
    PENDING
    CONFIRMED
    SHIPPED
    DELIVERED
    CANCELLED
  }
`

const resolvers = {
  Query: {
    order: async (_: any, { id }: { id: string }, ctx: any) => {
      return ctx.dataSources.ordersAPI.getOrder(id)
    },
    ordersByUser: async (_: any, args: any, ctx: any) => {
      return ctx.dataSources.ordersAPI.getOrdersByUser(args.userId, args.status)
    },
  },
  User: {
    orders: async (user: { id: string }, args: any, ctx: any) => {
      return ctx.dataSources.ordersAPI.getOrdersByUser(user.id, args.status)
    },
    totalSpent: async (user: { id: string }, _: any, ctx: any) => {
      return ctx.dataSources.ordersAPI.getTotalSpent(user.id)
    },
  },
  Order: {
    user: (order: { userId: string }) => ({ __typename: 'User', id: order.userId }),
  },
}

スーパーグラフ構成

# supergraph-config.yaml
# rover supergraph compose --config supergraph-config.yaml > supergraph.graphql
federation_version: =2.11.2
subgraphs:
  users:
    routing_url: http://users-subgraph:4001/graphql
    schema:
      subgraph_url: http://users-subgraph:4001/graphql
  orders:
    routing_url: http://orders-subgraph:4002/graphql
    schema:
      subgraph_url: http://orders-subgraph:4002/graphql
  reviews:
    routing_url: http://reviews-subgraph:4003/graphql
    schema:
      subgraph_url: http://reviews-subgraph:4003/graphql
# router-config.yaml (Apollo Router 설정)
supergraph:
  listen: 0.0.0.0:4000
  introspection: false # 프로덕션에서는 비활성화

# 헤더 전파
headers:
  all:
    request:
      - propagate:
          named: 'Authorization'
      - propagate:
          named: 'X-Request-ID'
      - propagate:
          named: 'X-User-ID'

# 서브그래프별 설정
traffic_shaping:
  all:
    timeout: 10s
  subgraphs:
    orders:
      timeout: 15s # 주문 서비스는 더 긴 타임아웃

# 쿼리 깊이 제한 (DoS 방지)
limits:
  max_depth: 15
  max_height: 200
  max_aliases: 30

# 텔레메트리
telemetry:
  exporters:
    tracing:
      otlp:
        enabled: true
        endpoint: http://otel-collector:4317
        protocol: grpc
    metrics:
      prometheus:
        enabled: true
        listen: 0.0.0.0:9090
        path: /metrics

# 응답 캐싱
preview_entity_cache:
  enabled: true
  subgraph:
    all:
      enabled: true
      ttl: 60s

認証と認可の統合

ゲートウェイ層JWT検証ミドルウェア

ゲートウェイでJWTを検証し、検証済みのクレームをヘッダーに変換して下位サービスに転送するのが標準パターンです。RS256またはES256アルゴリズムで署名されたトークンを使用し、IdPの公開鍵で署名を検証します。

// gateway/src/middleware/auth.ts
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'
import { Request, Response, NextFunction } from 'express'

// JWKS 클라이언트 (키 캐싱 포함)
const client = jwksClient({
  jwksUri: `${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`,
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 600000, // 10분
  rateLimit: true,
  jwksRequestsPerMinute: 10,
})

function getSigningKey(header: jwt.JwtHeader): Promise<string> {
  return new Promise((resolve, reject) => {
    client.getSigningKey(header.kid, (err, key) => {
      if (err) return reject(err)
      const signingKey = key?.getPublicKey()
      if (!signingKey) return reject(new Error('No signing key found'))
      resolve(signingKey)
    })
  })
}

// 경로별 인증 정책
const AUTH_POLICIES: Record<string, { required: boolean; scopes?: string[] }> = {
  '/api/v1/users/me': { required: true, scopes: ['read:profile'] },
  '/api/v1/orders': { required: true, scopes: ['read:orders'] },
  '/api/v1/admin': { required: true, scopes: ['admin:all'] },
  '/health': { required: false },
  '/api/v1/products': { required: false }, // 공개 API
}

export async function authMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  // 경로별 정책 조회
  const policy = Object.entries(AUTH_POLICIES).find(([path]) => req.path.startsWith(path))

  if (policy && !policy[1].required) {
    return next()
  }

  const authHeader = req.headers.authorization
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({
      error: 'unauthorized',
      message: 'Missing or invalid Authorization header',
    })
    return
  }

  const token = authHeader.substring(7)

  try {
    // JWT 디코딩 (검증 전 헤더 확인)
    const decoded = jwt.decode(token, { complete: true })
    if (!decoded || typeof decoded === 'string') {
      throw new Error('Invalid token format')
    }

    // 알고리즘 화이트리스트 검증
    if (!['RS256', 'ES256'].includes(decoded.header.alg)) {
      throw new Error(`Unsupported algorithm: ${decoded.header.alg}`)
    }

    // JWKS에서 공개 키를 가져와 서명 검증
    const signingKey = await getSigningKey(decoded.header)
    const payload = jwt.verify(token, signingKey, {
      algorithms: ['RS256', 'ES256'],
      audience: process.env.API_AUDIENCE,
      issuer: `${process.env.AUTH0_DOMAIN}/`,
      clockTolerance: 30, // 30초 시계 오차 허용
    }) as jwt.JwtPayload

    // 스코프 검증
    if (policy?.[1]?.scopes) {
      const tokenScopes = (payload.scope || '').split(' ')
      const requiredScopes = policy[1].scopes
      const hasAllScopes = requiredScopes.every((s) => tokenScopes.includes(s))

      if (!hasAllScopes) {
        res.status(403).json({
          error: 'insufficient_scope',
          message: `Required scopes: ${requiredScopes.join(', ')}`,
        })
        return
      }
    }

    // 검증된 클레임을 헤더로 전파 (하위 서비스에서 사용)
    req.headers['x-user-id'] = payload.sub || ''
    req.headers['x-user-email'] = payload.email || ''
    req.headers['x-user-roles'] = JSON.stringify(payload['https://api.example.com/roles'] || [])
    req.headers['x-auth-time'] = String(payload.iat || 0)

    // 원본 Authorization 헤더는 제거 (하위 서비스에 토큰 노출 방지)
    delete req.headers.authorization

    next()
  } catch (error: any) {
    const errorMap: Record<string, { status: number; message: string }> = {
      TokenExpiredError: { status: 401, message: 'Token has expired' },
      JsonWebTokenError: { status: 401, message: 'Invalid token' },
      NotBeforeError: { status: 401, message: 'Token not yet valid' },
    }

    const mapped = errorMap[error.name] || { status: 401, message: 'Authentication failed' }
    res.status(mapped.status).json({ error: error.name, message: mapped.message })
  }
}

レートリミッティングとサーキットブレーカー

多段階レートリミッティング戦略

効果的なレートリミッティングは単一層ではなく多段階で構成されます。EdgeではIP基準の積極的な制限でDDoSを防御し、ゲートウェイではユーザー/APIキーベースのきめ細かな制限を適用します。

// gateway/src/middleware/rateLimiter.ts
import Redis from 'ioredis'

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  maxRetriesPerRequest: 3,
  retryStrategy: (times) => Math.min(times * 100, 3000),
  enableReadyCheck: true,
})

interface RateLimitConfig {
  windowMs: number // 시간 창 (밀리초)
  maxRequests: number // 최대 요청 수
  keyPrefix: string // Redis 키 프리픽스
}

// 슬라이딩 윈도우 레이트 리미터 (Redis Sorted Set 활용)
async function slidingWindowRateLimit(
  identifier: string,
  config: RateLimitConfig
): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> {
  const key = `${config.keyPrefix}:${identifier}`
  const now = Date.now()
  const windowStart = now - config.windowMs

  // Redis 트랜잭션으로 원자적 처리
  const pipeline = redis.pipeline()
  pipeline.zremrangebyscore(key, 0, windowStart) // 만료된 항목 제거
  pipeline.zadd(key, now.toString(), `${now}:${Math.random()}`) // 현재 요청 추가
  pipeline.zcard(key) // 현재 윈도우 내 요청 수
  pipeline.expire(key, Math.ceil(config.windowMs / 1000)) // TTL 설정

  const results = await pipeline.exec()
  if (!results) throw new Error('Redis pipeline failed')

  const currentCount = results[2]?.[1] as number
  const allowed = currentCount <= config.maxRequests
  const remaining = Math.max(0, config.maxRequests - currentCount)

  if (!allowed) {
    // 가장 오래된 요청이 만료되는 시점 계산
    const oldestInWindow = await redis.zrange(key, 0, 0, 'WITHSCORES')
    const retryAfter =
      oldestInWindow.length >= 2
        ? Math.ceil((parseInt(oldestInWindow[1]) + config.windowMs - now) / 1000)
        : Math.ceil(config.windowMs / 1000)

    return { allowed: false, remaining: 0, retryAfter }
  }

  return { allowed: true, remaining }
}

// 티어별 레이트 리미팅 정책
const RATE_LIMIT_TIERS: Record<string, RateLimitConfig> = {
  free: { windowMs: 60_000, maxRequests: 60, keyPrefix: 'rl:free' },
  pro: { windowMs: 60_000, maxRequests: 600, keyPrefix: 'rl:pro' },
  enterprise: { windowMs: 60_000, maxRequests: 6000, keyPrefix: 'rl:enterprise' },
  // IP 기반 글로벌 제한 (DDoS 방어)
  global_ip: { windowMs: 1_000, maxRequests: 50, keyPrefix: 'rl:ip' },
}

export async function rateLimitMiddleware(req: any, res: any, next: any) {
  const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip
  const userId = req.headers['x-user-id']
  const tier = req.headers['x-user-tier'] || 'free'

  try {
    // 1단계: IP 기반 글로벌 레이트 리미팅
    const ipResult = await slidingWindowRateLimit(clientIp, RATE_LIMIT_TIERS.global_ip)
    if (!ipResult.allowed) {
      res.set('Retry-After', String(ipResult.retryAfter))
      return res.status(429).json({ error: 'Too many requests (IP limit)' })
    }

    // 2단계: 사용자/티어 기반 레이트 리미팅
    if (userId) {
      const config = RATE_LIMIT_TIERS[tier] || RATE_LIMIT_TIERS.free
      const userResult = await slidingWindowRateLimit(userId, config)

      res.set('X-RateLimit-Limit', String(config.maxRequests))
      res.set('X-RateLimit-Remaining', String(userResult.remaining))

      if (!userResult.allowed) {
        res.set('Retry-After', String(userResult.retryAfter))
        return res.status(429).json({ error: 'Rate limit exceeded', tier })
      }
    }

    next()
  } catch (error) {
    // Redis 장애 시 요청을 통과시킴 (fault-tolerant)
    console.error('Rate limiter error:', error)
    next()
  }
}

サーキットブレーカーパターン

サーキットブレーカーは失敗率が閾値を超えると回路を開いて高速な失敗レスポンスを返し、クールダウン後にHalf-Open状態で段階的に復旧を試みます。

// gateway/src/middleware/circuitBreaker.ts
enum CircuitState {
  CLOSED = 'CLOSED', // 정상 - 요청 통과
  OPEN = 'OPEN', // 차단 - 즉시 실패 반환
  HALF_OPEN = 'HALF_OPEN', // 테스트 - 제한적 요청 허용
}

interface CircuitBreakerConfig {
  failureThreshold: number // 실패 임계치 (예: 5)
  successThreshold: number // 복구 판정 성공 횟수 (예: 3)
  timeout: number // Open 상태 유지 시간 (ms)
  monitorWindow: number // 모니터링 윈도우 (ms)
  halfOpenMaxRequests: number // Half-Open에서 허용할 최대 요청
}

class CircuitBreaker {
  private state: CircuitState = CircuitState.CLOSED
  private failureCount = 0
  private successCount = 0
  private lastFailureTime = 0
  private halfOpenRequests = 0

  constructor(
    private name: string,
    private config: CircuitBreakerConfig
  ) {}

  async execute<T>(fn: () => Promise<T>, fallback?: () => T): Promise<T> {
    if (this.state === CircuitState.OPEN) {
      if (Date.now() - this.lastFailureTime >= this.config.timeout) {
        this.transitionTo(CircuitState.HALF_OPEN)
      } else {
        console.warn(`[CircuitBreaker:${this.name}] OPEN - rejecting request`)
        if (fallback) return fallback()
        throw new Error(`Service ${this.name} is unavailable (circuit open)`)
      }
    }

    if (this.state === CircuitState.HALF_OPEN) {
      if (this.halfOpenRequests >= this.config.halfOpenMaxRequests) {
        if (fallback) return fallback()
        throw new Error(`Service ${this.name} is testing recovery`)
      }
      this.halfOpenRequests++
    }

    try {
      const result = await fn()
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      if (fallback && this.state === CircuitState.OPEN) return fallback()
      throw error
    }
  }

  private onSuccess() {
    if (this.state === CircuitState.HALF_OPEN) {
      this.successCount++
      if (this.successCount >= this.config.successThreshold) {
        this.transitionTo(CircuitState.CLOSED)
      }
    }
    this.failureCount = 0
  }

  private onFailure() {
    this.failureCount++
    this.lastFailureTime = Date.now()
    if (this.failureCount >= this.config.failureThreshold) {
      this.transitionTo(CircuitState.OPEN)
    }
  }

  private transitionTo(newState: CircuitState) {
    console.log(`[CircuitBreaker:${this.name}] ${this.state} -> ${newState}`)
    this.state = newState
    if (newState === CircuitState.CLOSED) {
      this.failureCount = 0
      this.successCount = 0
    }
    if (newState === CircuitState.HALF_OPEN) {
      this.halfOpenRequests = 0
      this.successCount = 0
    }
  }

  getStatus() {
    return {
      name: this.name,
      state: this.state,
      failureCount: this.failureCount,
      lastFailureTime: this.lastFailureTime ? new Date(this.lastFailureTime).toISOString() : null,
    }
  }
}

// 서비스별 서킷 브레이커 인스턴스
export const circuitBreakers = {
  userService: new CircuitBreaker('user-service', {
    failureThreshold: 5,
    successThreshold: 3,
    timeout: 30_000, // 30초 후 Half-Open 전환
    monitorWindow: 60_000, // 1분 모니터링 윈도우
    halfOpenMaxRequests: 3,
  }),
  orderService: new CircuitBreaker('order-service', {
    failureThreshold: 3,
    successThreshold: 2,
    timeout: 60_000, // 결제 관련은 보수적으로 60초
    monitorWindow: 60_000,
    halfOpenMaxRequests: 1,
  }),
}

オブザーバビリティの統合

ゲートウェイはすべてのトラフィックが通過するポイントであるため、オブザーバビリティの中核ハブとなります。ログ、メトリクス、トレースの3つの柱(Three Pillars)をすべてゲートウェイ層で収集する必要があります。

OpenTelemetry統合設定

# otel-collector-config.yaml
# OpenTelemetry Collector 설정 - 게이트웨이 트래픽 수집
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  # 게이트웨이 메타데이터 추가
  attributes:
    actions:
      - key: deployment.environment
        value: production
        action: upsert
      - key: service.layer
        value: gateway
        action: upsert
  # 샘플링 (전체의 10%만 상세 트레이스)
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: error-policy
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: latency-policy
        type: latency
        latency:
          threshold_ms: 1000
      - name: probabilistic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

exporters:
  otlphttp/jaeger:
    endpoint: http://jaeger:4318
  prometheus:
    endpoint: 0.0.0.0:8889
  loki:
    endpoint: http://loki:3100/loki/api/v1/push
    labels:
      resource:
        service.name: 'service_name'
        service.layer: 'layer'

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, attributes, tail_sampling]
      exporters: [otlphttp/jaeger]
    metrics:
      receivers: [otlp]
      processors: [batch, attributes]
      exporters: [prometheus]
    logs:
      receivers: [otlp]
      processors: [batch, attributes]
      exporters: [loki]

コアメトリクスダッシュボード項目

ゲートウェイで必ずモニタリングすべきメトリクスは以下の通りです。

  • リクエストレート(Request Rate): 毎秒リクエスト数。異常な急増はDDoSまたはクライアントバグを意味します。
  • エラーレート(Error Rate): 4xx、5xxレスポンスの割合。サービスごとに分離して追跡します。
  • レイテンシ(Latency): p50、p95、p99パーセンタイル。p99がSLAを超過するとアラート発生。
  • サーキットブレーカー状態: 各サービスのサーキット状態をリアルタイムで表示します。
  • レートリミッティングヒット率: 制限に引っかかるリクエストの割合。正常なユーザーが頻繁に制限される場合、閾値の調整が必要です。
  • アップストリーム接続プール: アクティブ接続、待機リクエスト、タイムアウト数を追跡します。

トラブルシューティング

障害シナリオ1:ゲートウェイメモリリーク

症状: ゲートウェイPodのメモリ使用量が持続的に増加してOOM Killが発生。

原因: 大容量のリクエスト/レスポンスボディをバッファリングしながらメモリを解放できない場合。特にファイルアップロードAPIや大容量JSONレスポンスをプロキシする際に発生。

解決策:

# Kong: 요청/응답 본문 버퍼링 제한
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: request-size-limiting
config:
  allowed_payload_size: 10 # MB
  size_unit: megabytes
  require_content_length: true
plugin: request-size-limiting
# Envoy: 버퍼 제한 설정
http_filters:
  - name: envoy.filters.http.buffer
    typed_config:
      '@type': type.googleapis.com/envoy.extensions.filters.http.buffer.v3.Buffer
      max_request_bytes: 10485760 # 10MB

障害シナリオ2:カスケード障害

症状: 1つのバックエンドサービスの障害がゲートウェイを通じてシステム全体に伝播。

原因: サーキットブレーカー未設定またはタイムアウトが長すぎる場合。障害サービスへのリクエストがゲートウェイの接続プールを枯渇させる。

復旧手順:

  1. 障害サービスに対するサーキットブレーカーを直ちに手動で開放
  2. 該当サービスルートに静的フォールバックレスポンスを設定
  3. 接続プールの状態を確認し、必要に応じてゲートウェイPodのローリング再起動
  4. 障害サービス復旧後、トラフィックを段階的に復元(カナリア)

障害シナリオ3:TLS証明書の期限切れ

症状: 突然のAPI全体503エラー。クライアントでSSL/TLSハンドシェイク失敗ログを確認。

予防: cert-managerを活用した自動証明書更新と、期限切れ30日前のアラート設定。

# 인증서 만료일 확인 스크립트
#!/bin/bash
DOMAIN="api.example.com"
EXPIRY=$(echo | openssl s_client -servername $DOMAIN -connect $DOMAIN:443 2>/dev/null \
  | openssl x509 -noout -dates 2>/dev/null \
  | grep notAfter | cut -d= -f2)

EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

echo "Certificate expires: $EXPIRY ($DAYS_LEFT days remaining)"

if [ "$DAYS_LEFT" -lt 30 ]; then
  echo "WARNING: Certificate expiring soon!"
  # 슬랙/PagerDuty 알림 전송
fi

障害シナリオ4:GraphQL Federationサブグラフスキーマの衝突

症状: rover supergraph composeが失敗。2つのサブグラフが同じタイプの同じフィールドを異なる戻り値型で定義。

解決策: Federation v2の@shareableディレクティブを使用して共有タイプフィールドを明示的に宣言し、@overrideディレクティブでフィールドのオーナーシップを移行します。CI/CDパイプラインでrover subgraph checkを実行して互換性を事前検証する必要があります。

本番チェックリスト

デプロイ前の検証

  • ゲートウェイクラスタを最低3ノード以上でHA構成
  • TLS 1.3の強制適用と弱い暗号スイートの無効化
  • ヘルスチェックエンドポイント(/health/ready)の構成
  • リクエスト/レスポンスサイズ制限の設定(デフォルト10MB以下)
  • タイムアウト設定:接続5秒、読み取り30秒、書き込み30秒
  • DNS TTLの適切な設定(長すぎると障害復旧が遅延)

セキュリティ

  • すべての外部通信にTLS適用、内部通信にmTLS適用
  • JWT検証はゲートウェイで実行、トークンは下位サービスに転送しない
  • CORS許可ドメインのホワイトリスト適用
  • レートリミッティング:IPベース(DDoS)、ユーザーベース(公平使用)の二重適用
  • 機密ヘッダー(Authorization、Cookie)のロギングでのマスキング
  • Admin APIを内部ネットワークからのみアクセス可能に制限

オブザーバビリティ

  • 分散トレーシング:すべてのリクエストにX-Request-IDを付与して伝播
  • メトリクス:RED(Rate、Errors、Duration)メトリクスの収集とダッシュボード構築
  • アラート:エラー率5%超過、p99レイテンシSLA超過、サーキットブレーカーOpen時に即時アラート
  • ロギング:構造化JSONログ、リクエスト/レスポンスボディはデバッグモードでのみロギング

運用

  • カナリアデプロイ:ゲートウェイ設定変更時に5-10%のトラフィックで検証後、全体適用
  • 設定バージョン管理:すべてのゲートウェイ設定をGitで管理(GitOps)
  • ロールバック手順:30秒以内に以前の設定にロールバック可能な手順を確保
  • 負荷テスト:予想ピークトラフィックの2倍以上で定期的に負荷テストを実施
  • カオスエンジニアリング:ゲートウェイノード1台の障害時の自動復旧を検証

アンチパターンと失敗事例

アンチパターン1:God Gateway(神ゲートウェイ)

すべてのビジネスロジックをゲートウェイに実装するパターン。リクエスト検証、データ変換、ビジネスルール適用、レスポンス集約をすべてゲートウェイが処理すると、ゲートウェイがモノリス化します。ゲートウェイはルーティング、認証、レートリミッティングなどの横断的関心事のみ処理すべきです。

アンチパターン2:共有BFF

1つのBFFがWebとモバイルの両方にサービスを提供し始めると、双方の要件が衝突して複雑さが指数関数的に増加します。「このフィールドはモバイルでのみ必要」という条件文がBFF全体に広がり、BFFがなかった時よりも悪い状況になります。各フロントエンドタイプごとに専用のBFFを維持してください。

アンチパターン3:ゲートウェイでのデータ変換の濫用

ゲートウェイで過度なレスポンス変換(フィールド名変更、データ構造の再配置、ビジネスロジックに基づくフィールドフィルタリング)を実行すると、ゲートウェイのCPU使用量が急増しデバッグが困難になります。データ変換はBFFまたはサービス自体で実行すべきです。

アンチパターン4:レートリミッティングなしのデプロイ

「我々のサービスはトラフィックが少ないのでレートリミッティングは不要」というのは最もよくある間違いです。予期しないクローラー、不正に実装されたクライアントの無限リトライ、APIキー漏洩による不正使用はトラフィック規模に関係なく発生します。本番デプロイ時のレートリミッティングは選択ではなく必須です。

アンチパターン5:サーキットブレーカー未適用の同期呼び出しチェーン

ゲートウェイがサービスAを呼び出し、サービスAがサービスBを呼び出す同期チェーンでサーキットブレーカーがない場合、サービスBの障害がサービスAとゲートウェイを連鎖的にダウンさせます(カスケード障害)。すべての外部呼び出しポイントにサーキットブレーカーを適用し、可能であれば非同期通信に切り替えてください。

失敗事例:GraphQL Federation N+1クエリ問題

Federation環境でRouterがサブグラフAからユーザーリストを取得した後、各ユーザーの注文をサブグラフBから個別に照会するとN+1問題が発生します。Apollo RouterのQuery Planは可能な限りバッチリクエストで最適化しますが、サブグラフがバッチ照会をサポートしない場合、性能が急激に低下します。サブグラフの__resolveReferenceがバッチをサポートするようDataLoaderパターンを必ず適用する必要があります。

参考資料