유대선
프로젝트로
·트러블슈팅·4

배포는 됐는데 브라우저는 백지 — curl 200이 '됨'이 아니었다

진짜 Vite/React 앱을 딸깍 PaaS로 배포했다. curl로 HTML·JS 모두 200에 내용까지 정상 — '된다'고 선언. 그런데 실제 브라우저는 완전 백지였다. 원인은 자산 업로드 시 Content-Type 누락. 그리고 진짜 교훈은 내 검증이 가짜였다는 것.

증상

멀티테넌트로 진짜 Vite + React 앱을 배포했다. 배포 응답은 live. curl로 확인하니:

전부 200에 내용까지 정상. 그래서 "된다"고 대표(나 자신)에게 보고했다.

그런데 실제 브라우저로 열어보니 — 완전 백지. 아무것도 안 뜬다.

원인

curl -w '%{content_type}'로 보니:

[JS]   HTTP 200  type=(빈값)
[HTML] HTTP 200  type=(빈값)

Content-Type 헤더가 아예 없었다. <script type="module" src="/assets/index-*.js">는 브라우저 strict MIME 검사 때문에 JS MIME 타입 없이 오면 실행을 거부한다. → root div가 빈 채로 남음 → 백지.

근본 원인은 자산 업로드 코드:

form.set(hash, asset.base64);  // 순수 문자열 → 파트 Content-Type이 text/plain

Cloudflare Workers Static Assets는 업로드 시 각 파트의 Content-Type을 저장했다가 서빙 때 그대로 쓴다 (manifest 경로 확장자로 추론하지 않는다). 타입 없이 올렸으니 타입 없이 서빙.

수정

확장자→MIME 맵을 추가하고, base64를 타입 있는 Blob으로 감싸 파트 Content-Type을 지정:

form.set(hash, new Blob([asset.base64], { type: contentTypeForAsset(asset.path) }));

js → text/javascript, html → text/html, css → text/css … 바디는 여전히 base64 (?base64=true). 기존 자산 테스트는 이 버그의 회귀 테스트로 갱신(파트가 typed Blob이고 type이 맞는지 + base64 바디 보존).

재검증: 새 해시로 재배포 후 type=text/html·type=text/javascript 확인, 그리고 브라우저 accessibility tree에 제목·버튼이 렌더되는 것까지 눈으로 확인(React 실행 증거).

함정 하나 더: CF는 자산을 sha256(내용+확장자) 해시로 account-wide 중복제거한다. 같은 바이트를 재배포하면 업로드가 스킵돼 예전 typeless 자산이 그대로 재사용된다. 검증하려면 내용을 바꿔 새 해시를 만들어야 했다 (실사용자 앱은 매번 새 해시라 무관).

지시-과실 분석 (내 잘못이 뭐였나)

curl로 HTML/JS가 200+내용 정상이라 "된다"고 조기 선언했다. 그러나:

  1. curl 할 때 내가 매 요청에 손으로 ?tenant=를 붙였다. 진짜 브라우저는 sub-resource (JS/CSS)에 그 파라미터를 안 붙인다. 사용 경로가 달랐다.
  2. MIME 타입을 안 봤다. curl 200은 "바이트가 왔다"지 "브라우저가 실행한다"가 아니다.

CLAUDE.md RULE #3 "PTY change → spawn·write·read·kill 4개 다 검증"의 정신(실제 사용 경로로 검증)을 웹 배포엔 적용하지 않았다. 합격 기준이 "curl 200"이면 안 됐고 "브라우저가 렌더"였어야 했다.

회피 가능했나 — 예

첫 배포 때 curl 말고 실제 브라우저 렌더를 합격선으로 잡았으면 즉시 잡혔다. "200 = 됨"은 정적 사이트 검증에서 늘 함정이다.

패턴 (박제)

정적 사이트 배포의 최소 합격선은 curl 200이 아니라 (1) 서빙 Content-Type이 올바른 MIME (2) 실제 브라우저에서 DOM 렌더 두 가지다. 그리고 sub-resource는 쿼리파라미터를 물고 오지 않으므로, 쿼리파라미터 라우팅은 SPA에 부적합 — 실서비스는 hostname(서브도메인) 라우팅이어야 한다.