개발 취재록

여러 폰에서 같은 영상 동시 재생, 영상 서버 없이 만들기 — 신호만 중계하고 각자 파일은 부분 해시로 확인

영상은 각자 폰에 미리 저장하고, 서버는 재생 위치와 상태만 중계하면 된다. 여러 사람이 각자 폰으로 같은 안무 영상을 따라 보고 대형·동선을 겹쳐 보는 웹앱을 이렇게 옮겼다. 목표는 연습할 때마다 노트북 서버를 켜고 같은 와이파이에 연결한 뒤 바뀐 IP를 알려 주는 수고를 없애는 것이었다 .

결론

리더와 뷰어 모두 미리 받은 파일을 선택해 URL.createObjectURL(file)로 재생한다. 클라우드 서버는 재생·정지·재생 위치 신호와 선택적인 동선 데이터만 전달한다. 파일 대조에는 파일 크기 + 앞·중간·끝 각 8MB의 부분 해시를 사용했다 .

아래는 설계 설명용 예시다. 저장소 구현을 복사한 코드가 아니다. 앞·중간·끝 8MB를 읽는 방식은 세션에 있지만, 아래의 작은 파일 처리 기준·크기 직렬화·중간 오프셋·SHA-256 선택은 이 예시의 결정이다 .

const CHUNK = 8 * 1024 * 1024; // 정확히는 8 MiB

async function partialFingerprint(file) {
  const size = file.size;
  const offsets = size <= CHUNK * 3
    ? [0]
    : [0, Math.floor(size / 2 - CHUNK / 2), size - CHUNK];
  const parts = [new TextEncoder().encode(String(size))];
  for (const off of offsets) {
    const end = size <= CHUNK * 3 ? size : off + CHUNK;
    parts.push(new Uint8Array(await file.slice(off, end).arrayBuffer()));
  }
  const joined = new Blob(parts);
  const digest = await crypto.subtle.digest('SHA-256', await joined.arrayBuffer());
  return [...new Uint8Array(digest)]
    .map(b => b.toString(16).padStart(2, '0')).join('');
}

// 리더와 뷰어가 동일한 알고리즘으로 계산한 결과를 비교한다.
// 불일치하면 참여를 막는다. 일치해도 파일 전체가 같다는 증명은 아니다.

이 예시는 영상 바이트를 최대 24MiB 읽는다. 조각과 합친 버퍼가 함께 존재할 수 있으므로 최대 메모리 사용량이 24MiB라는 뜻은 아니다. 실제 앱과 연동하려면 양쪽의 지문 알고리즘을 똑같이 맞춰야 한다.

상황

이 웹앱에서 리더는 재생·정지·재생 위치를 조작하고, 참가자인 뷰어들은 자기 폰의 영상을 그 상태에 맞춘다. 동선 파일을 붙이면 영상 위에 대형도 표시한다. 기존에는 노트북 서버가 영상과 SSE 신호를 함께 제공했다. SSE는 서버가 브라우저에 이벤트를 계속 보내는 통신 방식이다 .

클라우드판은 다음 구조로 설계했다. DO(Durable Object)는 이 설계에서 방별 상태와 연결을 관리하는 단위다 .

[리더 / 뷰어 브라우저] ─ 영상은 각자 로컬 파일
          │ WebSocket: 재생 상태·동선·마크
          ▼
[Cloudflare Worker] ─ 페이지 제공 + 방 개설·목록 API
   ├─ Lobby DO 하나 : 활성 방 목록
   └─ 방별 Room DO  : 방 상태 저장 + 연결된 참가자에게 전달

리더는 방 이름과 영상을 선택한다. 동선 파일 formation.json은 선택 사항이며, 붙이면 서버를 거쳐 뷰어에게 전달된다. 뷰어는 동선 파일을 따로 선택할 필요가 없다 .

증상

문제는 반복되는 준비 작업이었다. 연습할 때마다 노트북 서버를 켜고, 모두 같은 와이파이에 접속하고, 서버 IP를 알려 줘야 했다 .

초기 논의는 클라우드 영상 전송 비용으로 흘렀다. 720p·2Mbps·194초 영상을 15명이 여러 차례 재생하는 조건에서 월 100GB가 넘는 전송량을 예상하고 저장소나 VM을 검토했다. 이는 세션 당시의 가정에 따른 계산이며 실측 사용량이나 청구액이 아니다 .

원인

해결해야 할 운영상의 제약은 영상 제공과 재생 신호 중계가 노트북 서버에 함께 묶여 있다는 점이었다. 전환점은 “영상을 스트리밍 하거나 다운로드하게 하진 않아도 돼”라는 요구 정리였다. 영상은 카톡 등으로 사전 공유하고, 공개된 고정 주소의 서버는 동기화만 담당하면 됐다 .

영상 전송을 빼면 중계할 데이터가 줄어든다. 다만 이를 곧바로 서버 전체 비용이 0이라는 보장으로 읽으면 안 된다. Durable Objects에는 요청·실행 시간·저장소 사용 등에 따른 과금 기준이 있다. 이 글에서 확인된 것은 영상 중계를 제거한 설계이며, 장기 운영 비용 측정은 아니다. Cloudflare 요금 문서

새로 생기는 문제는 파일 버전이다. 각자 다른 인코딩·길이·시작점의 영상을 준비하면 같은 재생 위치 신호를 받아도 다른 장면을 보게 된다. 그래서 선택한 파일을 대조하는 절차를 넣었다 .

해결

1. 영상은 선택한 로컬 파일로 재생

리더와 뷰어 모두 <input type=file>로 파일을 선택하고 URL.createObjectURL(file)<video>에 연결한다. 세션에서는 과거에 서버 영상을 통째로 Blob으로 만들다가 겪은 iOS 메모리 문제와 구분하면서도, 로컬 File 재생은 실기기에서 먼저 확인해야 할 설계의 핵심 위험으로 다뤘다 .

데스크톱 자동 검증에서는 재생 준비 상태 readyState=4를 확인했다. 이후 “아이폰도 되고.. 데스크탑도 되는데”라는 실제 사용 확인이 나왔다. 이 기록만으로 긴 영상의 메모리 사용량이나 모든 모바일 브라우저의 동작까지 검증됐다고 할 수는 없다 .

2. 파일 대조는 부분 해시로

세션의 구현 보고는 앞·중간·끝 각 8MB를 읽는 부분 해시다. 리더가 방을 만들 때 지문을 등록하고, 뷰어 파일의 지문과 다르면 안내 후 참여를 막도록 설계했다 .

자동 검증에서는 두 탭에 동일한 영상 바이트를 전달했다. 지문 일치와 참여를 확인한 뒤, 새로 불러온 뷰어가 리더를 따라 0.40→0.82→1.23초로 재생되는 것을 확인했다. 다른 파일의 참여 차단은 수동 테스트 항목으로 남겼으며, 이 세션에는 그 테스트 결과가 명시돼 있지 않다 .

3. 렌더링과 동기화 알고리즘을 재사용

core.jsseekComp, RESYNC_THRESHOLD를 포함한 기존 동기화 로직을 재사용하고, SSE·HTTP 요청 중심의 통신을 WebSocket으로 옮겼다. 기존 메시지 유형 sync, formation, mark, reload도 유지하는 방향이었다 .

이는 모든 서버리스 환경에서 SSE를 쓸 수 없다는 뜻은 아니다. 선택한 Cloudflare 구조에서는 WebSocket Hibernation으로 연결을 유지하면서 유휴 시 실행 비용을 줄이는 방식을 사용했다 . 해당 특성은 Cloudflare WebSocket 문서에 설명돼 있다.

영상이 로컬 파일이 되면서 클라우드판에는 뷰어 대역폭 절약을 위한 HLS 화질 단계와 ffmpeg 인코딩을 가져올 필요가 없어졌다 .

4. 별도 DB 대신 DO 내장 저장소

“외부 DB가 필요 없다”와 “저장할 상태가 없다”는 다르다. Cloudflare 설계에서는 Lobby DO가 방 목록을, Room DO가 방 메타데이터·동선·마크 등을 관리한다. 내장 저장소를 쓰므로 별도의 DB 서비스를 두지 않는 선택이었다 .

리더의 조작이 1시간 없으면 방을 정리하는 요구도 구현 항목에 들어갔다. 다만 당시 구현 보고는 실제 1시간 경과 후 alarm 발화를 배포 뒤 확인할 항목으로 구분했다. 이 세션만으로 장시간 만료 동작의 실측까지 완료됐다고 쓰지는 않는다 .

덤 — 파일 선택 실패처럼 보였던 스크립트 초기화 오류

웨일에서 파일 선택 후 진행하지 못하는 문제는 HTTPS 배포 후에도 남았다. 실제 로그에서는 crypto.subtle이 존재했지만 window.FormationFingerprintundefined여서 페이지 초기화 중 예외가 났다 .

세션에서는 fingerprint.js라는 파일명이 추적 차단에 걸렸다고 판단해 mediacheck.js로 바꿨고, 이후 “된다!! 됐어!”라는 확인을 받았다. 로그와 이름 변경 후 성공은 확인됐지만, 차단 규칙 자체를 추출해 검증한 기록은 없다. 이 사례를 모든 브라우저에서 특정 파일명을 금지해야 한다는 규칙으로 일반화하지는 않는다 .

취재 후기 — 영상을 어디로 보낼지가 아니라, 보내야 하는지부터

박도은
각자 폰으로 같은 안무 영상을 맞춰 보는 웹앱을 만들고 있어. 그런데 연습할 때마다 노트북 서버를 켜고, 모두 같은 와이파이에 붙인 뒤 IP를 알려 주는 게 번거로워 .
정바다
서버를 클라우드로 옮기면 편해지겠네. 다만 영상을 계속 내려주면 전송량이 커져. 15명이 반복해서 연습하는 조건으로 월 100GB 넘게 계산했어. 실제 사용량은 아니고 가정에 따른 추정이야 .
정하늘
그 계산은 서버가 영상까지 전달할 때의 이야기지. 우리가 없애려는 준비 과정에서, 영상 배포도 꼭 이 서버가 해야 하는지 먼저 확인하자.
박도은
영상은 카톡으로 미리 주고받으면 돼. 이 앱에서 다운로드나 스트리밍까지 할 필요는 없어. 서버를 매번 띄우고 주소를 알려 주는 과정을 없애고 싶은 거야 .
정하늘
그러면 영상은 각자 폰에 두고, 서버는 재생 위치와 상태만 전달하면 되겠네. 같은 와이파이에 모일 필요도 없고 노트북 서버도 필요 없어져 .
박도현
싸게 전송할 곳을 찾기 전에, 우리가 직접 전송해야 하는 데이터부터 가려야겠네. 준비의 번거로움을 줄이는 요구가 구조를 바꾼 셈이야.
정바다
그런데 각자 영상을 준비하면 다른 버전을 가져올 수도 있어. 재생 위치가 같아도 장면이 달라질 테니 파일 내용으로 계산한 지문, 즉 해시를 비교하자 .
정하늘
전체 파일을 한 번에 읽는 방식은 메모리부터 확인해야 해. 여기서 쓸 crypto.subtle.digest는 입력을 통째로 받아서, 수백 MB 파일을 그대로 넣는 방식을 피하려고 했어 .
박도은
그래서 앞·중간·끝 8MB씩 읽는 부분 해시를 구현했어. 전체 영상을 읽는 대신 일부만 읽고 파일 크기도 대조에 넣는 설계야 .
정하늘
읽는 양을 줄이는 선택이네. 하지만 읽지 않은 부분만 달라진 같은 크기의 파일은 놓칠 수 있어. 같은 파일을 사전 공유한다는 전제 아래 쓸 검사로 봐야 해 .
박도현
지문이 다르면 막을 수 있지만, 같다고 모든 바이트를 검사한 건 아니야. 검사 범위를 줄였으면 그 한계도 같이 설명해야 해.
*리더와 뷰어를 브라우저 두 탭에서 시험했다.*
박도은
선택한 파일을 임시 주소로 연결하는 createObjectURL로 리더 영상이 재생돼. 그런데 같은 파일로 들어온 뷰어는 지문과 참여가 정상인데도 0초에서 멈춰 있어 .
정바다
뷰어의 메시지 처리 중 예외가 났을 수도 있어. 처리 코드가 예외를 밖으로 드러내지 않는 구조라, 오류가 보여야 할 곳에서 조용할 가능성이 있어 .
정하늘
우선 서버가 보내는지부터 확인하자. WebSocket은 연결을 유지하며 양방향 메시지를 주고받는 통로야. 페이지 처리 코드를 거치지 않는 연결로 신호가 오는지 확인하자 .
박도은
직접 붙인 연결에는 신호가 와. 뷰어 페이지를 새로 불러오니 이번에는 재생 위치가 0.40, 0.82, 1.23초로 움직이며 리더를 따라가 .
정하늘
이번 시험에서는 새로 불러온 상태로 성공했네. 앞선 멈춤은 1.39초짜리 영상이 끝난 뒤 합류한 상태와 시험 중 상태 조작이 겹친 것으로 판단했어. 예외 가설은 확인되지 않았어 .
박도현
재시도 성공은 관찰이고, 앞선 실패 원인은 그 관찰을 설명하는 판단이야. 둘을 구분해야 한 번의 성공으로 모든 동기화 문제가 없다고 결론 내리지 않겠네.
*배포 환경을 확인하고 실제 폰에서도 시험했다.*
정바다
폰에서는 HTTPS 주소로 시험하자. 일반 HTTP의 PC IP로 접속하면 보안 컨텍스트, 즉 브라우저가 보안 API 사용을 허용하는 조건을 못 갖춰 지문 계산이 막혀 .
박도은
배포 환경에서는 보안 API와 리더·뷰어 신호 전달을 확인했어. 아이폰과 데스크톱도 된다는 확인이 있었지만, 웨일에서는 파일을 골라도 여전히 진행이 안 됐어 .
정하늘
그때 폰 로그에는 보안 API가 있다고 나왔어. 대신 지문 계산 모듈이 준비되지 않아 초기화에서 예외가 났지. 파일명 차단을 의심해 이름을 바꾼 뒤 성공 확인을 받았어 .
박도현
영상 중계를 없애면 운영 준비는 줄어들지만, 각 폰에서 파일을 대조하고 재생하는 책임이 커져. 전송을 덜 하는 설계일수록 실제 기기에서 그 경로를 끝까지 확인해야 해.