CORS
웹 개발자가 가장 자주 마주치는 보안 정책. 브라우저가 왜 요청을 막는지, 서버에서 어떻게 허용하는지, Preflight 요청의 동작을 정확히 이해한다.
SOP — 동일 출처 정책
CORS를 이해하려면 먼저 SOP(Same-Origin Policy, 동일 출처 정책)를 알아야 한다. SOP는 브라우저의 핵심 보안 모델로, "스크립트는 자신과 같은 출처(Origin)의 리소스에만 접근할 수 있다"는 규칙이다.
출처(Origin)는 프로토콜 + 도메인 + 포트의 조합이다. 셋 중 하나라도 다르면 다른 출처(Cross-Origin)다.
https://www.example.com:443https://www.example.com/api/data
경로만 다름
http://www.example.com/api
프로토콜 다름 (https vs http)
https://api.example.com/data
서브도메인 다름
https://www.example.com:8080
포트 다름
https://www.other.com/api
도메인 자체가 다름
evil.com의 스크립트가 사용자의 브라우저에서 bank.com의 API를 호출해 계좌 정보를 탈취할 수 있다. 사용자는 bank.com에 로그인된 상태이므로 쿠키가 자동 전송된다. SOP는 이 공격(CSRF 변형)을 원천 차단한다.
CORS — 교차 출처 리소스 공유
현실에서는 프론트엔드(app.com)와 API 서버(api.com)가 다른 출처인 경우가 많다. CORS(Cross-Origin Resource Sharing)는 SOP의 엄격한 제한을 서버가 명시적으로 허용하는 방식으로 완화하는 메커니즘이다.
중요한 것은 CORS 정책이 브라우저에서 강제된다는 점이다. curl이나 서버-서버 요청에는 CORS가 적용되지 않는다. 브라우저만이 SOP/CORS를 강제하는 주체다.
단순 요청 (Simple Request)
특정 조건을 만족하는 요청은 Preflight 없이 바로 전송된다. 조건: GET/POST/HEAD 메서드, 기본 헤더만 사용, Content-Type이 text/plain·multipart/form-data·application/x-www-form-urlencoded 중 하나.
Origin 헤더 자동 추가
Origin: https://app.com
Host: api.com
Access-Control-Allow-Origin: https://app.com
Content-Type: application/json
Access-Control-Allow-Origin을 확인. 허용된 출처면 응답을 스크립트에 전달. 아니면 차단.Preflight 요청
단순 요청 조건에 해당하지 않는 경우(PUT/DELETE 메서드, Authorization 헤더, application/json Content-Type 등), 브라우저는 실제 요청 전 Preflight 요청(사전 확인 요청)을 먼저 보낸다.
Preflight는 OPTIONS 메서드로 서버에 "이런 요청을 보내도 되냐?"고 물어보는 과정이다.
Origin: https://app.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Origin: https://app.com
Content-Type: application/json
Authorization: Bearer eyJ...
86400이면 하루 동안 같은 Preflight를 재전송하지 않는다. 개발 중에는 낮게 설정하고, 프로덕션에서는 높게 설정하면 불필요한 OPTIONS 요청을 줄일 수 있다.
주요 CORS 응답 헤더
| 헤더 | 설명 | 예시 |
|---|---|---|
Access-Control-Allow-Origin | 허용할 출처. *은 모든 출처 허용 (자격증명 불가). | https://app.com 또는 * |
Access-Control-Allow-Methods | 허용할 HTTP 메서드 목록 (Preflight 응답에 포함). | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 허용할 요청 헤더 목록 (Preflight 응답에 포함). | Content-Type, Authorization |
Access-Control-Allow-Credentials | 쿠키/인증 헤더 포함 요청 허용 여부. true 시 * 불가. | true |
Access-Control-Expose-Headers | 브라우저 스크립트에 노출할 추가 응답 헤더. | X-Custom-Header |
Access-Control-Max-Age | Preflight 결과 캐시 시간(초). | 86400 |
서버별 CORS 설정
# Express.js (Node.js)
const cors = require('cors');
// 모든 출처 허용 (개발용)
app.use(cors());
// 특정 출처만 허용 (프로덕션 권장)
app.use(cors({
origin: ['https://app.com', 'https://www.app.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
}));
# FastAPI (Python)
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
max_age=86400,
)
# Nginx CORS 헤더 설정
location /api/ {
add_header 'Access-Control-Allow-Origin' 'https://app.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type' always;
# Preflight OPTIONS 처리
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
}
Access-Control-Allow-Origin: *와 credentials: true를 동시에 사용할 수 없다. 자격증명(쿠키, Authorization 헤더)이 포함된 요청에는 반드시 구체적인 출처를 명시해야 한다. 이를 무시하면 브라우저가 오류를 발생시킨다.
개발 환경에서 CORS 우회
# Vite 개발 서버 프록시 (vite.config.js)
# 브라우저 입장에서는 같은 출처로 보임
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
# Create React App (package.json)
{
"proxy": "https://api.example.com"
}
Access-Control-Allow-Origin 등의 응답 헤더로 허용 여부를 알리며, CORS는 브라우저만 강제하는 정책이다.
댓글