CORS 에러와 해결 기준
fetch나 axios로 API를 호출하다 보면 CORS 에러를 자주 만난다.
콘솔에는 Cross-Origin Request Blocked 같은 문장이 나오고, 네트워크 탭에는 요청이 보이는데 코드에서는 응답을 읽지 못하는 식이다.
이때 먼저 분리해서 봐야 하는 기준이 있다.
CORS는 fetch나 axios의 기능 차이 때문에 생기는 문제가 아니다.
브라우저의 보안 정책과 서버가 내려주는 CORS 응답 헤더가 맞지 않을 때 생기는 문제다.
그래서 클라이언트 코드만 바꿔서 모든 CORS 에러를 해결할 수는 없다.
대부분은 API 서버가 어떤 출처의 브라우저 코드에게 응답을 읽게 허용할지 명시해야 해결된다.
API 호출 도구 자체의 차이가 헷갈린다면 fetch와 axios 차이를 먼저 봐도 좋다.
이 글에서는 도구 선택이 아니라 브라우저가 응답을 막는 기준을 중심으로 정리한다.
CORS가 자주 보이는 상황
개발 중에는 프론트엔드와 API 서버 주소가 자주 다르다.
예를 들어 화면은 http://localhost:3000에서 열고, API는 http://localhost:8080으로 호출하는 식이다.
const response = await fetch('http://localhost:8080/api/users')
const users = await response.json()이 요청이 서버까지 도착할 수는 있다.
하지만 브라우저는 응답을 JavaScript 코드에 넘겨주기 전에 CORS 규칙을 확인한다.
서버 응답에 필요한 CORS 헤더가 없으면 브라우저는 응답 본문을 코드에서 읽지 못하게 막는다.
그래서 서버 로그에는 요청이 찍혔는데 브라우저 코드에서는 실패처럼 보일 수 있다.
이 차이를 이해하는 것이 중요하다.
CORS 에러는 항상 “요청이 서버에 아예 가지 않았다”는 뜻이 아니다.
많은 경우에는 “브라우저가 응답을 JavaScript에 노출하지 않았다”에 더 가깝다.
Same-Origin Policy의 기준
브라우저는 기본적으로 Same-Origin Policy를 적용한다.
Same-Origin Policy는 한 출처에서 로드된 문서나 스크립트가 다른 출처의 리소스와 상호작용하는 방식을 제한하는 보안 정책이다.
origin은 scheme, host, port 조합으로 판단한다.
셋 중 하나라도 다르면 같은 origin이 아니다.
아래 두 주소는 host와 port가 같아도 scheme이 다르므로 다른 origin이다.
http://example.com
https://example.com아래 두 주소는 scheme과 host가 같아도 port가 다르므로 다른 origin이다.
http://localhost:3000
http://localhost:8080아래 두 주소는 scheme과 port가 같아도 host가 다르므로 다른 origin이다.
https://app.example.com
https://api.example.com프론트엔드 개발 서버와 백엔드 서버를 따로 띄우면 포트가 달라지는 경우가 많다.
그래서 로컬 개발 환경에서도 CORS 에러를 쉽게 만난다.
CORS의 역할
CORS는 Cross-Origin Resource Sharing의 약자다.
이름 그대로 다른 origin 사이에서 리소스 공유를 허용하는 HTTP 헤더 기반 메커니즘이다.
브라우저는 보안을 위해 cross-origin HTTP 요청을 제한한다.
다만 서버가 “이 origin은 응답을 읽어도 된다”는 헤더를 내려주면 브라우저가 그 응답을 JavaScript 코드에 노출할 수 있다.
흐름은 단순하게 보면 아래와 같다.
브라우저 코드 -> API 서버 요청
API 서버 -> CORS 응답 헤더 포함
브라우저 -> 헤더 확인 후 응답 노출 여부 결정여기서 판단하는 주체는 브라우저다.
허용 여부를 선언하는 주체는 서버다.
그래서 서버가 CORS 헤더를 내려주지 않으면 클라이언트 코드에서 응답을 읽을 방법이 없다.
이 점 때문에 CORS는 프론트엔드 개발자가 자주 만나지만, 실제 수정 지점은 서버 설정인 경우가 많다.
Access-Control-Allow-Origin
가장 핵심이 되는 응답 헤더는 Access-Control-Allow-Origin이다.
이 헤더는 어떤 origin의 요청 코드가 응답을 읽을 수 있는지 알려준다.
모든 origin에 공개해도 되는 리소스라면 아래처럼 *를 사용할 수 있다.
Access-Control-Allow-Origin: *특정 프론트엔드 주소만 허용하려면 명시적인 origin을 내려준다.
Access-Control-Allow-Origin: https://app.example.com로그인 쿠키나 인증 정보를 포함하는 요청에서는 더 조심해야 한다.
credentials 요청과 Access-Control-Allow-Origin: *는 같이 쓸 수 없다.
이런 경우 서버는 요청의 Origin을 확인하고, 허용 목록에 있는 origin일 때만 같은 값을 응답해야 한다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin여러 origin을 지원하면서 명시적인 origin을 내려주는 서버라면 Vary: Origin도 중요하다.
캐시가 origin마다 달라지는 응답을 같은 응답처럼 재사용하지 않게 돕기 때문이다.
preflight 요청
브라우저는 어떤 cross-origin 요청을 보내기 전에 OPTIONS 요청을 먼저 보낼 수 있다.
이 사전 확인 요청을 preflight 요청이라고 부른다.
예를 들어 커스텀 헤더를 보내거나, 단순 요청 범위를 벗어나는 method와 content type을 쓰면 preflight가 발생할 수 있다.
await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer <access-token>',
},
body: JSON.stringify({ name: 'sample-user' }),
})브라우저는 실제 POST 요청 전에 서버에 이런 요청이 허용되는지 물어본다.
OPTIONS /users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization서버는 허용할 origin, method, header를 응답해야 한다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization서버가 OPTIONS 요청을 처리하지 못하면 실제 API 요청 전에 막힐 수 있다.
그래서 API 서버의 라우트는 실제 요청뿐 아니라 preflight 응답도 함께 고려해야 한다.
클라이언트에서 해결할 수 없는 이유
CORS를 처음 만나면 프론트엔드 코드에서 헤더를 추가하려고 할 수 있다.
하지만 핵심 CORS 허용 헤더는 요청 헤더가 아니라 응답 헤더다.
아래처럼 클라이언트 요청에 Access-Control-Allow-Origin을 넣어도 해결되지 않는다.
await fetch('https://api.example.com/users', {
headers: {
'Access-Control-Allow-Origin': '*',
},
})이 헤더는 서버가 응답으로 내려줘야 의미가 있다.
브라우저는 서버 응답의 CORS 헤더를 보고 응답 노출 여부를 결정한다.
클라이언트에서 할 수 있는 일은 제한적이다.
요청 method, header, credentials 옵션이 서버가 허용한 범위와 맞는지 확인할 수는 있다.
하지만 서버가 허용하지 않는 origin을 클라이언트에서 강제로 허용시킬 수는 없다.
서버에서 확인할 항목
CORS 에러를 만났다면 먼저 브라우저 개발자 도구의 Console과 Network 탭을 같이 본다.
Console에는 CORS 실패 이유가 비교적 직접적으로 나온다.
Network 탭에서는 preflight OPTIONS 요청이 실패했는지, 실제 요청 응답에 CORS 헤더가 있는지 확인할 수 있다.
서버에서는 아래 항목을 우선 확인한다.
- 요청한 프론트엔드 origin이 허용 목록에 있는지
Access-Control-Allow-Origin이 실제 응답에 포함되는지- credentials 요청이라면
Access-Control-Allow-Credentials: true가 필요한지 - credentials 요청에서 wildcard
*를 쓰고 있지 않은지 - preflight 요청에 필요한
Access-Control-Allow-Methods가 있는지 - preflight 요청에 필요한
Access-Control-Allow-Headers가 있는지 - 에러 응답에도 CORS 헤더가 붙는지
마지막 항목도 중요하다.
정상 응답에는 CORS 헤더가 붙지만 401, 403, 500 같은 에러 응답에는 헤더가 빠지는 서버가 있다.
이 경우 실제 문제는 인증 실패나 서버 에러인데, 브라우저에서는 CORS 에러처럼 보일 수 있다.
no-cors의 오해
fetch에는 mode: 'no-cors' 옵션이 있다.
이 이름 때문에 CORS를 끄는 해결책처럼 보일 수 있다.
하지만 보통 API 응답을 읽어야 하는 상황에서는 해결책이 아니다.
await fetch('https://api.example.com/log', {
method: 'POST',
mode: 'no-cors',
})no-cors 모드에서는 응답이 opaque response가 된다.
opaque response는 JavaScript에서 status, header, body를 제대로 읽을 수 없다.
그래서 데이터를 받아 화면에 보여줘야 하는 API 호출에는 맞지 않는다.
analytics beacon처럼 응답 내용을 읽지 않아도 되는 일부 전송에는 쓸 수 있지만, 일반적인 JSON API 호출 문제를 해결해주지는 않는다.
정리
CORS는 fetch와 axios 중 무엇을 쓰느냐의 문제가 아니다.
브라우저가 다른 origin의 응답을 JavaScript에 노출할지 결정하는 보안 정책의 문제다.
origin은 scheme, host, port 조합으로 판단한다.
로컬 개발 환경에서도 포트가 다르면 다른 origin이다.
서버는 Access-Control-Allow-Origin을 비롯한 CORS 응답 헤더를 정확히 내려줘야 한다.
preflight가 발생하는 요청이라면 OPTIONS 요청에 대한 응답도 준비해야 한다.
클라이언트에서 Access-Control-Allow-Origin 요청 헤더를 추가하는 방식은 해결책이 아니다.mode: 'no-cors'도 응답을 읽어야 하는 API 호출의 해결책으로 보면 안 된다.
CORS 에러를 만나면 먼저 Console의 메시지와 Network의 응답 헤더를 같이 확인한다.
그다음 서버가 해당 origin, method, header, credentials 조건을 실제로 허용하는지 점검하는 편이 가장 빠르다.
참고 자료






