3편 - Web Worker 3중 버그를 잡은 기록

임기호·2026년 5월 25일

댓글필터

목록 보기
3/3

Web Worker 세 번 막힌 기록 — 댓글필터 개발기 (3)

1편에서는 왜 만들었는지와 무엇을 만들 건지를, 2편에서는 구현 방법을 정리했다.

모델도 골랐고 프롬프트도 다듬었다. 분류 로직도 돌아갔다. 이제 브라우저에서 모델만 받아오면 끝이라고 생각했다.

그런데 "AI 모델 다운로드" 버튼을 누르자 바로 실패했다.

재시도 오류
Worker 초기화 실패

문제는 하나가 아니었다. 세 개가 겹쳐 있었다.

첫 번째를 고치면 그 뒤에 가려져 있던 두 번째가 나왔고, 두 번째를 고치니 세 번째가 나왔다. 셋 다 겉으로 보이는 증상은 비슷했다.

며칠을 날렸다.

댓글필터


시작부터 로그가 없었다

처음 막힌 건 에러 자체보다 왜 에러가 났는지 알 수 없다는 것이었다.

worker.onerror = (err) => {
  console.error('Worker 에러:', err.message);
};

찍히는 건 이것뿐이었다.

Worker 에러: ""

err.message가 비어 있었다.

Chrome DevTools 콘솔에도 별다른 내용이 없었다. Network 탭을 보면 Worker 파일은 200으로 받아오고 있었다.

파일은 받아왔는데 Worker는 뜨지 않는다.

그래서 처음에는 모델 초기화 코드를 의심했다. transformers 설정도 보고 WASM 설정도 보고 Worker 안에서 실행되는 코드도 계속 뒤졌다.

문제는 그보다 앞에 있었다.


첫 번째 — Worker가 4.4MB였다

Network 탭에서 Worker 파일 크기가 눈에 들어왔다.

_app-pages-browser_src_features_llm_llm_worker_ts.js
→ 4.4 MB

Worker 하나가 4MB를 넘는 게 이상했다.

받아온 JS를 직접 열어봤다. react-dom 코드가 들어 있었다. document.xxx 참조도 수십 군데 있었다.

Web Worker에는 DOM이 없다.

window    X
document  X
DOM       X

그런 코드가 Worker 번들에 들어가 있으니 로드 과정에서 죽을 수밖에 없었다.

Worker는 이렇게 만들고 있었다.

const worker = new Worker(
  new URL('./llm.worker.ts', import.meta.url)
);

Next.js의 webpack 빌드 결과에서 Worker 쪽으로 필요 없는 의존성이 같이 따라 들어가고 있었다.

webpack 설정을 계속 만지는 것보다 Worker 빌드 자체를 떼어내는 게 빨라 보였다.

esbuild로 llm.worker.ts만 따로 번들링해서 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 MB1.4 MB
react-dom 참조16개0개
document 참조30개조건부 코드 안쪽 몇 군데
HMR 영향받음안 받음

package.json에는 build:worker를 추가했다.

이제 됐다고 생각했다.

아니었다.


두 번째 — 파일은 200인데 Worker가 안 뜬다

이번에는 독립 번들까지 만들었으니 단순하게 정적 파일을 띄웠다.

const worker = new Worker('/llm-worker.js');

그런데 또:

onerror: ""

같은 증상이었다.

/llm-worker.js를 브라우저에서 직접 열면 정상적으로 내려왔다. Network에서도 200.

그런데 Worker로 만들면 바로 실패했다.

이번에는 /posts에 걸어둔 보안 헤더를 봤다.

SharedArrayBuffer와 WASM threads를 사용하려고 다음 설정을 적용해 둔 상태였다.

Cross-Origin-Embedder-Policy: credentialless

일반 페이지에서 정적 JS를 가져오는 것과, 그 파일을 Worker의 실행 코드로 올리는 것은 같은 문제가 아니었다.

내 환경에서는 /llm-worker.js를 직접 Worker URL로 넘겼을 때 로드가 막혔다.

이번에도 Worker 내부 코드까지 들어가지 못했으니 onerror에는 제대로 된 메시지가 없었다.

Worker 파일을 URL로 직접 넘기는 대신 먼저 fetch로 읽어보기로 했다.

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가 떴다.

첫 번째 버그에서는 Worker 코드가 잘못 묶였고, 두 번째에서는 Worker 파일을 실행시키는 단계에서 막혔다.

둘 다 화면에 보이는 건 똑같은 빈 onerror였다.

그리고 여기서 끝났으면 좋았을 텐데.


세 번째 — Worker는 떴는데 모델이 안 뜬다

이번에는 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에서는 문제가 없었다.

http://localhost:3000/...

를 기준으로 /wasm/을 해석하면:

http://localhost:3000/wasm/

이 된다.

그런데 두 번째 문제를 해결하면서 Worker가 Blob URL에서 실행되고 있었다.

blob:http://localhost:3000/xxxxxxxx-xxxx-xxxx...

여기서 /wasm/을 그대로 넘기자 ONNX Runtime이 WASM 파일 URL을 만들지 못했다.

두 번째 문제를 해결하려고 도입한 Blob Worker가 세 번째 문제를 드러낸 셈이었다.

결국 Worker 안에서 원래 페이지의 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

그제야 모델 다운로드가 시작됐고 초기화까지 정상적으로 끝났다.


결국 세 번 다른 곳에서 막혔다

처음에는 전부 같은 에러처럼 보였다.

Worker 초기화 실패
onerror: ""

실제로는 실패한 위치가 전부 달랐다.

1. Worker 번들
   react-dom / DOM 의존 코드가 같이 들어감

2. Worker 로딩
   COEP가 적용된 환경에서 정적 Worker 실행이 막힘

3. Worker 내부
   Blob URL에서 WASM 상대 경로를 해석하지 못함

첫 번째는 무엇을 Worker에 넣었는지의 문제였고, 두 번째는 Worker를 어떻게 띄웠는지, 세 번째는 Worker 안에서 다른 파일을 어떻게 찾는지의 문제였다.

따로 보면 어려운 버그는 아니었다.

어려웠던 건 세 문제가 순서대로 가려져 있었다는 거다. 앞단에서 Worker가 죽으니 그 뒤의 문제는 보이지 않았다. 하나를 걷어낼 때마다 다음 문제가 처음 나타났다.

그리고 셋 다 한동안 같은 빈 onerror로 보였다.

이번에 가장 오래 걸린 이유도 거기 있었다.

에러 메시지가 없으니 자꾸 Worker 안쪽 코드부터 들여다봤다. 실제로는 Worker가 어디까지 갔는지부터 확인했어야 했다.

파일이 만들어졌는지, 크기는 정상인지, 실행은 시작됐는지, Worker 내부 메시지가 한 번이라도 왔는지.

그걸 순서대로 봤으면 훨씬 빨랐을 것 같다.


이걸로 댓글필터 개발기는 일단 마무리한다.

다음은 기능을 더 붙이는 것보다 분류 품질을 올리는 쪽이다. 예시 세트를 실제 결과를 보면서 조금씩 보강하고, 한국어 모델 교체와 RAG-lite 청킹도 테스트해볼 생각이다.

0개의 댓글