CORS 에러 완전 정리: 왜 막히는지부터 preflight, credentials까지 올바르게 해결하기
'has been blocked by CORS policy' 에러는 서버가 아니라 브라우저가 막은 것이다. 같은 출처 정책, 단순 요청과 preflight, 쿠키를 보내는 요청, 응답 헤더 노출까지 실제 브라우저로 확인한 결과와 함께 정리하고, mode: 'no-cors' 같은 잘못된 해결법도 짚어봤다.
서버는 정상인데 왜 에러가 나지?
프론트엔드(http://localhost:5000)에서 API 서버(http://localhost:5001)로 요청을 보냈더니 콘솔에 이런 에러가 뜬다.
Access to fetch at 'http://localhost:5001/users' from origin 'http://localhost:5000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header
is present on the requested resource.
Postman이나 curl로 보내면 잘 되는데, 브라우저에서만 안 된다. 그리고 놀랍게도 API 서버 로그를 보면 요청은 이미 도착해서 처리까지 끝났다.
[API] GET /users origin=http://localhost:5000 ← 서버는 요청을 받았고 응답도 보냈다
CORS 에러는 서버가 요청을 거부한 것이 아니라, 브라우저가 응답을 자바스크립트에 넘겨주지 않은 것이다. 서버가 "이 출처에는 응답을 보여줘도 된다"고 허락하는 헤더를 보내지 않았기 때문이다.
그래서 CORS는 프론트엔드 코드로는 고칠 수 없고, 서버의 응답 헤더로 고쳐야 한다.
출처(Origin)와 같은 출처 정책
브라우저는 기본적으로 다른 출처(Origin)의 응답을 자바스크립트가 읽지 못하게 막는다. 이를 같은 출처 정책(Same-Origin Policy)이라고 한다. 출처는 프로토콜 + 호스트 + 포트 세 가지가 모두 같아야 같은 출처다.
| https://blog.com과 비교 | 같은 출처? | 이유 |
|---|---|---|
| https://blog.com/posts | ✅ | 경로는 상관없음 |
| http://blog.com | ❌ | 프로토콜이 다름 |
| https://api.blog.com | ❌ | 호스트가 다름 (서브도메인도 다른 출처) |
| https://blog.com:8080 | ❌ | 포트가 다름 |
같은 출처 정책이 없다면, 내가 로그인해 둔 은행 사이트의 API를 악성 사이트의 자바스크립트가 마음대로 불러서 응답(잔액, 거래 내역)을 읽어갈 수 있다. CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)는 이 정책에 서버가 허락한 경우에만 예외를 열어주는 규칙이다.
해결의 기본: Access-Control-Allow-Origin
서버가 응답에 이 헤더를 넣으면 브라우저가 응답을 넘겨준다.
// Node.js 기본 http 모듈 예시
import http from "node:http";
http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "http://localhost:5000");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ users: [] }));
}).listen(5001);
Express를 쓴다면 cors 미들웨어가 같은 일을 해준다.
import cors from "cors";
app.use(cors({ origin: "https://my-frontend.com" }));
단순 요청과 preflight 요청
여기까지 하고 나서 POST로 JSON을 보내면, 또 막힌다.
Request header field content-type is not allowed by Access-Control-Allow-Headers
in preflight response.
브라우저는 요청을 두 종류로 나눠서 다룬다.
| 종류 | 조건 | 브라우저 동작 |
|---|---|---|
| 단순 요청 (Simple Request) | GET/HEAD/POST + 기본 헤더만 + Content-Type이 text/plain, multipart/form-data, application/x-www-form-urlencoded 중 하나 | 바로 요청을 보내고, 응답의 Allow-Origin을 확인 |
| 그 외 (preflight 대상) | PUT, DELETE, PATCH / Content-Type: application/json / Authorization 같은 커스텀 헤더 | 먼저 OPTIONS 요청으로 허락을 묻고, 허락받으면 진짜 요청을 보냄 |
application/json은 단순 요청 조건에 들어가지 않는다. 그래서 대부분의 API 요청은 preflight 대상이다.
직접 확인해보면, preflight에 제대로 답하지 않는 서버의 로그에는 이렇게 찍힌다.
[API] OPTIONS /posts origin=http://localhost:5000 ← 허락을 묻는 요청만 오고
← 진짜 POST는 아예 오지 않음
preflight가 실패하면 진짜 요청은 서버로 보내지지도 않는다. 단순 요청은 서버에 도착한 뒤 응답만 막히지만, preflight 대상 요청은 출발 전에 막힌다.
서버는 OPTIONS 요청에 허용할 메서드와 헤더를 알려줘야 한다.
http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "http://localhost:5000");
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "600"); // 10분 동안 preflight 결과 캐시
res.statusCode = 204;
res.end();
return;
}
// ...실제 요청 처리
}).listen(5001);
| preflight 응답 헤더 | 의미 |
|---|---|
| Access-Control-Allow-Methods | 허용하는 HTTP 메서드 |
| Access-Control-Allow-Headers | 요청에 붙여도 되는 헤더 |
| Access-Control-Max-Age | preflight 결과를 몇 초간 캐시할지 (매번 OPTIONS를 보내지 않게) |
쿠키를 함께 보낼 때: credentials
로그인 세션 쿠키를 다른 출처의 API로 보내려면 프론트와 서버 양쪽에 설정이 필요하다.
// 프론트엔드
fetch("https://api.my-service.com/me", { credentials: "include" });
// 서버
res.setHeader("Access-Control-Allow-Origin", "https://my-service.com"); // * 불가
res.setHeader("Access-Control-Allow-Credentials", "true");
여기서 가장 흔한 실수는 Allow-Origin: *과 쿠키를 같이 쓰는 것이다. 브라우저는 이 조합을 거부한다.
The value of the 'Access-Control-Allow-Origin' header in the response must not be
the wildcard '*' when the request's credentials mode is 'include'.
쿠키를 보내는 요청에는 *를 쓸 수 없고, 정확한 출처 하나를 적어야 한다. 허용할 출처가 여러 개라면 요청의 Origin 헤더를 허용 목록과 비교해서, 맞을 때만 그 값을 그대로 돌려준다. 이때 Vary: Origin 헤더도 함께 보내야 CDN이 다른 출처용 응답을 섞어서 캐시하지 않는다.
const ALLOWED = ["https://my-service.com", "https://admin.my-service.com"];
const origin = req.headers.origin;
if (ALLOWED.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
res.setHeader("Vary", "Origin");
응답 헤더가 null로 읽힐 때: Expose-Headers
CORS 요청에서는 응답 헤더도 기본적으로 몇 개(Content-Type 등)만 읽을 수 있다. 페이지네이션용으로 X-Total-Count 같은 헤더를 보냈는데 프론트에서 null이 나온다면 이 때문이다.
// 서버가 X-Total-Count: 42를 보냈지만...
res.headers.get("X-Total-Count"); // null
서버에서 노출할 헤더를 명시해야 한다.
res.setHeader("Access-Control-Expose-Headers", "X-Total-Count");
// 이제 res.headers.get("X-Total-Count") → "42"
잘못된 해결법들
❌ mode: "no-cors"
에러 메시지를 보고 이렇게 고치는 경우가 많다.
const res = await fetch("http://localhost:5001/users", { mode: "no-cors" });
res.type; // "opaque"
res.status; // 0
await res.json(); // 실패. 내용을 읽을 수 없다
에러는 사라지지만 응답이 "불투명(opaque)"해져서 상태 코드도, 내용도 읽을 수 없다. 에러를 숨긴 것이지 해결한 게 아니다.
❌ 모든 곳에 Access-Control-Allow-Origin: *
공개 API(누구나 쓰는 날씨 API 등)라면 *가 맞다. 하지만 로그인이 필요한 API에 습관처럼 *를 붙이면, 나중에 쿠키 인증을 붙이는 순간 막히고, 허용 범위를 고민할 기회도 사라진다. 허용할 출처를 명시하는 습관을 들이자.
❌ 브라우저 확장이나 실행 옵션으로 CORS 끄기
내 브라우저에서만 동작할 뿐, 사용자 브라우저에서는 여전히 막힌다. 개발 중 확인용으로도 진짜 문제를 가리기 쉬워서 권하지 않는다.
✅ 개발 환경에서는 프록시도 방법이다
프론트 개발 서버가 API 요청을 대신 전달하게 하면, 브라우저 입장에서는 같은 출처 요청이 되어 CORS 자체가 생기지 않는다.
// vite.config.js
export default {
server: {
proxy: {
"/api": "http://localhost:5001", // /api/* 요청을 API 서버로 전달
},
},
};
배포 환경에서도 프론트와 API를 같은 도메인 아래 두는 구성(예: my-service.com/api)이라면 CORS 설정이 아예 필요 없다.
CORS는 서버를 보호하지 않는다
마지막으로 꼭 알아둘 점이 있다. 처음 예시에서 봤듯이 단순 요청은 CORS에 막혀도 서버에 도착해서 실행된다. CORS는 "다른 출처의 응답을 브라우저가 읽지 못하게" 하는 장치이지, 서버로 오는 요청을 막아주는 방화벽이 아니다.
- curl, Postman, 서버 간 요청에는 CORS가 아예 적용되지 않는다.
- 데이터를 바꾸는 요청을 막는 건 인증, 권한 검사, CSRF 방어의 몫이다.
CORS 설정은 "누가 내 API의 응답을 브라우저에서 읽을 수 있는가"를 정하는 것이다. "누가 내 API를 호출할 수 있는가"는 인증과 권한 검사로 따로 지켜야 한다.
정리: 에러 메시지별 해결법
| 에러 메시지에 나오는 말 | 원인 | 서버에서 할 일 |
|---|---|---|
| No 'Access-Control-Allow-Origin' header | 허용 헤더 없음 | Access-Control-Allow-Origin 추가 |
| not allowed by Access-Control-Allow-Headers in preflight | preflight에서 헤더 미허용 | OPTIONS 응답에 Allow-Headers 추가 |
| Method PUT is not allowed | preflight에서 메서드 미허용 | OPTIONS 응답에 Allow-Methods 추가 |
| must not be the wildcard '*' when ... credentials | 쿠키 + * 조합 | 정확한 출처 + Allow-Credentials: true |
| (에러 없이) 응답 헤더가 null | 노출 안 된 헤더 | Access-Control-Expose-Headers 추가 |
CORS 에러를 만나면 프론트 코드를 고치기 전에 "브라우저가 서버에게 무엇을 허락받지 못했는가" 를 에러 메시지에서 찾자. 답은 거의 항상 서버의 응답 헤더 한 줄에 있다.