본문으로 건너뛰기
yutils

CORS 완벽 정리 — 프리플라이트 트리거·자주 보는 에러·해결법

Same-origin 정책부터 simple/preflight 분기, Access-Control-* 헤더, 쿠키 전송, 자주 마주치는 디버깅 함정까지.

약 9분 읽기

프론트엔드 개발자라면 한 번쯤은 본다 — 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.comhttps://api.example.com — 다른 origin (호스트 다름)
  • https://example.comhttp://example.com — 다른 origin (스키마 다름)
  • https://example.comhttps://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-Typeapplication/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허용 originhttps://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-HeadersJS 가 읽을 수 있는 응답 헤더X-Total-Count, X-Page
Access-Control-Max-Agepreflight 캐시 (초)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-Headersauthorization 추가.

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/json POST 는 이미 preflight 대상.
  • 쿠키 보내려면 credentials: include + 서버 Allow-Credentials: true + 구체적 origin (와일드카드 X).
  • OPTIONS 는 인증 없이 통과 시킴. preflight 캐시(Max-Age) 로 재요청 비용 ↓.
  • Network 탭 200 + JS 에러 = CORS 헤더 누락 가능성. curl 비교가 빠른 진단.
가이드 목록으로