MSA 환경에서 CORS 문제 다루기: DevOps 엔지니어를 위한 인프라 중심 가이드

목차

TL;DR
레거시 도메인 기반 통신에서 발생하는 CORS 문제를 인프라 레벨에서 근본적으로 해결하는 DevOps 가이드입니다. API Gateway와 Istio 설정으로 애플리케이션 코드 수정 없이 CORS 문제를 해결하는 방법을 다룹니다.

CORS란 무엇인가? (DevOps가 알아야 할 기본 개념)

CORS의 정의와 동작 원리

CORS(Cross-Origin Resource Sharing)는 웹 브라우저가 다른 출처(origin)의 리소스에 접근할 때 적용되는 보안 정책입니다.

bash
# Origin의 구성 요소
# https://app.company.com:443/dashboard
# └─────┘ └──────────────┘└┘└─────────┘
#  scheme      host      port  path
#
# Origin = scheme + host + port
# "https://app.company.com:443" ← 이것이 Origin

Same-Origin Policy(동일 출처 정책)에 의해 브라우저는 기본적으로 다른 출처로 가는 요청을 차단합니다.

bash
# 동일 출처 (OK)
https://app.company.com → https://app.company.com/api (허용)

# 다른 출처 (CORS 필요)
https://app.company.com → https://api.company.com (차단)
https://app.company.com → http://app.company.com (차단 - 스키마 다름)
https://app.company.com → https://app.company.com:8080 (차단 - 포트 다름)

CORS 요청의 두 가지 유형

1. Simple Request (단순 요청)

2. Preflight Request (사전 요청)

CORS 응답 헤더 (인프라에서 설정해야 할 항목들)

http
# 서버/게이트웨이에서 설정해야 할 응답 헤더
Access-Control-Allow-Origin: https://app.company.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600  # Preflight 캐시 시간

문제 상황: 레거시 인프라의 CORS 지옥

전형적인 레거시 아키텍처 문제

많은 기업이 아래와 같은 도메인 기반 분리 구조를 쓰고 있습니다.

코드
레거시 인프라 (AS-IS)
├── 사용자 앱: app1.company.com (ALB-1)
├── 관리자 앱: admin.company.com (ALB-2)
├── 대시보드: dashboard.company.com (ALB-3)
├── 쇼핑몰: shop.company.com (ALB-4)
└── API 서버: api.company.com (ALB-5)

DevOps가 겪는 실제 문제

1. 복잡한 CORS 설정 관리

nginx
# nginx/ALB에서 각각 설정해야 함
location /api {
    add_header Access-Control-Allow-Origin 
        "https://app1.company.com https://admin.company.com https://dashboard.company.com";
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
    add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With";
    
    # OPTIONS 요청 처리 (필수!)
    if ($request_method = 'OPTIONS') {
        add_header Access-Control-Max-Age 86400;
        return 204;
    }
    proxy_pass http://backend;
}

2. 새로운 서비스 추가 시마다 모든 곳에 CORS 재설정

3. 환경별 설정 불일치

bash
# 개발환경
CORS_ORIGIN="http://localhost:3000,http://dev.company.com"

# 스테이징환경  
CORS_ORIGIN="https://staging.company.com"

# 운영환경
CORS_ORIGIN="https://app1.company.com,https://admin.company.com,..."

해결책: 단일 도메인 + API Gateway 패턴

현대적 인프라 아키텍처 (TO-BE)

코드
현대적 인프라 (TO-BE)
                                    
┌─────────────────────────────────────┐
│     gateway.company.com (단일 ALB)  │
├─────────────────────────────────────┤
│  /user/*     → User App             │
│  /admin/*    → Admin Panel          │  
│  /dashboard/* → Dashboard           │
│  /shop/*     → Shopping Mall        │
│  /api/*      → API Server           │
└─────────────────────────────────────┘
         ↓ 단일 도메인이므로
      CORS 문제 완전 해결!

인프라 관점에서의 핵심 이점

구분 AS-IS (도메인 기반) TO-BE (Path 기반)
ALB 개수 5개 ALB 1개 ALB
CORS 설정 지점 20곳 (5×4환경) 4곳 (1×4환경)
SSL 인증서 5개 도메인 인증서 1개 와일드카드
DNS 관리 5개 도메인 관리 1개 도메인 관리
장애점 분산된 5개 지점 중앙집중 1개 지점

Kong Gateway 구현 (Kubernetes 환경)

Kong Ingress 설정

yaml
# Kong Gateway 라우팅 설정
apiVersion: configuration.konghq.com/v1
kind: KongIngress
metadata:
  name: single-domain-routing
  namespace: default
routing:
  # 사용자 앱 라우팅
  - name: user-app
    paths: ["/user"]
    service: user-service
    strip_path: true
    
  # 관리자 앱 라우팅  
  - name: admin-app
    paths: ["/admin"]
    service: admin-service
    strip_path: true
    
  # API 라우팅
  - name: api-service
    paths: ["/api"]
    service: backend-api
    strip_path: true

---
# 글로벌 CORS 플러그인 (한 번만 설정!)
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: global-cors
  namespace: default
config:
  # 같은 도메인이므로 CORS 문제 없음
  # 외부 파트너 API만 별도 설정
  origins: 
    - "https://partner.external.com"  # 외부 파트너만 명시
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
  headers: ["Accept", "Authorization", "Content-Type", "X-Requested-With"]
  credentials: false
  max_age: 3600

---
# Ingress 설정
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: gateway-ingress
  annotations:
    kubernetes.io/ingress.class: kong
    konghq.com/plugins: global-cors
spec:
  tls:
  - hosts:
    - gateway.company.com
    secretName: gateway-tls-secret
  rules:
  - host: gateway.company.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: kong-proxy
            port:
              number: 80

AWS ALB + API Gateway 구현

ALB 타겟 그룹 설정

yaml
# ALB 설정 (Terraform)
resource "aws_lb" "gateway" {
  name               = "gateway-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets           = var.public_subnet_ids

  tags = {
    Name = "gateway-company-com"
  }
}

# 단일 리스너로 모든 트래픽 처리
resource "aws_lb_listener" "gateway" {
  load_balancer_arn = aws_lb.gateway.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = aws_acm_certificate.gateway.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api_gateway.arn
  }
}

# API Gateway 타겟 그룹
resource "aws_lb_target_group" "api_gateway" {
  name     = "api-gateway-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = var.vpc_id

  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200"
    path                = "/health"
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 2
  }
}

API Gateway CORS 설정

yaml
# AWS API Gateway (CloudFormation/CDK)
Resources:
  ApiGateway:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: SingleDomainGateway
      EndpointConfiguration:
        Types:
          - REGIONAL
      
  # OPTIONS 메서드 (CORS Preflight 처리)
  OptionsMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref ApiGateway
      ResourceId: !GetAtt ApiGateway.RootResourceId
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        IntegrationResponses:
          - StatusCode: 200
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
              method.response.header.Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: "'*'"
              method.response.header.Access-Control-Max-Age: "'3600'"
        RequestTemplates:
          application/json: '{"statusCode": 200}'
      MethodResponses:
        - StatusCode: 200
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: false
            method.response.header.Access-Control-Allow-Methods: false
            method.response.header.Access-Control-Allow-Origin: false
            method.response.header.Access-Control-Max-Age: false

Istio Service Mesh 활용

MSA 환경에서 Istio를 사용한다면 CORS를 가장 세밀하게 제어할 수 있습니다.

1. Istio Gateway 설정 (진입점 통합)

yaml
# Istio Ingress Gateway - 단일 진입점
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: company-gateway
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - gateway.company.com
    # HTTP → HTTPS 리디렉션
    tls:
      httpsRedirect: true
  - port:
      number: 443
      name: https
      protocol: HTTPS
    tls:
      mode: SIMPLE
      credentialName: gateway-tls-secret  # K8s Secret
    hosts:
    - gateway.company.com  # 단일 도메인!

2. VirtualService 라우팅 설정

yaml
# 단일 도메인에서 Path 기반 라우팅
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: unified-routing
  namespace: default
spec:
  hosts:
  - gateway.company.com
  gateways:
  - istio-system/company-gateway
  
  http:
  # 사용자 앱 라우팅
  - match:
    - uri:
        prefix: "/user"
    route:
    - destination:
        host: user-frontend-service
        port:
          number: 80
    # 같은 도메인이므로 CORS 설정 불필요!
    
  # 관리자 앱 라우팅
  - match:
    - uri:
        prefix: "/admin"
    route:
    - destination:
        host: admin-frontend-service
        port:
          number: 80
          
  # 대시보드 라우팅
  - match:
    - uri:
        prefix: "/dashboard"
    route:
    - destination:
        host: dashboard-service
        port:
          number: 80
          
  # API 라우팅 (내부 서비스간 통신)
  - match:
    - uri:
        prefix: "/api/v1/users"
    route:
    - destination:
        host: user-service
        port:
          number: 8080
          
  - match:
    - uri:
        prefix: "/api/v1/orders"
    route:
    - destination:
        host: order-service
        port:
          number: 8080
          
  # 외부 API 접근용 (CORS 필요한 경우만)
  - match:
    - uri:
        prefix: "/external-api"
    # 여기서만 CORS 정책 설정
    corsPolicy:
      allowOrigins:
      - prefix: "https://partner.company.com"
      - exact: "https://trusted-client.com"
      allowMethods:
      - GET
      - POST
      allowHeaders:
      - Authorization
      - Content-Type
      - X-Requested-With
      allowCredentials: true
      maxAge: "24h"  # Preflight 캐시 시간
    route:
    - destination:
        host: external-api-service
        port:
          number: 8080

3. EnvoyFilter로 글로벌 CORS 제어

yaml
# 모든 서비스에 적용되는 글로벌 CORS 정책
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: global-cors-filter
  namespace: istio-system  # 전역 적용
spec:
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      context: SIDECAR_INBOUND
      listener:
        filterChain:
          filter:
            name: "envoy.filters.network.http_connection_manager"
    patch:
      operation: INSERT_BEFORE
      value:
        name: envoy.filters.http.cors
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
          # 전역 CORS 정책
          allow_origin_string_match:
          - safe_regex:
              google_re2: {}
              regex: "https://gateway\\.company\\.com.*"  # 같은 도메인 허용
          - exact: "https://partner.trusted.com"  # 신뢰된 외부 도메인
          allow_methods: "GET, POST, PUT, DELETE, OPTIONS"
          allow_headers: "authorization, content-type, x-requested-with, x-user-id"
          max_age: "86400"  # 24시간 캐시
          allow_credentials: true
          # 동적 헤더 추가
          expose_headers: "x-request-id, x-trace-id"

4. DestinationRule로 트래픽 정책 설정

yaml
# 서비스별 트래픽 정책
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: service-policies
  namespace: default
spec:
  host: "*.default.svc.cluster.local"  # 모든 서비스에 적용
  trafficPolicy:
    # mTLS 활성화 (서비스간 보안 통신)
    tls:
      mode: ISTIO_MUTUAL
    # 연결 풀 설정
    connectionPool:
      tcp:
        maxConnections: 100
      http:
        http1MaxPendingRequests: 50
        maxRequestsPerConnection: 10
    # 로드밸런싱
    loadBalancer:
      simple: LEAST_CONN

5. 외부 서비스 접근 설정

yaml
# 외부 API 호출 시 ServiceEntry 설정
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: external-partner-api
  namespace: default
spec:
  hosts:
  - partner-api.external.com
  ports:
  - number: 443
    name: https
    protocol: HTTPS
  location: MESH_EXTERNAL
  resolution: DNS

---
# 외부 서비스에 대한 가상 서비스
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: external-partner-routing
  namespace: default
spec:
  hosts:
  - partner-api.external.com
  http:
  - timeout: 30s
    retries:
      attempts: 3
      perTryTimeout: 10s
    route:
    - destination:
        host: partner-api.external.com

결론

이 가이드에서 다룬 방법으로 애플리케이션 코드 수정 없이 인프라 레벨에서 CORS 문제를 해결할 수 있습니다.

핵심 포인트

도식

100%

끌어서 이동, 두 손가락이나 휠로 확대·축소. 키보드: 화살표 이동, + − 확대·축소, 0 맞춤, Esc 닫기. 같은 내용은 그림 아래 “그림 관계를 글로 읽기”에 있다.