본문 바로가기
카테고리 없음

CORS 에러의 진짜 원인

by 테크구루스 2026. 7. 4.

CORS 에러의 진짜 원인을, 하루를 통째로 날린 삽질 끝에 이해한 대로 풀어 쓴 글입니다.

 

CORS 에러의 진짜 원인
CORS 에러의 진짜 원인

브라우저는 왜 내 요청을 막았는가

 

CORS 에러를 처음 만나면 대부분 서버가 요청을 거부했다고 오해합니다. 저도 그랬죠. 그런데 사실은 정반대에 가깝습니다. 요청은 서버에 잘 도착했고, 서버는 응답까지 정상적으로 돌려줬는데, 그 응답을 받아 본 브라우저가 "이건 규칙 위반이야"라며 자바스크립트에게 넘겨주지 않는 상황이 CORS 에러입니다. 즉 에러를 내는 주체는 서버가 아니라 브라우저입니다.

왜 이런 장치가 있을까요. 브라우저에는 same-origin policy, 즉 "같은 출처끼리만 자유롭게 통신하라"는 기본 원칙이 있습니다. 여기서 출처(origin)는 프로토콜·도메인·포트 세 가지가 모두 같아야 같은 것으로 칩니다. http://localhost:3000http://localhost:8080은 포트만 달라도 남남이죠.

 

이 원칙이 없으면, 사용자가 로그인해 둔 사이트의 정보를 악성 페이지가 몰래 긁어 가는 일이 너무 쉬워집니다. CORS는 이 빗장을 서버가 선택적으로 풀어 주기 위한 약속입니다. 서버가 응답 헤더로 "이 출처는 접근해도 괜찮다"고 밝히면, 브라우저가 그제야 통과시켜 주는 구조죠.

특히 값을 바꾸는 요청이나 특별한 헤더가 붙은 요청에는, 브라우저가 본 요청을 보내기 전에 OPTIONS 메서드로 "이런 요청 보내도 되나요?"를 먼저 물어봅니다. 이걸 프리플라이트(preflight)라고 부릅니다.

 

CORS 요청이 오가는 실제 순서
CORS 요청이 오가는 실제 순서

 

프리플라이트에서 서버가 허용 응답을 제대로 주지 않으면, 브라우저는 본 요청을 아예 보내지도 않고 거기서 막아 버립니다. 그래서 서버 로그에는 OPTIONS 요청만 찍히고 정작 내가 보내려던 GET은 흔적조차 없는, 처음엔 도무지 이해가 안 되는 상황이 벌어지죠.

참고로 모든 요청이 프리플라이트를 거치는 건 아닙니다. 예전부터 폼으로도 보낼 수 있었던 단순한 형태의 요청, 이른바 단순 요청(simple request)은 이 사전 확인을 건너뛰고 곧장 나갑니다. 반대로 PUT이나 DELETE 같은 메서드를 쓰거나, Authorization처럼 규격을 벗어난 헤더를 붙이거나, application/json으로 본문을 보내는 순간 브라우저는 "이건 확인이 필요하다"고 판단해 프리플라이트를 먼저 날립니다. 제가 겪은 에러들이 대부분 후자였던 이유도 여기 있었죠. 요즘 우리가 흔히 짜는 API 요청은 거의 다 이 조건에 걸리니까요.

 

단순요청vs프리플라이트
단순요청vs프리플라이트

 

Access-Control-Allow-Origin에 하루를 태운 이야기

제가 이걸 몸으로 배운 날은 지금도 생생합니다. 로컬에서 프런트는 3000번 포트, API 서버는 8080번 포트로 띄워 놓고 개발하던 중이였죠. 화면을 새로고침하자 콘솔에 빨간 글씨가 떴습니다.

Access to fetch at 'http://localhost:8080/api/users' from origin
'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

제가 특히 헷갈렸던 건, 같은 요청을 API 테스트 도구로 보내면 멀쩡히 응답이 오는데 브라우저에서만 막힌다는 점이었습니다. 알고 보면 당연한 일이었죠. same-origin policy와 CORS는 브라우저가 사용자를 보호하려고 두는 규칙이라, 브라우저 밖에서 보내는 요청에는 애초에 적용되지 않으니까요. "도구로는 되는데 코드로는 안 된다"며 프런트 코드만 의심하고 앉아 있던 그 그 시간이 지금 생각하면 참 아깝습니다.

부끄럽지만 저는 이 에러를 프런트엔드 문제라고 확신하고 엉뚱한 곳만 팠습니다. 우선 요청 헤더에 Access-Control-Allow-Origin을 직접 붙여 봤죠. 당연히 소용없었습니다. 그건 요청이 아니라 응답에 서버가 붙여야 하는 헤더였으니까요. 그다음엔 크롬 확장 프로그램으로 CORS를 꺼서 "된다"고 잠깐 좋아했다가, 그건 제 브라우저에서만 통하는 눈속임이라 사용자에게는 아무 의미가 없다는 걸 깨닫고 허탈해졌습니다. 급기야 fetchmode: 'no-cors'를 붙였더니 에러는 사라졌는데, 정작 응답 본문을 읽을 수 없어 결과가 텅 비어 나왔죠. 그렇게 오전을 통째로 날렸습니다.

원인은 허무할 만큼 단순했습니다. 서버가 응답에 허용 헤더를 전혀 내려 주지 않고 있었던 것이죠. 고칠 자리는 프런트가 아니라 서버였습니다. 백엔드에서 CORS 설정을 한 줄 넣자 거짓말처럼 해결됐습니다.

// Express 예시 — 서버가 허용 출처를 명시하는 설정
const cors = require("cors");
app.use(cors({
  origin: "http://localhost:3000",   // 허용할 출처
  credentials: true,                 // 쿠키를 함께 보낼 때 필요
}));

여기서 마지막 함정도 하나 만났습니다. 로그인 쿠키를 함께 보내려고 credentials: 'include'를 켰더니, 이번엔 "와일드카드 *와 credentials는 같이 못 쓴다"는 에러가 났죠. 인증 정보가 오가는 요청에서는 Access-Control-Allow-Origin*가 아니라 정확한 출처 하나로 명시해야 하고, Access-Control-Allow-Credentialstrue로 켜 줘야 한다는 걸 그제야 알았습니다.

결국 그날의 교훈을 한 줄로 요약하면 "에러 메시지를 끝까지 정확히 읽었어야 했다"였습니다. "No 'Access-Control-Allow-Origin'"과 "credentials 모드에서는 와일드카드를 못 쓴다"는 서로 완전히 다른 문제인데, 저는 둘 다 뭉뚱그려 그냥 "CORS 에러"로만 보고 같은 방식으로 두드렸죠. 메시지의 뒷부분까지 차분히 읽었더라면, 오전을 통째로 날리는 대신 처방을 훨씬 빨리 찾았을 겁니다. 에러 메시지는 대개 문제의 원인과 해결의 실마리를 이미 품고 있더군요.

CORS를 제대로 뚫는 순서

그날 이후 저는 CORS 에러를 만나면 다음 순서로 접근합니다.

  1. 에러 메시지를 끝까지 읽기. "No 'Access-Control-Allow-Origin'"인지, credentials 충돌인지, 허용되지 않은 헤더 때문인지에 따라 처방이 완전히 다릅니다.
  2. 네트워크 탭에서 OPTIONS 요청 확인하기. 프리플라이트가 실패했다면, 본 요청은 아직 나가지도 않은 상태입니다.
  3. 고칠 자리는 서버라고 전제하기. 허용 출처·메서드·헤더는 응답 헤더로 서버가 내려 줘야 합니다. 프런트에서 붙이는 헤더로는 해결되지 않습니다.
  4. 인증 정보를 실으면 *를 버리기. 정확한 출처를 명시하고 Allow-Credentials를 함께 켜야 합니다.
  5. 개발 중이라면 프록시로 우회하기. 개발 서버의 proxy 설정으로 요청을 같은 출처처럼 우회시키면, 로컬에서는 CORS 자체를 비켜 갈 수 있죠. 다만 이건 어디까지나 개발 편의를 위한 우회입니다. 운영 환경에서는 결국 서버가 허용 출처를 제대로 내려 주도록 설정해야 하고, 로컬에서만 되는 상태로 배포했다가 똑같은 에러를 다시 만나는 건—부끄럽지만 저도 한 번 더 반복한 실수입니다.

개인적으로 가장 크게 남은 교훈은, CORS는 대개 프런트에서 고칠 문제가 아니라는 점입니다. 브라우저가 낸 에러라 프런트 코드만 붙잡고 늘어지기 쉬운데, 정작 답은 서버의 응답 헤더에 있는 경우가 대부분이죠. 그 방향만 처음부터 잡았어도 저는 그 오전을 날리지 않았을 겁니다.