
CORS(Cross-Origin Resource Sharing)는 브라우저가 데이터를 요청한 출처와 데이터를 응답하는 출처가 다른 경우 데이터 접근을 제한하는 보안 정책이다.
여기서 "출처"란 프로토콜 + 도메인 + 포트의 조합이다. 셋 중 하나라도 다르면 브라우저는 이를 다른 출처로 판단한다.
예를 들어, 아래 두 주소는 IP는 같지만 포트가 다르므로 서로 다른 출처다.
| 구분 | URL |
|---|---|
| 프론트엔드 | http://192.168.0.49:80 |
| 백엔드 | http://192.168.0.49:8000 |
브라우저는 기본적으로 사용자 정보 보호를 위해 Same-Origin Policy(동일 출처 정책) 를 따른다. 따라서 다른 출처로 요청을 보내면 브라우저는 서버가 해당 요청을 허용했는지 확인한다.
처음에는 서버가 브라우저의 접근을 거부하는 것이라고 생각했다.
하지만 실제로는 브라우저가 제한하는 보안 정책이다.
브라우저는 서버로부터 응답을 받은 뒤 Access-Control-Allow-Origin 헤더를 확인한다. 만약 해당 헤더에 요청을 보낸 출처가 포함되어 있지 않다면, 브라우저는 응답을 사용자에게 전달하지 않고 CORS 에러를 발생시킨다.
즉, 서버가
Access-Control-Allow-Origin: http://192.168.0.49:80
를 응답하면, 브라우저는 해당 출처의 요청을 허용된 요청으로 판단하고 응답을 정상적으로 전달한다.
배포한 웹사이트를 아침에 처음 접속할 때마다 데이터는 정상적으로 조회되는데, 동시에 조회 실패 알림이 한번씩 발생했다.
처음에는 단순한 타이밍 문제라고 생각했다. 새로고침하거나 잠시 기다린 뒤 다시 요청하면 정상 동작했고, 이 현상이 매일 첫 접속에서만 반복됐다.
그래서 네트워크 탭을 열어봤는데 CORS 에러가 찍혀 있었다.
"어? 분명 CORS 설정은 해뒀는데 왜?" 싶어서 원인을 추적해 보기 시작했다.
결론부터 말하면 진짜 원인은 CORS가 아니라 Cold Start였다.
Docker 환경에서 백엔드 서버가 오랜 시간 요청을 받지 않은 상태였다가 다시 활성화되는 과정에서, 브라우저의 API 요청이 서버 초기화보다 먼저 도착한 것이다.
핵심 아이디어는 브라우저가 API 요청을 백엔드 서버로 보내지 않고, 같은 출처의 Nginx로 보내도록 만드는 것이다.
브라우저는 /api/ 요청을 현재 웹사이트와 동일한 출처로 인식하고, Nginx는 해당 요청을 FastAPI 서버로 전달한다.
브라우저
│
│ GET /api/wage (같은 Origin → CORS 없음)
▼
Nginx
│
│ proxy_pass
▼
FastAPI
브라우저 입장에서는 같은 출처로 요청한 것이므로 CORS 검사를 하지 않는다.
여기서 Nginx 같은 웹서버의 역할이 바로 이거다. JS 코드 → 브라우저 → 웹서버(Nginx) → 백엔드 서버 흐름에서 Nginx가 단순히 정적 파일만 서빙하는 게 아니라, 요청을 가로채서 다른 서버로 전달하는 중간 다리 역할을 한다.
참고로 Vite나 webpack도 개발 환경에서는 프록시 설정을 지원한다. 하지만 이는 개발 서버에서만 동작한다. 실제 서비스 환경에서는 Nginx 같은 웹서버를 이용해 프록시를 설정해야 한다.
① /default.conf — Nginx 프록시 블록 추가
server {
listen 80;
server_name localhost;
# /api/ 요청 → 백엔드 컨테이너로 프록시 (CORS 불필요)
location /api/ {
proxy_pass http://fastapi_app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# React SPA
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
② /.env.production — API URL을 상대경로(빈 값)로 설정
VITE_API_BASE_URL=
개발 환경에서는 FastAPI 주소를 직접 사용하고,
VITE_API_BASE_URL=http://192.168.0.49:8000
프로덕션 환경에서는 빈 값으로 설정해 axios가 자동으로 /api/... 형태의 상대 경로를 사용하도록 했다.
③ /src/lib/axios.ts — Cold Start 대비 재시도 추가
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 800;
api.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
if (!error.response && config && !config._retryCount) {
config._retryCount = 0;
}
if (!error.response && config && config._retryCount < MAX_RETRIES) {
config._retryCount += 1;
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
return api(config);
}
return Promise.reject(error);
}
);
서버 초기화 과정에서 발생하는 일시적인 네트워크 오류에 대응하기 위해 800ms 간격으로 최대 2회 재시도하도록 구성했다.
이번 작업을 하면서 배운 점은 두 가지였다.
첫 번째는 CORS가 서버가 아니라 브라우저가 강제하는 보안 정책이라는 점이다.
CORS는 브라우저 에러라고만 알고 있었는데, 이번에 직접 원인을 추적하면서 브라우저가 응답 헤더를 확인하고 사용자에게 응답을 전달할지 결정한다는 것을 제대로 이해할 수 있었다.
두 번째는 Nginx를 이용해 브라우저와 API 요청을 동일 출처로 구성할 수 있다는 점이다.
단순히 CORS 설정을 추가하는 것이 아니라, 브라우저가 항상 같은 Origin으로 요청하도록 구조 자체를 변경했다. 덕분에 프론트엔드와 백엔드 간의 CORS 설정을 별도로 관리하지 않아도 되는 구조를 만들 수 있었다.