tips

DASH 딥: SegmentTemplate, ContentProtection, 시점 전환 MPD 구조

MPD 입문의 그 다음——SegmentTemplate vs SegmentTimeline, $Number$ vs $Time$, ContentProtection 시그널링, 그리고 DASH가 HLS보다 파싱은 어려운데 다운로드는 쉬운 구조적 이유.
FlowPick 팀
15분 분량
# dash # mpd # 딥 # segmenttemplate # drm # 스트리밍

MPD 입문 가이드에서 MPD가 무엇인지, 왜 MP4가 아니라 12KB XML이 튀어나오는지 설명했다. 이 글은 속편——MPD를 열어서 400줄의 <SegmentTemplate>, <SegmentTimeline>, <ContentProtection>, <AdaptationSet>을 보고, 진짜로 쓸만한 게 뭔지 알고 싶은 사람을 위한 것.

DASH 파서를 쓰고 있거나, 다운로더가 어떤 스트림은 되고 어떤 스트림은 안 되는 이유를 알고 싶다면, 이 글이 지도가 된다.

MPD의 계층

모든 MPD는 이런 중첩 구조:

MPD
├── Period                  # 콘텐츠의 1세그먼트 (본편, 광고 등)
│   ├── AdaptationSet       # 교환 가능한 버전 그룹 (비디오, 오디오 등)
│   │   ├── Representation  # 구체적인 1버전 (1080p, 720p 등)
│   │   │   ├── SegmentList     # 세그먼트 URL을 명시적으로 나열
│   │   │   ├── SegmentTemplate # 세그먼트 URL을 템플릿화
│   │   │   └── SegmentBase     # 단일 세그먼트 + 바이트 범위
│   │   └── ContentProtection   # DRM 시그널링 (Widevine, PlayReady)
│   └── EventStream         # 대역 외 이벤트 (ID3 같은 것)

전형적인 영화의 MPD는 Period 1개, AdaptationSet 2개(비디오+오디오), 각 셋에 Representation 5-10개. 광고 있는 라이브는 Period가 4개 이상. 멀티 카메라 스포츠 라이브는 각 카메라가 AdaptationSet 하나씩.

핵심: DASH는 모든 세그먼트 URL을 나열하지 않도록 설계되어 있다. HLS는 1개씩 나열. DASH는 템플릿 사용.

SegmentTemplate: 90%의 케이스

<SegmentTemplate
  initialization="video/init.mp4"
  media="video/$Number$.m4s"
  startNumber="1"
  timescale="1000"
  duration="6000" />

해석:

  • 초기화 세그먼트는 video/init.mp4 (fMP4 초기화 박스)
  • 각 미디어 세그먼트는 video/$Number$.m4s. $Number$는 1부터
  • 각 세그먼트는 6000밀리초 (timescale=1000이므로 6초)

이 Representation을 다운로드하려면 $Number$를 치환해 URL을 생성:

const startNumber = 1
const duration = 6000        // ms
const timescale = 1000       // 1단위 = 1 ms
const totalDuration = 7200000 // ms——Representation의 @duration 속성에서

const segmentCount = Math.ceil(totalDuration * timescale / (duration * timescale))
// = 1200 세그먼트

const urls = []
for (let n = startNumber; n < startNumber + segmentCount; n++) {
  urls.push(`video/${n}.m4s`)
}

HLS보다 훨씬 컴팩트——1200줄의 #EXTINF + URL이 1개의 <SegmentTemplate>으로. 대가는 파서가 직접 계산을 해야 하는 것이고, 엣지 케이스(마지막 세그먼트가 짧음, 세그먼트 길이 변화)에 주의.

$Number$ vs $Time$

$Number$는 세그먼트 인덱스(1, 2, 3……). $Time$은 세그먼트 시작 시각(timescale 단위). 둘 다 쓰는 것, 하나만 쓰는 것이 있다:

<SegmentTemplate
  media="video/$Time$.m4s"
  timescale="1000"
  duration="6000" />

$Time$을 파싱하려면 세그먼트 인덱스에 길이를 곱한다:

const segmentTime = (n - startNumber) * duration
const url = `video/${segmentTime}.m4s`

timescale을 곱하는 걸 잊으면 URL이 1000배 어긋나서 전부 404. 이게 DASH 파싱에서 가장 흔한 버그.

SegmentTimeline: 세그먼트 길이가 고정이 아닐 때

라이브와 동적 콘텐츠는 <SegmentTimeline>을 쓴다:

<SegmentTemplate timescale="1000" initialization="init.mp4" media="$Number$.m4s">
  <SegmentTimeline>
    <S t="0" d="6000" />
    <S t="6000" d="6000" />
    <S t="12000" d="4000" />
    <S t="16000" d="6000" r="3" />  <!-- r=3은 3회 반복: 총 4세그먼트 -->
    <S t="40000" d="6000" />
  </SegmentTimeline>
</SegmentTemplate>
  • t——시작 시각 (timescale 단위)
  • d——길이 (timescale 단위)
  • r——반복 횟수 (r+1개 세그먼트를 의미)

라이브의 timeline은 시간이 지나면서 자란다. 전형적 흐름:

  1. 클라이언트가 MPD 획득. 세그먼트 1-50 보임. minimumUpdatePeriod는 2초
  2. 클라이언트는 1-50 재생
  3. 클라이언트는 MPD 재획득. 이제 1-52 (timeline이 늘어남)
  4. MPD@endOfAvailability 또는 스트림 종료까지 반복

다운로더의 선택지:

  • 스트림이 완전히 끝나기를 기다린다 (VOD로 전환)——항상 가능한 건 아님
  • 현재 timeline을 스냅샷하고, 이후 추가되는 콘텐츠는 포기

FlowPick은 스냅샷 방식: 현재 timeline을 잡고, 세그먼트를 다운로드, 병합. 스트림이 아직 살아있으면 사용자가 한 번 더 트리거해서 새 세그먼트를 잡을 수 있다.

ContentProtection: DRM 시그널링

<ContentProtection
  schemeIdUri="urn:mpeg:dash:mp4protection:2011"
  value="cenc"
  cenc:default_KID="12345678-1234-1234-1234-123456789012" />

<ContentProtection
  schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
  value="Widevine">
  <cenc:pssh>AAAA...</cenc:pssh>
</ContentProtection>

<ContentProtection
  schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"
  value="PlayReady">
  <cenc:pssh>BBBB...</cenc:pssh>
</ContentProtection>

UUID가 DRM 시스템을 식별:

  • edef8ba9-79d6-4ace-a3c8-27dcd51d21ed——Widevine (Google)
  • 9a04f079-9840-4286-ab92-e65be0885f95——PlayReady (Microsoft)
  • f239e769-efa3-4850-9c16-a9036d3fe1c1——Adobe Primetime
  • 94ce86fb-07ff-4f43-adb8-93d2fa968ca2——FairPlay (Apple, DASH 맥락에서)

<cenc:pssh>는 Protection System Specific Header——base64 blob으로, key ID와 시스템 고유 데이터를 담는다. 브라우저는 이걸 EME(Encrypted Media Extensions)에 넘기고, EME가 시스템의 CDM(Content Decryption Module)과 대화한다.

여기서 선을 긋는다: FlowPick은 DRM 보호된 DASH 콘텐츠를 해독하지 않는다, 안 한다, 못 한다. 정당한 브라우저 확장도 오픈소스 툴도 못 한다. CDM은 블랙박스——암호화 버퍼를 주면 표시용 복호 프레임을 반환, 영원히 익스포트하지 않는다. 이를 우회하는 것은 반회피법(미국 DMCA §1201, 다른 지역도 동등)에 위반, CDM 계약에도 위반.

DASH 사양에는 "보호되지 않은" 경로가 있다: 어떤 AdaptationSet이 ContentProtection을 갖지 않으면, 세그먼트는 암호화되지 않고, 다운로더가 자유롭게 페치하고 병합할 수 있다. 그래서 FlowPick은 Bilibili, Udemy 무료 코스, 대부분의 기업 LMS를 처리할 수 있다. Netflix/Disney+/HBO Max는 못 한다.

"보호처럼 보이지만" DRM이 아닌 일반적 패턴

"다운로드 안 됨"이 전부 DRM인 건 아니다. 다운로드를 어렵게 하지만 실제로는 암호화하지 않는 패턴:

  1. 짧은 TTL의 토큰 URL——?token=abc&expires=1234567890. 토큰이 몇 초 만에 만료되어서, 청단을 파싱하면 바로 세그먼트를 페치해야 한다. FlowPick은 그렇게 한다. "몇 시간 전에 URL을 파싱하고 지금 다운로드" 식의 배치 다운로더는 실패.
  2. CDN의 CORS 제한——하지만 확장은 페이지 origin에서 요청을 보내므로 CORS에 묶이지 않아, 스니퍼를 사실상 막지 못한다.
  3. <BaseURL> 재작성——MPD의 BaseURL 요소는 문서 중간에 바뀔 수 있다. 각 BaseURL은 부모에 대해 상대적으로 해석되어, URL 체인을 추적해야 한다. 이건 DASH판 HLS 상대 URI인데, 에러를 일으키기 더 쉽다.

멀티 Period MPD: 광고와 라이브 이벤트

<Period id="ad1" start="PT0S" duration="PT30S">...</Period>
<Period id="main" start="PT30S">...</Period>
<Period id="ad2" start="PT1800S" duration="PT30S">...</Period>
<Period id="main2" start="PT1830S">...</Period>

각 Period는 자체 AdaptationSet을 가진다. 전체 콘텐츠를 다운로드하려면:

  1. @start로 Period 정렬
  2. 각 Period에서 비디오+오디오 세그먼트 전부 획득
  3. Period 사이 결합——단 codec이 Period 사이에 변하는 경우 주의

광고 브레이크는 종종 다른 codec(광고는 H.264 baseline, 본편은 H.264 high). 무식하게 결합하면 본편은 정상 재생되다가 광고 부분에서 글리치.

수정:

  1. 광고 Period를 건너뛴다 (FlowPick의 접근——사용자는 광고를 원하지 않으므로)
  2. 광고 Period를 본편과 동일 codec으로 재인코딩 (느림, 트랜스코딩 필요)
  3. 더 똑똑한 멀티플렉서로 codec 전환 처리 (FFmpeg은 가능하지만 복잡)

시점 전환과 trick-mode AdaptationSet

<AdaptationSet id="1" contentType="video" group="1">
  <!-- 메인 카메라 -->
</AdaptationSet>
<AdaptationSet id="2" contentType="video" group="1">
  <!-- 백업 카메라, 같은 group -->
</AdaptationSet>
<AdaptationSet id="3" contentType="video" group="2">
  <!-- Trick mode (시크바 썸네일) -->
</AdaptationSet>

group="1"은 같은 콘텐츠의 다른 시점을 의미. 플레이어는 사용자에게 카메라 전환을 제공. 다운로더는 하나를 선택——보통 첫 번째지만, 이상은 사용자에게 선택시킨다.

Trick-mode AdaptationSet(여기서 group=2)은 시크바에서 드래그 시 미리보기용 저비트레이트 저프레임레이트 버전. 이걸 다운로드해봤자 소용없다——파서가 이걸 "비디오"로 잘못 인식하지 않게.

프리페치와 병렬화

DASH 다운로드에서 가장 실용적인 최적화: 세그먼트를 병렬 페치.

async function downloadRepresentation(rep, concurrency = 6) {
  const segmentUrls = computeSegmentUrls(rep)
  const initBytes = await fetch(rep.initUrl).then(r => r.arrayBuffer())

  const results = new Array(segmentUrls.length)
  let nextIndex = 0

  async function worker() {
    while (nextIndex < segmentUrls.length) {
      const i = nextIndex++
      const resp = await fetch(segmentUrls[i])
      results[i] = new Uint8Array(await resp.arrayBuffer())
    }
  }

  await Promise.all(Array.from({ length: concurrency }, () => worker()))

  // init + 세그먼트를 순서대로 결합
  const total = initBytes.byteLength + results.reduce((s, b) => s + b.byteLength, 0)
  const out = new Uint8Array(total)
  let offset = 0
  out.set(new Uint8Array(initBytes), offset); offset += initBytes.byteLength
  for (const buf of results) {
    out.set(buf, offset); offset += buf.byteLength
  }
  return out
}

6개 병렬 연결이 스위트스팟——그 이상은 CDN 레이트 리미트를 트리거, 그 이하는 대역 낭비. Promise.all의 worker 풀 패턴으로 세그먼트가 순서 없이 완료되어도 올바른 배열 위치에 떨어진다.

이게 FlowPick 내부에서 쓰는 패턴. 브라우저 내 병합 글에서 최종 버퍼를 어떻게 멀티플렉스하는지 설명.

밟은 함정

함정 1: BaseURL 무시. MPD가 https://cdn.example.com/v/manifest.mpd에 있고, 안에 <BaseURL>https://other-cdn.example.com/</BaseURL>이면, 이 섹션의 상대 URL은 other-cdn.example.com으로 해석. MPD의 origin이 아니다. 이걸 잊으면 "전부 404" 버그의 1등 원인.

함정 2: timescale 파싱 실수. duration="6000"이고 timescale="1000"이면 6초. timescale="90000"(비디오에서 흔함)이면 0.067초. timescale을 반드시 확인——같은 MPD 내에서 AdaptationSet마다 다를 수 있다.

함정 3: init 세그먼트 잊기. HLS(TS의 경우)는 각 세그먼트가 자기 기술적이지만, DASH의 fMP4 세그먼트는 init 세그먼트 없이는 쓸모없다. "다운로드 성공한 것처럼 보이는데" 재생 안 되는 파일은 initialization="init.mp4"를 잊은 경우가 많다.

함정 4: 라이브 MPD는 갱신된다. 한 번 파싱하고 스냅샷으로 다운로드하면, 이후 추가된 세그먼트를 놓친다. VOD는 문제없음. 라이브는 주기적으로 재획득하거나 스트림 종료를 기다린다.

참고 자료

정리

DASH와 HLS는 같은 문제——HTTP 기반의 적응형 스트리밍——를 반대 접근으로 푼다. HLS는 각 세그먼트를 나열. DASH는 템플릿을 쓴다. HLS는 기본이 MPEG-TS. DASH는 fMP4. HLS의 DRM은 Apple 독점(FairPlay). DASH의 DRM은 멀티 벤더(Widevine, PlayReady, FairPlay)로 CENC를 통과.

다운로더 관점에서 DASH는 구조적으로 더 다루기 쉽다: fMP4 세그먼트는 직접 결합 가능, init 세그먼트가 codec 정보를 명시, $Number$ 치환은 청단을 추가 페치 없이 URL을 계산. 복잡도는 XML 파싱에 있다——하지만 SegmentTemplate, SegmentTimeline, BaseURL, ContentProtection을 다룰 수 있으면, 현실의 MPD의 95%를 커버.

다음은 fMP4의 결합이 왜 되는지, moof 박스에 뭐가 들었는지, 왜 "리먹스하고 트랜스코딩하지 않는"게 올바른 철학인지.


추천 글