이제 머신러닝 모델은 브라우저에서 돌아갑니다. 장난감 수준이 아닙니다 — 노래를 보컬과 반주로 나누는 음원 분리 모델과, 음성 복제 기능이 있는 텍스트 음성 변환 엔진이 둘 다 WebAssembly로 컴파일되어 방문자 자신의 프로세서에서 실행됩니다. 업로드도, 추론 서버도, GPU 청구서도 없습니다.
추론은 오히려 쉬운 절반이었습니다. 130 MB짜리 모델을 정적 호스팅에 올리고, 브라우저가 그것을 실행하도록 만드는 과정에서 네 번 실패했습니다. 세 번은 쓸 만한 오류 메시지를 주지 않았고, 한 번은 사실과 정반대를 알려 주었습니다.
아래 수치는 모두 배포된 결과물에서 직접 측정한 것이며, 추정치가 아닙니다.
전제
pthreads 기반 WebAssembly 빌드에는 SharedArrayBuffer가 필요합니다. 브라우저는 이것을 교차 출처 격리 상태인 페이지에만 제공하며, 그러려면 응답 헤더 세 개가 필요합니다:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-origin
흥미로운 건 세 번째이고, 대부분의 글이 틀리는 곳도 여기입니다. COEP: require-corp 환경에서는 Emscripten의 pthreads 워커와, 엔진이 다시 띄우는 중첩 워커가, 브라우저가 불투명한 출처로 취급하는 곳에서 각자의 하위 리소스를 가져옵니다. Cross-Origin-Resource-Policy: same-origin 설정은 이것을 차단합니다. 그래서 값은 cross-origin이어야 합니다. 직감이 말하는 것보다 더 느슨한 값이며, 사이트 전체가 아니라 그것이 필요한 경로 접두사 하나에만 적용해야 합니다. 왜냐하면 cross-origin 설정은 어떤 사이트든 그 리소스를 삽입할 수 있게 해 주기 때문입니다.
Cloudflare Pages에서는 이것을 _headers 파일에 적습니다. 첫 번째 실패는 바로 여기에 있습니다.
실패 1: Pages는 같은 이름의 헤더를 합치는데, COOP는 토큰 하나짜리다
안전하게 하려고 디렉터리 자체와 그 아래 전부를 둘 다 선언했습니다:
/ai-text-to-speech/
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
/ai-text-to-speech/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
두 규칙 모두 이 문서에 일치합니다. Pages는 더 구체적인 규칙이 이기게 하지 않습니다 — 같은 이름의 헤더를 쉼표로 이은 하나의 값으로 합칩니다:
cross-origin-opener-policy: same-origin, same-origin
cross-origin-embedder-policy: require-corp, require-corp
COOP와 COEP는 토큰 하나짜리 구조화 헤더입니다. 이 값은 파싱에 실패하고, 브라우저는 파싱할 수 없는 헤더에 대해 할 수 있는 최악의 일을 합니다. 바로 unsafe-none 상태로 조용히 되돌아가는 것입니다. 헤더에 대한 콘솔 경고는 전혀 없습니다. 대신 전혀 엉뚱한 곳에서 이런 메시지가 나옵니다:
DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated
이 메시지 어디에도 응답 헤더를 가리키는 내용은 없습니다. 중복된 헤더는 더 말할 것도 없고요.
기억해 둘 점 두 가지:
*기호는 빈 문자열에도 일치합니다./prefix/*패턴은 이미/prefix/자체에도 일치합니다. 정확한 경로용 규칙을 하나 더 둔 것은 이중 안전장치가 아니라 바로 문제의 원인이었습니다.- 합치는 것은
Cache-Control에는 해가 없습니다. 지시어 목록이기 때문입니다. 하지만 문법상 토큰이 하나뿐인 헤더에는 치명적입니다.
이 중복은 첫 번째 점을 증명한 방법이기도 합니다. 모든 헤더가 정확히 두 번씩 나타나는 것은 두 패턴이 모두 일치했을 때만 가능합니다.
이제 제 배포 스크립트는 같은 헤더를 설정하는 두 규칙에 동시에 일치하는 파일이 있으면 빌드를 실패시킵니다. 이 검사는 스무 줄이면 되고, 하루를 아껴 줄 수 있었습니다.
실패 2: HTTP 캐시는 본문만이 아니라 응답 헤더도 저장한다
수정한 _headers 파일을 배포했는데도 여전히 실패했습니다. 같은 페이지, 같은 종류의 오류였지만, 이번에는 다른 곳에서 났습니다. 워커가 아예 시작되지 않았습니다. 이때 ErrorEvent 객체의 message가 비어 있고, filename도 비어 있었으며, 줄 번호는 0이었습니다. 워커를 생성하고 약 4 ms 뒤에 발생했습니다.
원인: 브라우저의 HTTP 캐시가 들고 있던 tts-worker.js 사본은, 격리 헤더가 생기기
전에 받아 온 것이었습니다. 캐시된 응답은 저장될 때의 헤더를 그대로 달고 다니는데, 그 항목의 max-age 값이 네 시간이었습니다. 교차 출처 격리된 페이지는 자신의 스크립트에
COEP: require-corp 헤더가 없는 워커의 실행을 거부합니다 — 그것도 아무런 설명 없이.
즉 서버는 올바랐고, 네트워크 탭도 올바르게 보였지만, 페이지는 네 시간 전의, 올바르지 않은 응답을 불러오고 있었습니다.
문제를 확정한 진단 방법이며, 두고두고 쓸 만합니다:
const headers = async (url, cache) =>
[...(await fetch(url, { cache })).headers]
.filter(([k]) => k.startsWith('cross-origin'));
console.log('fresh ', await headers(url, 'no-store'));
console.log('cached', await headers(url, 'force-cache'));
이 둘이 다르면, 지금 보고 있는 것은 서버 문제가 아니라 오염된 캐시 항목입니다. 제 경우 fresh 쪽에는 헤더 세 개가 모두 있었고, cached 쪽에는 하나도 없었습니다.
해결책은 두 가지이고, 어느 하나만으로는 부족합니다:
Cache-Control: no-cache설정을 프런트엔드 소스에 적용합니다. 13–31 KB에 불과하므로, 다시 검증하는 비용은 서버 쪽의 어떤 변경으로도 끝낼 수 없는 네 시간짜리 장애에 비하면 아무것도 아닙니다.- 워커 URL에 버전 토큰(
tts-worker.js?v=…)을 붙여서, 이미 오염된 항목을 피해 갈 수 있게 합니다. 격리 헤더가 바뀔 때마다 이 값을 올리세요.
이 문제 자체보다 시간을 더 잡아먹은 부수 효과가 있습니다. 제 로컬 개발 서버는 ?v= 파라미터가 붙은 URL을 모두 immutable로 취급했습니다. 그래서 버전 토큰을 붙이는 순간부터 로컬에서는 워커 수정 사항이 반영되지 않았고, 더 나쁜 것은 제가 디버깅하던 바로 그 부분에서 로컬과 운영 환경의 동작이 달라졌다는 점입니다.
이 일을 어렵게 만든 것은 로컬과 운영 환경의 캐시 동작 차이였지, 캐시 그 자체가 아니었습니다.
실패 3: 파일당 25 MiB의 벽
Cloudflare Pages는 25 MiB를 넘는 파일은 하나도 받지 않습니다. 가장 큰 모델 파일은 82.0 MiB이고, 다른 하나는 37.9 MiB입니다.
쪼개는 것은 당연한 일이고, 적어 둘 가치가 있는 것은 무엇을 기준으로 삼느냐입니다. 분할 도구는 각 조각의 경로, 바이트 수, SHA-256을 매니페스트에 기록하고, 클라이언트가 믿는 것은 원본 파일이 아니라 그 매니페스트가 됩니다:
models/flow_lm_main_delta_attn_flow_int8.onnx 82.0 MiB → 4 parts
models/mimi_encoder.onnx 37.9 MiB → 2 parts
워커 안에서 다시 합치는 과정은 특별할 것이 없지만, 개발 중에 둘 다 실제로 발동한 단언문 두 개만은 예외입니다:
async function fetchAsset(entry, onDelta) {
if (!entry.parts?.length) return await fetchBytes(`${BASE}${entry.path}`, onDelta);
const out = new Uint8Array(entry.bytes);
let off = 0;
for (const p of entry.parts) {
const buf = await fetchBytes(`${BASE}${p.path}`, onDelta);
if (off + buf.length > out.length) throw new Error(`${entry.path}: parts exceed the declared size`);
out.set(buf, off);
off += buf.length;
}
if (off !== entry.bytes) throw new Error(`${entry.path}: assembled ${off} of ${entry.bytes} bytes`);
return out;
}
잘린 모델은 불러올 때 예외를 던지지 않습니다. 그럴듯하게 들리는 쓰레기 오디오를 내놓을 뿐입니다. 바이트 수를 단언하세요.
실패 4: 호스트는 정작 중요한 바이트를 압축하지 않고, HEAD는 그에 대해 거짓말을 한다
Cloudflare는 콘텐츠 유형에 따라 응답을 압축합니다. .wasm, .js, .css 및
.html 파일은 자동으로 처리됩니다. .onnx, 모델 조각, 음성 임베딩은
application/octet-stream이고, 이런 것들은 압축되지 않은 채 나갑니다.
이것이 전체 용량의 대부분입니다. 그래서 바이트를 패키징할 때 미리 압축하고 원래 파일 이름 그대로 저장한 다음, Content-Encoding: br 헤더를 파일마다 _headers 파일에 선언합니다.
파일마다, 와일드카드가 아니라. 압축기는 의미 있게 줄일 수 없는 파일은 건너뛰는데, 압축하지 않은 원본 파일을 Content-Encoding: br 헤더와 함께 내보내면 그 파일은 그냥 깨진 파일입니다. 예를 들어 /models/* 같은 규칙은 한 번도 압축되지 않은 파일까지 끌어들입니다. 패키징 도구는 실제로 압축한 파일마다 규칙을 하나씩, 의도가 아니라 결과를 기준으로 생성합니다.
이제 오후 한나절을 날린 부분입니다. 제대로 됐는지 확인하려면 가장 먼저 떠오르는 명령은 이것입니다:
curl -sI https://example.net/models/big.onnx | grep -i content-encoding
돌아오는 값은 매번 content-encoding: br 입니다. 이건 틀렸습니다. Cloudflare는 HEAD 요청에는 이 헤더를 붙여 응답하지만, 큰 application/octet-stream
파일에 대한 실제 GET은 전혀 압축되지 않습니다. 응답 헤더를 읽으면 사실과 정반대의 결론을 얻게 됩니다.
유일하게 믿을 수 있는 확인 방법은 GET으로 받은 바이트 수를 세는 것입니다:
U=https://example.net/models/big.onnx
echo "wire $(curl -s -H 'Accept-Encoding: br' "$U" | wc -c) decoded $(curl -s --compressed "$U" | wc -c)"
출력을 wc -c로 파이프하고, %{size_download} 변수는 쓰지 마세요 — 그건 회선으로 오간 바이트를 세지 디코딩된 바이트를 세지 않기 때문에, 여러분이 기대하던 답이 무엇이든 조용히 맞장구를 칩니다.
--compressed 옵션에는 brotli를 지원하도록 빌드된 curl이 필요합니다 (curl -V | grep -i brotli).
압축은 실제로 얼마나 효과가 있나
배포된 조각에서 측정한 값:
| 바이트 | ||
|---|---|---|
| 20.5 MiB짜리 int8 ONNX 조각 하나, 원본 | 21,507,048 | |
| 같은 조각, brotli -q 11 | 8,752,904 | 40.7% |
| 배포 전체, 원본 | 267.6 MiB | |
| 배포 전체, 저장본 | 184.9 MiB | 82.7 MiB 절약 |
40.7%라는 숫자는 보기보다 흥미롭습니다. 이 조각에 들어 있는 바이트의 40%가
0x00이고, 0차 엔트로피는 바이트당 4.19비트입니다 — 즉 기호의 빈도만 보는 부호화기로는 52.4% 아래로 내려갈 수 없다는 뜻입니다. Brotli는 이 하한을 넉넉히 밑도는 40.7%에 도달했는데, 이는 그 문맥 모델링이 바이트를 단순히 세는 게 아니라 바이트를 가로지르는 구조를 찾아낸다는 구체적인 증거입니다. 양자화된 가중치 텐서에는 이런 구조가 가득합니다. 0이 줄줄이 이어지는 구간, 반복되는 지수 패턴, 거의 중복된 블록 같은 것들입니다.
양자화된 모델을 네트워크로 배포하면서 아직 이것을 측정해 보지 않았다면, 아마 여러분이 얻을 수 있는 가장 싼 이득일 겁니다.
하나 더, 따로 절을 두지 않고
한동안 워커는 실제로 자신의 실패를 보고하고 있었는데, 저는 그걸 볼 수 없었습니다. 메시지는 console.debug를 거쳤는데, Chrome은 기본 콘솔 수준에서 이것을 숨깁니다. Emscripten의 표준 오류 출력과 모든 라우팅 실패가, 아무도 보지 않는 수준에 기록되고 있었던 것입니다. 진단에 필요한 것은 무엇이든 warn 이상의 수준으로 남겨야 합니다.
체크리스트
pthreads WASM 모델을 정적 호스팅에 올린다면:
- 한 경로에 대해 두 개의
_headers규칙이 같은 헤더를 설정하게 두지 마세요. 빌드에서 이것을 단언하세요.*기호는 빈 문자열에도 일치합니다. - 설정할
CORP: cross-origin헤더는 격리된 접두사에만 두고, 다른 곳에는 두지 마세요. - 프런트엔드 소스는
no-cache로 제공하고, 워커 URL에는 버전을 붙이세요. 캐시된 응답은 예전 헤더를 그대로 달고 다닙니다. - 로컬과 운영 환경의 캐시 동작을 똑같이 유지하세요. 한 시간짜리 문제를 하루짜리 문제로 만드는 것이 바로 그 차이입니다.
- 다시 합친 바이트 수를 단언하세요. 잘린 모델은 예외가 아니라 결과물을 내놓습니다.
- 선언할
Content-Encoding헤더는 파일마다, 실제로 압축된 것을 기준으로 생성하세요. - GET으로 받은 바이트 수를 세어 압축을 확인하세요. HEAD는 여러분에게 거짓말을 합니다.
- 진단 로그는
warn이상의 수준으로 남기세요.
이 글의 바탕이 된 도구는 Audiofilter 입니다 — 브라우저에서 보컬 분리, 소음 제거, 텍스트 음성 변환을 하고, 어느 것도 파일을 업로드하지 않습니다. 전달 쪽이 아니라 신호 처리 쪽이 궁금하다면 위상 상쇄가 보컬과 함께 베이스까지 가져가는 이유 편에서 그 이야기를 다룹니다.