Article

CORS 에러, 프론트가 아니라 서버에서 풀어야 하는 이유

CORS 에러는 처음 만나면 거의 항상 같은 착각으로 시작합니다. 콘솔에 빨간 글씨가 떴으니 내 프론트엔드 코드가 잘못됐다고 생각하고, fetch 옵션을 이리저리 바꿔보는 겁니다. 그런데 대부분의 경우 프론트에서 할 수 있는 일은 없습니다. CORS는 브라우저가 거는 제약이고, 그 제약을 풀어줄 권한은 응답을 보내는 서버에 있기 때문입니다. 어디를 고쳐야 하는지를 잘못 잡으면, 멀쩡한 코드를 몇 시간씩 헛으로 만지게 됩니다.

요약 박스

  • CORS 에러는 서버가 보낸 게 아니라, 브라우저가 응답을 보고 차단한 것입니다.
  • 해결은 거의 항상 서버 응답에 Access-Control-Allow-Origin 헤더를 붙이는 일입니다.
  • 쿠키나 인증 정보를 함께 보낼 때는 * 와일드카드를 쓸 수 없습니다. 정확한 출처를 적어야 합니다.
  • 프론트에서 임시로 막힌 걸 뚫는 방법(프록시, 확장프로그램)은 개발 중에만 쓰고 운영에 올리면 안 됩니다.

최종 업데이트: 2026-06-09

CORS는 누구를 막는 규칙일까?

CORS는 Cross-Origin Resource Sharing의 줄임말입니다. 이름 그대로 “다른 출처(origin) 간 자원 공유”를 다루는 규칙인데, 핵심은 이게 공격자를 막는 보안 장치가 아니라 브라우저가 사용자를 보호하려고 거는 제약이라는 점입니다.

여기서 출처(origin)는 프로토콜, 도메인, 포트 세 가지의 조합입니다. https://app.example.comhttps://api.example.com은 도메인이 다르니 다른 출처고, http://localhost:3000http://localhost:8080은 포트가 다르니 다른 출처입니다. 셋 중 하나만 달라도 브라우저는 “교차 출처 요청”으로 봅니다. 로컬 개발에서 프론트는 3000번, API 서버는 8080번에 띄워놓고 CORS 에러를 처음 만나는 이유가 바로 이것입니다.

중요한 건 차단의 주체입니다. 서버는 요청을 정상적으로 받아 응답까지 보냅니다. 그 응답이 브라우저에 도착했을 때, 브라우저가 “이 응답에 내 출처를 허용한다는 표시가 없네”라고 판단하면 자바스크립트가 응답 내용을 읽지 못하게 막습니다. 그래서 서버 로그에는 200 OK가 찍혀 있는데 프론트에서는 에러가 나는, 처음엔 이해 안 되는 상황이 생깁니다. (출처: MDN - Cross-Origin Resource Sharing (CORS))

왜 프론트 코드를 고쳐도 안 풀릴까?

이 지점을 받아들이면 디버깅 방향이 정리됩니다. 응답을 허용할지 말지는 응답을 보내는 쪽, 즉 서버가 헤더로 선언합니다. 브라우저는 그 헤더를 읽고 판단할 뿐입니다. 클라이언트 자바스크립트는 이 판단에 끼어들 수 없습니다.

그래서 fetchmode를 바꾸거나, 헤더를 추가하거나, 옵션을 조합해도 근본 해결이 안 됩니다. mode: 'no-cors' 같은 옵션이 에러를 사라지게 만드는 것처럼 보일 때가 있는데, 이건 해결이 아니라 응답 본문을 아예 읽을 수 없는 불투명(opaque) 응답으로 바꿔버리는 것입니다. 에러는 없어졌지만 데이터도 못 받는, 사실상 더 나쁜 상태가 됩니다.

정리하면 이렇습니다. CORS 에러를 보면 가장 먼저 의심할 곳은 내 프론트 코드가 아니라 서버 응답 헤더입니다. 내가 그 서버를 고칠 수 있는지부터 따지는 게 순서입니다.

가장 흔한 해결: 서버 응답 헤더 추가

내가 서버를 제어할 수 있다면 해결은 단순합니다. 응답에 허용 출처를 명시하는 헤더를 붙이면 됩니다.

Access-Control-Allow-Origin: https://app.example.com

이 한 줄이 “이 출처에서 온 요청은 응답을 읽어도 된다”는 선언입니다. 프레임워크별로 적용 위치만 다를 뿐 원리는 같습니다. Express라면 cors 미들웨어, Spring이라면 @CrossOrigin이나 전역 CORS 설정, Nginx 앞단이라면 add_header로 붙입니다.

여기서 자주 하는 실수가 Access-Control-Allow-Origin: * 와일드카드를 습관적으로 쓰는 겁니다. 모든 출처를 다 허용한다는 뜻이라 당장은 편하지만, 두 가지 문제가 있습니다.

  • 공개 API가 아니라면 굳이 전 세계에 응답을 열어줄 이유가 없습니다.
  • 쿠키나 인증 토큰을 함께 보내는 요청에서는 *를 쓸 수 없습니다. 이건 선택이 아니라 사양상 금지입니다.

운영 환경이라면 허용할 출처를 정확히 적거나, 허용 목록을 두고 요청 출처가 목록에 있을 때만 그 출처를 헤더에 돌려주는 방식이 안전합니다.

preflight 요청이 따로 뜨는 경우

개발자 도구 네트워크 탭을 보면 실제 요청 전에 OPTIONS 메서드 요청이 한 번 더 가는 걸 볼 때가 있습니다. 이게 preflight(사전 확인) 요청입니다. 브라우저가 “이런 요청을 보내도 되는지” 서버에 먼저 물어보는 절차입니다.

모든 요청이 preflight를 거치진 않습니다. 단순 요청(GET, 일부 POST 등 특정 조건을 만족하는 요청)은 바로 갑니다. 하지만 다음 같은 경우엔 브라우저가 먼저 OPTIONS로 확인합니다.

  • PUT, DELETE, PATCH 같은 메서드를 쓸 때
  • Content-Type: application/json처럼 단순 요청 조건을 벗어나는 헤더를 보낼 때
  • Authorization 같은 커스텀 헤더를 붙일 때

(출처: MDN - Preflight request)

이때는 본 요청뿐 아니라 OPTIONS 요청에 대한 응답도 CORS 헤더를 제대로 갖춰야 합니다. 자주 빠뜨리는 헤더가 이 둘입니다.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

프론트에서 Authorization 헤더를 붙였는데 서버의 Access-Control-Allow-Headers에 그 헤더가 없으면 preflight 단계에서 막힙니다. “GET은 되는데 POST만 안 된다”, “헤더 하나 추가했더니 갑자기 막혔다” 같은 증상은 거의 이 preflight 설정 누락입니다. 본 요청 응답만 고치고 OPTIONS 응답을 빠뜨렸는지 확인해 보세요.

쿠키·인증 정보를 함께 보낼 때

세션 쿠키나 인증 정보를 교차 출처 요청에 실어 보내려면 양쪽을 모두 맞춰야 합니다. 한쪽만 설정하면 조용히 실패하거나 에러가 납니다.

프론트는 요청에 자격 증명을 포함하겠다고 명시해야 합니다.

fetch("https://api.example.com/me", {
  credentials: "include",
});

서버는 자격 증명을 허용한다고 응답하고, 이때 허용 출처를 와일드카드가 아니라 정확한 값으로 돌려줘야 합니다.

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

다시 강조하면, Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin: *는 함께 쓸 수 없습니다. 쿠키를 보내야 한다면 출처를 반드시 구체적으로 적어야 합니다. (출처: MDN - Access-Control-Allow-Credentials)

서버를 못 고칠 때: 임시방편과 그 한계

문제는 내가 그 서버의 주인이 아닐 때입니다. 외부 API를 호출하는데 그쪽이 내 출처를 허용해주지 않는 경우, 브라우저에서 직접 부르는 건 막힙니다. 이때 선택지는 나뉩니다.

개발 중 임시방편(운영 금지):

  • 브라우저 확장프로그램으로 CORS 검사를 끄는 방법이 있습니다. 내 브라우저에서만 동작하니 다른 사용자에겐 그대로 막힙니다. 잠깐 확인용으로만 씁니다.
  • 개발 서버의 프록시 기능(예: Vite, webpack dev server의 proxy 설정)으로 같은 출처처럼 보이게 우회할 수 있습니다. 로컬 개발에는 편하지만 빌드 결과물에는 적용되지 않습니다.

운영에서 쓸 수 있는 정공법:

  • 내 백엔드에 중계 엔드포인트를 두는 방법입니다. 프론트는 내 서버를 부르고, 내 서버가 외부 API를 호출해 결과를 돌려줍니다. 서버 간 통신에는 CORS 제약이 없으니 깔끔하게 풀립니다.
  • 이 방식은 외부 API 키를 프론트에 노출하지 않아도 된다는 장점도 같이 따라옵니다.

임시방편을 운영에 그대로 올리면 안 되는 이유는 분명합니다. 확장프로그램 우회는 내 환경에서만 통하고, “CORS를 끄는” 식의 설정을 운영 서버에 무분별하게 적용하면 보호 장치를 스스로 걷어내는 셈이 됩니다.

증상별 빠른 점검 체크리스트

CORS 에러를 만났을 때 순서대로 확인하면 헤매는 시간을 줄일 수 있습니다.

  • 에러 메시지에 적힌 차단된 출처와 요청 대상 URL이 정말 다른 출처인지 확인했는가? (프로토콜·도메인·포트 중 하나라도 다르면 교차 출처)
  • 이 서버를 내가 고칠 수 있는가, 아니면 외부 API인가?
  • 서버 응답에 Access-Control-Allow-Origin이 있는가? (네트워크 탭 응답 헤더에서 확인)
  • 네트워크 탭에 OPTIONS 요청이 따로 있는가? 있다면 그 응답에도 CORS 헤더가 붙어 있는가?
  • 쿠키·인증을 보낸다면 프론트에 credentials: 'include', 서버에 Allow-Credentials: true + 구체적 출처가 둘 다 있는가?
  • 와일드카드 *와 자격 증명을 동시에 쓰고 있지 않은가?

자주 묻는 질문

Q. 서버 로그엔 200이 찍히는데 왜 프론트는 에러가 나나요? 서버는 요청을 정상 처리해 응답을 보냅니다. 그 응답을 받은 브라우저가 허용 헤더가 없다고 판단해 자바스크립트의 접근을 차단한 것입니다. 차단 주체가 서버가 아니라 브라우저라서 생기는 현상입니다.

Q. mode: 'no-cors'를 쓰면 에러가 사라지던데 이걸로 해결하면 안 되나요? 에러는 사라지지만 응답 본문을 읽을 수 없는 불투명 응답이 됩니다. 데이터를 받아야 하는 상황이라면 해결이 아니라 더 나쁜 상태입니다.

Q. Postman에서는 잘 되는데 브라우저에서만 막혀요. CORS는 브라우저의 규칙입니다. Postman이나 curl, 서버 간 통신에는 적용되지 않습니다. 그래서 “도구로는 되는데 브라우저만 안 되는” 상황이 정상입니다.

Q. 와일드카드 *를 쓰면 다 해결되지 않나요? 공개 API가 아니라면 권장하지 않고, 쿠키·인증을 함께 보내는 요청에서는 사양상 쓸 수 없습니다. 그 경우 허용할 출처를 구체적으로 적어야 합니다.

정리

CORS 에러에서 가장 많이 낭비하는 시간은 “어디를 고칠지”를 잘못 잡는 데서 나옵니다. 브라우저가 거는 제약이라는 사실 하나만 기억하면 방향이 잡힙니다. 내가 서버를 고칠 수 있으면 응답 헤더로 출처를 허용하고, 고칠 수 없는 외부 API면 내 백엔드를 거쳐 호출하는 게 정공법입니다. 프론트에서 fetch 옵션을 바꾸는 것으로 풀리는 문제가 아니라는 점을 처음에 받아들이는 게, 결국 가장 빠른 길입니다.

출처

댓글