Mozartiade는 모차르트의 생애와 작품을 다루는 한국어 웹 서비스입니다. Next.js로 만들어졌고 콘텐츠는 데이터베이스에 들어 있으며, 작품 감상은 유튜브 임베드로 제공합니다. 여기에 "데스크탑 버전"을 만들기로 했습니다. 목표는 두 가지였습니다.
- 웹의 콘텐츠를 그대로 재사용한다: 작품 카탈로그, 읽을거리, 연표, 살롱까지.
- 브라우저가 못 하는 일을 보탠다: 디스크에 있는 로컬 음악·동영상 파일 재생.
이 글은 그 데스크탑 앱을 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 빈 줄, 코덱 같은 플랫폼의 규칙이 있었습니다. 데스크탑 미디어 앱은 결국 이런 규칙들과 사이좋게 지내는 일이라는 걸 다시 배웠습니다.