1편에서 왜 / 무엇, 2편에서 어떻게 를 다뤘다. 모델도 정해졌고 프롬프트도 다듬었고 분류 로직도 잘 짜뒀는데, 정작 "AI 모델 다운로드" 버튼을 누르면 곧바로 에러가 났다. "재시도 오류", "Worker 초기화 실패". 그것뿐.
문제는 세 개의 버그가 겹쳐 있었다는 거다. 하나를 잡으면 그 뒤에 가려져 있던 다음 버그가 튀어나오는 구조였다. 며칠을 날렸다. 그 기록.
https://merbl-filter.vercel.app/posts
onerror 가 빈 문자열을 던진다가장 먼저 막힌 지점은 디버깅이 안 된다는 사실 그 자체 였다.
worker.onerror = (err) => {
console.error('Worker 에러:', err.message); // ← ""
};
err.message 가 빈 문자열. Chrome DevTools 콘솔도 깨끗함. Network 탭에서 Worker 파일은 200으로 잘 받아오는 것처럼 보였다. 증거가 없어서 무엇부터 의심해야 할지 막막한 상태.
나중에 알게 된 사실: Chrome은 Worker 스크립트가 cross-origin 또는 보안 정책 위반으로 실패하면 보안상 에러 메시지를 숨긴다. onerror 가 빈 문자열을 던진다는 건 "이거 그냥 에러난 게 아니라 로드 자체가 실패한 거다" 라는 신호였는데, 그걸 모르고 모델 코드 쪽을 한참 뒤졌다.
Network 탭에서 Worker 파일 크기를 봤다.
_app-pages-browser_src_features_llm_llm_worker_ts.js → 4.4 MB
4.4 MB. Worker용 번들이 4 MB가 넘는 게 이상했다. 받아온 파일을 열어보니 react-dom 코드가 통째로 들어있었다. document.xxx 참조가 수십 군데.
Worker 컨텍스트엔 DOM이 없다. document 가 정의되지 않은 객체라 코드가 로드되는 순간 런타임 크래시. 그래서 onerror 가 떴고, 그래서 메시지가 빈 문자열이었다.
Next.js(webpack 5) 에서 흔히 쓰는 Worker 패턴:
const worker = new Worker(new URL('./llm.worker.ts', import.meta.url));
이걸 쓰면 webpack이 Worker 코드를 Next.js 페이지 청크와 같은 그룹에 넣어버린다. 결과적으로 Worker 청크에 react-dom·Next.js 런타임이 같이 묶임. Worker는 DOM을 못 쓰는데 DOM 의존 코드가 같이 들어가 있으니 죽을 수밖에.
webpack 설정으로 Worker 청크만 깔끔히 분리하는 시도는 잘 안 됐다. 빌드 도구 자체를 분리하기로 했다. esbuild로 Worker 파일만 독립 번들로 만들어 public/ 에 박아둠.
esbuild src/features/llm/llm.worker.ts \
--bundle --outfile=public/llm-worker.js \
--platform=browser --target=chrome90,firefox90,safari15 \
--format=iife \
--define:process.env.NODE_ENV='"production"' \
--define:process.platform='"browser"'
결과:
| 항목 | webpack 빌드 | esbuild 빌드 |
|---|---|---|
| 파일 크기 | 4.4 MB | 1.4 MB |
| react-dom 참조 | 16개 | 0개 |
document 참조 | 30개 | 조건부 코드 안쪽 몇 군데 |
| HMR 영향 | 받음 | 안 받음 (정적 파일) |
package.json 에 "build:worker" 스크립트를 추가하고, pnpm dev 전후로 한 번 빌드해주면 끝.
버그 1을 잡고 "이제 되겠지" 했는데 또 빈 메시지 onerror. 같은 증상, 다른 원인.
const worker = new Worker('/llm-worker.js'); // ← 즉시 onerror
public/llm-worker.js 는 분명 200으로 응답하는데 Worker가 안 뜸.
/posts 라우트에 SharedArrayBuffer · WASM threads 활성화 목적으로 COEP 헤더를 걸어둔 상태였다:
Cross-Origin-Embedder-Policy: credentialless
이 환경에서 new Worker(url) 처럼 직접 URL 로 Worker를 만들면, Chrome은 해당 스크립트에 CORP(Cross-Origin-Resource-Policy) 헤더가 있어야 로드를 허용한다. public/ 의 정적 파일은 CORP 헤더가 없음 → 차단.
이것도 onerror 가 빈 메시지였다. Chrome이 또 막아놓고 왜 막혔는지는 안 알려주는 패턴.
fetch 로 Worker 스크립트를 데이터로 가져온 다음, Blob 객체로 만들어서 URL.createObjectURL() 로 Worker를 띄움.
const res = await fetch('/llm-worker.js');
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
URL.revokeObjectURL(blobUrl); // Worker가 이미 로드했으니 즉시 해제 가능
fetch 자체는 same-origin 요청이라 정상 통과. Blob URL로 만든 Worker는 COEP의 CORP 검사 대상이 아님. 우회 성공.
버그 1·2를 모두 잡고 Worker가 드디어 뜨긴 떴다. 그런데 모델 초기화에서 또 에러:
Error: no available backend found.
ERR: [wasm] TypeError: Failed to execute 'fetch' on 'WorkerGlobalScope':
Failed to parse URL from /wasm/ort-wasm-simd.wasm
@xenova/transformers 가 ONNX Runtime WASM 파일을 받아와야 하는데, 경로 설정이 상대 경로 로 되어 있었다.
env.backends.onnx.wasm.wasmPaths = '/wasm/';
일반 Worker 에선 self.location.href = 'http://localhost:3000/posts'. 그래서 /wasm/ 이 http://localhost:3000/wasm/ 으로 정상 해석됨.
Blob Worker 에선 self.location.href = 'blob:http://localhost:3000/xxxxxxxx-...'. Blob URL은 path base가 없는 특이한 형태 라서 /wasm/ 을 resolve할 origin이 없다. 그래서 URL 파싱 실패 → fetch 실패.
버그 2의 해결책(Blob URL 패턴)이 버그 3을 새로 만든 셈.
blob: 접두사를 떼면 그 뒤에 원본 페이지 URL이 따라옴. 그걸 파싱해서 origin만 뽑으면 됨.
const _pageOrigin = (() => {
const href = (self as unknown as WorkerGlobalScope).location.href;
try {
return new URL(href.startsWith('blob:') ? href.slice(5) : href).origin;
} catch {
return (self as unknown as WorkerGlobalScope).location.origin;
}
})();
env.backends.onnx.wasm.wasmPaths = `${_pageOrigin}/wasm/`;
// 결과: 'http://localhost:3000/wasm/'
이걸 Worker 코드 자체에 박아두고 pnpm build:worker 로 재빌드. 그제야 모델이 정상 로드됐다.
이번 일에서 가장 크게 배운 건 디버깅이 막혔을 때 어디를 의심해야 하는지에 대한 감각 이었다.
onerror 가 빈 메시지로 떨어지는 건 로딩 자체가 실패한 신호일 가능성이 컸다 — 코드 안쪽보다 로딩 환경부터 의심해야 시간이 안 낭비됐다세 버그는 각자 따로 보면 그렇게 어렵지 않은데, 셋이 겹쳐서 같은 증상(빈 onerror)으로 나타나는 순간 추적이 어려워졌다. 같은 환경(Next.js + WebGPU/WASM Worker) 에서 비슷한 문제로 헤매는 사람이 있다면, 이 글이 적어도 며칠은 덜 날리게 해주길 바라며.
이걸로 댓글필터 시리즈는 마무리. 다음 작업은 예시 세트 점진적 보강 과 요약 품질 개선 (한국어 모델 교체, RAG-lite 청킹 등) 으로 이어질 예정.