-
curl은 되는데 브라우저만 CORS 오류가 나는 이유Programming 2026. 9. 20. 10:53728x90반응형

출발 위치와 접근 경계를 설명하기 위한 AI 생성 개념 이미지입니다. CORS 오류는 브라우저의 Origin, 사전 요청, 실제 응답 헤더를 차례로 대조해 원인을 좁히세요.
curl에서는 JSON이 잘 오는데 웹 화면에서는 CORS 오류가 납니다. 서버가 완전히 정상인데 브라우저만 고장 났다는 뜻은 아닙니다. curl이 응답을 받는 것과 브라우저가 다른 출처의 스크립트에 그 응답을 읽게 허용하는 것은 다른 조건입니다.
특히 CORS 오류가 났다고 실제 요청이 서버에 전혀 도착하지 않았다고 단정하면 안 됩니다. 사전 요청에서 막힌 것인지, 본 요청은 처리됐지만 응답을 읽지 못한 것인지 먼저 구분해야 합니다. 아래는 2026년 9월 13일 MDN·Fetch 관련 문서 기준의 설명이며 실제 서버에 요청하거나 설정을 바꾸지 않았습니다.
브라우저가 비교하는 Origin부터 확인합니다
Origin은 프로토콜, 호스트, 포트의 조합입니다.
https://app.example.test와https://api.example.test는 호스트가 달라 서로 다른 origin입니다. 같은 호스트라도 HTTP와 HTTPS가 다르거나 포트가 다르면 조건이 달라질 수 있습니다. URL의 경로만 비교해서는 안 됩니다. CORS와 출처브라우저의 fetch는 다른 origin의 응답을 스크립트에 공유할 때 CORS 조건을 확인합니다. 서버는 응답 헤더로 어떤 origin의 코드가 이 응답을 읽을 수 있는지 표시합니다.
curl은 브라우저 페이지의 같은 출처 정책을 적용하는 환경이 아닙니다. 따라서 같은 URL에서 본문을 받았다는 사실은 연결과 서버 응답을 확인하는 데 도움이 되지만, 브라우저의 CORS 검사를 통과했다는 증거는 아닙니다.
진단할 때는 요청을 시작한 페이지의 실제 주소를 봅니다. 로컬 개발 포트, 프리뷰 도메인, 리다이렉트 뒤 주소가 설정한 허용 origin과 같은지 확인해야 합니다. 눈으로 비슷해 보이는 도메인 문자열보다 실제 Origin 헤더가 기준입니다.
가상의 GET 요청도 사전 요청이 필요할 수 있습니다
예시에서는 app.example.test의 웹 코드가 api.example.test에서 공개 목록을 읽는다고 하겠습니다. 로그인 쿠키는 사용하지 않는 요청입니다.
const response = await fetch("https://api.example.test/items", { headers: { "X-Demo-Client": "demo" }, }); if (!response.ok) { throw new Error("HTTP " + response.status); } const items = await response.json();GET이라도 이 예시처럼 허용 목록에 없는 사용자 지정 요청 헤더를 추가하면 브라우저가 preflight를 할 수 있습니다. 모든 GET이 사전 요청 없이 전송되는 것은 아닙니다. JSON POST나 다른 메서드·헤더 조합에서도 조건을 따로 확인해야 합니다. 사전 요청이 생기는 조건
preflight는 보통 브라우저가 자동으로 만드는 OPTIONS 요청입니다. 개발자가 본 요청 전에 별도 fetch로 OPTIONS를 직접 보내 구현하는 순서가 아닙니다. 요청에는 출발 Origin, 사용하려는 메서드, 추가 요청 헤더의 이름 등이 담깁니다. Preflight 요청의 구조
예시의 핵심 조건을 쓰면 Origin은
https://app.example.test, 요청할 메서드는 GET, 추가 헤더는x-demo-client입니다. 서버의 OPTIONS 처리와 허용 정책이 이 조건을 받아들여야 본 요청으로 이어질 수 있습니다.OPTIONS와 실제 GET 응답을 따로 봅니다
다음은 이 가상 요청을 허용하는 서버 정책의 핵심 헤더 예시입니다. 실제 서버 응답을 캡처한 내용이 아닙니다.
Access-Control-Allow-Origin: https://app.example.test Access-Control-Allow-Methods: GET Access-Control-Allow-Headers: X-Demo-Client Vary: Origin사전 요청에 적절하게 응답했다고 실제 GET의 응답 헤더를 생략해도 되는 것은 아닙니다. 본 요청의 응답에도 해당 origin으로 공유할 수 있음을 나타내는 Access-Control-Allow-Origin이 필요합니다. 이 예시에서 실제 응답에 둘 핵심은 다음과 같습니다.
Access-Control-Allow-Origin: https://app.example.test Vary: Origin Content-Type: application/json여러 origin을 허용한다면 서버에서 요청 origin을 허용 목록과 대조한 뒤 해당 값을 선택해야 합니다. 들어온 Origin을 검증 없이 그대로 반사하는 방식은 허용 대상을 제한하지 못합니다. 응답이 Origin에 따라 달라지면 Vary: Origin으로 캐시에도 그 차이를 알려 줍니다. Allow-Origin과 캐시
Access-Control-Allow-Origin은 서버의 응답 헤더입니다. 클라이언트 fetch의 요청 헤더에 같은 이름을 추가해서 허용을 선언할 수는 없습니다. 설정할 위치부터 구분해야 불필요한 사용자 지정 헤더와 사전 요청만 늘리는 일을 피할 수 있습니다.
Network에서 두 단계의 실패를 나눕니다
브라우저 개발자 도구에서 해당 URL의 OPTIONS와 실제 요청을 찾아봅니다. 사전 요청 결과가 캐시돼 매번 OPTIONS가 보이지 않을 수 있으므로, 항상 두 줄이 나타나야 정상이라고 가정하지 않습니다. preflight 캐시는 일반 HTTP 응답 캐시와 별도입니다. 사전 요청 캐시
OPTIONS가 실패했다면 응답 상태와 허용 메서드·헤더·origin을 대조합니다. 인증 미들웨어가 사전 요청부터 로그인 페이지로 보내거나, 경로가 OPTIONS를 처리하지 못하는지도 볼 수 있습니다. 본 요청 핸들러만 살펴서는 이 실패를 놓칩니다.
실제 GET이 보인다면 그 응답의 헤더와 상태를 확인합니다. 정상 응답에는 헤더가 있는데 오류 응답에는 빠져 있을 수 있습니다. CDN, 프록시, 앱 서버 중 어느 계층이 응답을 만들었는지도 함께 봅니다.
코드의
response.ok검사는 브라우저가 응답을 코드에 전달한 뒤의 HTTP 상태 검사입니다. CORS에서 막히면 그 응답 객체를 원하는 방식으로 읽지 못할 수 있습니다. fetch가 실패했다고 모든 경우를 같은 서버 오류로 처리하기보다 콘솔의 CORS 메시지와 Network 기록을 대조해야 합니다. fetch와 교차 출처 응답curl은 서버가 무엇을 돌려주는지 확인하는 데 씁니다
브라우저에서 확인한 요청 조건을 curl에 명시하면 서버의 OPTIONS 처리 결과를 비교하는 데 도움이 됩니다. 다음은 가상 주소에 대한 명령 형태이며 실행하지 않았습니다.
curl -i -X OPTIONS 'https://api.example.test/items' \ -H 'Origin: https://app.example.test' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Headers: x-demo-client'이 명령에서 볼 것은 상태와 응답 헤더입니다. 브라우저가 실제로 보낸 Origin과 메서드·헤더 목록이 같은지 먼저 맞춥니다. 기본 curl GET과 브라우저의 사전 요청을 비교해 같은 요청이라고 생각하면 원인이 어긋날 수 있습니다.
하지만 이 curl이 응답을 받는다고 브라우저 검사까지 끝난 것은 아닙니다. curl은 응답의 CORS 헤더를 브라우저와 같은 정책으로 적용해 스크립트 읽기를 허용·차단하는 검증기가 아닙니다. 마지막에는 원래 브라우저 요청으로 다시 확인해야 합니다.
개발자 도구에서 요청을 복사할 때는 Cookie나 Authorization 같은 민감한 값이 포함되는지도 확인합니다. 공개 질문이나 코드 저장소에 그대로 붙여 넣지 않습니다. 진단 자료에는 필요한 헤더 이름과 비밀값을 제거한 조건을 남기는 것으로 충분할 수 있습니다.
no-cors나 브라우저 보안 끄기가 해결은 아닙니다
mode: "no-cors"는 CORS 오류를 없애고 JSON을 자유롭게 읽게 만드는 옵션이 아닙니다. 메서드와 헤더가 제한되며, 다른 출처의 응답은 본문과 헤더를 JavaScript에서 읽을 수 없는 opaque 응답이 됩니다. no-cors의 응답 범위따라서 목록 데이터를 읽어 화면에 표시하려는 예시의 목적을 달성하지 못합니다. 코드가 오류 없이 어떤 객체를 받았다는 사실과 필요한 JSON을 사용할 수 있다는 사실을 구분해야 합니다.
브라우저 보안을 끄거나 임의 확장으로 허용 헤더를 덧붙이는 것도 실제 사용자 환경의 서버 설정을 검증하지 못합니다. 개발 중에만 우회한 상태를 배포 성공으로 보고하면 같은 문제가 다시 나타납니다.
공개 API에서 자격 증명 없는 응답을 모든 origin에 공유하는 정책이라면 wildcard를 의도적으로 선택할 수 있습니다. 그러나 로그인 쿠키를 보내는 요청에는 정확한 origin과 credentials 관련 조건이 따로 필요합니다. 모든 문제를 별표 하나로 해결하려 하지 않습니다. 와일드카드와 credentials
CORS 차단과 서버 접근 권한은 다릅니다
사전 요청이 필요한 경우에는 preflight 실패로 본 요청이 전송되지 않을 수 있습니다. 반면 사전 요청이 필요 없는 요청은 서버에 도착하고도 브라우저가 응답 공유를 차단할 수 있습니다. 그래서 CORS 오류만 보고 “서버에서 아무 작업도 하지 않았다”고 판단해서는 안 됩니다.
CORS는 사용자 인증이나 모든 CSRF 방어를 대신하지 않습니다. 브라우저 밖 클라이언트의 요청까지 같은 방식으로 막아 주는 인증 장치도 아닙니다. 서버는 요청자의 권한과 상태 변경 요청의 보호를 별도로 처리해야 합니다. 사전 요청 없는 요청과 CSRF 경계
수정 후 확인할 순서는 출발 Origin, 실제 메서드·요청 헤더, preflight 필요 여부와 결과, 본 응답의 헤더, credentials와 캐시 조건입니다. 허용한 origin에서 읽히는지뿐 아니라 허용하지 않은 origin에 불필요하게 열려 있지 않은지도 확인합니다.
curl 성공은 서버 응답을 확인하는 한 조각의 증거입니다. 브라우저가 어느 단계에서 막혔는지와 실제 응답이 어떤 정책을 전달했는지까지 맞춰 봐야 CORS 문제를 제대로 좁힐 수 있습니다.
728x90반응형'Programming' 카테고리의 다른 글
Cache-Control: no-cache인데 왜 캐시에 저장될까 (0) 2026.09.20 로그인 쿠키가 있는데 다른 사이트 요청에는 안 붙는 이유 (0) 2026.09.20 UPDATE 전후 값을 한 번에 받고 싶다면: PostgreSQL 18 RETURNING (0) 2026.09.20 ruff check와 ruff format은 왜 둘 다 필요할까 (0) 2026.09.19 Cloudflare Workflows 실행 기록, 왜 예전보다 빨리 없어질까 (0) 2026.09.19