Skip to content
Published on

API 게이트웨이 패턴과 BFF 설계: Kong·Envoy·GraphQL Federation 실전 구현

Authors
  • Name
    Twitter
API Gateway BFF

들어가며

마이크로서비스 아키텍처가 산업 전반에 보편화되면서, 서비스 간 통신의 복잡성을 어떻게 관리할 것인가는 모든 엔지니어링 조직의 핵심 과제가 되었다. 글로벌 API 관리 시장은 2026년 기준 약 51억 달러 규모로 성장했으며, CAGR 32.3%의 폭발적 확장세를 보이고 있다. 2025년 기준으로 조직의 31%가 복수의 API 게이트웨이를 운영하고 있으며, 이 중 11%는 세 개 이상의 게이트웨이를 병행 운영한다.

API 게이트웨이 패턴은 클라이언트와 백엔드 서비스 사이에 단일 진입점(Single Entry Point)을 두어 라우팅, 인증, 레이트 리미팅, 프로토콜 변환 등의 횡단 관심사(Cross-Cutting Concerns)를 중앙 집중화하는 아키텍처 패턴이다. 여기에 BFF(Backend for Frontend) 패턴을 결합하면 웹, 모바일, IoT 등 각 프론트엔드 유형에 최적화된 전용 백엔드를 제공할 수 있다.

이 글에서는 API 게이트웨이 패턴과 BFF 패턴의 핵심 원리를 살펴보고, Kong Gateway, Envoy Proxy, GraphQL Federation이라는 세 가지 주요 도구의 실전 구성 방법을 다룬다. 인증/인가 통합, 레이트 리미팅, 서킷 브레이커, 관찰가능성(Observability) 설정까지 프로덕션 운영에 필요한 모든 내용을 실전 코드와 함께 제공한다.

API 게이트웨이 패턴 이해

게이트웨이의 역할

API 게이트웨이는 마이크로서비스 아키텍처에서 모든 클라이언트 요청의 단일 진입점 역할을 한다. 내부 서비스 토폴로지의 복잡성을 추상화하여 클라이언트에게 깨끗하고 일관된 인터페이스를 제공한다. 핵심 책임은 다음과 같다.

  • 요청 라우팅: URL 경로, 헤더, 메서드 기반으로 적절한 백엔드 서비스로 트래픽 전달
  • 프로토콜 변환: REST-to-gRPC, HTTP-to-WebSocket 등 프로토콜 간 변환
  • 응답 집계: 여러 서비스의 응답을 하나로 합쳐 클라이언트에 반환(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 게이트웨이로 모든 클라이언트를 서비스하다 보면 필연적으로 "하나의 크기가 모두에게 맞지 않는" 문제에 직면한다. 웹 애플리케이션은 풍부한 데이터를 한 번에 가져오기 원하지만, 모바일 앱은 대역폭 절약을 위해 최소한의 필드만 필요하다. 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. 하나의 BFF는 하나의 프론트엔드에 대응: 웹 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)를 하나의 통합된 슈퍼그래프(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,
  }),
}

관찰가능성 통합

게이트웨이는 모든 트래픽이 통과하는 지점이므로, 관찰가능성의 핵심 허브가 된다. 로그, 메트릭, 트레이스의 세 기둥(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. 즉시 장애 서비스에 대한 서킷 브레이커 수동 개방
  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 실패. 두 서브그래프가 동일 타입의 동일 필드를 다른 반환 타입으로 정의.

해결: 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

하나의 BFF가 웹과 모바일을 모두 서비스하기 시작하면, 양쪽의 요구사항이 충돌하면서 복잡도가 기하급수적으로 증가한다. "이 필드는 모바일에서만 필요하다"는 조건문이 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 패턴을 반드시 적용해야 한다.

참고자료