CORS 제대로 이해하기 — 삽질 없이 설정하는 법
CORS 에러가 반복되는 이유
많은 개발자들이 CORS 에러에 시달린다. 해결책을 구글링해서 Access-Control-Allow-Origin: *을 쓰거나, 프록시를 만든다. 하지만 근본 원인을 모르면 계속 같은 문제에 걸린다. CORS(Cross-Origin Resource Sharing)는 보안 정책이자 동시에 웹 개발의 필수 요소다. 경험상 원리만 이해하면 대부분의 CORS 문제는 5분 만에 해결된다.
Same-Origin Policy — 왜 이 정책이 필요한가?
브라우저에는 Same-Origin Policy가 있다. 한 origin에서 로드된 웹 페이지는 다른 origin의 리소스에 접근할 수 없다는 정책이다. Origin은 protocol + domain + port의 조합이다. https://example.com과 https://api.example.com은 다른 origin이다(subdomain이 다르므로). https://example.com:3000과 https://example.com은 다른 origin이다(port가 다르므로).
이 정책이 없으면 악의적인 웹사이트가 사용자의 은행 계좌에 접근할 수 있다. 만약 사용자가 은행 계좌에 로그인한 상태라면, 그 세션 쿠키를 이용해 계좌 이체 요청을 보낼 수 있다. 따라서 같은 origin의 요청만 가능하게 제한하는 것이 필수다.
Preflight Request — 브라우저가 자동으로 하는 보안 체크
특정 요청(복잡한 요청)에는 preflight 요청이 앞선다. 예를 들어 POST로 JSON을 보낼 때:
// 브라우저가 자동으로 먼저 이 요청을 보냄
OPTIONS /api/data HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
// 서버의 응답이 OK면 실제 요청을 보냄
POST /api/data HTTP/1.1
Content-Type: application/json
Origin: https://example.com
Body: {data...}
서버가 preflight 요청에 제대로 응답하지 않으면 CORS 에러가 난다.
Access-Control 헤더 — 어떤 origin을 허용할 것인가?
서버는 다음 헤더로 CORS를 제어한다:
// 어떤 origin이 접근 가능한가?
Access-Control-Allow-Origin: https://example.com
// 또는 모든 origin 허용 (권장하지 않음)
Access-Control-Allow-Origin: *
// 어떤 HTTP 메서드를 허용할 것인가?
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
// 어떤 헤더를 허용할 것인가?
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header
// 브라우저가 응답 헤더에 접근할 수 있나?
Access-Control-Expose-Headers: X-Total-Count, X-Page-Count
// 인증 정보(쿠키, Authorization 헤더)를 포함할 것인가?
Access-Control-Allow-Credentials: true
// Preflight 요청을 캐시할 시간 (초)
Access-Control-Max-Age: 3600
Credentials 모드 — 쿠키를 보낼 것인가?
CORS 요청에서 인증 정보를 포함할지 결정하는 중요한 설정이다:
// 클라이언트 측 (JavaScript)
fetch('/api/data', {
method: 'POST',
credentials: 'include', // 쿠키 포함
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({...})
})
// 이 경우 서버의 응답에 반드시 포함되어야 함:
// Access-Control-Allow-Credentials: true
// (Access-Control-Allow-Origin: * 는 불가능, 명시적 origin 필요)
credentials: 'include'를 쓰면서 Allow-Origin: *를 쓰면 CORS 에러가 난다. 이 조합은 불가능하다.
실제 설정 예제
Express에서:
const cors = require('cors');
// 단순: 모든 origin 허용 (개발용)
app.use(cors());
// 실무: 특정 origin만 허용
const allowedOrigins = ['https://example.com', 'https://app.example.com'];
app.use(cors({
origin: (origin, callback) => {
if (allowedOrigins.includes(origin) || !origin) { // !origin은 curl 요청
callback(null, true);
} else {
callback(new Error('CORS not allowed'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 3600
}));
// Preflight 요청 처리 (cors 미들웨어가 자동 처리)
app.options('/api/:path', cors());
또는 FastAPI에서:
from fastapi.middleware.cors import CORSMiddleware
allowed_origins = [
'https://example.com',
'https://app.example.com',
'http://localhost:3000'
]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=['GET', 'POST', 'PUT', 'DELETE'],
allow_headers=['Content-Type', 'Authorization'],
max_age=3600
)
개발용 프록시 — 로컬에서 CORS 우회하기
로컬 개발할 때 백엔드 API가 다른 포트에 있으면 CORS 문제가 난다. 프록시를 사용해서 우회할 수 있다:
// Next.js: next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8000/api/:path*'
}
]
}
}
// 클라이언트 코드는 /api를 그대로 사용
fetch('/api/data') // http://localhost:3000/api/data로 요청되지만
// 서버에서 http://localhost:8000/api/data로 프록시됨
또는 Create React App: .env에 PROXY 설정:
REACT_APP_API_URL=http://localhost:8000
CORS와 보안
CORS는 단순히 개발자의 불편함을 위한 정책이 아니다. 보안이 핵심이다. 따라서:
- 프로덕션에서 Access-Control-Allow-Origin: *를 쓰지 말 것
- 인증이 필요한 엔드포인트에서 credentials를 고려할 것
- 필요한 origin만 명시적으로 허용할 것
결론
CORS 에러는 보안 정책 때문이고, 이 정책은 필요하다. 원리만 이해하면 설정은 매우 간단하다. 앞으로 CORS 에러를 만나면 먼저 origin이 다른지 확인하고, preflight 요청이 제대로 응답하는지 브라우저의 Network 탭에서 확인하자.