CORS 에러 완벽 해결: 콘솔 에러 원문별 진단표 + 서버별 복붙 설정
API를 호출했더니 분명 Postman에서는 200이 잘 떨어지는데, 브라우저에서만 빨간 콘솔 에러가 뜬 경험, 누구나 한 번쯤 있을 겁니다. 그리고 십중팔구 "코드는 분명 맞는데 왜 막히지?"라는 생각이 듭니다.
여기서 가장 먼저 풀어야 할 오해가 있습니다. CORS는 서버가 요청을 거부하는 게 아니라, 브라우저가 응답을 가로채는 것입니다. 서버는 정상적으로 응답을 보냈지만, 응답에 적절한 Access-Control-* 헤더가 없으면 브라우저가 "이건 보안상 너에게 못 넘겨줘"라며 자바스크립트로 결과가 전달되는 걸 막습니다. 그래서 curl이나 Postman에서는 멀쩡하고 브라우저에서만 터집니다.
이 글은 에러 문구 → 원인 → 서버 설정 순서로 구성했습니다. 콘솔에 뜬 영문 문구를 그대로 입력값 삼아 진단표에서 원인을 찾고, 본인 서버 스택의 복붙 코드로 바로 해결하면 됩니다.
CORS 동작 원리 30초 정리
브라우저는 Same-Origin 정책 때문에 프로토콜+호스트+포트가 다른 출처로의 요청 응답을 기본적으로 차단합니다. https://app.com에서 https://api.com을 부르면 출처가 다르므로(cross-origin) CORS 검사가 작동합니다.
요청은 두 가지로 나뉩니다.
- 단순 요청(Simple Request): GET, POST(HEAD), 그리고
Content-Type이text/plain,application/x-www-form-urlencoded,multipart/form-data인 경우. 바로 본 요청을 보냅니다. - 프리플라이트(Preflight): PUT/DELETE/PATCH 같은 비단순 메서드,
Authorization같은 커스텀 헤더,Content-Type: application/json등이 포함되면 브라우저가 본 요청 전에OPTIONS요청을 먼저 보내 "이 메서드/헤더로 보내도 돼?"라고 물어봅니다.
REST API에서 JSON 바디를 보내는 순간 대부분 프리플라이트가 발생한다는 점, 꼭 기억하세요. 이게 CORS 에러의 절반을 차지합니다.
에러 문구별 원인 진단표
콘솔에 뜬 문구를 아래 표에서 찾으세요.
| 콘솔 에러 원문 | 원인 유형 | 한 줄 진단 |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present on the requested resource | ① 응답 헤더 누락 | 서버가 CORS 헤더 자체를 안 보냄 |
The value of the 'Access-Control-Allow-Origin' header ... must not be the wildcard '*' when the request's credentials mode is 'include' | ③ wildcard+credentials 충돌 | *와 쿠키 인증을 동시에 쓸 수 없음 |
Response to preflight request doesn't pass access control check | ② preflight 실패 | OPTIONS 응답이 비정상(4xx/헤더 누락) |
Method PUT is not allowed by Access-Control-Allow-Methods | ④ Allow-Methods 누락 | 허용 메서드 목록에 PUT 없음 |
Request header field authorization is not allowed by Access-Control-Allow-Headers | ④ Allow-Headers 누락 | 허용 헤더 목록에 authorization 없음 |
... has been blocked by CORS policy ... contains multiple values '...' | ⑤ 헤더 중복 | 프록시+백엔드가 헤더를 이중 추가 |
5가지 원인 유형별 정밀 진단
① 단순요청 응답 헤더 누락
가장 흔합니다. 서버 응답에 Access-Control-Allow-Origin이 아예 없습니다. 네트워크 탭 → 해당 요청 → Response Headers를 열어 access-control-allow-origin이 있는지 확인하세요. 없다면 서버에 CORS 설정이 빠진 겁니다.
② preflight OPTIONS 실패
네트워크 탭에 본 요청 직전 회색 OPTIONS 요청이 보입니다. 이게 404/405거나 응답에 CORS 헤더가 없으면 본 요청은 아예 발사되지 않습니다. 라우터가 OPTIONS 메서드를 처리하지 못하는 경우가 많습니다.
③ credentials + wildcard 충돌
프런트에서 credentials: 'include'(쿠키 전송)를 켰는데 서버가 Access-Control-Allow-Origin: *로 응답하면 브라우저가 거부합니다. 보안상 "모든 출처 허용 + 쿠키 전송"은 불가능하기 때문입니다.
④ Allow-Methods / Allow-Headers 누락
프리플라이트 응답에 Access-Control-Allow-Methods나 Access-Control-Allow-Headers 값이 실제 요청을 못 담으면 차단됩니다. authorization 헤더를 보내는데 서버가 허용 목록에 안 넣은 경우가 대표적입니다.
⑤ 프록시/리다이렉트로 헤더 유실·중복
Nginx 같은 리버스 프록시와 백엔드가 둘 다 CORS 헤더를 추가하면 multiple values 에러가 납니다. 반대로 301/302 리다이렉트를 거치면 CORS 헤더가 소실되기도 합니다. CORS 헤더는 한 군데에서만 책임지게 하세요.
와일드카드 + credentials 함정 시연
이게 안 되는 조합입니다.
// ❌ 브라우저가 거부: '*' + 쿠키 동시 사용 불가
fetch('https://api.com/me', { credentials: 'include' })
// 서버 응답: Access-Control-Allow-Origin: *
// Access-Control-Allow-Credentials: true → 에러올바른 패턴은 Origin을 동적으로 반사하되 화이트리스트로 검증하는 것입니다.
const allowList = new Set(['https://app.com', 'https://admin.app.com']);
function setCors(req, res) {
const origin = req.headers.origin;
if (allowList.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin); // '*' 아님!
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin'); // 캐시 오염 방지
}
}Vary: Origin을 빼먹으면 CDN/프록시가 한 출처의 응답을 다른 출처에 캐싱해 버리는 버그가 생기니 꼭 넣어주세요.
서버별 복붙 해결 설정
Express (cors 미들웨어)
const cors = require('cors');
const allowList = ['https://app.com', 'https://admin.app.com'];
app.use(cors({
origin: (origin, cb) => {
// origin이 없는 경우(서버간 호출, 동일 출처)도 허용
if (!origin || allowList.includes(origin)) return cb(null, true);
cb(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// cors 미들웨어가 OPTIONS 프리플라이트를 자동 처리합니다.Nginx (add_header + 프리플라이트 분기)
location /api/ {
set $cors_origin "";
if ($http_origin ~* (https://app\.com|https://admin\.app\.com)) {
set $cors_origin $http_origin;
}
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 86400 always;
return 204; # 프리플라이트는 본문 없이 204
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
proxy_pass http://backend;
}⚠️ 백엔드(Express 등)에서 이미 CORS 헤더를 추가한다면 Nginx에서는 추가하지 마세요. 둘 다 넣으면 multiple values 에러가 납니다.
Spring Boot (CorsConfigurationSource)
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// 패턴 사용 시 setAllowedOriginPatterns, 정확한 출처면 setAllowedOrigins
config.setAllowedOriginPatterns(List.of("https://app.com", "https://admin.app.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}Spring Security를 쓴다면 http.cors(Customizer.withDefaults())를 반드시 호출하세요. 안 그러면 시큐리티 필터가 CORS 설정보다 먼저 요청을 차단합니다.
FastAPI (CORSMiddleware)
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.com", "https://admin.app.com"], # '*' 금지
allow_credentials=True,
allow_methods=["*"],
allow_headers=["Content-Type", "Authorization"],
)allow_credentials=True와 allow_origins=["*"]를 함께 쓰면 Starlette가 내부적으로 막으니 반드시 명시적 리스트로 적으세요.
실무 경험 한마디
저는 Next.js 프런트와 Spring Boot API를 분리 운영하면서 가장 많이 헤맨 게 ⑤번 중복 헤더였습니다. 로컬에선 백엔드 CORS만으로 잘 되다가, 운영에 ALB+Nginx를 끼우자 multiple values 에러가 터졌죠. 결론은 CORS 헤더 책임을 한 레이어로 단일화하는 것. BFF나 API Gateway 패턴을 쓴다면 게이트웨이에서만 CORS를 처리하고 백엔드는 끄는 편이 디버깅이 훨씬 쉽습니다. 쿠키 인증이라면 SameSite=None; Secure까지 세트로 챙겨야 크로스 도메인에서 쿠키가 살아남습니다.
보안 체크리스트 & 디버깅 순서
- ✅ 운영 환경에서
Access-Control-Allow-Origin: *+credentials: true절대 금지 - ✅ Origin은 반드시 화이트리스트로 검증 후 반사(reflect)
- ✅
Vary: Origin헤더 추가로 캐시 오염 방지 - ✅ CORS 헤더는 프록시·백엔드 중 한 곳에서만 추가
- ✅ 리다이렉트(301/302)를 거치는 엔드포인트는 CORS 헤더 소실 주의
디버깅 순서: ① 네트워크 탭에서 OPTIONS 요청 유무 확인 → ② 실패 응답의 Response Headers에서 어떤 Access-Control-*가 빠졌는지 확인 → ③ 진단표에서 원인 유형 매핑 → ④ 서버 스택 복붙 코드 적용.
자주 묻는 질문 (FAQ)
Q. Postman에서는 되는데 브라우저에서만 CORS 에러가 납니다. 왜죠? A. CORS는 브라우저만 강제하는 정책입니다. Postman/curl은 Same-Origin 정책을 적용하지 않으므로 정상 응답이 옵니다. 서버 응답 헤더 자체는 정상이고, 브라우저용 CORS 헤더가 없는 상태입니다.
Q. 와일드카드 '*'를 정말 쓰면 안 되나요?
A. 쿠키/인증정보를 보내지 않는 공개 API라면 *도 괜찮습니다. 하지만 credentials: include(쿠키 인증)를 쓰는 순간 *는 사용 불가이며, 명시적 Origin 반사가 필수입니다. 보안상으로도 화이트리스트를 권장합니다.
Q. OPTIONS 요청에서 404가 떠요. A. 라우터가 OPTIONS 메서드를 처리하지 못하는 경우입니다. Express의 cors 미들웨어, FastAPI/Spring의 CORS 설정은 OPTIONS를 자동 처리하지만, 수동 라우팅이라면 OPTIONS에 204 + CORS 헤더로 응답하는 핸들러를 추가해야 합니다.
이 글은 AI 에이전트가 자료 조사와 1차 초안 작성을 담당하고, 사람 편집자가 사실관계·출처·톤과 맥락을 검토한 뒤 발행했습니다. 환경(OS·버전)에 따라 결과가 다를 수 있으니 적용 전 공식 문서를 함께 확인하세요. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.