Mozartiade는 모차르트의 생애와 작품을 다루는 한국어 웹 서비스입니다. Next.js로 만들어졌고 콘텐츠는 데이터베이스에 들어 있으며, 작품 감상은 유튜브 임베드로 제공합니다. 여기에 "데스크탑 버전"을 만들기로 했습니다. 목표는 두 가지였습니다.

  1. 웹의 콘텐츠를 그대로 재사용한다 — 작품 카탈로그, 읽을거리, 연표, 살롱까지.
  2. 브라우저가 못 하는 일을 보탠다 — 디스크에 있는 로컬 음악·동영상 파일 재생.

이 글은 그 데스크탑 앱을 Electron으로 만든 과정과, 중간에 밟은 함정들을 정리한 기록입니다.

설계콘텐츠는 하나의 진실로 둔다

가장 먼저 정한 원칙은 "콘텐츠의 단일 소스(single source of truth)를 흔들지 않는다" 였습니다. 웹앱은 서버에서 데이터베이스를 직접 조회해 렌더하는 구조라, 콘텐츠를 정적으로 추출해 오프라인 번들로 만드는 건 비현실적이었습니다. 그래서 데스크탑은 콘텐츠를 복제하지 않고 라이브 웹을 그대로 품는 클라이언트로 두고, 거기에 네이티브 기능만 얹기로 했습니다.

결정 선택 이유
콘텐츠 배포된 웹앱을 임베드 단일 소스 유지, 관리 비용 0
프레임워크 Electron (Forge + Vite + React) 로컬 파일·OS 통합이 쉬움
로컬 재생 네이티브 신규 구현 브라우저 샌드박스로는 불가능한 영역

문제는 "웹을 어떻게 품느냐"였습니다. 첫 직감은 <iframe>이었지만 곧 막혔습니다. 사이트가 X-Frame-Options: DENY와 엄격한 CSP를 보내기 때문에 iframe 임베드가 차단됩니다.

해법은 Electron의 WebContentsView 였습니다. iframe이 아니라 별도의 웹 콘텐츠라서 사이트의 프레임 차단·CSP에 걸리지 않고, 무엇보다 로그인 쿠키·세션이 그대로 동작합니다.

셸(React)은 상단 탭 바와 하단 미니플레이어만 그리고, "둘러보기" 탭일 때 그 중간 영역에 WebContentsView를 겹쳐 웹을 띄웁니다. "음악/동영상" 탭일 때는 웹 뷰를 숨기고 네이티브 뷰를 보여줍니다. 오디오 엔진은 셸 쪽에 두어, 탭을 옮겨도 음악이 끊기지 않게 했습니다.

함정 1네이티브 모듈이 V8과 안 맞다

로컬 라이브러리(스캔한 곡·재생목록)를 저장할 곳으로 better-sqlite3를 골랐습니다. 그런데 Electron의 V8 버전과 네이티브 모듈이 컴파일 단계에서 충돌했습니다. 플래그로 넘길 수 있는 문제가 아니라, 라이브러리가 아직 그 V8 API 변화에 따라오지 못한 경우였습니다. 게다가 이건 Windows 빌드에서도 똑같이 발목을 잡을 문제였습니다.

다행히 처리는 가벼웠습니다. 저장소 접근을 처음부터 함수 API 뒤에 숨겨 둔 덕분입니다.

// 호출부는 이 함수들만 알지, 뒤가 SQLite인지 JSON인지 모른다
export function listTracks(query?: string, kind?: MediaKind): Track[] { /* ... */ }
export function upsertTrack(t: UpsertTrack): 'added' | 'updated' { /* ... */ }

그래서 SQLite를 걷어내고 순수 JS의 JSON 저장소(메모리 보관 + 디바운스 저장)로 바꾸는 데 db.ts 한 파일만 손대면 됐습니다. 단일 사용자의 라이브러리 규모라면 차고 넘치는 선택이고, 덤으로 네이티브 모듈이 0개가 되어 mac/Windows 빌드 매트릭스가 단순해졌습니다.

SQL/ORM 타입을 모듈 경계 밖으로 흘리지 않은 것이 결정적이었습니다. 저장 엔진 교체가 한 파일 안에서 끝났습니다.

로컬 재생의 핵심media:// 와 Range

로컬 파일을 <audio>/<video>로 재생하려면 렌더러에 파일을 안전하게 흘려보낼 통로가 필요합니다. 커스텀 프로토콜 media://를 권한 있는(privileged) 스킴으로 등록하고, 감시 폴더 안의 파일만 통과시키도록 했습니다.

처음엔 단순히 파일을 통째로 응답했는데, 증상이 이상했습니다. 프로그레스바를 클릭해도 그 지점부터 재생되지 않고, 큰 파일은 처음 로딩이 느렸습니다. 로컬 파일인데 "버퍼링"을 하는 것처럼 보였습니다.

원인은 HTTP Range 미지원이었습니다. 로컬 파일이라도 <video>는 HTTP 시맨틱으로 통신합니다. 서버(여기선 프로토콜 핸들러)가 Range 요청을 무시하고 200으로 전체를 주면, 브라우저는 시크할 때마다 처음부터 다시 받습니다. 그래서 직접 Range를 처리했습니다.

const range = request.headers.get('Range');
if (range) {
  const [, s, e] = /bytes=(\d*)-(\d*)/.exec(range) ?? [];
  const start = s ? +s : 0;
  const end = e ? +e : size - 1;
  return new Response(toWeb(fs.createReadStream(file, { start, end })), {
    status: 206,
    headers: {
      'Content-Range': `bytes ${start}-${end}/${size}`,
      'Accept-Ranges': 'bytes',
      'Content-Length': String(end - start + 1),
      'Content-Type': mime,
    },
  });
}

206 부분 응답을 돌려주자 시크가 즉시 동작하고, 큰 파일도 필요한 구간만 점진적으로 스트리밍됐습니다. 같은 통로로 앨범 아트와 자막도 서빙합니다.

커스텀 동영상 컨트롤

처음엔 <video controls>의 네이티브 컨트롤을 그대로 썼습니다. 그런데 컨트롤에 포커스를 준 뒤 스페이스바를 누르면 주황색 포커스 테두리가 남았습니다. 페이지 CSS로 outline: none을 줘도 사라지지 않았는데, 그 포커스 링이 브라우저의 user-agent shadow DOM 안에 있어 바깥 CSS가 닿지 않기 때문이었습니다.

결국 네이티브 컨트롤을 버리고 커스텀 컨트롤(재생/시크/볼륨/음소거/전체화면/자막)을 직접 만들었습니다. shadow DOM이 사라지니 포커스 링 문제도 근본적으로 없어지고, 앱 테마와 통일된 UI를 덤으로 얻었습니다. 스페이스바 재생/정지와 좌우 화살표 10초 시크도 직접 달았는데, 단축키가 시크바(<input type=range>)에 포커스가 가도 동작하도록 "텍스트 입력일 때만 무시" 하는 규칙으로 다듬었습니다.

자막srt·vtt·smi 를 VTT로

브라우저 <video>는 사실상 WebVTT만 자막으로 받습니다. .srt.smi는 직접 못 읽습니다. 그래서 영상과 같은 폴더에서 같은 이름의 자막 파일을 찾아 VTT로 변환한 뒤 <track>으로 주입했습니다.

  • SRT → VTT: 타임스탬프의 ,.로 바꾸고 헤더만 붙이면 됩니다.
  • SMI(SAMI) → VTT: <SYNC Start=ms> 단위로 큐를 만들고, 다음 SYNC 시작을 그 큐의 종료 시각으로 잡았습니다.
  • 인코딩: 한글 자막은 euc-kr이 많아, UTF-8 디코딩에 실패하면 euc-kr로 폴백합니다.

여기서 작은 함정을 하나 만났습니다. 긴 자막이 한 줄만 나오는 문제였는데, 원인은 WebVTT 문법이었습니다.

WebVTT는 빈 줄을 큐의 끝으로 해석합니다. 변환 결과의 큐 텍스트 중간에 빈 줄이 섞이면 그 앞 한 줄만 표시됩니다. 큐 안의 빈 줄을 제거하니 여러 줄 자막이 온전히 나왔습니다.

MKV·AVI는 리먹스로

코덱 이야기를 짚고 넘어가야 합니다. Electron의 Chromium은 H.264·AAC·MP3 같은 코덱을 내장해 MP4/WebM 계열은 잘 재생합니다. 하지만 WMA·AVI·상당수의 MKV·HEVC는 디코더가 없어 재생되지 않습니다. "지원되도록 켜는" 옵션 같은 건 없습니다. 방법은 변환뿐입니다.

그래서 리먹스(remux) 를 택했습니다. 재인코딩 없이 컨테이너만 다시 포장하는 방식이라 빠르고 무손실입니다. 재생 시 ffprobe로 코덱을 확인하고,

  • 영상이 H.264면 → mp4로 리먹스
  • 영상이 VP8/VP9/AV1이면 → webm으로 리먹스
  • 오디오만 호환이 안 되면(예: AC3) → 오디오만 가볍게 변환
  • 그 외(HEVC·DivX 등)는 → "지원하지 않는 코덱" 으로 안내
// H.264 영상은 복사, 호환 안 되는 오디오만 AAC로
await runFfmpeg(['-y', '-i', file, '-c:v', 'copy', ...audioArgs,
                 '-movflags', '+faststart', out]);

변환 결과는 원본 경로+수정시각 해시로 캐시해서 두 번째 재생부터는 즉시 뜨게 했습니다. ffmpeg/ffprobe 바이너리는 번들에 포함하되, 실행 가능하도록 asar 밖으로 unpack하도록 패키징을 설정했습니다.

음악과 유튜브의 상호 정지

웹 탭에서 작품 유튜브를 틀면 하단의 로컬 음악과 소리가 겹쳤습니다. 둘은 다른 프로세스에 있습니다 — 유튜브는 WebContentsView(웹), 로컬 음악은 셸 렌더러. 서로의 존재를 모릅니다.

양방향으로 묶었습니다. 웹에서 미디어가 시작되면(media-started-playing 이벤트) 로컬 오디오를 멈추고, 반대로 로컬 음악이 시작되면 웹 뷰의 미디어를 멈춥니다. 유튜브는 교차 출처 iframe이라 바깥에서 직접 제어할 수 없는데, 하위 프레임에 직접 스크립트를 실행해 해결했습니다.

// 유튜브 iframe을 포함한 모든 하위 프레임에서 재생 중지
for (const frame of webContents.mainFrame.framesInSubtree) {
  frame.executeJavaScript(
    "document.querySelectorAll('video,audio').forEach(m=>m.pause())", true,
  ).catch(() => {});
}

같은 표는 한 번만 만든다

음악 목록에 정렬·열 너비 조절 헤더를 붙이고 나니, 동영상 목록에도 "똑같이" 해달라는 요청이 왔습니다. 복붙 대신 컬럼 정의를 받는 재사용 테이블 컴포넌트로 추출했습니다.

<TrackTable
  tracks={tracks}
  columns={columns}        // { key, label, width|flex, sortValue, render } 배열
  onPlay={(ordered, i) => player.playQueue(ordered, i)}
  storageKey="mzd.music"   // 열 너비·정렬을 localStorage에 보존
/>

음악과 동영상이 같은 컴포넌트를 쓰니, 한쪽을 고치면 양쪽이 같이 좋아집니다. 좁은 사이드바에서 시간 열이 0폭까지 줄어 두 줄로 깨지던 문제는, 제목을 자동확장 열로, 시간을 고정폭 열로 두어 해결했습니다.

마무리

데스크탑 앱이라고 해서 모든 걸 다시 만들 필요는 없었습니다. 콘텐츠는 웹이라는 단일 소스에 그대로 두고, 브라우저가 못 하는 영역(로컬 파일·코덱·OS 통합)만 네이티브로 보태는 것 — 이 경계 설정이 전체 작업을 가볍게 만들었습니다.

정작 시간을 많이 쓴 곳은 화려한 기능이 아니라, "로컬인데 왜 버퍼링을 하지?" 같은 작은 위화감들이었습니다. 그 끝에는 대개 Range, shadow DOM, WebVTT 빈 줄, 코덱 같은 플랫폼의 규칙이 있었습니다. 데스크탑 미디어 앱은 결국 이런 규칙들과 사이좋게 지내는 일이라는 걸 다시 배웠습니다.