
브라우저 다국어 TTS가 86%에서 멈추는 원인과 WebGPU·WASM·캐시 설계로 해결한 이야기
요약
브라우저 기반 다국어 TTS 구현 중 모델 로딩이 86%에서 멈추는 문제를 분석하고 해결 과정을 다룹니다. 브라우저 스토리지 쿼터 초과 문제를 해결하기 위해 상태 관리 체계를 세분화하고 캐시 설계 방식을 개선했습니다.
핵심 포인트
- 모델 로딩 정지 현상은 네트워크 다운로드가 아닌 캐시 및 초기화 단계의 오류임
- 진척률 표시를 다운로드와 엔진 초기화 단계로 분리하여 사용자 경험 개선
- 브라우저 스토리지 쿼터 초과를 방지하기 위한 캐시 키 설계 최적화
- WebGPU와 WASM을 활용한 효율적인 브라우저 내 AI 추론 환경 구축
브라우저 상에서 AI 음성 합성 (AI Speech Synthesis)을 완결할 수 있다면, 사용자의 문장이나 편집 소재를 서버로 전송하지 않아도 되며, 추론 서버의 운영 비용도 억제할 수 있습니다.
하지만 실제로 다국어 TTS를 프로덕트에 통합하면, 단순히 ONNX 모델을 불러오는 것만으로는 안정적이지 않습니다.
오픈 소스 브라우저 동영상 편집 도구인 Timeline Studio에서 다국어 음성 합성을 구현했을 때, 모델 로딩 표시가 86%에서 정지하는 문제에 직면했습니다. 생성 버튼은 「생성 중」 상태로 유지되었고, 중국어로는 작동하는 경우가 있어도 영어·독일어·한국어·태국어·일본어로 전환하면 실패하기 쉬워졌습니다.
콘솔에 나타난 중요한 에러는 다음과 같습니다.
Unable to cache file QuotaExceededError: Quota exceeded.
QuotaExceededError:
The operation failed because it would cause the application
...
86%라는 숫자는 원인이 아니라, 브라우저 스토리지 (Browser Storage), 여러 음성 런타임 (Runtime), Service Worker, WebGPU 초기화, 지역별 모델 미러 (Model Mirror)가 결합된 결과였습니다.
본 기사에서는 이 문제를 어떻게 분류하고, 재발하기 어려운 구성으로 변경했는지 소개합니다.
브라우저에서 TTS 모델을 이용하기까지는 적어도 다음과 같은 처리가 있습니다.
- 설정, 사전, 토크나이저 (Tokenizer) 등을 취득
- ONNX 모델을 다운로드
- Cache Storage에 저장
- ONNX Runtime의 세션을 생성
- WebGPU의 그래프를 컴파일하거나 WASM을 초기화
- 웜업 추론 (Warm-up Inference)을 실행
- 입력 문장에서 음성을 생성
당초의 진척률은 주로 네트워크 다운로드만을 나타내고 있었습니다.
모델 취득이 끝났더라도, 그 이후의 캐시 쓰기나 추론 세션 생성에서 예외가 발생하면 UI에는 마지막으로 받은 진척값만 남게 됩니다. 그것이 우연히 86%였습니다.
즉, 화면상으로는 「남은 14%를 다운로드 중」으로 보일지라도, 실제로는 다른 페이즈(Phase)로 넘어간 뒤에 실패하고 있었던 것입니다.
그래서 처리 상태를 다음과 같이 분리했습니다.
- 로컬 캐시 확인 중
- 모델 다운로드 중
- 로컬 추론 엔진 초기화 중
- 선택한 음성 준비 중
- 음성 생성 중
다운로드 종료 후에는 부자연스러운 진척률을 유지하지 않고, 초기화 페이즈로서 올바른 메시지를 표시합니다.
Timeline Studio에서는 언어별 품질과 브라우저 호환성을 고려하여 여러 TTS 런타임을 사용하고 있습니다.
| 언어 | 주요 런타임 | 실행 방식 |
|---|---|---|
| 중국어 | Piper | WebGPU 우선, WASM 폴백 (Fallback) |
| ... | ||
| 이것들은 모두 동일한 오리진 (Origin) 상에서 동작합니다. |
Cache Storage, IndexedDB, Service Worker의 캐시는 실질적으로 동일한 사이트 스토리지 용량을 다툽니다. 사용자가 여러 언어를 시도하면 다음과 같은 데이터가 축적되었습니다.
- 현재 사용 중인 모델
- 오래된 리비전 (Revision)
- FP32나 양자화 (Quantization) 모델 등의 다른 버전
- 국내외 미러에서 취득한 동일 파일
- Service Worker가 저장한 응답 (Response)
- 런타임마다 만들어진 독립 캐시
개별 캐시는 정상이라도, 합계하면 브라우저의 쿼터 (Quota)를 초과하게 됩니다.
큰 개선점은 URL을 그대로 캐시 키 (Cache Key)로 사용하지 않는 것이었습니다.
다음과 같은 설계에서는 같은 파일이라도 배포처가 다르면 별개의 캐시가 됩니다.
const cacheKey = modelDownloadUrl;
중국향으로는 ModelScope를, 해외향으로는 Hugging Face를 이용하고 있습니다. 양쪽 모두에 동일한 모델을 배치해도 URL이 다르기 때문에 브라우저는 별개의 파일로 저장합니다.
그래서 배포처에 의존하지 않는 논리 ID (Logical ID)를 만들었습니다.
const cacheIdentity = [
modelFamily,
immutableRevision,
...
예를 들어 영어 Kokoro Q8라면 다음과 같은 ID가 됩니다.
kokoro:revision-20260804:en:female:q8
ModelScope에서 취득하든 Hugging Face에서 취득하든, 동일한 논리 ID로 저장합니다.
이를 통해 네트워크 상황에 따라 배포처를 전환하더라도 재다운로드가 발생하지 않으며, 동일 모델의 중복 저장도 방지할 수 있습니다.
또한, 프로덕션에서 사용하는 모델은 모두 변경 불가능한 리비전(Revision)으로 고정했습니다. main 브랜치를 직접 참조하면 배포처의 업데이트로 인해 모델 내용이 변할 수 있고, 어느 날 갑자기 브라우저 측 구현과 호환성이 없어질 가능성이 있기 때문입니다.
최초 이용 시에는 모델 파일을 네트워크에서 가져와야 합니다.
구현 시에는 중국어 UI 및 일본 국내용 세션에서는 ModelScope 미러를 우선하고, 그 외에는 Hugging Face를 우선합니다. 우선 배포처가 실패할 경우 자동으로 다른 한쪽을 시도합니다.
async function loadVoiceArtifact(artifact: VoiceArtifact) {
const cached = await readSharedVoiceCache(artifact.cacheIdentity);
if (cached) return cached;
...
사용자에게 브라우저 유래의 Failed to fetch 에러를 그대로 보여주지 않습니다.
"모델을 가져오지 못했습니다", "대체 배포처도 시도했습니다", "네트워크를 확인하고 다시 실행해 주세요"와 같이 다음 행동을 알 수 있는 로컬라이즈(Localized)된 메시지로 변환합니다.
영어 음성의 경우, 당초 약 325MB의 Kokoro FP32 모델을 WebGPU 우선 방식으로 사용하고 있었습니다.
벤치마크상으로는 매력적이었지만, 실제 브라우저에서는 다음과 같은 문제가 있었습니다.
- 최초 다운로드 시간이 길다
- Cache Storage를 많이 소비한다
- WebGPU 그래프의 컴파일 시간을 예측하기 어렵다
- GPU나 드라이버별 차이가 크다
- 타 언어 모델을 저장할 여유가 줄어든다
그래서 영어는 약 92MB의 Q8 양자화(Quantization) 모델과 WASM 실행 방식으로 전환했습니다.
const session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
...
영상 편집 도구에서의 음성 생성은 항상 GPU를 풀가동하는 처리가 아닙니다.
이론상의 최고 속도보다 다음과 같은 특성을 우선했습니다.
- 첫 번째 생성이 성공할 것
- 두 번째 이후부터는 캐시를 재사용할 것
- 언어를 전환해도 기존 모델을 손상시키지 않을 것
- 중간 사양의 단말기에서도 동작할 것
- WebGPU를 사용할 수 없는 경우 WASM으로 폴백(Fallback)할 수 있을 것
양자화 모델은 속도뿐만 아니라 "프로덕트로서 성공할 확률"을 높이는 선택이었습니다.
용량이 부족할 때 모든 캐시를 삭제하면 일시적으로는 해결됩니다. 하지만 사용자가 직전에 가져온 모델까지 다시 다운로드하게 됩니다.
그래서 현재 사용 중인 음성 모델을 보호하고, 오래된 음성 모델부터 삭제하는 방식을 채택했습니다.
- 현재 선택 중인 음성에 필요한 논리 ID를 가져온다
- 해당 ID를 보호 대상으로 지정한다
- 오래된 모델 리비전을 삭제한다
- 최근에 사용되지 않은 타 언어 모델을 삭제한다
- 앱 본체의 정적 리소스는 유지한다
- 캐시 쓰기를 재시도한다
- 저장에 실패하더라도 가능하다면 메모리 상의 모델로 이번 추론을 계속한다
async function ensureVoiceStorage(activeIdentity: string) {
const estimate = await navigator.storage.estimate();
if (!estimate.quota || !estimate.usage) return;
...
이를 통해 QuotaExceededError를 복구 불가능한 에러가 아니라, 리소스 정리를 통해 복구 가능한 상태로 다룰 수 있게 되었습니다.
Service Worker는 JavaScript, CSS, 아이콘 등의 정적 리소스에 적합합니다.
반면, 모델 로더가 이미 관리하고 있는 거대한 ONNX 응답을 Service Worker 측에서도 복제하면, 동일한 파일을 이중으로 저장할 가능성이 있습니다.
따라서 책임 범위를 다음과 같이 정리했습니다.
- 큰 음성 모델은 모델 매니저(Model Manager)만 관리한다
- Service Worker는 거대한 ONNX 파일을 중복 저장하지 않는다
- 일반적인 앱 정적 리소스는 계속해서 Service Worker가 관리한다
- 모든 TTS 런타임(Runtime)이 공통의 모델 매니페스트(Manifest)를 사용한다
저장 위치와 삭제 담당이 명확해졌으며, 쿼터(Quota) 사용량을 예측하기 쉬워졌습니다.
5KB의 설정 파일과 92MB의 ONNX 모델을 동일한 1개의 파일로 취급하면, 진행률이 실제 상황과 일치하지 않습니다.
다운로드 진행률은 실제 바이트(Byte) 수를 기준으로 계산합니다.
const progress = loadedBytes / totalBytes;
onProgress(Math.round(progress * 100));
네트워크 취득이 끝난 후에는 동일한 진행 바(Progress bar)를 「추론 엔진 초기화」 단계로 전환합니다.
모든 런타임(Runtime)이 동일한 내부 진행률을 반환할 수 있는 것은 아닙니다. 따라서 공통의 상위 스테이지와 각 어댑터(Adapter)가 제공할 수 있는 상세 진행률을 분리했습니다.
중요한 것은 취득할 수 없는 진행률을 가공의 숫자로 보충하지 않는 것입니다.
React에서 「생성 중」으로 상태를 변경하더라도, 그 직후에 무거운 WASM 처리를 시작하면 브라우저가 화면을 다시 그리지(Redraw) 못할 수 있습니다.
사용자 입장에서는 상태 표시가 바뀌지 않은 채 화면이 프리징(Freeze)된 것처럼 보입니다.
그래서 추론을 시작하기 전에 딱 1프레임만 그리기 타이밍을 넘겨줍니다.
setGenerationState({
status: "generating",
progress: 0,
...
추론 자체가 빨라지는 것은 아니지만, 무거운 처리로 들어가기 전에 올바른 UI 상태를 표시할 수 있기 때문에 체감 품질이 크게 개선됩니다.
Piper, Kokoro, MMS, Supertonic은 모델 구조와 전처리(Pre-processing) 방식이 다릅니다.
하지만 영상 편집 UI나 타임라인이 각각의 구현 상세를 알 필요는 없습니다.
각 런타임을 공통 인터페이스(Interface)에 적합하도록 맞추었습니다.
interface VoiceRuntime {
prepare(options: VoiceOptions): Promise<void>;
synthesize(text: string): Promise<AudioBuffer>;
...
상위 레이어(Layer)는 생성된 음성만을 다룹니다.
이를 통해 양자화(Quantization) 방식, 실행 프로바이더(Execution provider), 미러(Mirror) 우선순위를 변경하더라도 타임라인이나 소재 라이브러리를 수정할 필요가 없습니다.
수정 후에는 다음 케이스를 중점적으로 확인했습니다.
- 영어 모델의 최초 셋업 및 재생성
- 독일어의 연속 생성
- 한국어의 로컬 추론
- 태국어 모델의 취득 및 음성 합성
- 일본어 Supertonic의 초기화
- 중국어 모델의 배포처 전환
- 스토리지 상한에 가까운 상태에서의 자동 정리
- 페이지 새로고침 후의 캐시 재사용
두 번째 생성 시 모델 다운로드 표시가 반복되는 현상은 사라졌으며, 쿼터 부족으로 인해 UI가 86% 상태로 남아있던 문제도 해결했습니다.
「86%에서 멈추는」 증상은 단순한 프로그레스 바(Progress bar)의 오류가 아니었습니다.
근본적으로는 다음과 같은 문제들이 있었습니다.
- 거대 모델의 중복 캐시
- 브라우저 스토리지의 쿼터 초과
- 국내외 미러에 따른 서로 다른 캐시 키(Cache key)
- WebGPU 초기화 실패 시의 회복 경로 부족
- 다운로드와 초기화를 혼동한 진행률 표시
- 무거운 처리로 인해 UI 그리기가 지연되는 문제
양자화 모델, 배포처에 의존하지 않는 캐시 ID, 국내외 미러 폴백(Fallback), 오래된 모델의 자동 삭제, 스테이지별 진행률, WASM으로의 회복 경로를 도입함으로써, 다국어 음성 합성을 브라우저 내에서 안정적으로 이용할 수 있게 되었습니다.
브라우저 AI에서 「한 번 추론할 수 있다」는 것은 시작점에 불과합니다.
모델 배포, 버전 고정, 캐시, 용량 부족으로부터의 회복, UI 상태까지를 하나의 시스템으로 설계하는 것이 실제 사용자 환경에서 동작하는 기능으로 이어집니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기