1 배경: 왜 두 개의 서버가 필요한가
FastAPI 입문에서 Python으로 API 서버를 만들었다. 이 서버는 localhost:8000에서 JSON 데이터를 주고받는다. 그런데 실제 서비스에서는 사용자가 브라우저에서 보는 화면(프론트엔드)이 필요하다. 이 화면을 만드는 기술이 React, Vue 같은 프론트엔드 프레임워크이다.
개발 환경에서는 이 구조가 두 개의 별도 서버로 나뉜다:
프론트엔드 개발 서버 (localhost:5173) ← 화면(HTML/CSS/JS)을 제공
백엔드 API 서버 (localhost:8000) ← 데이터(JSON)를 제공
브라우저는 프론트엔드 서버에서 화면을 받아 표시하고, 화면의 JavaScript 코드가 백엔드 서버에 데이터를 요청한다. 이때 포트가 다르기 때문에 브라우저가 요청을 차단하는 문제가 발생한다. 이것이 이 포스트에서 다루는 CORS 문제이다.
- CORS(브라우저 관점): 브라우저가 다른 출처(origin: 프로토콜+호스트+포트)로부터 온 응답을 JavaScript가 읽는 것을 기본적으로 차단하는 보안 정책이다. 서버가
Access-Control-Allow-*헤더로 허용을 선언하면 브라우저가 응답을 허용한다.
- 왜 필요한가: 악성 사이트가 사용자의 인증 쿠키를 이용해 백엔드에 임의 요청을 보내는 것을 막기 위함이다.
- FastAPI에서의 해결(서버 쪽):
CORSMiddleware로 허용 출처와 메서드를 설정하면 브라우저의 Preflight(OPTIONS) 요청에 응답해준다.
# Python (FastAPI)
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)- 프록시(개념): 클라이언트의 요청을 대신 전달해 주는 중간 서버. 개발환경에서는 프론트엔드 개발 서버가 백엔드로 요청을 프록시하면 브라우저 입장에선 같은 출처로 보이므로 CORS가 문제되지 않는다. 프로덕션에서는 Nginx/Caddy 같은 리버스 프록시가 프론트엔드 정적 파일과 API를 같은 도메인으로 묶어준다.
- Vite 개발 프록시 예시:
// vite.config.ts
export default defineConfig({
server: {
port: 5173,
proxy: {
"/agents": { target: "http://localhost:8000", changeOrigin: true }
}
}
});- 차이점 요약:
- CORS = 서버가 브라우저에게 허용을 ’선언’하는 방식(브라우저 기반 보안 제어).
- Proxy = 요청을 서버-서버로 전달해 브라우저가 다른 출처에 직접 접근하지 않게 하는 우회(동작 위치: 프론트/리버스 서버).
- CORS = 서버가 브라우저에게 허용을 ’선언’하는 방식(브라우저 기반 보안 제어).
- 실무 권장:
- 개발: Vite Proxy로 편리하게 개발하고, 동시에 백엔드엔 안전한 CORS 설정을 해두기.
- 프로덕션: 리버스 프록시(Nginx 등)로 같은 도메인에서 서빙하거나, 필요하면 엄격한 CORS 정책 사용.
- 개발: Vite Proxy로 편리하게 개발하고, 동시에 백엔드엔 안전한 CORS 설정을 해두기.
- 주의사항:
allow_origins=["*"]와allow_credentials=True는 브라우저에서 허용되지 않음; 자격증명(쿠키/인증) 사용 시 특정 출처만 지정해야 함.
fetch는 브라우저에 내장된 HTTP 클라이언트 함수이다. Python의 requests.get()이나 터미널의 curl과 같은 역할을 하지만, 브라우저의 JavaScript 코드 안에서 실행된다.
// 브라우저 JavaScript에서 백엔드 API를 호출하는 코드
const response = await fetch("http://localhost:8000/agents/qna_chatbot/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "RAG란?" })
});
const data = await response.json();참고) const는 블록 스코프 상수 선언자다 — 선언한 식별자에 재할당할 수 없지만, 객체나 배열 같은 값의 내부는 변경할 수 있다.
const x = 1;
x = 2; // TypeError: Assignment to constant variable.
const obj = { a: 1 };
obj.a = 2; // 허용
obj = {}; // TypeError이 fetch 호출이 CORS 차단의 대상이 된다. curl은 브라우저가 아니므로 차단되지 않는다.
2 문제: 왜 API 호출이 차단되는가
2.1 용어 정리
브라우저 (localhost:5173)
↓ fetch("/agents/…")
Vite 개발 서버 (localhost:5173) — 프록시로 전달
↓ proxy -> http://localhost:8000
FastAPI (localhost:8000) — API 응답(JSON)
(브라우저는 5173에만 요청하므로 CORS 없음)
- 클라이언트(client): 최종 사용자 쪽 프로그램(예: 브라우저, 모바일 앱). 사용자가 직접 실행하고 UI를 보여준다.
- 프론트엔드(frontend): 클라이언트에서 실행되는 코드·자산(HTML/CSS/JS, React 등). 화면 렌더링과 사용자 입력 처리를 담당한다.
- 서버(server): 네트워크로 요청을 듣고 응답을 주는 쪽의 컴퓨터(또는 프로세스). 예:
uvicorn,nginx가 서버 역할을 한다.
- 백엔드(backend): 서버에서 동작하는 애플리케이션 로직(예: FastAPI), 데이터베이스 접근, 인증, 외부 API 호출 등을 수행한다.
관계(흐름): - 사용자가 클라이언트(브라우저)에서 UI(프론트엔드)를 조작 → 프론트엔드는 HTTP 요청(fetch/ajax)으로 백엔드(서버)를 호출 → 백엔드는 비즈니스 로직 수행 + DB 접근 → 응답(JSON/HTML)을 프론트엔드에 반환 → 프론트엔드가 화면 업데이트.
- 물리적/운영적 분리: 프론트엔드와 백엔드는 같은 머신에 있을 수도 있고(프로덕션: 리버스 프록시로 통합), 개발 중엔 서로 다른 포트/서버로 분리되어 실행된다(예: localhost:5173 프론트엔드, localhost:8000 백엔드).
- 중간 요소: 리버스 프록시(Nginx), CDN, 인증 게이트웨이 등은 요청을 라우팅·보호한다.
- 보안/브라우저 제약: 브라우저는 출처(origin)를 기준으로 요청을 제한(CORS); 프록시를 쓰면 브라우저 입장에서는 같은 출처로 보여 CORS 문제를 피할 수 있다.
프론트엔드 개발 서버(localhost:5173)에서 받은 JavaScript 코드가 FastAPI 서버(localhost:8000)로 fetch를 보내면 브라우저가 요청을 차단한다.
Access to fetch at 'http://localhost:8000/agents/qna_chatbot/run'
from origin 'http://localhost:5173' has been blocked by CORS policy
서버는 정상이고, curl로 같은 요청을 보내면 문제없이 응답한다. 차단의 주체는 서버가 아니라 브라우저이다. 이 브라우저 보안 정책이 Same-Origin Policy이다.
3 Same-Origin Policy
Origin은 프로토콜 + 호스트 + 포트의 조합이다.
| URL | Origin |
|---|---|
http://localhost:5173/home |
http://localhost:5173 |
http://localhost:8000/api |
http://localhost:8000 |
https://example.com/page |
https://example.com (포트 443 암묵) |
- Same-Origin Policy는 출처가 다른 리소스에 대한 JavaScript 접근을 차단하는 브라우저 보안 정책이다.
- 포트가 하나만 달라도 다른 출처로 간주한다.
| 비교 | 같은 출처? | 이유 |
|---|---|---|
http://localhost:5173 vs http://localhost:8000 |
X | 포트 다름 |
http://example.com vs https://example.com |
X | 프로토콜 다름 |
http://api.example.com vs http://example.com |
X | 호스트 다름 |
http://localhost:5173/a vs http://localhost:5173/b |
O | 경로만 다름 |
3.1 왜 이런 정책이 필요한가
Same-Origin Policy가 없으면 악성 사이트가 사용자의 브라우저를 통해 다른 사이트의 API를 호출할 수 있다.
1. 사용자가 은행 사이트에 로그인한 상태
2. 악성 사이트(evil.com)를 방문
3. evil.com의 JavaScript가 bank.com/api/transfer를 호출
4. 브라우저가 bank.com 쿠키를 자동으로 첨부
5. 사용자 모르게 송금 실행
Same-Origin Policy는 3번 단계에서 요청을 차단하여 이 시나리오를 방지한다.
3.2 차단 대상과 비차단 대상
모든 크로스 오리진 요청이 차단되는 것은 아니다.
| 요소 | 차단 여부 | 이유 |
|---|---|---|
<img src="..."> |
허용 | 이미지 로드는 데이터 접근 없음 |
<script src="..."> |
허용 | CDN에서 라이브러리 로드 허용 |
<link href="..."> |
허용 | 외부 CSS 로드 |
fetch() / XMLHttpRequest |
차단 | JavaScript가 응답 데이터를 읽을 수 있으므로 |
fetch가 차단되는 이유는 JavaScript 코드가 응답 본문을 읽고 조작할 수 있기 때문이다. 이미지 태그는 픽셀만 렌더링하고 데이터를 코드로 읽을 수 없으므로 허용된다.
4 CORS: 서버가 허용을 선언하는 방식
CORS는 서버가 HTTP 응답 헤더를 통해 “이 출처의 요청은 허용한다”고 브라우저에 알리는 메커니즘이다. Same-Origin Policy의 예외를 안전하게 허용하는 표준이다.
4.1 단순 요청 (Simple Request)
조건을 충족하는 요청은 Preflight 없이 바로 전송된다.
- 메서드:
GET,HEAD,POST중 하나 - 헤더:
Content-Type이application/x-www-form-urlencoded,multipart/form-data,text/plain중 하나 - 커스텀 헤더 없음
[브라우저] → GET /data HTTP/1.1
Origin: http://localhost:5173
[서버] ← HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
서버 응답에 Access-Control-Allow-Origin 헤더가 있고 요청 출처와 일치하면, 브라우저가 응답을 JavaScript에 전달한다.
4.2 Preflight 요청
JSON 본문(Content-Type: application/json)을 보내는 POST 요청처럼 단순 요청 조건을 벗어나면, 브라우저가 먼저 OPTIONS 요청을 보내 서버에 허용 여부를 확인한다.
[브라우저] → OPTIONS /agents/qna_chatbot/run HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type
[서버] ← HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type
[브라우저] → POST /agents/qna_chatbot/run HTTP/1.1 ← 본 요청
Origin: http://localhost:5173
Content-Type: application/json
{"text": "RAG란?"}
[서버] ← HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
{"response": {"text": "..."}}
AI Agent API에서 JSON 본문을 사용하므로 거의 모든 POST 요청은 Preflight를 거친다. 이 OPTIONS 요청은 브라우저가 자동으로 보내며, 개발자가 명시적으로 작성하지 않는다.
4.3 CORS 관련 헤더 정리
| 헤더 | 방향 | 설명 |
|---|---|---|
Origin |
요청 | 요청을 보낸 출처 (브라우저가 자동 추가) |
Access-Control-Allow-Origin |
응답 | 허용할 출처 (* 또는 특정 출처) |
Access-Control-Allow-Methods |
응답 | 허용할 HTTP 메서드 목록 |
Access-Control-Allow-Headers |
응답 | 허용할 요청 헤더 목록 |
Access-Control-Allow-Credentials |
응답 | 쿠키/인증 정보 포함 허용 여부 |
Access-Control-Max-Age |
응답 | Preflight 결과 캐시 시간 (초) |
5 FastAPI에서 CORS 설정
FastAPI는 CORSMiddleware로 CORS 헤더를 자동 추가한다.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)이 설정이 하는 일:
- 모든 요청에
Access-Control-Allow-Origin헤더를 추가한다 OPTIONSPreflight 요청에 자동으로 200 응답을 보낸다allow_credentials=True이면Access-Control-Allow-Credentials: true를 추가한다
5.1 주요 매개변수
| 매개변수 | 설명 | 개발용 | 프로덕션용 |
|---|---|---|---|
allow_origins |
허용할 출처 목록 | ["http://localhost:5173"] |
["https://app.example.com"] |
allow_methods |
허용할 HTTP 메서드 | ["*"] |
["GET", "POST"] |
allow_headers |
허용할 요청 헤더 | ["*"] |
["Content-Type", "Authorization"] |
allow_credentials |
쿠키 포함 허용 | True |
필요할 때만 True |
allow_origins=["*"]와 allow_credentials=True는 동시에 사용할 수 없다. 브라우저가 거부한다. 자격 증명을 포함하는 요청은 반드시 특정 출처를 명시해야 한다.
5.2 환경별 분리
import os
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
if ENVIRONMENT == "development":
origins = ["http://localhost:5173", "http://localhost:3000"]
else:
origins = ["https://app.example.com"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)개발 환경에서는 여러 로컬 포트를 허용하고, 프로덕션에서는 실제 도메인만 허용한다.
6 개발 환경: Vite Proxy
CORS 설정은 서버 측 해결이다. 개발 환경에서는 프론트엔드 프록시로 Same-Origin Policy 자체를 우회할 수 있다.
Vite는 프론트엔드 개발 서버 + 빌드 도구이다. React, Vue 같은 프론트엔드 프레임워크로 만든 코드를 브라우저에서 실행할 수 있도록 변환하고, 개발 중에는 localhost:5173에서 파일 변경을 감지하여 즉시 반영한다. FastAPI에서 uvicorn이 하는 역할의 프론트엔드 버전이다.
Vite의 핵심 기능 중 하나가 프록시 설정으로, 프론트엔드에서 백엔드로의 API 요청을 대신 전달해주어 CORS 문제를 우회할 수 있다.
6.1 원리
프록시는 프론트엔드 개발 서버가 API 요청을 대신 전달하는 방식이다. 브라우저 입장에서는 같은 출처(localhost:5173)에 요청하므로 CORS가 발생하지 않는다.
프록시 없이:
브라우저(5173) → fetch("http://localhost:8000/agents/...") → CORS 차단
프록시 사용:
브라우저(5173) → fetch("/agents/...") → Vite(5173) → FastAPI(8000) → 응답
같은 출처이므로 CORS 없음 서버 간 통신이므로 CORS 없음
핵심은 브라우저는 localhost:5173에만 요청하고, 5173 → 8000 전달은 서버 간 통신이므로 Same-Origin Policy가 적용되지 않는다는 것이다.
6.2 Vite 설정
Vite의 설정 파일(vite.config.ts)에서 프록시 규칙을 정의한다. 이 파일은 프론트엔드 프로젝트 루트에 위치한다.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/agents": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/monitoring": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/health": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});/agents로 시작하는 모든 요청은 Vite가 http://localhost:8000/agents로 전달한다. 프론트엔드 코드에서는 상대 경로만 사용한다.
// 프록시 사용 시 — 상대 경로
const response = await fetch("/agents/qna_chatbot/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "RAG란?" }),
});
// 프록시 미사용 시 — 절대 경로 (CORS 필요)
const response = await fetch("http://localhost:8000/agents/qna_chatbot/run", {
// ...
});6.3 Proxy vs CORS: 어떤 것을 사용할까
| 기준 | Vite Proxy | CORS 미들웨어 |
|---|---|---|
| 동작 위치 | 프론트엔드 개발 서버 | 백엔드 서버 |
| 적용 범위 | 개발 환경만 | 개발 + 프로덕션 |
| 설정 대상 | vite.config.ts |
FastAPI main.py |
| 브라우저 네트워크 탭 | 같은 출처로 보임 | 크로스 오리진으로 보임 |
| Preflight 오버헤드 | 없음 | 있음 (OPTIONS 요청) |
개발 환경에서는 둘 다 설정하는 것이 일반적이다. Proxy가 주된 해결책이고, CORS는 프록시를 거치지 않는 직접 호출(테스트, Swagger UI 등)을 위한 백업이다.
7 프로덕션: Reverse Proxy
개발 환경의 Vite Proxy는 빌드 후 사라진다. 프로덕션에서는 Reverse Proxy(Nginx, Caddy, 클라우드 로드 밸런서 등)가 같은 역할을 한다.
개발:
브라우저 → Vite(5173) → FastAPI(8000)
프로덕션:
브라우저 → Nginx(443) ──┬── /api/* → FastAPI(8000)
└── /* → React 정적 파일
Nginx 설정 예시:
server {
listen 443 ssl;
server_name app.example.com;
# React 정적 파일
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# API 프록시
location /agents/ {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /monitoring/ {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
}
}프론트엔드와 백엔드가 같은 도메인, 같은 포트로 서빙되므로 CORS 자체가 발생하지 않는다. 이것이 프로덕션에서 가장 깔끔한 해결책이다.
7.1 Docker 멀티스테이지 빌드와의 결합
Docker에서 React와 FastAPI를 하나의 컨테이너로 배포할 때도 같은 원리가 적용된다.
# Stage 1: React 빌드
FROM node:20 AS frontend
WORKDIR /app/frontend
COPY frontend/ .
RUN npm ci && npm run build
# Stage 2: FastAPI + 정적 파일 서빙
FROM python:3.12
COPY --from=frontend /app/frontend/dist /app/static
COPY src/ /app/src/
# FastAPI가 정적 파일도 함께 서빙
CMD ["uvicorn", "src.services.api.main:app", "--host", "0.0.0.0", "--port", "8000"]from fastapi.staticfiles import StaticFiles
# React 빌드 결과를 FastAPI가 직접 서빙
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")하나의 서버가 프론트엔드와 API를 모두 서빙하므로 Same-Origin이 된다. 소규모 프로젝트에서는 별도 Nginx 없이 이 방식으로 충분하다.
8 디버깅 가이드
8.1 CORS 에러 확인 방법
브라우저 개발자 도구에서 확인할 수 있다.
1. F12 → Network 탭
2. 실패한 요청 선택
3. Response Headers에서 Access-Control-Allow-Origin 확인
4. Console 탭에서 CORS 에러 메시지 확인
8.2 흔한 실수와 해결
| 증상 | 원인 | 해결 |
|---|---|---|
No 'Access-Control-Allow-Origin' header |
서버에 CORS 미들웨어 미설정 | CORSMiddleware 추가 |
Preflight OPTIONS 405 |
서버가 OPTIONS 메서드 미처리 | allow_methods=["*"] 설정 |
| 쿠키가 전송되지 않음 | credentials 설정 누락 | allow_credentials=True + fetch에 credentials: "include" |
| 프록시 설정했는데 CORS 발생 | 절대 경로 사용 | 상대 경로(/agents/...)로 변경 |
| 개발에선 되는데 프로덕션에서 안 됨 | Vite Proxy가 빌드 후 사라짐 | Nginx Reverse Proxy 또는 CORS 헤더 설정 |
8.3 curl로 CORS 시뮬레이션
curl은 브라우저가 아니므로 CORS가 적용되지 않지만, Origin 헤더를 수동으로 추가하여 서버 응답을 확인할 수 있다.
9 관련 주제
선행 지식
- API 기초 – HTTP, JSON, 상태 코드
- FastAPI 입문 – 8단계에서 CORS 미들웨어 설정을 다룸. 이 포스트는 그 원리를 심화한다
후속 주제
- SSE – 실시간 스트리밍의 가벼운 선택지 – SSE도 CORS/Proxy 설정의 영향을 받는다
- React 기초 – 컴포넌트, State, Props, Hook – 이 포스트에서 언급한 “프론트엔드”를 만드는 기술
- React에서 API 호출 – fetch, 타입 안전 클라이언트 – fetch와 프록시 설정의 프론트엔드 측면