조용진의 개발 블로그
← 글 목록

브라우저 TTS를 Web Worker와 WebGPU 폴백으로 안정화한 과정

TTSWeb WorkerWebGPUWASM성능 최적화

브라우저 TTS 추론을 Worker로 분리하고 WASM·WebGPU 실행 경로와 폴백을 구성해 UI 응답성을 지킨 과정을 정리합니다.

브라우저에서 Kokoro 82M ONNX 모델로 음성을 만들 때 약 3초 동안 화면이 멈췄다. Performance trace에서는 음성 추론이 메인 스레드를 점유한 긴 작업으로 나타났다. 음성 생성 시간만 줄이는 것보다 생성 중에도 입력과 애니메이션이 동작하게 만드는 것이 먼저였다.

모델과 추론을 전용 Worker로 옮겼다

기존에는 화면 코드가 kokoro-js를 불러와 모델 생성과 추론을 직접 실행했다. 이를 Dedicated Web Worker로 옮기고 메인 스레드는 요청 ID로 Promise를 관리하도록 분리했다.

아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.

worker = new Worker(new URL("./speech.worker.ts", import.meta.url), {
  type: "module",
  name: "speech-worker",
});

pendingJobs.set(id, { resolve, reject });
worker.postMessage({ ...request, id });

Worker 내부에서는 모델 Promise를 재사용하고 요청을 직렬 큐에 연결했다. 생성된 WAV는 Blob을 거쳐 ArrayBuffer로 만든 뒤 Transferable로 전달했다.

const audio = await model.generate(request.text, {
  voice: request.voice,
  speed: request.speed,
});
const blob = audio.toBlob();
const buffer = await blob.arrayBuffer();

workerContext.postMessage({ id, type: "audio", buffer }, [buffer]);

버퍼 소유권을 Worker에서 메인 스레드로 넘기면 큰 오디오 데이터를 한 번 더 복사하는 비용을 피할 수 있다. Worker 오류가 발생하면 진행 중인 요청을 모두 reject하고 Worker를 종료해 다음 시도에서 깨끗하게 다시 만들도록 했다.

두 Worker와 ONNX 내부 스레드를 구분했다

문장 여러 개를 준비할 때는 Worker 풀을 2개로 만들고, 각 WASM Worker의 ONNX 스레드 수도 2개로 설정했다. 두 값은 같은 병렬성이 아니다. Worker 풀은 서로 다른 문장을 나누어 처리하고, ONNX 스레드는 한 추론 내부 연산을 나눈다.

COOP·COEP 응답 헤더로 crossOriginIsolated 환경을 구성하고 측정 정보에 Worker 번호, 풀 크기, ONNX 스레드 수, 문장 길이를 함께 기록했다. 단순히 Worker 개수를 늘리는 것이 아니라 어떤 병렬 설정에서 측정했는지 남기기 위한 구조다.

WebGPU를 우선하고 실패하면 WASM으로 다시 구성했다

최종 실행 정책은 두 가지 설정으로 고정했다.

const GPU_BACKEND_OPTIONS = {
  device: "webgpu",
  dtype: "fp32",
  workerCount: 1,
  threadCount: 1,
};

const CPU_BACKEND_OPTIONS = {
  device: "wasm",
  dtype: "q8",
  workerCount: 2,
  threadCount: 2,
};

브라우저에 navigator.gpu가 없으면 바로 WASM 구성을 선택한다. WebGPU API가 있더라도 모델 초기화가 실패할 수 있으므로 실제 Worker 모델 준비까지 성공해야 GPU 경로를 확정한다. 실패하면 기존 Worker와 대기 요청을 정리하고 WASM Worker 풀을 새로 만든다.

flowchart TD
  A[음성 모델 준비] --> B{WebGPU API 사용 가능?}
  B -- 아니오 --> E[q8 WASM Worker 2개]
  B -- 예 --> C[fp32 WebGPU Worker 준비]
  C --> D{모델 초기화 성공?}
  D -- 예 --> F[GPU 경로 사용]
  D -- 아니오 --> E

기능 감지와 실제 초기화 성공을 분리한 것이 fallback의 핵심이다.

실행 환경을 캐시 키에도 포함했다

WebGPU의 fp32 결과와 WASM의 q8 결과는 같은 문장이어도 생성 조건이 다르다. 텍스트·음성·속도만으로 캐시하면 다른 실행 경로의 오디오가 섞일 수 있다. 그래서 모델 버전과 device, dtype을 캐시 키에 넣고, Worker 준비가 끝난 실제 설정으로 저장 키를 다시 계산했다.

당시 동일 조건 trace에는 메인 스레드 최장 작업이 3,056.98ms에서 2.60ms, Blocking Time이 3,006.98ms에서 0ms로 줄어든 기록이 있다. 대신 Worker 분리 직후 음성 준비 시간은 약 10.4% 늘었다. 이는 한 번의 측정 기록이며 WebGPU 적용 효과와 혼동해서는 안 된다.

이번 개선은 추론을 무조건 빠르게 만든 작업이 아니다. 먼저 UI 반응성과 모델 처리 시간을 서로 다른 지표로 분리했고, 그 위에 GPU 우선 경로와 CPU 호환 경로를 명시적으로 설계했다.