<img>는 되는데 fetch는 막히는 이유

막는 건 CORS가 아니라 SOP고, SOP가 막는 건 요청이 아니라 읽기다

corssopbrowser-securityfetch

난이도: ★★★★☆


핵심 요약

  1. 차단하는 건 SOP(Same-Origin Policy)고, CORS는 그 차단을 푸는 절차다. 이름 그대로 Cross-Origin Resource Sharing — 공유하게 해주는 스펙이지 막는 스펙이 아니다. 일상어로 굳은 “CORS 에러”의 정체는 “SOP 위반인데 CORS로 허가를 못 받은 상태”다.

  2. SOP가 금지하는 건 “읽기”뿐이다. 보내기와 삽입은 허용한다. <img>, <script>, <link>로 다른 오리진 리소스를 불러와 화면에 그리고 실행하는 건 원래 된다. 막히는 건 JS가 그 바이트에 손을 대는 순간이다. 그래서 같은 URL이라도 <img>는 뜨고 fetch는 막힌다.

  3. 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 requestnon-simple
메서드GET · HEAD · POSTPUT · DELETE · PATCH …
Content-Typeform-urlencoded · multipart · text/plainapplication/json …
커스텀 헤더없음Authorization 등
처리보내고, 읽기만 차단OPTIONS로 먼저 허락받음

왼쪽 열은 전부 <form> 태그가 CORS 이전부터 20년간 할 수 있던 것들이다. 새로운 위험이 아니므로 “보내고 나서 읽기만 막기”로 충분하다.

오른쪽은 다르다. DELETE /api/users/1은 크로스 오리진으로 보낼 방법이 원래 없었다. 그냥 보내면 서버가 실행해버리고, 응답을 차단해봐야 유저는 이미 삭제됐다.

되돌릴 수 없는 요청은 보내기 전에 허락을 받는다. 그게 preflight다.

현대 API는 대부분 Content-Type: application/json을 쓴다. 저 목록에 없다. 즉 요즘 프론트엔드의 API 호출은 거의 전부 preflight를 탄다 — 왕복이 2배가 되는 게 기본값이라는 뜻이고, Access-Control-Max-Age로 preflight 결과를 캐시하는 게 첫 최적화 포인트인 이유다.