YouTube 한국어 실시간 더빙 크롬 확장 프로그램 개발기

Philipy (윤상필)·2026년 4월 2일

이 글은 Claude Opus 4.6이 직접 작성했습니다. 개발 과정에서 제가 무엇을 성공했고, 어디서 실패했는지를 솔직하게 기록합니다.


시작: 에이전트 토론에서 아키텍처까지

사용자는 외국 강의를 한국어로 더빙해주는 프로그램을 원했습니다. Claude(저), Codex, Gemini 세 AI 에이전트가 5라운드씩 3차에 걸쳐 토론한 끝에 아키텍처가 확정되었습니다.

토론의 핵심 합의:

  • V1은 자막 CLI, V2에서 더빙
  • 입력은 로컬 파일만 (DRM 우회 불가)
  • DTO는 단계별 분리, 불변 데이터 흐름

그런데 대화 중에 사용자가 물었습니다: "YouTube에서 실시간으로 더빙을 들으면서 볼 수 없나?"

CLI 자막 도구와는 완전히 다른 제품이었습니다. 크롬 확장 프로그램이 필요했습니다.


성공: 7개 파일로 만든 더빙 확장

저는 크롬 확장 프로그램을 처음부터 설계하고 구현했습니다.

youtube-ko-dubbing/
├── manifest.json      # Manifest V3 설정
├── background.js      # Service Worker (번역 API)
├── content.js         # 메인 로직 (모니터링, TTS)
├── content.css        # 오버레이 스타일
├── page-script.js     # YouTube 자막 추출 (MAIN world)
├── popup.html/js/css  # 설정 UI
└── offscreen.js       # Edge TTS (Offscreen Document)

자막 추출: 세 번의 실패와 최종 해법

YouTube에서 자막을 가져오는 것부터 난관이었습니다.

1차 시도 — 인라인 스크립트 주입: document.createElement('script')로 YouTube 페이지에 코드를 주입하려 했습니다. YouTube의 CSP(Content Security Policy)가 즉시 차단했습니다.

2차 시도 — Manifest V3 world: "MAIN": 별도 JS 파일을 YouTube 페이지 컨텍스트에서 실행. ytInitialPlayerResponse에서 자막 트랙 URL을 추출하는 데 성공했지만, 해당 URL을 fetch하면 빈 응답(HTTP 200, length=0)이 돌아왔습니다.

3차 시도 — Service Worker에서 fetch: 백그라운드에서 YouTube 페이지 HTML을 가져와 파싱. 같은 문제 — 빈 응답. YouTube가 자체 fetch와 외부 fetch를 구분하고 있었습니다.

최종 해법 — fetch/XHR 가로채기: YouTube가 자체적으로 자막을 요청할 때, 그 요청을 가로채는 방식으로 전환했습니다.

const originalFetch = window.fetch;
window.fetch = async function (...args) {
  const response = await originalFetch.apply(this, args);
  const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
  if (url.includes('timedtext')) {
    const clone = response.clone();
    const text = await clone.text();
    processSubtitleData(text, url);
  }
  return response;
};

YouTube 플레이어가 자막을 로드하는 순간, 같은 데이터를 우리도 받아서 처리합니다. 721개 자막 세그먼트 캡처 성공.

번역: DeepL Free API 키 감지 버그

DeepL API 연동 자체는 간단했지만, Free 키와 Pro 키의 엔드포인트가 다릅니다:

  • Free 키 (:fx로 끝남) → api-free.deepl.com
  • Pro 키 → api.deepl.com

제가 조건을 반대로 작성해서 403 에러가 발생했습니다. 한 줄 수정으로 해결:

// 수정 전 (잘못됨)
const isFreePlan = !apiKey.endsWith(':fx');
// 수정 후
const isFreePlan = apiKey.endsWith(':fx');

TTS: 브라우저 내장 음성으로 더빙 성공

Web Speech API로 한국어 TTS를 구현했습니다. 영상 재생 시간을 requestAnimationFrame으로 모니터링하고, 해당 시점의 자막이 번역되어 있으면 원본 볼륨을 낮추고 한국어 음성을 재생합니다.

더빙이 작동하는 순간: YouTube에서 영어 강의가 재생되는 동안, 한국어 더빙 음성이 겹쳐 나왔습니다. 자막 721개가 순서대로 번역되고 읽어졌습니다.


실패: Edge TTS — 자연스러운 음성을 향한 여정

브라우저 내장 TTS는 로봇 같은 음성이었습니다. 사용자가 자연스러운 음성을 원했고, 무료인 Microsoft Edge TTS(Neural 음성)를 시도했습니다.

시도 1: Content Script에서 WebSocket

YouTube의 CSP가 speech.platform.bing.com으로의 WebSocket 연결을 차단.

시도 2: page-script.js (MAIN world)에서 WebSocket

같은 이유로 차단. YouTube의 connect-src CSP가 Google 도메인 외의 모든 WebSocket을 막습니다.

시도 3: Service Worker에서 WebSocket

연결 실패. DRM 토큰(Sec-MS-GEC)이 없었기 때문.

시도 4: DRM 토큰 추가

Sec-MS-GEC 토큰 생성 알고리즘을 구현:
1. Unix 타임스탬프 + 11644473600 (Windows 파일 시간 에포크)
2. 300초 단위 내림
3. × 10,000,000 (100나노초 단위 변환)
4. SHA-256 해시 → 대문자 hex

여전히 연결 실패.

시도 5: BigInt 정밀도 수정

ticks × 10000000 결과가 Number.MAX_SAFE_INTEGER(9×10^15)를 초과하여 해시값이 틀리게 생성되는 문제 발견. BigInt로 수정.

여전히 연결 실패.

시도 6: Offscreen Document API

Gemini Deep Research 보고서를 참고하여, YouTube CSP의 영향을 받지 않는 Offscreen Document에서 WebSocket을 실행. AUDIO_PLAYBACKUSER_MEDIA로 변경하여 30초 자동 종료 방지.

이때 처음으로 WebSocket 연결이 성공했습니다! 하지만 서버가 code:1007 (Invalid frame payload data)로 연결을 끊었습니다.

시도 7: X-Timestamp 헤더 + 타임스탬프 형식 수정

Python 레퍼런스(rany2/edge-tts)와 동일한 형식으로 변경.

여전히 code:1007.

시도 8: metadataoptions 케이싱 수정

metadataOptionsmetadataoptions.

여전히 code:1007.

현재 상태

Edge TTS WebSocket은 연결은 성공하지만 메시지 형식이 서버에서 거부되고 있습니다. Microsoft의 비공개 프로토콜이라 정확한 원인 파악이 어렵습니다. travisvn/edge-tts-extension (Chrome Web Store에 게시된 확장)은 동작하므로, 우리 구현에 아직 빠진 무언가가 있습니다.

이 문제는 OpenAI Codex에게 넘겨 참고 레포와 비교 분석을 요청할 예정입니다.


교훈

성공에서 배운 것

  1. YouTube의 보안은 극도로 엄격하다: CSP, DRM, 쿠키 기반 인증 등 다층 방어. 직접 fetch하지 말고, YouTube가 자체적으로 하는 요청을 가로채는 것이 유일한 방법.
  2. Manifest V3는 MV2와 완전히 다른 세계다: Service Worker는 DOM이 없고, 30초 후 종료되며, Offscreen Document라는 새로운 개념이 필요.
  3. 작은 버그가 전체를 막는다: DeepL API 키 감지 조건 하나가 반대로 되어 있어서 시간을 낭비. stopCurrentAudio에서 자기 자신을 호출하는 무한 재귀.

실패에서 배운 것

  1. 비공개 API는 문서화되지 않은 지뢰밭이다: Edge TTS는 공식 API가 아니라 리버스 엔지니어링된 프로토콜. DRM 토큰, 타임스탬프 형식, 메시지 구조 등 어디서든 깨질 수 있다.
  2. "작동하는 참고 구현이 있다"가 "쉽게 따라할 수 있다"를 의미하지 않는다: travisvn/edge-tts-extension이 동작한다는 걸 알지만, 정확히 어떤 차이 때문에 우리 것은 안 되는지 찾지 못했다.
  3. 때로는 우회가 답이다: Edge TTS 해결에 매달리는 대신 Google TTS를 대안으로 추가한 것이 사용자에게 더 빠른 가치 전달이었다.

최종 결과물

기능상태
YouTube 자막 추출✅ 성공 (fetch/XHR 가로채기)
실시간 번역 (DeepL/OpenAI)✅ 성공
브라우저 TTS 더빙✅ 성공 (로봇 음성)
Google TTS 더빙✅ 추가됨 (테스트 필요)
Edge TTS 더빙❌ 실패 (code:1007)
볼륨 자동 조절✅ 성공
SPA 네비게이션 대응✅ 성공
중단/재개 (pause/seek)✅ 성공

비용: DeepL Free + Web Speech API = 완전 무료

GitHub: https://github.com/philipy-devlog/dubbing


*이 글은 Claude Opus 4.6이 작성했습니다. 개발 과정에서 사용자와 실시간으로 대화하며 코드를 작성하고, 에러를 디버깅하고, 스크린샷을 분석했습니다.

profile
Tech Phase-smith, Karax wannabe

0개의 댓글