<img>는 되는데 fetch는 막히는 이유
막는 건 CORS가 아니라 SOP고, SOP가 막는 건 요청이 아니라 읽기다
난이도: ★★★★☆
핵심 요약
-
차단하는 건 SOP(Same-Origin Policy)고, CORS는 그 차단을 푸는 절차다. 이름 그대로 Cross-Origin Resource Sharing — 공유하게 해주는 스펙이지 막는 스펙이 아니다. 일상어로 굳은 “CORS 에러”의 정체는 “SOP 위반인데 CORS로 허가를 못 받은 상태”다.
-
SOP가 금지하는 건 “읽기”뿐이다. 보내기와 삽입은 허용한다.
<img>,<script>,<link>로 다른 오리진 리소스를 불러와 화면에 그리고 실행하는 건 원래 된다. 막히는 건 JS가 그 바이트에 손을 대는 순간이다. 그래서 같은 URL이라도<img>는 뜨고fetch는 막힌다. -
CORS는 HTTP 스펙이 아니라 브라우저 스펙이다(WHATWG Fetch Standard).
Access-Control-Allow-Origin은 HTTP 입장에선 아무 행동도 유발하지 않는 문자열이다. 서버는 검사하지 않고 진술만 한다 — 검사와 집행은 100% 브라우저 몫이다. 그래서curl에는 CORS가 없고, 서버 프록시를 끼우면 사라진다.
왜 헷갈렸나
-
내가 아는 CORS의 전부는 **“BE에게 localhost도 허용해달라고 부탁하는 것”**이었다. 그 부탁이 서버 쪽에서 정확히 무슨 일을 하는 건지, 왜 그게 필요한지는 궁금해하지 않았다. 규칙만 외우고 몇 년을 썼다.
-
내가 믿고 있던 문장은 **“CORS는 다른 도메인으로의 요청·응답을 막는다”**였다. 그런데
<img src="https://cdn.other.com/a.jpg">는 다른 도메인인데도 그냥 뜬다. 두 사실이 동시에 참일 수 없는데, 그동안 부딪히는 줄도 몰랐다. -
무너진 지점은 여기다. CORS는 요청을 보내는 것도, 응답을 받는 것도 막지 않는다.
<img>는 요청·응답 후 브라우저가 바로 픽셀로 그리고 끝나지만,fetch는 JS가 그 응답 데이터에 접근해서 써야 한다. 차이는 도메인이 아니라 “JS가 손을 대느냐”였다. -
그리고 한 번 더 틀렸었다. 처음엔 “CORS는 응답 데이터에 JS가 접근할 수 있는지 체크하는 정책”이라고 정리했는데, 체크하고 차단하는 건 SOP고 CORS는 그 차단을 해제하는 쪽이다. 방향이 반대였다.
메커니즘
SOP는 크로스 오리진 동작을 세 가지로 나눠서 다르게 취급한다.
| 동작 | SOP | 예시 |
|---|---|---|
| 쓰기(write) | 허용 | 링크 이동, <form> 전송 |
| 삽입(embed) | 허용 | <img> <script> <link> <iframe> |
| 읽기(read) | 금지 | fetch XHR getImageData() |
이 표 하나가 전부를 설명한다.
[ 삽입 — <img src="https://cdn.other.com/a.jpg"> ]
브라우저 ──── 요청 ────▶ cdn.other.com
브라우저 ◀─── 응답 ───── 200 OK (바이트 전부 수신)
│
▼ 렌더 파이프라인이 픽셀로 그린다
화면에 표시됨 ✅
│
✗ JS는 이 바이트에 접근할 통로가 없다
→ 읽을 수 없으니 유출도 없다 → SOP가 허용
[ 읽기 — fetch('https://cdn.other.com/a.jpg') ]
브라우저 ──── 요청 ────▶ cdn.other.com ← 요청은 나갔다
브라우저 ◀─── 응답 ───── 200 OK ← 응답도 왔다
│
▼ "이 바이트를 JS에게 넘겨도 되나?"
Access-Control-Allow-Origin 확인
├── 있고 내 오리진과 일치 → JS에 전달 ✅
└── 없음 → 전달하지 않음 → Promise reject ❌
TypeError: Failed to fetch
※ 요청은 이미 나갔고 서버는 이미 처리했다
두 경우 모두 요청은 나갔고 응답도 도착했다. 갈라지는 건 마지막 한 칸, “JS에게 넘길까”뿐이다.
그 “마지막 한 칸”은 브라우저 어디인가
우리가 코드를 쓰는 곳(V8, DOM)은 브라우저의 맨 윗칸이다. 응답은 이미 그 바로 아랫칸까지 도착해 있고, 마지막 한 칸을 못 올라온다.
┌──── 브라우저 ─────────────────────────────────────┐
│ │
│ [렌더러 프로세스] JS 엔진(V8) · DOM │ ← 내 코드가 사는 곳
│ ▲ │
│ │ ★ "이 바이트를 넘겨도 되나?" │
│ │ Access-Control-Allow-Origin 판정 │
│ │ │
│ [네트워크 프로세스] │
│ · 응답 수신 완료 — 헤더도 바디도 전부 도착 │
│ ▲ │
│ [네트워크 스택] HTTP → TLS → TCP → IP │ ← 여기엔 CORS가 없다
└──────────┼────────────────────────────────────────┘
│
[ 인터넷 ]
네트워크 스택의 일은 응답이 도착한 시점에 이미 끝났다. CORS는 그 위에서, 브라우저가 스스로 내리는 판단이다. 그래서 “CORS는 네트워크 레이어의 규칙”이라는 말은 틀렸다.
단, 프로세스 분리는 구현이지 스펙이 아니다
여기서 층을 섞으면 안 된다.
스펙 (WHATWG Fetch Standard)
→ "CORS check를 통과하지 못하면 fetch는 network error를 반환한다"
→ 스펙 어디에도 '프로세스'라는 단어가 없다
구현 (Chrome)
→ 그 판정을 프로세스 경계에서 강제한다
→ 차단된 바이트를 렌더러 프로세스 메모리에 아예 올리지 않는다 (CORB/ORB)
논리적 증거: 프로세스가 하나뿐인 브라우저가 있어도 CORS는 그대로 동작한다. 스펙이 프로세스를 요구하지 않기 때문이다. 프로세스 분리는 같은 결과를 더 단단하게 보장하는 방법일 뿐이다.
Chrome이 굳이 여기까지 간 이유는 Spectre다 — JS가 정식으로 못 읽어도 같은 프로세스 메모리에 있는 것만으로 CPU 투기적 실행의 부작용을 타이밍으로 측정해 훔칠 수 있다는 게 밝혀졌기 때문이다. 그래서 방어선을 프로세스 경계까지 끌어올렸다.
왜 status code조차 못 보나
try {
await fetch('https://api.other.com/users')
} catch (e) {
e.message // "Failed to fetch"
e.status // undefined ← 401인지 500인지 200인지 알 수 없다
}
실패 사유를 알려주는 것 자체가 정보 유출이기 때문이다. “401이 왔다”는 사실만으로도 그 엔드포인트가 존재하고 인증이 필요하다는 게 새어나간다. 그래서 자세한 내용은 콘솔에만 찍힌다 — 사람은 읽을 수 있고 JS는 읽을 수 없는 채널이다.
서버는 판단하지 않고 진술한다
서버 : "이 응답은 https://myapp.com이 읽어도 됩니다" ← 진술
브라우저 : 그 진술을 읽고 → 통과시키거나 막는다 ← 판단·집행
가게 문에 붙은 “단골 전용” 팻말과 같다. 팻말이 물리적으로 막는 게 아니라, 글을 읽는 손님이 스스로 발길을 돌린다. 그래서 글을 안 읽는 손님(curl, 서버 간 통신)은 그냥 들어간다.
여기서 세 가지가 따라 나온다.
- CORS는 서버 보안 장치가 아니다. 헤더를 안 붙여도
curl은 다 읽는다. 서버 보호는 인증과 방화벽이 한다. - 서버 프록시를 끼우면 CORS가 사라진다. 가운데 서버는 팻말을 안 읽는다. Vite의
server.proxy, Vercel rewrite가 전부 이 원리다. 편법이 아니라 구조적 귀결이다. - 로컬에선 안 나는데 배포하면 나는 이유도 대개 이것이다. 개발 서버가 프록시 역할을 하고 있었을 뿐이다.
직접 확인하기
말로 백 번 설명하는 것보다 이 코드 한 번이 낫다. 어느 줄에서 터지는지가 전부를 증명한다.
const img = new Image()
img.src = 'https://cdn.other.com/a.jpg'
img.onload = () => {
document.body.appendChild(img) // ✅ 화면에 잘 뜬다
ctx.drawImage(img, 0, 0) // ✅ canvas에 그리는 것도 된다
ctx.getImageData(0, 0, 10, 10) // ❌ SecurityError
// The canvas has been tainted by cross-origin data
}
같은 이미지, 같은 URL, 같은 태그다. JS가 픽셀을 읽으려는 그 줄에서만 터진다.
canvas가 “오염됐다(tainted)“고 표현하는 게 이거다. 크로스 오리진 이미지가 한 번 그려지면 그 canvas 전체가 읽기 금지가 된다. 읽어야 한다면 이렇게 해야 한다.
<!-- 클라이언트: 익명 CORS 요청으로 보내겠다고 선언 -->
<img src="https://cdn.other.com/a.jpg" crossorigin="anonymous">
# 서버: 읽어도 된다고 진술
Access-Control-Allow-Origin: https://myapp.com
주의: crossorigin을 붙이면 요청이 CORS 모드로 바뀐다. 서버가 헤더를 안 주면 이미지 자체가 안 뜬다. 안 붙였을 땐 잘 뜨던 게 붙이니까 깨지는 이유가 이것이다.
다음에 이 상황을 만나면
CORS 에러를 만나면 → “백엔드에 열어달라고 하기” 전에 아래를 먼저 본다.
- Network 탭에서 OPTIONS만 실패인가, 본 요청도 나갔나? → 본 요청이 나갔다면 서버는 이미 처리했다. 재시도가 중복 생성을 일으키지 않는지 확인할 것.
- 백엔드에 정확히 뭘 요청할지 아는가? → “CORS 열어주세요”가 아니라 “응답에
Access-Control-Allow-Origin: <내 오리진>을 붙여주세요”. - 쿠키를 실어 보내는가(
credentials: 'include')? → 그러면*는 금지다. 정확한 오리진 +Access-Control-Allow-Credentials: true가 필요하고, 오리진을 echo back 한다면Vary: Origin을 반드시 붙여야 한다. 없으면 CDN이 A의 응답을 B에게 준다. - 로컬에서만 멀쩡한가? → dev server의 proxy 설정을 확인한다.
- CORS로 API를 보호하고 있다고 착각하고 있지 않은가? → 브라우저 밖에서는 아무 방어도 아니다.
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- Origin의 정의 —
scheme + host + port셋이 모두 같아야 동일 출처다.https://a.com과http://a.com은 다른 오리진이고,a.com과sub.a.com도 다른 오리진이다. - SOP(Same-Origin Policy) — 브라우저의 기본 차단선. CORS는 이 벽에 문을 내는 프로토콜이므로, 벽을 모르면 문이 왜 필요한지 알 수 없다.
→ 확장 개념 (여기서 더 나아가면)
- Origin vs Site — 쿠키는 SOP를 따르지 않는다. 오리진이 아니라 site(scheme + 등록 가능 도메인) 기준이다. 그래서
myapp.com↔api.myapp.com은 오리진은 달라 CORS가 걸리는데, site는 같아 쿠키는 살아있다. 실무에서 서브도메인 구성을 쓰는 정확한 이유. - CSRF와의 층 구분 — SOP는 읽기만 막는다. 쓰기는 안 막는다. 그래서 악성 사이트가
POST /transfer를 보내는 건 여전히 가능하고 응답만 못 읽는다. 송금은 이미 됐다. 방어선이 다르다 — 정보 탈취는 SOP·CORS가, CSRF는 SameSite 쿠키·CSRF 토큰이 막는다. CORS가 CSRF를 막아준다고 생각하면 안 된다. - SRI(Subresource Integrity) — “삽입은 허용”이 가벼운 얘기가 아니다. 크로스 오리진 스크립트는 내 페이지 컨텍스트에서 실행되어 내 DOM과 쿠키를 다 만진다. CDN이 한 번 털리면 내 사이트가 털린다.
integrity속성이 그 방어다. - Site Isolation과 Spectre — 본문에서 다룬 프로세스 경계 방어의 배경. 투기적 실행(speculative execution)과 캐시 타이밍 사이드채널을 이해하면 “왜 메모리에 있는 것만으로 위험한가”가 풀린다. 브라우저 보안이 CPU 아키텍처까지 내려가는 드문 사례다.
↔ 같은 원리가 적용되는 곳
robots.txt— 서버가 “여기는 긁지 마세요”라고 진술할 뿐, 강제력은 없다. 지키기로 한 크롤러만 지킨다. 악의적 크롤러는 그냥 긁는다. 선언은 서버가 하고 집행은 클라이언트가 하는 구조가 CORS와 정확히 같다.
부록: preflight — “요청을 막지 않는다”의 유일한 예외
본문 내내 “CORS는 요청을 막지 않는다”고 했지만, preflight는 진짜로 요청을 막는다. 층이 다르므로 뭉개면 안 된다.
일반 CORS → 요청은 나간다. 응답 읽기를 막는다.
preflight → 요청 자체를 안 보낸다.
갈리는 기준은 위험도가 아니라 **“CORS 이전에도 보낼 수 있던 요청인가”**다.
| simple request | non-simple | |
|---|---|---|
| 메서드 | GET · HEAD · POST | PUT · DELETE · PATCH … |
| Content-Type | form-urlencoded · multipart · text/plain | application/json … |
| 커스텀 헤더 | 없음 | Authorization 등 |
| 처리 | 보내고, 읽기만 차단 | OPTIONS로 먼저 허락받음 |
왼쪽 열은 전부 <form> 태그가 CORS 이전부터 20년간 할 수 있던 것들이다. 새로운 위험이 아니므로 “보내고 나서 읽기만 막기”로 충분하다.
오른쪽은 다르다. DELETE /api/users/1은 크로스 오리진으로 보낼 방법이 원래 없었다. 그냥 보내면 서버가 실행해버리고, 응답을 차단해봐야 유저는 이미 삭제됐다.
되돌릴 수 없는 요청은 보내기 전에 허락을 받는다. 그게 preflight다.
현대 API는 대부분 Content-Type: application/json을 쓴다. 저 목록에 없다. 즉 요즘 프론트엔드의 API 호출은 거의 전부 preflight를 탄다 — 왕복이 2배가 되는 게 기본값이라는 뜻이고, Access-Control-Max-Age로 preflight 결과를 캐시하는 게 첫 최적화 포인트인 이유다.