브라우저 기반 다국어 TTS가 왜 86%에서 계속 멈췄는지, 그리고 어떻게 해결했는지
요약
브라우저 기반 다국어 TTS 구현 중 모델 로딩이 86%에서 멈추는 문제를 분석하고 해결책을 제시합니다. QuotaExceededError와 같은 브라우저 저장소 할당량 문제와 모델 로딩 단계를 세분화하여 상태 머신을 개선하는 방법을 다룹니다.
핵심 포인트
- 브라우저 저장소 할당량 초과로 인한 모델 캐싱 실패 문제 해결
- 네트워크 다운로드 외에 세션 생성, WebGPU 초기화 등 단계별 상태 관리 필요
- 모델 로딩 과정을 다단계 작업으로 세분화하여 UI 정확도 향상
- ONNX Runtime 및 WebGPU 환경에서의 안정적인 TTS 파이프라인 구축
텍스트 음성 변환 (Text-to-Speech, TTS)을 브라우저에서 완전히 실행하는 것은 간단해 보입니다. ONNX 모델을 다운로드하고, 추론 세션 (inference session)을 생성하며, 사용자의 텍스트나 미디어를 서버로 보내지 않고 오디오를 합성하면 됩니다.
하지만 실제 운영 환경에서는 훨씬 더 어렵습니다.
Timeline Studio를 위한 다국어 음성 생성 기능을 구축하는 동안, 우리는 동일한 실패를 반복해서 목격했습니다. 모델 로딩 UI가 **86%**에서 멈추고, 생성 (Generate) 버튼은 계속 작동 중인 상태로 유지되었으며, 중국어에서 영어, 독일어, 한국어, 태국어 또는 일본어로 전환할 때 이 문제가 발생할 확률이 더 높아졌습니다.
가장 유용한 콘솔 메시지는 다음과 같았습니다:
Unable to cache file QuotaExceededError: Quota exceeded.
QuotaExceededError:
...
진행률 표시줄은 증상일 뿐이었습니다. 진짜 문제는 대용량 모델 파일, 다중 TTS 런타임 (runtimes), 브라우저 저장소 할당량 (storage quotas), 서비스 워커 (service-worker) 캐싱, WebGPU 초기화, 그리고 지역별 모델 미러 (mirrors) 사이의 상호작용이었습니다.
이 포스트는 다국어 파이프라인을 안정적으로 만든 아키텍처 변경 사항에 대해 설명합니다.
브라우저는 진행률 표시줄이 보여주는 것보다 더 많은 일을 하고 있었습니다
브라우저 TTS 모델은 보통 여러 단계를 거칩니다:
- 설정 (configuration), 음소 변환기 (phonemizer), 토크나이저 (tokenizer) 및 어휘 (vocabulary) 파일을 다운로드합니다.
- 하나 이상의 ONNX 모델 파일을 다운로드합니다.
- 결과물 (artifacts)을 캐시 스토리지 (Cache Storage)에 저장합니다.
- ONNX Runtime 세션을 생성합니다.
- WebGPU를 위해 그래프를 컴파일하거나 WASM을 초기화합니다.
- 웜업 추론 (warm-up inference)을 실행합니다.
- 마지막으로 요청된 음성을 합성합니다.
우리의 원래 진행률 계산은 주로 네트워크 다운로드를 나타냈습니다. 만약 파일 다운로드는 완료되었지만 캐시 삽입이나 세션 생성이 실패한다면, UI는 마지막으로 보고된 값 — 종종 86% — 을 유지하게 됩니다.
따라서 브라우저가 반드시 무언가를 계속 다운로드하고 있었던 것은 아닙니다. 브라우저는 이미 표현되지 않은 단계(unrepresented stage)로 넘어갔으며, 상태 머신(state machine)이 성공 또는 유용한 오류 상태에 도달하기 전에 예외(exception)를 발생시킨 상태였습니다.
해결책은 모델 설정(model setup)을 실제 다단계 작업(multi-stage operation)으로 취급하는 것부터 시작되었습니다:
- 로컬 모델 캐시(local model cache) 확인
- 모델 아티팩트(model artifacts) 다운로드
- 로컬 추론 엔진(local inference engine) 초기화
- 선택된 음성(voice) 준비
- 음성 생성(generating speech)
이제 초기화 과정 동안 UI는 다른 파일을 여전히 다운로드하고 있는 척하는 대신, 실제로 무엇이 일어나고 있는지를 표시합니다.
하나의 오리진, 다수의 경쟁하는 캐시
Timeline Studio는 여러 브라우저 로컬 음성 런타임(voice runtimes)을 지원합니다. 하나의 모델 제품군(model family)이 모든 언어에 최선의 선택은 아니기 때문입니다:
| 언어 그룹 | 런타임 (Runtime) | 실행 경로 (Execution path) |
|---|---|---|
| 중국어 | Piper | WebGPU 우선, WASM 폴백 (fallback) |
| ... |
이 모든 런타임은 동일한 브라우저 오리진(origin) 아래에서 작동합니다. 따라서 캐시 스토리지(Cache Storage), IndexedDB, 그리고 서비스 워커(service-worker) 캐시는 동일한 사이트 할당량(site quota)을 두고 경쟁하게 됩니다.
사용자가 여러 음성을 시도한 후, 해당 오리진에는 다음과 같은 것들이 포함될 수 있습니다:
- 현재 모델 파일
- 이전 모델 버전 (older model revisions)
- 다수의 양자화 변체 (multiple quantization variants)
- 서로 다른 미러(mirrors)로부터 온 중복 파일
- 서비스 워커 응답 복사본
- 런타임별 캐시 항목
각각의 개별 캐시는 합리적인 수준으로 보였지만, 이들이 모이면 브라우저의 저장 용량 허용치를 초과할 수 있었습니다.
모델의 정체성을 다운로드 URL과 분리하기
가장 중요한 캐싱 변경 사항은 모델 URL을 모델의 정체성(identity)으로 취급하는 것을 중단한 것입니다.
이는 취약한 방식입니다:
const cacheKey = modelDownloadUrl;
동일한 불변 아티팩트(immutable artifact)라도 중국의 ModelScope와 그 외 지역의 Hugging Face에서 제공될 수 있습니다. 만약 전체 URL이 캐시 키(cache key)가 된다면, 두 제공업체로부터 온 동일한 바이트 데이터가 두 개의 독립적인 캐시 항목을 차지하게 됩니다.
대신, 우리는 제공업체에 의존하지 않는 정체성을 생성합니다:
const cacheIdentity = [
modelFamily,
immutableRevision,
...
예를 들어:
kokoro:revision-20260804:en:female:q8
두 미러(mirror) URL 모두 동일한 논리적 엔트리(logical entry)로 연결됩니다. 이를 통해 수백 메가바이트를 다시 다운로드하거나 중복 생성할 필요 없이 제공자(provider)를 변경할 수 있습니다.
또한 이는 캐시 마이그레이션(cache migration)을 예측 가능하게 만듭니다. 아티팩트 리비전(artifact revision)은 명시적이며, 새로운 리비전은 자연스럽게 새로운 식별자(identity)를 부여받습니다.
캐시 파편화 없이 지역 미러(regional mirrors) 사용하기
브라우저 로컬 추론(inference)은 음성을 처음 사용할 때 여전히 네트워크 연결이 필요합니다.
중국어 및 국내 세션의 경우, Timeline Studio는 소유하고 있는 ModelScope 미러를 먼저 시도합니다. 다른 세션은 소유하고 있는 Hugging Face 저장소(repository)를 우선적으로 사용합니다. 선호하는 소스(source)가 실패하면, 로더(loader)가 자동으로 폴백(fallback) 소스를 시도합니다.
단순화된 흐름은 다음과 같습니다:
async function loadVoiceArtifact(artifact: VoiceArtifact) {
const cached = await readSharedVoiceCache(artifact.cacheIdentity);
...
모든 프로덕션 아티팩트(production artifact)는 불변의 제공자 리비전(immutable provider revision)에 고정(pinned)됩니다. 이는 원격의 main 브랜치가 모델 바이트를 조용히 변경하거나 브라우저 런타임(runtime)을 깨뜨리는 것을 방지합니다.
또한 제품은 절대로 가공되지 않은 Failed to fetch 메시지를 표시하지 않습니다. 사용자에게는 모델을 다운로드할 수 없으며 폴백 소스가 시도되었다는 현지화된 설명을 제공합니다.
더 작은 모델이 더 빠른 제품 결정이 될 수 있습니다
영어 파이프라인(pipeline)은 원래 WebGPU 우선 경로를 사용하는 약 325 MB 크기의 FP32 Kokoro 모델을 사용했습니다.
해당 구성은 벤치마크에서는 매력적으로 보였지만, 실제 환경에서는 다음과 같은 몇 가지 문제점을 야기했습니다:
- 긴 첫 다운로드 시간
- 높은 캐시 스토리지(Cache Storage) 압박
- 예측 불가능한 WebGPU 그래프 컴파일(graph compilation)
- 드라이버 특이적 오류(Driver-specific failures)
- 다른 언어 모델과의 더 심한 경쟁
우리는 영어 경로를 약 92 MB 크기의 Q8 모델과 안정적인 WASM 실행 제공자(execution provider)로 전환했습니다:
const session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
...
에디터(editor)의 경우, 음성 생성은 보통 지속적으로 포화된 추론(inference) 워크로드라기보다 가끔 발생하는 작업입니다. 이론적으로 더 빠른 GPU 경로가 추론이 시작되기도 전에 빈번하게 실패하는 것보다, 더 많은 기기에서 안정적으로 로드되는 더 작은 양자화된 모델(quantized model)이 더 나은 사용자 경험을 제공합니다.
설계 목표는 다음과 같았습니다:
- 첫 번째 생성은 성공해야 함
- 반복되는 생성은 캐시(cache)를 재사용해야 함
- 언어를 전환해도 기존 음성이 깨지지 않아야 함
- 중급 사양의 기기(mid-range devices)를 계속 지원해야 함
- WebGPU 환경에서 적절한 경우 제한된 범위의 WASM 폴백(fallback)을 제공해야 함
전체 애플리케이션 캐시가 아닌, 오래된 음성 모델을 제거하라
저장 공간이 가득 찼을 때 모든 캐시를 삭제하는 것은 쉽지만, 이는 사용자가 방금 다운로드한 모델을 제거함으로써 사용자에게 불이익을 줍니다.
대신, 캐시 관리자(cache manager)는 활성화된 음성을 보호하고 오래된 음성 아티팩트(artifacts)를 먼저 제거합니다:
- 현재 음성에 필요한 캐시 식별자(identity)를 결정합니다.
- 해당 항목을 보호 대상으로 표시합니다.
- 구식 모델 리비전(revisions)을 제거합니다.
- 가장 오랫동안 사용되지 않은(least-recently-used) 비활성 음성 모델을 제거합니다.
- 애플리케이션 자산(assets)과 활성 음성을 보존합니다.
- 캐시 쓰기를 재시도합니다.
- 영구 저장소(persistent storage) 쓰기가 여전히 실패할 경우, 가능한 경우 현재 추론이 메모리에서 계속 진행될 수 있도록 합니다.
간소화된 할당량(quota) 확인 로직은 다음과 같습니다:
async function ensureVoiceStorage(activeIdentity: string) {
const estimate = await navigator.storage.estimate();
...
이를 통해 엄격한 할당량 실패를 복구 가능한 리소스 관리 이벤트로 전환할 수 있습니다.
대용량 모델 파일에는 단 하나의 캐싱 소유권만 부여하라
서비스 워커(Service workers)는 JavaScript 번들, 스타일, 아이콘 및 일반적인 정적 리소스에 매우 탁월합니다. 하지만 모델 로더(model loader)에 의해 이미 관리되고 있는 매우 큰 ONNX 응답을 독립적으로 캐싱하기 시작하면 위험해질 수 있습니다.
두 계층 모두에서 동일한 응답을 복제하고 캐싱하면 저장 공간 사용량이 조용히 두 배로 늘어날 수 있습니다.
우리는 단일 책임 원칙(single-responsibility rule)을 수립했습니다:
- 음성 모델 매니저(voice model manager)가 대규모 모델 아티팩트(model artifacts)를 소유합니다.
- 서비스 워커(service worker)는 대규모 ONNX 파일을 중복 생성하지 않습니다.
- 일반 애플리케이션 에셋(assets)은 서비스 워커의 제어 하에 유지됩니다.
- 모든 TTS 런타임(runtime)은 공유된 음성 아티팩트 매니페스트(voice-artifact manifest)를 사용합니다.
이를 통해 저장 공간 사용량을 측정 가능하게 만들고, 모델 정리(cleanup)를 결정론적(deterministic)으로 수행할 수 있게 되었습니다.
진행률은 바이트 가중치를 적용하고 단계별로 인지되어야 합니다
5 KB 크기의 설정 파일과 92 MB 크기의 모델이 진행률에 동일한 비중으로 기여해서는 안 됩니다.
이제 다운로더는 실제 바이트를 사용하여 진행률을 보고합니다:
const progress = loadedBytes / totalBytes;
onProgress(Math.round(progress * 100));
네트워크 단계가 종료되면, UI는 임의의 다운로드 퍼센티지에서 멈춰 있는 대신 초기화 단계(initialization phase)로 리셋됩니다.
모든 런타임이 동일한 내부 진행률을 노출하는 것은 아니므로, 애플리케이션은 각 어댑터(adapter)가 제공할 수 있는 최선의 신호를 허용하면서도 공유된 상위 수준의 계약(high-level contract)을 사용합니다. 그래프 컴파일(graph compilation) 자체가 바이트 수준의 진행률을 제공할 수 없는 경우에도 UI는 정직한 상태를 유지합니다.
무거운 WASM 작업 전에 브라우저가 화면을 그리도록 하세요
작지만 중요한 또 다른 문제가 있었습니다.
React 상태 업데이트는 비동기적(asynchronous)입니다. 만약 생성 상태를 설정하자마자 즉시 동기적(synchronous)이거나 CPU 집약적인 WASM 작업을 시작하면, 메인 스레드(main thread)가 새로운 상태를 화면에 그리지(paint) 못할 수 있습니다. 사용자에게는 유용한 메시지가 표시되기도 전에 페이지가 멈춘 것처럼 보일 수 있습니다.
이제 우리는 추론(inference)을 시작하기 전에 한 프레임을 양보(yield)합니다:
setGenerationState({
status: "generating",
progress: 0,
...
이것이 추론 속도를 빠르게 만들지는 않지만, 브라우저가 무거운 작업에 진입하기 전에 전환 상태를 표시할 수 있게 함으로써 제품이 반응성이 좋다고 느끼게 만듭니다.
하나의 인터페이스 뒤로 런타임의 다양성을 숨기세요
다국어 제품이라고 해서 에디터 UI가 모든 모델 패밀리(model family)를 이해하도록 강제해서는 안 됩니다.
각 어댑터는 공통 계약을 구현합니다:
interface VoiceRuntime {
prepare(options: VoiceOptions): Promise<void>;
synthesize(text: string): Promise<AudioBuffer>;
...
타임라인(timeline), 에셋 라이브러리(asset library), 그리고 익스포트 파이프라인(export pipeline)은 소스가 Piper, Kokoro, MMS, 또는 Supertonic인지 여부와 관계없이 생성된 오디오와 함께 작동합니다.
그러한 분리 덕분에 제품 레벨의 편집 기능을 다시 작성하지 않고도 양자화(quantization), 실행 제공자(execution providers), 또는 미러 라우팅(mirror routing)을 변경하는 것이 가능해졌습니다.
검증 내용
변경 사항 적용 후, 다음 항목들을 테스트했습니다:
- 최초 영어 모델 설정 및 반복 생성
- 반복적인 독일어 생성
- 한국어 로컬 추론 (local inference)
- 태국어 모델 다운로드 및 합성 (synthesis)
- 일본어 Supertonic 초기화
- 중국어 미러 선호도 및 제공자 폴백 (provider fallback)
- 브라우저 할당량 (browser quota) 근처에서의 자동 정리 (cleanup)
- 페이지 새로고침 후 캐시 재사용
이제 반복 생성이 또 다른 전체 모델 다운로드로 나타나지 않으며, 할당량 실패로 인해 인터페이스가 86%에서 영구적으로 멈춰 있는 현상도 더 이상 발생하지 않습니다.
브라우저 AI 애플리케이션을 위한 교훈
가장 큰 교훈은 로컬 프로토타입에서 한 번 실행되는 모델이 신뢰할 수 있는 브라우저 AI 기능과 같지 않다는 점입니다.
실제 프로덕션 구현(production implementation)은 다음의 모든 질문에 답할 수 있어야 합니다:
- 모델 파일이 불변 리비전 (immutable revisions)에 고정되어 있는가?
- 지역별 미러 (regional mirrors)가 캐시 식별자 (cache identity)를 공유하는가?
- 서비스 워커 (service worker)가 대용량 아티팩트 (artifacts)를 복제할 수 있는가?
- 영구 저장소 (persistent storage)가 거의 가득 찼을 때 어떤 일이 발생하는가?
- 진행률 (progress)이 실제 바이트와 실제 단계를 나타내는가?
- 무거운 추론 (inference)이 시작되기 전에 UI가 화면을 그릴 (paint) 기회를 갖는가?
- WebGPU를 사용할 수 없을 때 안정적인 폴백 (fallback)이 있는가?
- 사용하지 않는 언어 모델이 결국 제거 (evicted)되는가?
원래의 “86%에서 멈춤” 보고는 진행 표시줄(progress-bar) 버그처럼 보였습니다. 하지만 실제로는 저장소, 네트워킹, 추론, 그리고 UI 스케줄링(UI scheduling)에 걸쳐 있는 아키텍처 문제를 드러낸 것이었습니다.
양자화된 모델(quantized models), 공유된 아티팩트 식별자, 지역 미러 폴백, 할당량 인지 제거(quota-aware eviction), 단계 인지 진행률(stage-aware progress), 그리고 안정적인 WASM 경로를 도입함으로써, 다국어 음성 생성은 브라우저와 지역에 관계없이 훨씬 더 예측 가능해졌습니다.
만약 브라우저에서 로컬 우선 (local-first) AI를 구축하고 있다면, 모델 배포 (model distribution)와 저장 (storage)을 일급 인프라 (first-class infrastructure)로 취급하십시오. 성공적인 추론 (inference)은 시작일 뿐입니다. 기기, 네트워크, 그리고 저장 조건 전반에 걸쳐 신뢰할 수 있는 복구 (recovery)를 구현하는 것이야말로 이를 실제 제품으로 만드는 핵심입니다.
오픈 소스 프로젝트 (Open-source project): https://github.com/MartinDelophy/ai-video-editor
온라인 체험하기: https://video-editor.ai-creator.top/
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기