프론트엔드 개발자라면 한 번쯤은 본다 — Access to fetch at '...' has been blocked by CORS policy. 이 가이드는 CORS (Cross-Origin Resource Sharing) 가 정확히 무엇을 막고 무엇을 허용하는지, 어떤 요청이 프리플라이트 (preflight) 를 트리거하는지, 실제 에러가 났을 때 어디부터 봐야 하는지 정리한다.
왜 CORS 가 존재하나 — Same-Origin Policy
브라우저는 기본적으로 다른 origin 에서 온 응답을 JavaScript 가 읽지 못하게 막는다. origin = 스키마 + 호스트 + 포트.
https://app.example.com↔https://api.example.com— 다른 origin (호스트 다름)https://example.com↔http://example.com— 다른 origin (스키마 다름)https://example.com↔https://example.com:8443— 다른 origin (포트 다름)
이 정책 없이는 어느 사이트가 열어둔 탭에서 fetch("https://your-bank.com/balance") 를 호출해 쿠키 인증된 응답을 그대로 읽어 갈 수 있다. SOP 가 모든 웹 보안 의 기본 전제 다.
URL 의 origin 이 헷갈리면 URL 파서 에 URL 을 넣으면 protocol/host/port 분해해 보여준다.
CORS 가 하는 일
CORS 는 SOP 를 완화 하는 메커니즘이다. 서버가 응답에 특정 헤더 (Access-Control-Allow-Origin 등) 를 박으면 브라우저가 "이 origin 에서 읽어도 됨" 이라고 인정하고 JS 에 응답을 넘긴다.
중요한 두 가지 사실:
- 요청은 항상 서버에 도달한다. CORS 는 요청을 막는 게 아니라 응답을 JS 가 못 읽게 하는 것. (단, 프리플라이트의 경우 본 요청 전에 OPTIONS 가 먼저 가서 거부될 수 있음.)
- CORS 는 브라우저 안에서만 동작. curl, Node.js, Postman, 모바일 앱은 CORS 무시. 서버는 CORS 헤더가 없어도 정상 응답.
Simple Request vs Preflight
브라우저는 요청 종류에 따라 두 가지 흐름으로 분기한다.
Simple Request — 직접 보냄
다음 조건을 모두 만족하면 브라우저는 본 요청을 바로 보내고 응답 헤더를 본다.
- method =
GET,HEAD,POST중 하나. - 헤더는 자동 헤더 + 사용자 정의는 다음만:
Accept,Accept-Language,Content-Language,Content-Type(제한 있음),Range. Content-Type이application/x-www-form-urlencoded/multipart/form-data/text/plain중 하나.ReadableStream본문 X,fetch()의signal외 옵션 영향 없음.
대부분의 REST API 호출은 simple 이 아니다 — JSON 본문은 Content-Type: application/json 인데, 이는 simple 조건에서 제외돼 자동으로 프리플라이트 발생.
Preflight — OPTIONS 먼저
조건에서 벗어나면 브라우저는 본 요청 전에 OPTIONS 요청을 먼저 보낸다.
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization서버는 다음 헤더로 답한다:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400프리플라이트가 통과하면 본 요청을 보낸다. Access-Control-Max-Age 는 결과 캐시 시간 (초) — 같은 요청을 매번 OPTIONS 하지 않게 한다. Chrome 최대 2 시간 (7200) 캡, Firefox 24 시간.
응답 헤더 정리
| 헤더 | 역할 | 값 예시 |
|---|---|---|
Access-Control-Allow-Origin | 허용 origin | https://app.example.com 또는 * |
Access-Control-Allow-Methods | 허용 method (preflight 응답) | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 허용 사용자 정의 헤더 | content-type, authorization |
Access-Control-Allow-Credentials | 쿠키/토큰 전송 허용 | true |
Access-Control-Expose-Headers | JS 가 읽을 수 있는 응답 헤더 | X-Total-Count, X-Page |
Access-Control-Max-Age | preflight 캐시 (초) | 86400 |
쿠키와 자격 증명
fetch(url, { credentials: "include" }) 또는 XMLHttpRequest.withCredentials = true 로 쿠키를 함께 보내려면:
- 서버는
Access-Control-Allow-Credentials: true응답. Access-Control-Allow-Origin은 구체적인 origin 이어야 함.*와 함께 쓸 수 없음 — 보안상 와일드카드 + 쿠키 조합은 금지.Access-Control-Allow-Headers/Methods도 마찬가지로*가 인증 요청에서는 무시됨.
SameSite 쿠키 속성도 함께 봐야 한다. cross-site 요청에 쿠키를 보내려면 SameSite=None; Secure 필요. Lax 는 top-level navigation 만 허용.
흔히 보는 에러와 해결
1. No 'Access-Control-Allow-Origin' header is present
서버가 CORS 헤더 자체를 안 박았다. 백엔드에서 응답에 Access-Control-Allow-Origin 추가. Express: cors 미들웨어, FastAPI: CORSMiddleware, Spring: @CrossOrigin 등.
2. The 'Access-Control-Allow-Origin' header has a value '*' which must not be used when the request's credentials mode is 'include'
쿠키 보내면서 와일드카드 사용. Access-Control-Allow-Origin 을 요청한 origin 으로 동적 echo (요청 Origin 헤더 검증 후 그대로 응답). Vary: Origin 헤더도 함께 — 캐시가 origin 별로 분리되도록.
3. Request header 'authorization' is not allowed
프리플라이트 응답에 해당 헤더가 누락. 서버의 Access-Control-Allow-Headers 에 authorization 추가.
4. 200 OK 인데 JS 가 응답을 못 받음
Network 탭엔 200 인데 JS 에서 에러. CORS 헤더 누락 시 응답이 도달해도 브라우저가 막는다. Console 의 CORS 에러 메시지 확인. curl 로 같은 요청 해 보면 (cURL 빌더 로 명령 생성) CORS 와 무관하므로 응답이 보인다 — 디버깅용 비교에 유용.
5. preflight 가 401/403
OPTIONS 요청에 인증을 요구하는 백엔드. OPTIONS 는 항상 인증 없이 통과 시켜야 한다. 미들웨어 순서를 확인 — auth 보다 cors 가 먼저.
6. 와일드카드 서브도메인
https://*.example.com 같은 와일드카드는 표준 X. 서버가 요청 Origin 을 정규식 매칭해 통과 시 그대로 echo 하는 패턴.
설정 vs 보안 — 흔한 오해
"CORS 를 풀면 보안이 뚫린다"는 오해. CORS 는 브라우저 쪽 안전장치일 뿐, 서버 인증·권한과 별개다. 단:
Access-Control-Allow-Origin: *+ 인증 데이터 응답 = 위험. 어느 사이트가든 해당 API 응답을 자기 사이트로 가져갈 수 있음.- 공개 API (인증 없는 read-only) 는
*가 적절. - credentialed API 는 origin 화이트리스트 필수.
대안 — CORS 우회 전략
CORS 가 깔끔하게 안 되는 상황:
- 같은 origin 으로 만들기 — 프록시 서버로 API 를
/api/*같은 path 로 노출. 가장 단순. - 서버 사이드 fetch — Next.js Route Handler / Server Actions / Cloudflare Workers 등에서 외부 API 호출 후 결과만 클라 이언트로. 클라이언트는 자기 origin 만 호출.
- JSONP — 옛 방식. CSP 와 충돌하므로 비추.
요약
- SOP = 다른 origin 응답을 JS 가 못 읽음. CORS = 서버가 헤더로 완화.
- Simple request 조건은 빡빡 —
application/jsonPOST 는 이미 preflight 대상. - 쿠키 보내려면
credentials: include+ 서버Allow-Credentials: true+ 구체적 origin (와일드카드 X). - OPTIONS 는 인증 없이 통과 시킴. preflight 캐시(
Max-Age) 로 재요청 비용 ↓. - Network 탭 200 + JS 에러 = CORS 헤더 누락 가능성. curl 비교가 빠른 진단.