
CORS 에러 해결법 — 왜 발생하고 프론트엔드·백엔드에서 어떻게 푸는가
CORS 에러는 서버 코드가 틀려서가 아니라 브라우저 보안 정책 때문에 생깁니다. Access-Control-Allow-Origin 응답 헤더, preflight(OPTIONS), 와일드카드·credentials 충돌, 개발 프록시까지 — CORS 에러 해결법을 원인별로 짚고 프론트엔드·백엔드에서 각각 어떻게 푸는지 정리했습니다.
분명히 백엔드는 멀쩡히 돌아가는데, 브라우저 콘솔만 열면 빨간 글씨로 Access-Control-Allow-Origin 어쩌고 하는 에러가 뜨면서 요청이 막혔던 경험, 다들 한 번쯤 있으시죠. Postman으로 같은 주소를 때리면 200이 잘 날아오는데 브라우저에서만 안 되니 더 미칠 노릇입니다. 서버 코드를 아무리 들여다봐도 멀쩡하고, 구글에 에러 메시지를 통째로 붙여넣어 봐도 다들 말이 조금씩 달라서 뭘 믿어야 할지 헷갈립니다.
CORS는 처음 만나면 "내가 대체 뭘 잘못했지" 싶게 만드는 대표적인 에러입니다. 그런데 이게 왜 생기는지 원리만 한 번 제대로 잡아두면, 다음부터는 콘솔 에러 한 줄만 보고도 "아, 저기 고치면 되겠네" 하고 손이 바로 갑니다. 저도 처음엔 프론트에서 헤더를 억지로 붙여보겠다고 반나절을 날렸는데, 알고 보니 애초에 손댈 곳이 거기가 아니었습니다.

CORS는 코드가 틀려서가 아니라, 브라우저의 보안 규칙에 걸려서 나는 에러입니다.
에러부터 다시 읽자 — 이건 네트워크 장애가 아니다
먼저 오해 하나를 풀어야 합니다. CORS 에러가 떠도 요청 자체는 서버까지 잘 도착했고, 서버도 응답을 정상적으로 돌려보낸 경우가 대부분입니다. 막는 건 서버가 아니라 브라우저입니다. 브라우저가 "이 응답은 다른 출처에서 왔고, 그 출처가 자기를 읽어도 된다고 허락한 흔적(헤더)이 없네" 하고 자바스크립트가 응답을 읽지 못하게 차단하는 겁니다.
콘솔에 보통 이렇게 찍힙니다.
Access to fetch at 'https://api.example.com/users' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
여기서 핵심은 마지막 줄, No 'Access-Control-Allow-Origin' header 입니다. 서버 응답에 "너(app.example.com)가 이 응답 읽어도 돼"라는 도장이 안 찍혀 있다는 뜻이죠. Postman이나 curl에서 같은 요청이 멀쩡히 되는 이유도 여기 있습니다. CORS는 브라우저만의 규칙이라, 브라우저가 아닌 도구는 이 검사를 아예 하지 않습니다. 그래서 "터미널에선 되는데 화면에선 안 돼요"가 CORS의 가장 전형적인 증상입니다.
왜 막히는가 — '출처가 다르다'의 진짜 의미
출처(Origin)는 세 가지가 합쳐진 값입니다. 프로토콜(http/https) + 도메인 + 포트. 이 셋 중 하나라도 다르면 브라우저는 "다른 출처(cross-origin)"로 봅니다.
http://localhost:3000→http://localhost:8080: 포트가 달라서 cross-originhttps://app.com→http://app.com: 프로토콜이 달라서 cross-originhttps://app.com→https://api.app.com: 서브도메인이 달라서 cross-origin
프론트 개발 서버(3000)에서 백엔드 API 서버(8080)를 부르는 순간, 포트가 다르니 바로 cross-origin이 됩니다. 로컬에서 개발할 때 CORS를 거의 100% 만나는 이유가 이것입니다. 운영에 올리면 같은 도메인으로 묶여서 멀쩡하다가, 로컬에서만 터지는 경우도 같은 맥락이고요.
원인별 해결 체크리스트
위에서 아래 순서로 짚어보면 거의 다 잡힙니다.
1. 정석 — 서버에 허용 헤더를 추가한다
CORS는 서버가 "허락"을 응답 헤더로 내려줘야 풀립니다. 프론트에서 할 수 있는 일이 아닙니다. 프레임워크별로 한 줄이면 끝나는 경우가 많습니다.
Spring Boot라면 전역 설정으로:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
}
Express(Node)라면:
const cors = require("cors");
app.use(cors({
origin: "http://localhost:3000",
credentials: true,
}));
2. preflight(OPTIONS) 요청을 막지 않았는지 본다
Content-Type: application/json을 쓰거나 커스텀 헤더(예: Authorization)를 붙이면, 브라우저는 본 요청을 보내기 전에 OPTIONS 메서드로 "이 요청 받아줄 거야?"를 먼저 물어봅니다. 이걸 preflight라고 합니다. 서버가 이 OPTIONS에 2xx와 올바른 CORS 헤더로 답하지 못하면, 본 요청은 아예 날아가지도 못합니다. 라우팅이나 인증 필터가 OPTIONS 요청을 400·401·404로 쳐내고 있지 않은지 꼭 확인하세요. 의외로 여기서 막히는 경우가 많습니다.

CORS는 프론트가 아니라 서버(또는 그 앞단의 프록시)에서 푸는 문제입니다.
**3. 와일드카드()와 쿠키를 같이 쓰지 않았는지 본다*
로그인 쿠키나 세션처럼 자격 증명을 함께 보내는 요청(프론트의 credentials: "include")에서는, 서버가 Access-Control-Allow-Origin: 를 쓸 수 없습니다. 보안상 금지라 브라우저가 거부합니다. 이때는 대신 요청을 보낸 출처를 정확히 되돌려주고, Access-Control-Allow-Credentials: true를 함께 내려야 합니다. 허용 목록(allowlist)을 두고 들어온 Origin이 목록에 있을 때만 그 값을 그대로 돌려주는 방식이 안전합니다.
4. 프론트 개발 프록시로 우회한다
서버를 당장 못 고치는 상황이라면, 개발 중에는 프론트 개발 서버의 프록시 기능으로 피할 수 있습니다. 브라우저 입장에선 같은 출처로 보이게 만들어 CORS 검사 자체를 비켜가는 방법입니다.
Vite라면 vite.config.js에:
export default {
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
};
Next.js라면 next.config.js의 rewrites로 같은 효과를 냅니다. 다만 이건 개발 환경 우회책이라는 걸 기억하세요. 운영에서는 결국 서버/게이트웨이/Nginx 단에서 CORS를 제대로 풀어줘야 합니다.
그래도 안 될 때 — 흔히 빠지는 함정
- 헤더를 두 번 넣는 경우: 애플리케이션 코드와 Nginx가 각각
Access-Control-Allow-Origin을 붙이면, 값이 두 개가 되어 브라우저가 "헤더가 여러 개"라며 또 거부합니다. 한 군데에서만 붙이세요. - 에러 응답에는 헤더가 안 붙는 경우: 500 에러가 나는 경로는 CORS 헤더가 빠지기 쉽습니다. 그러면 콘솔엔 CORS 에러로 보이지만 진짜 원인은 서버 500입니다. Network 탭에서 실제 상태 코드를 먼저 확인하세요.
- 브라우저 캐시: preflight 결과는
Access-Control-Max-Age동안 캐시됩니다. 서버를 고쳤는데도 그대로라면 캐시거나, 브라우저가 디스크 캐시에서 옛 응답을 꺼내 쓰는 경우입니다. 강력 새로고침으로 한 번 걸러보세요.
여기서 한국 개발 현장 이야기를 조금 보태자면, CORS 질문은 velog나 개인 블로그 검색 1페이지를 아무리 봐도 "나는 이렇게 풀었다"는 단편적 후기가 대부분이라, 정작 자기 상황에 안 맞는 설정을 복붙했다가 더 꼬이는 경우가 흔합니다. 특히 사내망에서 리버스 프록시(Nginx·게이트웨이)를 한 겹 더 두는 구조가 많은데, 이럴 땐 CORS 헤더를 앱에서 붙일지 프록시에서 붙일지 책임 지점을 한 곳으로 정하는 것이 먼저입니다. 둘 다 붙이면 위의 '헤더 중복' 함정에 그대로 빠집니다. 그리고 Access-Control-Allow-Origin에 *를 박아놓고 "일단 되니까" 넘어가는 코드가 운영까지 올라가는 경우가 정말 많은데, 이건 아무 사이트에서나 당신의 API를 브라우저로 호출할 수 있다는 뜻이라 인증이 걸린 API라면 특히 위험합니다. 개발 편의로 열어둔 와일드카드는 배포 전에 반드시 허용 목록으로 좁히는 습관을 들이는 게 좋습니다.
다시 안 겪으려면
CORS는 "프론트 코드가 틀렸나?"를 의심하는 순간 미궁에 빠집니다. 순서를 거꾸로 잡으면 됩니다. ① 콘솔 에러에서 어떤 출처가 어떤 출처를 부르다 막혔는지 읽고, ② Network 탭에서 실제 상태 코드와 응답 헤더를 확인하고, ③ 서버(또는 프록시)에서 허용 헤더·preflight·credentials 세 가지를 점검한다. 이 세 단계만 몸에 익혀두면, 다음에 또 빨간 에러가 떠도 당황하지 않고 10분 안에 끝낼 수 있습니다.
여러분은 CORS를 주로 어디서 푸시나요? 앱 코드에서 직접? 아니면 Nginx 같은 프록시 단에서 한 번에 처리하는 편인가요? 각자의 실무 구성이 궁금합니다.
참고
댓글 0
댓글을 불러오는 중…