개발에 AtoZ까지

CORS 에러, 왜 나고 어떻게 푸나 — 원인부터 해결까지 본문

프론트엔드

CORS 에러, 왜 나고 어떻게 푸나 — 원인부터 해결까지

AtoZ 개발자 2026. 7. 19. 23:13
반응형

😀 개요

안녕하세요 😀

프론트엔드 개발하다 이 빨간 줄, 다들 한 번쯤 보셨을 겁니다.

Access to fetch at 'https://api.example.com/users' from origin
'https://myapp.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

분명 URL도 맞고, Postman에서는 200이 잘 떨어지는데, 브라우저에서만 막힙니다. 그래서 fetch 코드를 이리저리 고쳐 보지만 소용이 없습니다.

결론부터 말하면, 이건 대부분 내 JavaScript 코드의 버그가 아닙니다. 서버가 "이 출처는 허용한다"는 응답 헤더를 안 보냈고, 그걸 본 브라우저가 응답을 막은 것입니다. 그래서 프론트에서 아무리 고쳐도 안 풀리는 경우가 많습니다.

이 글은 예전 Socket 통신과 HTTP 통신 비교 같은 네트워크 기초 글과 같은 결로, CORS를 "왜 나는지"부터 "어디서 고치는지"까지 정리합니다.

🎯 오늘 끝내는 목표

이 글을 다 읽으면 아래를 할 수 있습니다.

  • CORS 에러가 왜 나는지 한 문장으로 설명할 수 있습니다.
  • 내 요청이 단순 요청인지, 프리플라이트가 붙는지, 자격증명이 걸렸는지 진단할 수 있습니다.
  • 상황에 맞는 올바른 해결책(서버 헤더 설정 / 개발 프록시) 을 고를 수 있습니다.

에러 메시지를 없애는 게 아니라, 어디를 고쳐야 하는지 판단하는 기준을 손에 넣는 것이 목적입니다.

🧭 근거부터 짚고 갑니다

미리 말씀드리면, 이 글은 제 특정 프로젝트의 디버깅 경험담이 아니라 웹 표준(WHATWG Fetch)과 MDN 공식 문서에서 확인해 정리한 것입니다.

브라우저·서버 프레임워크마다 설정 방법의 세부는 다르지만, 여기서 다루는 개념과 판단 기준은 표준이라 바뀌지 않습니다. 헤더 이름과 규칙은 확인일(2026년 7월 19일, MDN·Fetch Standard 기준) 이며, 구체적인 설정 문법은 본인이 쓰는 서버/도구의 현재 문서로 확인하시는 편이 안전합니다.

🧱 CORS가 대체 뭔가요

용어부터 풀겠습니다. CORS(Cross-Origin Resource Sharing) 는 쉽게 말하면 "다른 출처(origin)의 자원을 브라우저에서 읽어도 되는지"를 서버가 헤더로 허락하는 방식입니다.

여기서 출처(origin)스킴 + 호스트 + 포트 세 가지를 묶은 것입니다. 세 개가 모두 같아야 "같은 출처"입니다.

  • https://myapp.comhttps://myapp.com/users : 같은 출처(경로만 다름)
  • https://myapp.comhttp://myapp.com : 다른 출처(스킴 http vs https)
  • https://myapp.comhttps://api.myapp.com : 다른 출처(호스트가 다름)
  • https://myapp.comhttps://myapp.com:8080 : 다른 출처(포트가 다름)

브라우저에는 동일 출처 정책(Same-Origin Policy) 이라는 보안 규칙이 있습니다. 쉽게 말하면 "A 사이트에서 실행되는 스크립트가 B 사이트의 응답을 마음대로 읽지 못하게" 막는 것입니다. 로그인된 웹메일이나 사내망 데이터를 악성 사이트가 몰래 긁어가는 걸 막기 위한 장치입니다.

그래서 다른 출처로 fetch를 보내면, 서버가 "이 출처는 읽어도 된다" 는 응답 헤더(Access-Control-Allow-Origin)를 붙여 줘야 브라우저가 응답을 프론트로 넘겨줍니다. 이 헤더가 없거나 안 맞으면, 요청 자체는 서버에 도착해 200이 떨어졌더라도 브라우저가 응답을 가로막고 CORS 에러를 띄웁니다.

 

그림 2. MDN "Cross-Origin Resource Sharing (CORS)" 문서. CORS를 "서버가 자신 이외의 출처를 브라우저가 로드하도록 허용함을 알리는 HTTP 헤더 기반 메커니즘"으로 정의하고 Access-Control-Allow-Origin 등 응답 헤더를 설명합니다.

  • "Postman·curl에서는 되는데 왜 브라우저만?" — CORS는 브라우저의 fetch/XMLHttpRequest에만 적용됩니다. Postman·curl·서버 간(server-to-server) 통신은 동일 출처 정책을 구현하지 않으므로 CORS를 아예 신경 쓰지 않습니다. 그래서 "Postman은 되는데 브라우저만 막힌다"가 지극히 정상입니다.
  • "CORS가 내 API를 보호해 주나?" — 아닙니다. CORS는 서버를 지키는 보안 장치가 아니라, 사용자의 브라우저를 지키는 장치입니다. curl로는 그냥 뚫리니까요. 서버 보안은 인증/인가로 따로 해야 합니다.

🔍 왜 OPTIONS 요청이 먼저 날아가나

Network 탭을 보면, 내가 보낸 적 없는 OPTIONS 요청이 실제 요청 앞에 하나 더 붙는 걸 볼 때가 있습니다. 이게 프리플라이트(preflight), 쉽게 말하면 브라우저가 서버에 미리 "이런 요청 보내도 돼?"라고 묻는 사전 확인입니다.

모든 요청에 붙는 건 아닙니다. 아래 단순 요청(simple request) 조건을 전부 만족하면 프리플라이트 없이 바로 갑니다(공식 문서 기준).

  • 메서드가 GET · HEAD · POST 중 하나
  • 직접 설정한 헤더가 안전 목록(Accept, Accept-Language, Content-Language, Content-Type, Range) 안에만 있음
  • Content-Typeapplication/x-www-form-urlencoded · multipart/form-data · text/plain 중 하나

하나라도 어기면 프리플라이트가 붙습니다. 실무에서 프리플라이트를 부르는 대표 3가지는 이렇습니다.

  • Content-Type: application/json 으로 JSON을 보낼 때 (가장 흔함)
  • Authorization 헤더나 커스텀 X-... 헤더를 붙일 때
  • PUT · DELETE · PATCH 메서드를 쓸 때

프리플라이트가 붙으면 순서는 이렇습니다. 브라우저가 OPTIONSAccess-Control-Request-Method(쓸 메서드)와 Access-Control-Request-Headers(보낼 헤더)를 먼저 보내고, 서버가 Access-Control-Allow-Origin · Access-Control-Allow-Methods · Access-Control-Allow-Headers 로 답합니다. 이 답이 요청과 맞아야 그때서야 실제 요청이 나갑니다. 프리플라이트가 실패하면 실제 요청은 아예 보내지지 않습니다.

그림 3. MDN "Preflight request" 문서. OPTIONS 요청에 Access-Control-Request-Method·Access-Control-Request-Headers가 실리고, 서버가 대응하는 Access-Control-Allow-*로 답하는 교환을 설명합니다. 

매번 OPTIONS가 붙는 게 부담이면, 서버가 Access-Control-Max-Age(초 단위)로 프리플라이트 결과를 캐시하게 할 수 있습니다. 다만 브라우저가 상한을 둡니다(Chromium 계열 최대 2시간=7200초, Firefox 최대 24시간=86400초).

🍪 쿠키·토큰을 같이 보낼 때(자격증명)

로그인 세션 쿠키나 인증 토큰을 함께 보내는 요청, 즉 자격증명(credentials) 요청(fetchcredentials: 'include', XHR의 withCredentials = true)에는 함정이 하나 있습니다.

이때는 서버의 Access-Control-Allow-Origin에 와일드카드 * 를 쓸 수 없습니다. 반드시 요청한 출처를 그대로 적어 줘야 합니다.

# ❌ 자격증명 요청인데 와일드카드 → 브라우저가 응답 차단
Access-Control-Allow-Origin: *

# ✅ 구체적인 출처를 명시 + 자격증명 허용
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

Access-Control-Allow-Credentials는 값이 true 하나뿐이고 대소문자를 가립니다. 그리고 자격증명 요청에서는 Access-Control-Allow-HeadersAccess-Control-Allow-Methods에 쓴 * 도 와일드카드가 아니라 글자 그대로 *라는 헤더 이름으로 취급되므로, 허용할 헤더·메서드를 하나하나 적어 줘야 합니다.

콘솔에 Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*' 가 뜬다면 정확히 이 경우입니다. 서버 설정에서 * 를 구체적인 출처로 바꾸면 됩니다.

🧭 그래서 어디를 고쳐야 하나

이제 실제 해결입니다. 핵심 원칙 하나만 기억하면 됩니다. CORS 에러는 대부분 서버에서 고칩니다. 브라우저는 보안상 에러의 상세 이유를 JS에 알려주지 않으므로(그래서 try/catch로 못 잡습니다), 먼저 DevTools 콘솔과 Network 탭에서 원인을 확인하고 아래 순서로 판단합니다.

 

① 서버를 내가 고칠 수 있다 → 서버에 허용 헤더를 추가합니다.

가장 정석입니다. 서버(또는 CORS 미들웨어)가 요청 출처에 맞는 헤더를 응답에 붙이게 설정합니다.

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

여러 출처를 허용해야 하면 요청 Origin을 확인해 그에 맞는 값을 돌려주고, 이때 Vary: Origin 을 함께 보내 캐시가 출처별로 응답을 구분하게 합니다.

② 외부 API라 서버를 못 고친다 → 내가 통제하는 프록시로 우회합니다.

브라우저가 같은 출처로 인식하게 만들면 CORS 자체가 적용되지 않습니다. 개발 중에는 번들러의 개발 서버 프록시가 편합니다.

// vite.config.js — /api 로 시작하는 요청을 외부 API로 대신 전달
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
      },
    },
  },
}

이러면 브라우저는 내 개발 서버(같은 출처)와만 통신하므로 CORS 헤더가 필요 없어집니다. 운영 환경에서는 내가 만든 백엔드가 대신 외부 API를 호출(server-to-server라 CORS 무관)해 응답을 돌려주는 방식으로 같은 효과를 냅니다.

해결이 아닌 것 두 가지도 알아 두세요.

  • fetch(url, { mode: 'no-cors' }) — 에러는 사라진 것처럼 보이지만, 응답이 opaque(상태 0, 헤더·본문 읽기 불가)가 됩니다. 데이터를 얻는 게 아니라 못 읽게 되는 것이라, 로깅처럼 응답을 안 읽는 요청 말고는 해결책이 아닙니다.
  • 브라우저 보안 끄기(실행 플래그, CORS 해제 확장) — 내 컴퓨터에서만 검사를 끄는 것이라, 실제 사용자 브라우저는 여전히 막습니다. 디버깅용이지 배포용 해결이 아닙니다.

✅ CORS 에러 만났을 때 체크리스트

에러를 만나면 이 순서대로 짚으면 대부분 원인이 잡힙니다. 필요하면 복사해서 쓰세요.

[진단 — 콘솔/Network 탭 먼저]
- [ ] Network 탭에 OPTIONS 요청이 따로 있나? → 프리플라이트가 붙는 요청
- [ ] 콘솔 에러 문구 확인: 'Allow-Origin missing'인가 / '*' + credentials인가 / preflight 실패인가
- [ ] Postman/curl로는 되나? → 되면 서버는 정상, 브라우저 CORS 문제 확정

[요청 성격 파악]
- [ ] Content-Type이 application/json인가, Authorization/커스텀 헤더가 있나, PUT/DELETE인가 → 프리플라이트 대상
- [ ] 쿠키/토큰을 같이 보내나(credentials: 'include')? → Allow-Origin에 '*' 금지

[해결]
- [ ] 서버를 고칠 수 있다 → Access-Control-Allow-Origin/-Methods/-Headers 추가 (자격증명이면 구체 출처 + Allow-Credentials: true)
- [ ] 외부 API라 못 고친다 → 개발 프록시(vite/webpack) 또는 내 백엔드가 대신 호출
- [ ] no-cors·브라우저 보안끄기는 해결이 아님(임시 진단용만)

🫡 마무리

정리하면, CORS 에러는 "내 코드가 틀렸다"는 신호가 아니라 "서버가 이 브라우저 출처를 아직 허락하지 않았다" 는 신호입니다. 그래서 대부분 프론트가 아니라 서버(또는 프록시)에서 풀립니다.

순서는 늘 같습니다. 콘솔에서 원인을 읽고 → 단순 요청인지 프리플라이트·자격증명이 걸렸는지 진단하고 → 서버를 고칠 수 있으면 허용 헤더를, 못 고치면 프록시를 씁니다. no-cors나 보안 끄기로 도망가지 않는 것이 핵심입니다.

같은 네트워크 기초로, 통신 방식 자체가 궁금하다면 Socket 통신과 HTTP 통신 비교를, 응답 상태 코드를 제대로 다루는 법은 HTTP Status Code 제어를 함께 보셔도 좋습니다.

다음 글에서는 자주 만나는 HTTP 상태 코드(401·403·404·500 등)를 상황별로 어떻게 읽고 대응하는지 이어서 정리해 보겠습니다.

읽어 주셔서 감사합니다 🙇

참고 자료

반응형
Comments