
본편("브라우저 녹음은 되는데 음성합성 API는 왜 거절할까")에서 오디오 변환을 브라우저 안의 WebAssembly 트랜스코딩 엔진으로 처리했다고 썼다. 거기서 한 문단으로 줄였던 이야기가 이 글이다. 무거운 WASM 의존성에서는 변환 속도보다 언제 받고, 몇 번 받느냐가 체감을 좌우했다.
필요할 때만 받는다
트랜스코딩 엔진 코어는 수십 MB짜리 WASM 바이너리다. 모든 방문자에게 페이지 진입 시 받게 하는 건 명백히 과했다. 음성 기능을 쓰지 않는 사용자가 대다수인데 그들에게 수십 MB를 미리 내려받게 할 이유가 없다. 그래서 지연 로딩을 썼다. 변환이 처음 필요한 순간에만 바이너리를 받는다.
지연 로딩은 경합을 부른다
지연 로딩은 새로운 문제를 만든다. 사용자가 음성 기능을 처음 누르는 순간, 그 한 번의 클릭이 여러 비동기 요청으로 갈라질 수 있다. 여러 컴포넌트가 거의 동시에 엔진을 요구하면 같은 수십 MB 바이너리를 중복으로 받기 시작한다.
그래서 로더를 세 겹으로 막았다. 한 번 초기화된 엔진은 메모리에 잡아두고 두 번째 호출부터 즉시 반환한다(싱글톤). 로딩이 진행 중일 때 들어오는 요청은 새 다운로드를 시작하지 않고 진행 중인 단일 Promise에 붙는다. 열 개의 동시 요청이 와도 다운로드는 한 번이다. 마지막으로 로딩 상태 플래그로 같은 초기화 루틴의 중복 진입을 막았다.
실패는 상태를 비워야 끝난다
로딩이 실패하면 거기서 끝내면 안 된다. 실패한 Promise를 공유 상태에 남겨두면 다음 시도가 그 실패에 영원히 매달린다. 그래서 실패 시 안내 메시지를 띄우고 공유 Promise를 비워 상태를 리셋했다. 다음 시도는 처음부터 다시 받는다. 한 번의 실패가 세션 전체의 음성 기능을 영구히 죽이지 않게 하는 안전장치다.
멀티스레드는 아무 데서나 켜지지 않는다
엔진의 멀티스레드 모드는 SharedArrayBuffer를 요구한다. 이 객체는 COOP·COEP 헤더가 적용된 교차출처 격리 환경에서만 쓸 수 있고, 헤더가 없는 환경에서는 아예 정의되지 않는다. 그래서 변환을 시도하기 전에 SharedArrayBuffer의 존재를 런타임에 감지해 분기한다.
정직하게 적어두면, 지금 코드는 감지에서 멈춘다. 미지원 브라우저를 위한 폴백 분기는 아직 없다. 감지가 곧 대응은 아니다.
정리
수십 MB 의존성의 로딩은 두 축으로 설계했다. 언제 받느냐는 지연 로딩이 답했고, 몇 번 받느냐는 싱글톤과 Promise 공유가 답했다. 그리고 실패 경로에서 상태를 비우는 한 줄이 이 모든 구조를 한 번의 네트워크 오류로 무너지지 않게 했다.
다음에 읽을 글

긴 영상을 한 번에 렌더하지 않은 이유
서버리스 렌더에는 약 10분의 실행 시간 제한이 있다. 긴 영상은 프레임을 청크로 나눠 병렬로 렌더하고 하나로 결합했다. 총 시간은 가장 느린 청크 수준으로 줄었고, 이 병렬성은 렌더를 프레임 번호의 결정적 함수로 설계해 둔 데서 공짜로 얻었다.

외부 API 응답은 왜 정규화 계층이 필요한가
외부 API의 응답 형태는 우리가 정하지 못한다. Content-Type을 진실로 삼아 응답을 세 갈래로 가르고, 봉투 네 종과 별칭 일곱 종을 하나의 스키마로 정규화하고, 상태코드는 사람이 행동할 수 있는 말로 번역했다. 통합의 품질은 어긋날 때 드러난다.

브라우저 녹음은 되는데 음성합성 API는 왜 거절할까
브라우저는 WebM·48kHz·스테레오로 녹음하고, 음성합성 API는 WAV·16kHz·Mono·3MB 이하 입력을 요구한다. WebAssembly 트랜스코더를 브라우저에서 필요할 때만 로드해 형식을 맞추고, 보내기 전에 용량을 두 번 검사했다. 기술보다 중요했던 건 둘 사이를 잇는 통합 계층이었다.