개발하다 보면 누구나 한 번쯤은 마주치는 그 빨간 에러.
Access to XMLHttpRequest at 'https://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
처음 보면 황당하다. 코드는 분명히 맞는데, 브라우저가 요청을 막는다. 이 글에서는 CORS가 왜 생기는지, 어떻게 해결하는지를 레벨별로 정리한다.
1. CORS가 뭔데?
CORS (Cross-Origin Resource Sharing) — 직역하면 "교차 출처 리소스 공유".
브라우저는 기본적으로 Same-Origin Policy(동일 출처 정책)를 따른다. 즉, http://localhost:3000에서 실행 중인 페이지가 https://api.example.com에 요청을 보내면, 출처(origin)가 다르기 때문에 브라우저가 막는다.
여기서 Origin = 프로토콜 + 도메인 + 포트 세 가지가 모두 같아야 동일 출처다.
| 비교 | 동일 출처? |
|---|---|
http://example.com vs https://example.com | ❌ 프로토콜 다름 |
http://example.com vs http://api.example.com | ❌ 도메인 다름 |
http://example.com:3000 vs http://example.com:8080 | ❌ 포트 다름 |
http://example.com/a vs http://example.com/b | ✅ 동일 출처 |
2. 핵심 원리 — 브라우저가 하는 일
중요한 포인트: CORS는 서버가 막는 게 아니라 브라우저가 막는다.
서버는 이미 응답을 줬다. 하지만 브라우저가 그 응답에 Access-Control-Allow-Origin 헤더가 없으면 자바스크립트 코드에 넘겨주지 않고 차단한다. curl이나 Postman으로는 에러 없이 잘 되는 이유가 이것이다.
Preflight 요청
GET, POST 외의 메서드거나, 커스텀 헤더가 있거나, Content-Type이 application/json이면 브라우저는 실제 요청 전에 OPTIONS 메서드로 사전 확인 요청(Preflight)을 먼저 보낸다.
3. 해결 방법
🟢 초보자 — 로컬 개발 환경
프론트엔드 개발 서버 프록시 설정 (가장 흔한 방법)
CRA (package.json):
{
"proxy": "https://api.example.com"
}
Vite (vite.config.ts):
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
🟡 중급자 — 서버 사이드 CORS 헤더 설정
Node.js (Express):
const cors = require('cors');
app.use(cors({
origin: ['https://myapp.com', 'https://www.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
Spring Boot (Java):
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://myapp.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true);
}
}
Nginx:
location /api/ {
add_header 'Access-Control-Allow-Origin' 'https://myapp.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://backend;
}
🔴 고급 — 자주 빠지는 함정들
① credentials: true인데 * 와일드카드 쓰면 안 된다
쿠키나 Authorization 헤더 포함 요청은 Access-Control-Allow-Origin: *이어도 브라우저가 차단한다. 정확한 출처를 명시해야 한다.
# 틀린 예 Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true # 맞는 예 Access-Control-Allow-Origin: https://myapp.com Access-Control-Allow-Credentials: true
② Preflight OPTIONS 요청 처리를 빠뜨리는 경우
수동으로 헤더를 추가할 때 OPTIONS 처리를 생략하는 실수가 많다. 204를 반환해야 본 요청이 들어온다.
③ 여러 서버에서 헤더가 중복으로 붙는 경우
Access-Control-Allow-Origin이 두 개 붙으면 브라우저가 에러를 낸다. 한 레이어에서만 처리해야 한다.
④ 동적 출처 허용
const allowedOrigins = ['https://myapp.com', 'https://app2.com'];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
next();
});
4. 상황별 체크리스트
| 상황 | 해결책 |
|---|---|
| 로컬 개발 중 API 서버 연결 안 됨 | 프론트 dev 서버 프록시 설정 |
| Postman은 되는데 브라우저만 에러 | 서버에 CORS 헤더 추가 |
| OPTIONS 요청이 405 에러 | Preflight 핸들러 추가 |
| 쿠키/JWT 포함 요청이 차단됨 | * 대신 정확한 origin + credentials: true |
| 배포 후 갑자기 안 됨 | Nginx/로드밸런서 헤더 중복 확인 |
| 서드파티 API (서버 못 건드림) | 내 서버를 프록시로 우회 |
마무리
CORS는 처음엔 황당하지만 원리를 알면 단순하다. 브라우저가 보안 정책으로 막는 것이고, 서버가 헤더로 허용해줘야 풀린다. Postman은 되는데 브라우저에서만 안 된다면 거의 무조건 CORS 문제다.
운영 환경에서는 절대 * 와일드카드 쓰지 말고, 꼭 필요한 출처만 명시하는 것이 기본이다.