브라우저는 이제 단순한 문서 뷰어가 아니다. HTML5 <video>, <canvas>, Web Audio API, MediaRecorder가 조합되는 순간, 우리는 서버 없이도 제법 그럴듯한 미디어 편집 환경을 만들 수 있다. 그런데 '제법 그럴듯한'에서 '실제로 작동하는'으로 넘어가는 구간에는 생각보다 많은 함정이 숨어 있다.
canvas.captureStream()은 영상만 잡는다
dev.to에 공개된 AetherCut 개발기는 그 함정을 정면으로 파고든다. 개발자는 100% 인브라우저, 업로드 없는 클라이언트 사이드 비디오 편집기를 만들면서 자연스러운 파이프라인을 떠올렸다. <canvas>에 프레임을 그리고, canvas.captureStream(30)으로 스트림을 뽑아 MediaRecorder로 내보내면 된다고. 문제는 보이스오버와 배경음악을 추가한 뒤에야 터졌다. 사용자가 "왜 내보낸 파일이 무음이냐"고 물어왔을 때.
원인은 단순하다. canvas.captureStream()은 비디오 트랙만 캡처한다. 오디오는 포함되지 않는다. 이 사실은 MDN에도 명시되어 있지만, 막상 파이프라인을 구성할 때 체감하기 전까지는 쉽게 지나친다.
Web Audio API로 믹싱 그래프를 직접 짜야 한다
해법은 Web Audio API의 AudioContext 그래프를 직접 구성하는 것이다. 각 <video>, <audio> 엘리먼트를 createMediaElementSource()로 그래프에 연결하고, MediaStreamDestination을 통해 오디오 트랙을 추출해 MediaRecorder에 합친다. 개념은 간단해 보이지만, 실제 구현에는 다섯 가지 주요 함정이 기다리고 있다.
첫째, createMediaElementSource()는 엘리먼트당 딱 한 번만 호출할 수 있다. 두 번 호출하면 InvalidStateError가 던져진다. React 환경에서는 리렌더링마다 재생성하지 않도록 마운트 시점에 딱 한 번 바인딩하고, Map으로 안정적으로 관리해야 한다.
둘째, video.muted = true는 스피커 출력뿐 아니라 AudioContext 그래프 입력까지 함께 막아버린다. 사용자가 오피스에서 미리보기 음소거를 켜두고 내보내기 하면 무음 파일이 생성된다. 이 버그는 재현하기도 어렵고, 사용자가 인지하기는 더 어렵다.
셋째, 미리보기 음소거와 내보내기 음량은 독립적으로 제어되어야 한다. 해결책은 스피커 출력만 담당하는 masterGain 노드를 따로 두고, 각 소스의 gainNode는 masterGain과 내보내기 탭 양쪽에 연결하는 구조다. 음소거는 masterGain.gain.value = 0으로만 처리하고, 내보내기 탭은 그 상류에서 신호를 가져간다.
넷째, MediaStreamDestination은 자동 정리되지 않는다. 내보내기가 끝난 뒤 gainNode.disconnect(dest)를 명시적으로 호출하지 않으면, 다음 내보내기에 이전 믹스가 유령처럼 쌓인다.
다섯째, audioContext.resume()은 사용자 제스처가 필요하다. 내보내기 버튼 클릭 이후 recorder.start()를 바로 호출하면, 컨텍스트가 실제로 재개되기 전 첫 청크가 무음으로 기록될 수 있다. 경험적으로 150ms의 여유를 두는 것이 Chrome·Safari·Firefox 전반에서 유효하다고 저자는 밝힌다.
싱글톤 그래프 설계가 핵심
이 다섯 함정을 관통하는 해법의 공통 원리는 하나의 싱글톤 AudioContext와 엘리먼트별 gainNode 맵이다. 모든 미디어 엘리먼트는 마운트 시점에 단 한 번 그래프에 등록된다. 내보내기 시에는 getMixTap()으로 현재 믹스를 스냅샷해 MediaRecorder에 연결하고, recorder.onstop에서 반드시 disconnect()를 호출해 탭을 해제한다. 미리보기 음소거는 masterGain만 건드린다. 구조가 명확해지면 버그의 원인도 명확해진다.
브라우저와 데스크톱 경계의 프라이버시 설계
흥미롭게도 같은 맥락에서 다른 방향의 사례도 눈에 띈다. Tauri v2 + Rust + React 19로 만든 파일 자동 정리 앱 Mouzi는 브라우저 기반이 아닌 데스크톱 네이티브 환경을 선택했지만, 그 철학은 AetherCut과 닮아 있다. 파일 이름과 내용이 기기 밖으로 나가지 않는다. 클라우드 없이, 구독 없이, 모든 처리가 로컬에서 완결된다.
Mouzi가 Electron 대신 Tauri를 고른 이유도 같은 맥락이다. 번들 크기 5MB 대 100MB 이상, 시스템 WebView 활용, Rust의 네이티브 파일 시스템 성능. 사용자에게 필요한 것은 무거운 런타임이 아니라 조용하고 신뢰할 수 있는 도구다.
클라이언트 사이드의 진짜 의미
두 사례가 교차하는 지점은 '클라이언트 사이드'라는 단어의 재정의다. AetherCut은 브라우저 안에서, Mouzi는 데스크톱 위에서, 둘 다 서버 없이 미디어와 파일을 처리한다. 이 흐름의 본질은 기술 선택이 아니라 사용자의 데이터가 어디서 처리되는가에 대한 태도다. 개인정보 감수성이 높아지고, 구독 피로가 쌓이는 지금, '아무것도 업로드하지 않는' 경험은 기능이 아니라 신뢰의 언어가 되어가고 있다.
프론트엔드 개발자에게 남는 질문
Web Audio API 믹싱 그래프는 복잡하다. Tauri 기반 데스크톱 앱은 웹 배포보다 진입 장벽이 높다. 그런데 그 복잡함을 감수하는 이유가 '사용자의 파일이 서버로 가지 않는다'는 한 문장으로 수렴된다면, 그 기술 선택은 단순한 엔지니어링 결정이 아니다. 어떤 경험을 사용자에게 보장할 것인가에 대한 프로덕트 결정이다.
브라우저가 미디어 스튜디오가 되려면, 개발자는 API의 경계를 정확히 알고, 그 경계를 설계로 메워야 한다. canvas.captureStream()이 오디오를 빠뜨린다는 사실을 모르는 개발자와 아는 개발자가 만드는 제품은 사용자 경험에서 완전히 다른 결과를 낸다. 함정을 아는 것이 곧 품질이다.