[Dev] CORS 상황 해결: 같은 도메인의 다른 nginx가 응답하고 있었다

· 14 분 소요


클라우드(AWS EC2)에서 운영하던 서비스를 사내 외부 접근이 가능한 온프레미스 서버로 마이그레이션하게 되었다. 프론트엔드를 옮긴 뒤 로그인을 시도하니 CORS 에러가 발생했다. nginx CORS 설정 문제인 줄 알고 한참을 들여다봤는데, 알고 보니 전혀 다른 곳이었다. 같은 도메인에 nginx 컨테이너가 2개 떠 있었고, 요청이 의도하지 않은 nginx로 가고 있었다.

SOP와 Origin의 기본 개념은 Origin에 대한 고찰 - 정의, SOP, CORS에 정리한 적이 있다. 이번 트러블슈팅은 그 개념이 실제 nginx 프록시 아키텍처에서 어떻게 작동하는지를 경험한 사례다.


TL;DR

구분 내용
현상 프론트엔드 로그인 시 OPTIONS preflight → 405 Method Not Allowed
근본 원인 .env.productionVITE_API_URL=https://foo.example.com/(절대 경로)으로 설정되어 있어, 브라우저가 API 요청을 같은 도메인의 다른 nginx(포트 443의 proxy-server)로 보냄
혼란 포인트 응답 헤더 Server: nginx가 찍혀서, 프론트 nginx가 차단한 것처럼 보였음
해결 VITE_API_URL=/ 상대 경로로 변경 → 재빌드 → 컨테이너 재시작
교훈 CORS 에러 시 응답 주체가 누구인지 먼저 확인


배경

마이그레이션 맥락

클라우드(AWS EC2)에서 운영하던 MLOps 서비스를 사내 서버(외부 접근 가능)로 마이그레이션하기로 결정했다. 프론트엔드도 이 과정에서 함께 옮기게 되었는데, 마이그레이션 후 새 환경에서 로그인을 시도하니 CORS 에러가 터졌다.

인프라 구성

마이그레이션 대상 서버에는 이미 Docker 컨테이너 여러 개가 운영되고 있었다.

[외부] → proxy-server (443, TLS 종단) → 각 서비스 컨테이너
                                          ├─ ws-bar (40000)
                                          └─ api-service (40005)

[외부] → frontend-app (8004) ← 직접 노출 (과도기적 상태)
  • proxy-server: L7 진입점 리버스 프록시. 포트 443에서 TLS를 종단하고, 요청 path에 따라 내부 서비스로 라우팅한다.
  • frontend-app: 이번에 추가된 프론트엔드 앱 nginx. 포트 8004(내부 포트 80)에서 정적 파일을 서빙하고, /api 요청은 proxy_pass로 백엔드에 전달한다.

둘 다 nginx이지만, 역할이 완전히 다르다. 클라우드로 치면 proxy-server는 ALB/Ingress Controller에 해당하고, frontend-app은 Pod 안의 nginx sidecar에 해당한다. 포워드 프록시와 리버스 프록시의 개념 차이는 Proxy / Reverse Proxy에 정리했다. 이번 케이스에서 등장하는 두 nginx가 각각 어떤 역할인지 이해하려면 이 글을 먼저 읽는 게 도움이 된다.

문제는, frontend-app이 proxy-server에 등록되지 않은 채로 8004 포트에 직접 노출되어 있었다는 점이다. 이 과도기적 상태가 이번 트러블슈팅의 출발점이 되었다.


배경 지식

VITE_API_URL: 빌드 타임 vs 런타임

이번 문제를 이해하려면 Vite의 환경변수 주입 방식을 알아야 한다. Vite는 프론트엔드 빌드 도구로, VITE_ 접두사가 붙은 환경변수를 빌드 시점에 번들 JS에 문자열로 박아넣는다.

핵심은 빌드 타임런타임의 구분이다.

  1. 빌드 타임 (yarn build → 내부적으로 vite build): Vite가 .env.productionVITE_API_URL 값을 번들에 문자열로 치환한다. 상대 경로인지 절대 경로인지 의미를 해석하지 않는다. 그냥 문자열을 박아 넣을 뿐이다.

     // 빌드 결과물 (dist/assets/index-xxxx.js)
     const API_BASE_URL = "/";  // ← 그냥 문자열 "/"
    
  2. 런타임 (브라우저): 유저가 http://foo.example.com:8004에 접속하면 JS가 실행되고, 브라우저가 URL을 해석한다.

     axios.post("/" + "api/user/login")
     // → axios.post("/api/user/login")
    

실제 런타임에 경로를 해석하는 주체는 Vite가 아니라 브라우저다. Vite는 문자열 치환만 하면 역할이 끝난다. 이후는 nginx + 인프라의 영역이다.

상대 경로 vs 절대 경로

설정 요청이 가는 곳 이유
VITE_API_URL=/ http://foo.example.com:8004/api/... 브라우저가 현재 origin 기준으로 해석 → Same-Origin
VITE_API_URL=https://foo.example.com/ https://foo.example.com/api/... 절대 경로 그대로 → 포트 443의 proxy-server → Cross-Origin

같은 코드, 같은 빌드 결과물인데도, .env.production에 뭘 넣어줬느냐에 따라 요청이 완전히 다른 서버로 갈 수 있다. 누가 설정했느냐보다, 설정값이 최종 배포 환경의 origin과 일치하는가가 핵심이다. 상대 경로(/)로 하면 이걸 자동으로 보장해 주니까 안전하다.

nginx 프록시 뒤에 배포하는 경우, /(상대 경로)를 쓰는 게 베스트 프랙티스다. 다만 개발 초기에는 VITE_API_URL=http://10.0.1.100:30001처럼 백엔드를 직접 호출하는 절대 경로로 시작하는 경우가 많다. 프록시 구성이 완료되면 /로 바꿔야 한다.


nginx에서도 CORS를 설정하는 이유

이번 케이스에서는 백엔드(Go gin)에서도 CORS 미들웨어를 설정해 두었고, 프론트 nginx에도 CORS 설정이 들어가 있었다. 왜 nginx에서까지 CORS를 처리하는지 짚고 넘어간다.

백엔드 CORS + nginx CORS: 이중 방어

실무에서는 백엔드에서 CORS를 처리하는 것이 기본이다. 그런데 nginx(리버스 프록시)에서도 방어적으로 CORS 설정을 추가하는 경우가 많다.

  • 중앙 집중 관리: 백엔드가 여러 개여도 nginx 한 곳에서 CORS 정책을 통일할 수 있다.
  • 백엔드 부담 경감: OPTIONS preflight 요청을 nginx에서 204로 바로 끊어주면, 백엔드까지 도달하지 않는다.
  • 방어적 설정: 백엔드 CORS 미들웨어 설정에 실수가 있더라도, nginx에서 잡아줄 수 있다.

이번 케이스에서도 백엔드 CORS가 있는 상태에서 nginx에도 방어적으로 넣어 둔 구성이었다.

OPTIONS 처리와 응답 헤더: 두 가지 역할

nginx의 CORS 설정은 두 가지 역할을 한다.

1. OPTIONS preflight를 nginx에서 직접 처리한다. Cross-Origin 요청 전에 브라우저가 보내는 사전 확인 요청(OPTIONS)을 백엔드까지 보내지 않고, nginx에서 204로 바로 끊어준다.

if ($request_method = 'OPTIONS') {
    return 204;
}

2. 실제 요청(POST, GET 등)의 응답에도 CORS 헤더를 추가한다. 브라우저는 Preflight뿐만 아니라 실제 응답에도 CORS 헤더를 체크한다. nginx의 add_header ... always 설정으로 백엔드 응답에 CORS 헤더를 덧붙인다.

add_header 'Access-Control-Allow-Origin' $allow_origin always;

여기서 always 키워드가 중요하다. always가 없으면 2xx, 3xx 응답에만 헤더가 추가되고, 4xx/5xx 에러 응답에는 CORS 헤더가 붙지 않는다. 에러 응답에 CORS 헤더가 없으면 브라우저가 에러 내용조차 JS에 전달하지 않아서, 프론트엔드 개발자가 에러 메시지를 볼 수조차 없게 된다.

Same-Origin인데 CORS 헤더를 붙이는 이유

nginx가 프론트 서빙 + API proxy_pass를 동시에 하고, VITE_API_URL을 상대 경로(/)로 설정하면 브라우저 입장에서 Same-Origin이 된다. Same-Origin이면 브라우저가 CORS 헤더를 체크하지 않으므로, 헤더가 있든 없든 상관없다. 그런데도 붙여두는 이유는 방어적 설정이다.

  • 나중에 아키텍처가 바뀌어서 Cross-Origin이 될 수 있다.
  • 로컬 개발 환경에서는 프론트(localhost:3000)와 nginx(다른 포트)가 Cross-Origin일 수 있다.
  • 있어서 해가 되지 않고, 없으면 나중에 빠뜨릴 수 있다.

nginx에서의 CORS 처리 방식과 add_header ... always의 동작에 대해서는 Origin에 대한 고찰 - 정의, SOP, CORS에도 정리해 두었다.

상황

프론트엔드 코드와 설정

프론트엔드 코드에서는 VITE_API_URL 환경변수를 기반으로 API base URL을 설정하고 있었다.

const API_BASE_URL = import.meta.env.VITE_API_URL || '';

export const publicAxios = axios.create({
    baseURL: API_BASE_URL,
});

// 로그인 호출
const res = await publicAxios.post('/api/user/login', {
    username: id,
    password: pw,
});

.env.production 파일에는 다음과 같이 설정되어 있었다.

# .env.production
VITE_API_URL=https://foo.example.com

nginx 설정

프론트 nginx(frontend-app)의 설정을 확인해 보자.

server {
    listen 80;

    set $allow_origin "";
    if ($http_origin ~* "^https?://(10\.0\.1\.\d+|localhost|127\.0\.0\.1)(:\d+)?$") {
        set $allow_origin $http_origin;
    }

    location / {
        root /var/www/html;
        index index.html;
        try_files $uri $uri/ /index.html;

        add_header 'Access-Control-Allow-Origin' $allow_origin always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;

        if ($request_method = 'OPTIONS') {
            return 204;
        }
    }

    location ^~ /api {
        proxy_pass http://10.0.1.100:30001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        add_header 'Access-Control-Allow-Origin' $allow_origin always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;

        if ($request_method = 'OPTIONS') {
            return 204;
        }
    }
}

설정을 하나씩 짚어 보면:

  • location /: 프론트엔드 정적 파일을 서빙한다. try_files $uri $uri/ /index.html은 SPA 라우팅을 위한 설정으로, 파일이 없으면 index.html을 반환해서 React Router 같은 클라이언트 사이드 라우터가 URL을 해석할 수 있게 한다.
  • location ^~ /api: /api로 시작하는 요청을 백엔드로 프록시한다. ^~는 “접두사 매칭이 되면 정규식 검사를 건너뛴다”는 의미로, 정적 파일 캐싱용 정규식(location ~* \.(js|css|png|...)$ 등)이 /api 요청을 가로채는 것을 방어한다.
  • proxy_pass, proxy_set_header: nginx가 서버 사이드에서 백엔드로 요청을 중계한다. 브라우저는 이 과정을 모르므로 Same-Origin이 유지된다. HostX-Real-IP 헤더 전달은 리버스 프록시에서의 원본 정보 전달 목적이다.
  • CORS 헤더 + OPTIONS 처리: 앞서 배경 지식에서 설명한 대로, add_header ... always로 모든 응답에 CORS 헤더를 붙이고, OPTIONS 요청은 204로 바로 반환한다. Same-Origin 구성에서도 방어적으로 넣어 둔 것이다.

정리하면, CORS 설정은 잘 되어 있고, OPTIONS도 204로 처리하고 있었다. 이 nginx를 요청이 거쳤다면 문제가 없어야 했다.

Docker Compose 설정

Docker Compose로 8004:80 포트 매핑으로 운영 중이었다.

services:
  frontend-app:
    build:
      context: .
      dockerfile: infra/docker/Dockerfile
    container_name: frontend-app
    ports:
      - "8004:80"
    volumes:
      - ./infra/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
    environment:
      - NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx/conf.d
    env_file:
      - .env.production

nginx 공식 Docker 이미지의 NGINX_ENVSUBST_OUTPUT_DIR 기능을 사용하고 있었다. 컨테이너 시작 시 /etc/nginx/templates/*.template 파일에서 ${ENV_VAR}를 실제 환경변수 값으로 치환해서 실제 nginx conf를 생성하는 방식이다.


문제 분석

증상

프론트엔드에서 로그인 시, 브라우저 네트워크 탭에 OPTIONS 요청만 찍히고 실제 POST 요청은 보이지 않았다. 에러는 405 Method Not Allowed. 응답 헤더에 Server: nginx가 찍혀 있었다.

정상이라면 OPTIONS preflight는 프론트 nginx(8004)를 거치거나, 거친 뒤 백엔드로 전달되어야 한다. 그런데 백엔드 로그에 OPTIONS 조차 찍히지 않았다. 요청이 우리가 CORS를 설정해 둔 그 nginx로 간 게 아니라는 뜻이다. 이걸 보고 “혹시 포트 443의 다른 서버가 받고 있는 건 아닐까?” 하는 의심이 생겼다.

요청 흐름 분석: 왜 Cross-Origin이 되었나

.env.productionVITE_API_URL=https://foo.example.com/이 설정되어 있었으므로, 빌드된 JS에서 API 요청 URL이 절대 경로로 생성되었다.

  • 브라우저 origin: http://foo.example.com:8004 (프론트 nginx, HTTP)
  • 요청 URL: https://foo.example.com/api/user/login (절대 경로)


브라우저의 Same-Origin 판단:

  • 프로토콜 다름: http vs https
  • 포트 다름: 8004 vs 443 (https 기본 포트)
  • Cross-Origin

Cross-Origin 요청에 Content-Type: application/json을 사용하므로 Simple Request 조건을 만족하지 못한다.

Preflight 요청의 조건과 흐름에 대해서는 사전 요청 섹션을 참고한다.


정상이어야 할 흐름은 다음과 같다. 상대 경로(/)였다면 요청이 같은 origin(8004)으로 가고, 프론트 nginx가 OPTIONS에 204를 반환하거나 proxy_pass로 백엔드까지 전달했을 것이다.

브라우저 → OPTIONS /api/user/login (origin 8004)
         → 프론트 nginx (8004) 수신
         → 204 또는 백엔드 전달
         → Preflight 성공
         → POST 요청 발생

실제로는 절대 경로 때문에 브라우저가 OPTIONS를 https://foo.example.com(포트 443)으로 보냈다. 443에서 받는 쪽은 프론트 nginx가 아니다(프론트는 8004·HTTP). 그쪽에서 405를 돌려주면서 preflight가 실패했고, POST는 아예 나가지 않았다.

브라우저 → OPTIONS https://foo.example.com/api/user/login (→ 포트 443)
         → 405 Method Not Allowed (443에서 응답한 서버)
         → Preflight 실패
         → POST 요청 자체를 보내지 않음

결정적인 단서는 백엔드 로그에 OPTIONS조차 없다는 점이다. OPTIONS가 프론트 nginx를 거쳤다면 proxy_pass로 백엔드까지 전달되어 로그에 남았을 것이다. 로그에 없다는 건 요청이 프론트 nginx로 가지 않았다는 뜻이고, 따라서 “포트 443의 어떤 서버가 받은 것”이라는 추론으로 이어진다.


백엔드 CORS 설정 확인

CORS 에러로 보였기 때문에 트러블슈팅 시 백엔드 설정을 먼저 살펴봤다. 백엔드(Go gin)에는 CORS 미들웨어(예: gin-contrib/cors)가 붙어 있었고, AllowOrigins, AllowMethods, AllowHeaders가 설정되어 있었으며 OPTIONS 요청은 미들웨어에서 204로 처리하도록 되어 있었다. 즉 백엔드만 보면 CORS 설정은 정상이었고, 요청이 백엔드까지 도달했다면 거기서 preflight가 처리됐을 것이다. 그런데 백엔드 로그에 OPTIONS가 전혀 찍히지 않았으므로, 문제는 요청이 백엔드에 도달하기 단계—어딘가에서 405를 반환하고 있다—로 좁혀졌다.


그런데, nginx 설정은 정상인데?

“443의 다른 서버가 받은 것 아닐까?” 하는 의심을 확인하려고, 응답 주체를 좁혀 나갔다. 프론트 nginx(frontend-app)에는 CORS 설정이 분명히 잘 되어 있었다. 컨테이너 안에 직접 들어가서 확인해 봐도 환경변수가 정상 치환되어 있었고, OPTIONS 요청에 대한 204 반환 설정도 있었다.

# 프론트엔드 nginx 컨테이너 내부에서 확인
$ cat /etc/nginx/conf.d/default.conf
# → CORS 헤더 설정 정상
# → OPTIONS → 204 설정 정상
# → proxy_pass 대상 IP/포트 정상

만약 이 nginx를 거쳤다면, OPTIONS 요청에 204를 반환하고, CORS 헤더도 정상적으로 붙여줬을 것이다. Preflight가 성공했을 것이고, 이후 POST 요청도 백엔드까지 도달했어야 한다. 그런데 백엔드 로그에 OPTIONS·POST 둘 다 안 찍힌다. 요청이 프론트 nginx까지 도달하지 않았다는 뜻이다.

게다가, 프론트 nginx는 HTTP(포트 80)로 서빙하고 있었는데, 브라우저가 보낸 요청은 HTTPS(포트 443)로 가고 있었다. 이 사실만으로도 응답 주체가 프론트 nginx가 아니라는 게 명확해진다.

프론트 nginx (frontend-app):
  - Port: 8004 → 내부 80
  - Protocol: HTTP (TLS 없음)
  - OPTIONS 처리: 설정됨

브라우저 요청 대상:
  - URL: https://foo.example.com/api/user/login
  - Port: 443
  - Protocol: HTTPS
  → 프론트 nginx가 아닌 다른 서버로 가고 있음!


원인 추적

curl -v로 재현

상황을 재현하기 위해 같은 OPTIONS 요청을 curl -v로 보내봤다.

curl -X OPTIONS https://foo.example.com/api/user/login \
  -H "Origin: http://foo.example.com:8004" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type" \
  -v

결과를 보니, TLS handshake가 맺어지고 있었다. 포트 443으로 연결되어 HTTPS 통신이 이루어졌다.

*   Trying 203.0.113.10:443...
* Connected to foo.example.com (203.0.113.10) port 443 (#0)
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
...
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* Server certificate:
*  subject: CN=foo.example.com
...
> OPTIONS /api/user/login HTTP/1.1
> Host: foo.example.com
> Origin: http://foo.example.com:8004
> Access-Control-Request-Method: POST
> Access-Control-Request-Headers: content-type
>
< HTTP/1.1 405 Not Allowed
< Server: nginx/1.29.1
< Content-Type: text/html
< Content-Length: 157
<
<html>
<head><title>405 Not Allowed</title></head>
<body>
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx/1.29.1</center>
</body>
</html>

응답의 Server: nginx/1.29.1을 보는 순간 의문이 들었다. 우리 프론트 nginx 이미지의 버전은 1.19.6이었다. 버전이 다르다. 이건 우리 프론트 nginx가 아니다.

진짜 원인: 같은 도메인의 다른 nginx

해당 서버의 컨테이너 목록을 확인해 봤다.

$ docker ps
CONTAINER ID   IMAGE              PORTS                                        NAMES
73d02c0acba5   frontend-app       0.0.0.0:8004->80/tcp                         frontend-app
9d7f3d89b9d1   nginx:latest       0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp    proxy-server
482162960f95   api-service        0.0.0.0:40005->40005/tcp                     api-service
ee6db37e989c   golang:alpine      0.0.0.0:40000->40000/tcp                     ws-bar

proxy-server라는 이름의 nginx 컨테이너가 포트 443에서 돌고 있었다.(..!) 이 컨테이너의 nginx 설정을 확인해 봤다.

# proxy-server의 /etc/nginx/conf.d/default.conf
server {
    server_name  foo.example.com;

    # Security Headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "DENY" always;
    # ...

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    location /bar/ {
        proxy_pass http://ws-bar:40000/;
        # WebSocket 관련 헤더...
    }

    location /secondary/ {
        proxy_pass http://api-service:40005;
        # ...
    }

    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/foo.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/foo.example.com/privkey.pem;
}

server {
    listen       80;
    server_name  foo.example.com;
    return 301 https://$server_name$request_uri;
}

/api 라우팅이 없다. CORS 설정도 없다. OPTIONS 처리도 없다.

퍼즐이 맞춰졌다.

상황 도식 정리

이번 문제는 요청이 CORS 설정이 있는 백엔드 서버에도, 프론트 nginx에 도달하지 못하고, 같은 도메인의 다른 nginx로 갔던 것이 근본 원인이었다.

nginx-fe-troubleshooting-1.png

  1. VITE_API_URL=https://foo.example.com/
  2. axios.post('https://foo.example.com/api/user/login')
  3. 브라우저 Same-Origin 판단: Cross-Origin
  4. 브라우저 Preflight 요청 판단: Preflight 필요
  5. OPTIONS https://foo.example.com/api/user/login
  6. 포트 443의 proxy-server(nginx/1.29.1)가 받음
  7. /api 라우팅 없음
  8. CORS 설정 없음
  9. 405 Method Not Allowed
  10. Preflight 실패
  11. POST 안 보냄


해결

결과적으로 요청은 프론트 nginx(CORS·OPTIONS 처리 설정이 되어 있던 곳)에도, 백엔드(CORS 미들웨어가 붙어 있던 곳)에도 도달하지 않았다. 대신 CORS 설정이 전혀 없는 443의 다른 nginx가 OPTIONS를 받아 405를 돌려준, 참 웃픈 상황이었다. 아래에서는 이걸 어떻게 해결할 수 있는지, 채택한 방법과 대안을 정리한다.

방법 1: VITE_API_URL 상대 경로 설정 (채택)

.env.production을 수정하고 재빌드 후 컨테이너를 재시작했다.

# .env.production
VITE_API_URL=/

방법 1 적용 시 흐름은 다음과 같다.

nginx-fe-troubleshooting-2.png

  1. VITE_API_URL=/
  2. axios.post('/api/user/login') (상대 경로)
  3. 브라우저가 현재 origin(http://foo.example.com:8004) 기준으로 경로 해석
  4. 요청 URL: http://foo.example.com:8004/api/user/login
  5. 브라우저 Same-Origin 판단: Same-Origin
  6. CORS 검사 없음
  7. 프론트 nginx(8004)가 수신
  8. location ^~ /api → proxy_pass로 백엔드 전달
  9. 로그인 요청 정상 도달

다만 이렇게 할 경우, 8004 포트가 외부에 직접 노출된다. 문제는 해결하더라도, 아직 과도기적 상태가 유지된다. VITE_API_URL=/로 바꿔서 당장의 문제는 해결할 수 있겠지만, 마이그레이션 완료 후에는 proxy-server를 통한 통합 라우팅으로 전환해야 한다. 사용자에게 :8004 포트를 직접 입력하게 할 수는 없는 노릇이다.

방법 2: proxy-server에 라우팅 추가 (더 나은 아키텍처)

인프라 아키텍처 측면에서는 proxy-server에 프론트엔드와 API 라우팅을 모두 등록하는 것이 더 바람직하다.

# proxy-server (443) 설정에 추가
server {
    listen 443 ssl;
    server_name foo.example.com;

    # 프론트엔드
    location / {
        proxy_pass http://frontend-app:80;
    }

    # API
    location /api {
        proxy_pass http://10.0.1.100:30001;
    }

    # 기존 서비스들...
}

이렇게 하면:

  • 외부에는 443 하나만 노출
  • VITE_API_URL=/(상대 경로)로 설정하면 모든 게 Same-Origin
  • 8004 포트를 외부에 노출할 필요 없음


교훈

nginx CORS 쪽에 심오한 문제가 있는 줄 알았는데, 알고 보니 같은 도메인에 다른 nginx 컨테이너가 또 있어서 꽤 당황했다. “이런 구성이 있다”는 걸 미리 알았더라면 디버깅에 쏟은 삽질을 많이 줄였을 텐데 하는 아쉬움이 남았다.

이 구조가 흔한가

리버스 프록시 + 앱별 nginx 구조 자체는 온프레미스에서 굉장히 흔한 환경 구성이다.

[외부] → proxy-server (443, TLS 종단) → 각 서비스 컨테이너
                                          ├─ ws-bar (40000)
                                          ├─ api-service (40005)
                                          └─ frontend-app (8004) ← 여기가 빠져 있었음

nginx를 2단으로 쓰는 것이다.

  1. 1단 (L7 진입점)proxy-server: 포트 443에서 TLS 종단하고, 요청 path에 따라 내부 서비스로 라우팅
  2. 2단 (앱 레벨)frontend-app: 포트 8004(내부 80)에서 정적 파일 서빙 + /api proxy_pass

둘 다 nginx이지만 역할이 완전히 다르다. 클라우드로 치면 1단은 ALB/Ingress Controller, 2단은 Pod 안의 nginx sidecar에 해당한다. 클라우드에서는 1단 역할을 ALB/Ingress가 대신하니까 이런 이슈가 잘 안 보이지만, 온프레미스에서는 이 2단 구조가 꽤나 흔하다.

이번 상황의 문제

같은 도메인에 nginx가 2개 떠 있는데 역할이 다르고, 한쪽(proxy-server)에만 라우팅이 설정되어 있지 않아서 혼란이 났다. 과도기적 상태에서 생긴 일이다.

추가된 순서를 역추적해보자.

  1. 태초에 proxy-server만 있었음 (443, HTTPS, bar·api-service 등)
  2. 새 프론트엔드(frontend-app)를 추가하면서 Docker로 띄우고, 8004 포트로 임시 오픈
  3. “나중에 proxy-server에 라우팅 추가해야지” → 그리고 잊음
  4. .env.productionVITE_API_URL=https://foo.example.com/ 설정 → proxy-server를 거치도록 의도했지만, proxy-server에 /api 라우팅이 없었으므로 405

임시로 열어 둔 구성을 나중에 proxy-server에 반영했어야 하는데 잊어버리는 식으로 진행되면서 이렇게 됐다. 온프레미스에서 서비스를 붙여 나갈 때 정말 자주 나오는 실수이고, 서비스 성장의 부산물이라고 보면 된다.

다만 이런 상황은 디버깅이 어렵다. 뚜껑을 열어 보면 간단한 상황임에도, 잘 모르니 생각보다 많은 시간을 낭비하게 된다.

  • 에러 응답의 Server: nginx만 보면, 우리 프론트 nginx인지 다른 nginx인지 구분이 안 된다.
  • 도메인이 같아서 “같은 서버인데 왜?”라는 착각이 생긴다.
  • nginx 버전(1.19.6 vs 1.29.1)이 다르다는 걸 확인하고 나서야, 비로소 “이건 다른 nginx구나”를 알 수 있었다.


다음에는

예방

1순위는 기존 인프라 구조를 먼저 확인하는 것이다. 그다음에 신규 서비스를 어디에 붙일지 결정하는 게 안전하다.

임시로 포트를 열어서 노출할 때도, proxy-server에 먼저 라우팅을 추가하는 쪽이 낫다. nginx conf에 블록 하나 넣는 건 금방이고, “나중에 하겠지”하고 미루는 순간 잊기 쉽다. 정말 임시로 포트를 직접 열어야 한다면, VITE_API_URL은 반드시 상대 경로(/)로 잡아 두는 게 안전하다. 절대 경로를 쓰는 순간 “이 도메인의 어떤 포트로 갈지” 문제가 생겨 버린다.

체크 리스트

신규 서비스 추가 시, 아래와 같은 점을 체크하자:

  1. docker ps로 같은 도메인에서 이미 돌고 있는 컨테이너 확인
  2. 443/80 포트를 잡고 있는 프록시가 있는지 확인
  3. 있으면 → 거기에 라우팅 추가가 우선
  4. 프론트 VITE_API_URL은 상대 경로(/) 기본

개발 영역과 인프라 영역의 합의

VITE_API_URL 같은 환경변수는 “프론트 영역이냐, 인프라 영역이냐”로 나누면 안 된다. 둘 다의 영역이다.

  프론트엔드 개발자 인프라 팀
책임 VITE_API_URL어떻게 쓸지 설계 배포 환경에서 실제 값을 결정
구체적으로 코드에서 import.meta.env.VITE_API_URL을 base URL로 쓰는 구조 설계, .env.development 기본값 세팅 .env.production 값 세팅, nginx 라우팅 구조와 일치시키기
알아야 하는 것 상대 경로(/) vs 절대 경로의 차이, 빌드 시점에 박힌다는 것 프론트가 이 값을 어떻게 쓰는지, 어떤 origin으로 요청이 나가는지

이번 케이스에서 생긴 갭을 복기해 보자. 프론트에서 VITE_API_URL을 쓰는 구조를 만들어 두었고, 인프라 설정 시 .env.productionhttps://foo.example.com/을 넣었다. 인프라 입장에서는 “HTTPS 도메인으로 통일하면 되겠지”라는 의도였을 수 있지만, 이 값이 빌드 타임에 번들에 박혀서 브라우저가 그대로 요청 URL로 쓴다는 걸 모르면, proxy-server에 /api 라우팅이 없다는 사실과 맞물려 깨진다. 한쪽이 다른 쪽의 맥락 없이 값을 바꾸면 깨지기 쉽다.

VITE_API_URL“기본값 설계”는 프론트 개발자, “운영 환경에서 쓸 값 결정”은 인프라와 프론트가 함께 합의해야 하는 영역이다. 체크리스트나 문서화가 중요한 부분이다.

따라서 인프라에서 이 환경변수를 바꿀 때에는 아래처럼 접근해야 한다.

  1. 해당 환경변수가 빌드 결과물에 어떻게 반영되는지 확인
  2. VITE_ 접두사 환경변수는 클라이언트 번들에 박히고, 런타임에 브라우저가 해석한다는 점 인지
  3. nginx 라우팅 구조(proxy_pass)와 이 값이 일치하는지 검증

감지 (디버깅 팁)

CORS 에러가 났을 때, 응답을 준 서버가 내가 생각하는 그 서버가 맞는지를 먼저 의심하라.

  1. 요청이 실제로 어디까지 갔는지 — 백엔드 로그에 해당 요청이 찍혔는지 확인. 안 찍혔으면 백엔드까지 안 간 것이다.
  2. 프로토콜/포트 교차 확인 — 브라우저 origin이 http://...:8004인데 요청이 https://...(443)으로 가고 있다면, 아예 다른 서버로 가고 있는 것이다.
  3. curl -v로 직접 재현 — TLS handshake 여부, 응답 서버 버전이 바로 보인다.
  4. Server 헤더의 nginx 버전 — 이번 케이스의 결정적 단서. nginx/1.19.6 vs nginx/1.29.1. 버전이 다르면 다른 nginx다.
  5. 응답 body의 에러 페이지 스타일 — nginx 기본 에러 페이지 하단에 버전이 찍힌다 (<center>nginx/1.29.1</center>).
  6. X-Served-By 커스텀 헤더 (예방적) — 각 nginx에 구분용 헤더를 넣어두면 디버깅이 훨씬 쉬워진다.
# proxy-server
add_header X-Served-By "proxy-server" always;

# frontend-app
add_header X-Served-By "frontend-app" always;

이렇게 해 두면 응답 헤더만 봐도 어떤 nginx가 응답했는지 즉시 알 수 있다. 비용은 거의 없으면서 효과가 크다. 온프레미스에서 nginx를 여러 개 운영할 경우, 습관적으로 넣어 두면 이번 같은 상황에서 디버깅 시간을 크게 줄일 수 있다.

  1. TLS 여부http로 서빙하는 nginx로 갔으면 TLS handshake가 없고, https(443) nginx로 갔으면 있다. curl -v에서 바로 보인다.


결론

CORS 설정이 아무리 잘 되어 있어도, 요청이 그 설정이 있는 서버로 가야 의미가 있다. 이번 케이스에서는 같은 도메인에 nginx 컨테이너가 2개 떠 있었고, VITE_API_URL 절대 경로 설정 때문에 요청이 의도하지 않은 nginx로 갔다. 단순히 “CORS 에러 → CORS 설정 확인”이 아니라, “응답을 준 주체가 누구인지”를 먼저 확인하는 습관이 중요하다.

이번 경험이 좋은 안테나가 될 수 있을 것 같다. 같은 상황을 다시 마주했을 때 예방·감지 둘 다 더 빨리 할 수 있을 것이다.




hit count

댓글남기기