CORS 에러 해결: Access-Control-Allow-Origin 완전 정리
브라우저 CORS 에러가 나는 이유와 프리플라이트·자격증명·와일드카드 함정까지 상황별 해결법.
프론트엔드에서 API를 호출했더니 콘솔에 blocked by CORS policy라는 빨간 에러가 떴는데, 같은 요청을 Postman이나 curl로 보내면 멀쩡히 응답이 옵니다. 그래서 “서버가 막는 건가?” 하고 서버 방화벽부터 뒤지기 쉽지만, 방향이 틀렸습니다. CORS 에러는 서버가 아니라 브라우저가 막는 것이고, 요청 자체는 대부분 서버까지 도달해 정상 처리됩니다. 브라우저가 응답을 받아 놓고도 “이 응답을 자바스크립트에게 넘겨도 된다는 허가 헤더가 없다”는 이유로 폐기하는 것입니다.
그래서 해결의 핵심은 프론트엔드 코드가 아니라 서버가 어떤 응답 헤더를 내려주느냐에 있습니다. 이 가이드는 동일 출처 정책이 무엇인지부터, 에러 메시지를 정확히 읽는 법, 프리플라이트(OPTIONS)가 언제 발생하는지, 그리고 자격증명(쿠키)·와일드카드 함정까지 상황별로 어떤 헤더를 추가해야 하는지를 실제 요청 예시와 함께 정리합니다.
동일 출처 정책 — 막는 건 서버가 아니라 브라우저다
브라우저는 보안상 동일 출처 정책(Same-Origin Policy)을 강제합니다. 출처(origin)는스킴://호스트:포트 세 가지 조합이며, 하나라도 다르면 다른 출처입니다. 예를 들어https://app.example.com에서 https://api.example.com을 호출하면 호스트가 다르므로 교차 출처(cross-origin) 요청입니다. http://localhost:3000과 http://localhost:8080도 포트가 달라 서로 다른 출처입니다.
이 정책이 없다면, 악성 사이트가 방문자의 브라우저를 이용해 로그인된 은행 사이트의 API를 몰래 읽어갈 수 있습니다. 그래서 브라우저는 스크립트(fetch/XHR)가 다른 출처의 응답을 읽는 것을 기본 차단하고, 서버가CORS(Cross-Origin Resource Sharing) 응답 헤더로 명시적으로 허가한 경우에만 열어줍니다. 중요한 사실 두 가지:
- Postman·curl·서버 간 통신에서는 CORS 에러가 절대 안 납니다.동일 출처 정책은 브라우저에만 있는 보안 장치이기 때문입니다. “curl로는 되는데 브라우저에서만 안 된다”는 현상은 버그가 아니라 CORS의 정의 그 자체입니다.
- CORS는 서버를 보호하지 않습니다. 요청은 이미 서버에 도달했고(단순 요청의 경우), 브라우저가 응답을 자바스크립트에 넘기지 않을 뿐입니다. 접근 제어가 필요하면 인증·인가로 해결해야 합니다.
에러 메시지부터 정확히 읽기
크롬 콘솔의 CORS 에러는 “무엇이 빠졌는지”를 이미 알려주고 있습니다. 문구별로 원인이 다르므로 같은 CORS 에러라도 처방이 다릅니다.
No 'Access-Control-Allow-Origin' header is present— 서버 응답에 허가 헤더가 아예 없음. 서버에 CORS 설정 자체가 없거나, 에러 응답(4xx/5xx)에는 헤더를 안 붙이는 경우입니다.The 'Access-Control-Allow-Origin' header has a value '...' that is not equal to the supplied origin— 헤더는 있는데 값이 내 출처와 불일치. 허용 목록에 현재 출처가 빠졌거나www유무·포트가 다른 경우가 흔합니다.Response to preflight request doesn't pass access control check— 본요청이 아니라OPTIONS 프리플라이트 단계에서 실패. OPTIONS가 404/401을 반환하거나Allow-Methods/Allow-Headers가 부족한 경우입니다.The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'— 쿠키를 보내면서 서버는*로 응답한 와일드카드 함정(아래 참조).
단순 요청 vs 프리플라이트 — OPTIONS는 언제 날아가나
브라우저는 교차 출처 요청을 두 부류로 나눕니다. 단순 요청(simple request)은 바로 보내고 응답 헤더만 검사하지만, 조건을 벗어나는 요청은 본요청 전에 OPTIONS 프리플라이트를 먼저 보내 “이런 요청을 보내도 되느냐”고 서버에 묻습니다. 단순 요청의 조건은 꽤 좁습니다.
- 메서드가
GET·HEAD·POST중 하나 - 직접 지정한 헤더가
Accept,Accept-Language,Content-Language,Content-Type정도로 제한 Content-Type이application/x-www-form-urlencoded,multipart/form-data,text/plain중 하나
즉 실무에서 흔한 Content-Type: application/json POST, Authorization 헤더가 붙는 요청,PUT/DELETE/PATCH는 전부 프리플라이트 대상입니다. 실제 교환을 따라가 봅시다.https://app.example.com의 SPA가 토큰을 붙여 API를 호출하는 경우입니다.
1단계 — 브라우저가 자동으로 보내는 프리플라이트:
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type2단계 — 서버가 반드시 내려야 하는 응답(상태코드는 200 또는 204, 본문 불필요):
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 864003단계 — 통과하면 브라우저가 본요청(POST)을 보내고, 그 응답에도Access-Control-Allow-Origin: https://app.example.com이 있어야 최종적으로 자바스크립트가 응답을 읽을 수 있습니다. 프리플라이트와 본요청 둘 다 헤더가 필요하다는 점, 그리고 OPTIONS 요청이 인증 미들웨어에 걸려 401로 떨어지면 안 된다는 점(프리플라이트에는 Authorization 헤더가 실려오지 않음)이 가장 흔한 함정입니다. Access-Control-Max-Age를 주면 브라우저가 프리플라이트 결과를 캐시해 매 요청마다 OPTIONS가 나가는 것을 줄일 수 있습니다.
상황별 필요 헤더 정리 + 와일드카드 함정
| 상황 | 서버가 내려야 하는 응답 헤더 |
|---|---|
| 공개 API GET (쿠키·커스텀 헤더 없음) | Access-Control-Allow-Origin: * 하나면 충분 |
| JSON POST / PUT / DELETE / 커스텀 헤더(프리플라이트 발생) | 본요청: Allow-Origin. OPTIONS 응답: Allow-Origin + Access-Control-Allow-Methods + Access-Control-Allow-Headers |
쿠키·세션 포함(credentials: "include") | Access-Control-Allow-Origin: <요청 Origin 그대로 echo>(* 금지) + Access-Control-Allow-Credentials: true + Vary: Origin |
| JS에서 커스텀 응답 헤더를 읽어야 함 | Access-Control-Expose-Headers: X-Request-Id, ... |
와일드카드 함정: fetch에 credentials: "include"를 주고 쿠키를 보내는 순간, 브라우저는 Access-Control-Allow-Origin: *를 무조건 거부합니다.Access-Control-Allow-Headers: *, Allow-Methods: *도 자격증명 모드에서는 리터럴*라는 이름의 헤더/메서드로 취급되어 무효입니다. 해결책은 서버가 요청의 Origin 헤더 값을 허용 목록과 대조한 뒤 그 값을 그대로 echo하고 Vary: Origin을 붙이는 것입니다 (캐시가 다른 출처의 응답을 섞어 주지 않도록). 아무 Origin이나 무조건 echo하면 사실상 모든 사이트에 쿠키 인증 API를 열어주는 것이므로, 반드시 화이트리스트 검증을 거쳐야 합니다.
수정 확인: 실측으로 검증하기
서버 설정을 고쳤다면 추측으로 끝내지 말고 실제 헤더를 확인하세요. 순서는 이렇게 권장합니다.
- CORS 검사 도구에 API URL과 호출할 출처를 넣어 프리플라이트와 본요청 관점에서 어떤 허가 헤더가 내려오는지, 무엇이 빠졌는지 한 번에 진단합니다.
- 응답 전체 맥락이 궁금하면 HTTP 헤더 조회로
Access-Control-*계열과Vary가 실제 응답에 실려 있는지 확인합니다. CDN·프록시가 중간에서 헤더를 지우는 경우도 여기서 드러납니다. - 프리플라이트를 직접 재현하려면 curl 명령 생성기로
-X OPTIONS에Origin·Access-Control-Request-Method헤더를 붙인 명령을 만들어 서버 응답을 눈으로 확인합니다. 브라우저 캐시(프리플라이트 캐시 포함)의 영향 없이 서버의 진짜 응답을 볼 수 있습니다.
마지막 팁: 에러 응답(401·500 등)에도 CORS 헤더를 붙이도록 설정하세요. 헤더가 없으면 브라우저에서는 진짜 원인 (인증 실패, 서버 오류)이 전부 “CORS 에러”로 보여 디버깅이 훨씬 어려워집니다.
자주 묻는 질문
Postman과 curl에서는 되는데 브라우저에서만 CORS 에러가 나는 이유는 뭔가요?
프론트엔드 코드(fetch 옵션)만 고쳐서 CORS 에러를 해결할 수 있나요?
OPTIONS 프리플라이트 요청은 언제 발생하나요?
Access-Control-Allow-Origin: * 로 설정했는데도 차단되는 이유는요?
프리플라이트가 매 요청마다 나가서 느려지는데 줄일 수 있나요?
이 가이드와 함께 쓰면 좋은 도구
관련 가이드
- 필수 보안 헤더 설정 가이드(CSP·HSTS·X-Frame-Options)꼭 설정해야 할 보안 헤더와 각 헤더의 역할·권장 값·흔한 실수.
- 301 vs 302 vs 307/308: 올바른 리다이렉트리다이렉트 상태코드의 차이와 SEO·메서드 보존 관점에서 올바르게 고르는 법.