지난 DASH 매니페스트 파서 편에서 우리는 단일 MPD(XML)가 계층 상속과 Period 평탄화를 거쳐 내부 manifest_ 모델이 되는 과정을 따라갔습니다. 그리고 정리에서 다음을 예고했죠.
HLS는 마스터 M3U8과 미디어 플레이리스트라는 2단 구조를 가집니다.
이번 편은 그 약속을 지키는 글입니다. lib/hls/hls_parser.js로 들어가, M3U8(텍스트 태그)이 어떻게 같은 manifest_ 모델로 번역되는지를 추적하면서, 줄곧 DASH와 대조해 보겠습니다. 매니페스트 파서 2부작의 완결편이자, PreloadManager 편에서 흘려 두었던 한 가지 떡밥 — "HLS는 DRM 정보가 미디어 플레이리스트 안에 있어 세그먼트 인덱스를 DRM보다 먼저 만들어야 한다" — 을 회수하는 자리이기도 합니다.
먼저 start()를 봅시다. DASH 파서의 그것과 거의 판박이입니다.
요청하고, 최종 URI를 기록하고, parseManifest_에 데이터를 넘기고, manifest_를 돌려준다. DASH 편에서 본 "얇은 진입점이 내부 메서드에 위임한다"는 패턴이 여기서도 그대로 반복됩니다. 이 시리즈 내내 거의 모든 진입점이 이렇게 생겼습니다. 무게는 항상 parse... 쪽에 실려 있죠.
DASH와 HLS의 첫 번째 갈림길이 여기서 드러납니다. DASH는 XML 트리를 타고 내려갔지만, HLS는 평평한 태그 목록을 이름으로 긁어 모읍니다.
핵심 분기는 playlist.type이 MEDIA냐 MASTER냐입니다.
filterTagsByName으로 EXT-X-STREAM-INF(화질별 variant), EXT-X-MEDIA(오디오·자막 렌디션), EXT-X-I-FRAME-STREAM-INF(트릭플레이) 같은 태그를 이름별로 추려 모은 뒤 조립합니다. DASH의 계층 상속 같은 건 없습니다. 그저 납작한 태그 목록을 분류할 뿐입니다.또 하나 눈에 띄는 것은 manifest_의 periodCount가 항상 1이라는 점입니다. DASH는 광고 삽입 등으로 여러 Period가 생기고 그것을 PeriodCombiner로 평탄화해야 했지만, HLS에는 Period라는 개념 자체가 없습니다. 그래서 DASH 편의 주인공이었던 평탄화 단계가 HLS에는 통째로 빠져 있습니다.
DASH가 MPD@type 속성 하나로 VOD/LIVE를 즉시 알 수 있었던 것과 달리, HLS는 미디어 플레이리스트를 한 번 들여다보기 전까지는 알 수 없습니다. 그래서 코드 주석이 솔직하게 고백합니다. "We don't know if the presentation is VOD or live until we parse at least one media playlist."일단 정적(VOD) 타임라인으로 시작해 두는 이유입니다.
여기가 HLS 파서의 심장이자, DASH와 가장 크게 갈리는 지점입니다.
DASH의 단일 MPD에는 모든 화질의 세그먼트 정보가 한 문서 안에 들어 있습니다. 반면 HLS의 마스터 플레이리스트에는 "각 화질의 미디어 플레이리스트가 어디 있는지" 그 주소와 메타데이터만 적혀 있습니다. 실제 세그먼트 목록은 각 미디어 플레이리스트를 따로 받아야 알 수 있죠.
그래서 Shaka는 마스터를 파싱하는 시점에는 세그먼트 인덱스가 비어 있는 스트림 객체만 만들어 둡니다.
그리고 실제 미디어 플레이리스트 다운로드는 stream.createSegmentIndex()가 호출되는 그 순간으로 미뤄집니다.
PreloadManager 편에서 본 loadInner_의 lazy-load("실제 재생할 variant만 segment index를 만든다")가 바로 이 createSegmentIndex()를 호출하는 쪽이었습니다. 즉 수십 개 화질의 미디어 플레이리스트를 처음부터 다 받지 않고, 실제 재생할 것만 받는 구조입니다. DASH처럼 한 번에 모든 정보를 주는 포맷에서는 필요 없던 최적화가, 2단 구조인 HLS에서는 필수가 됩니다.
마스터 없이 미디어 플레이리스트 URL을 곧장 넘기면 어떻게 될까요? 이때는 variant가 하나뿐이라 lazy-load할 이유가 없습니다. parseManifest_의 MEDIA 분기는 곧바로 convertParsedPlaylistIntoStreamInfo_를 호출해 세그먼트까지 그 자리에서 만들어 버립니다. 단 하나의 스트림(primary)이니까요.
이제 PreloadManager 편의 떡밥을 회수할 차례입니다. 그때 우리는 이런 코드를 봤습니다.
왜 HLS만 DRM 초기화 전에 세그먼트 인덱스를 먼저 만들어야 했을까요? 그 답이 방금 본 2단 구조에 있습니다. HLS의 암호화 정보를 담은 EXT-X-KEY 태그는 마스터가 아니라 각 미디어 플레이리스트 안에 들어 있기 때문입니다.
마스터 플레이리스트만 봐서는 이 콘텐츠가 어떤 키 시스템으로 보호되는지 알 수 없습니다. METHOD, KEYFORMAT 같은 정보가 전부 미디어 플레이리스트의 EXT-X-KEY에 있으니까요. 그리고 그 미디어 플레이리스트는 앞서 봤듯 lazy-load 시점, 즉 createSegmentIndex()에서야 받아집니다.
그래서 순서가 꼬입니다. DRM 엔진을 초기화하려면 키 시스템을 알아야 하는데, 키 시스템을 알려면 미디어 플레이리스트를 먼저 받아야 한다. PreloadManager가 HLS에 한해 세그먼트 인덱스 생성을 DRM 초기화 앞으로 당겼던 이유가 바로 이것입니다. 컨테이너 포맷의 구조적 차이가 상위 로딩 파이프라인의 실행 순서까지 비트는, DASH 편에서 본 createFrame_ 못지않게 흥미로운 사례입니다.
앞서 HLS는 일단 정적 타임라인으로 시작한다고 했습니다. 그 정적 타임라인이 라이브로 바뀌는 순간을 따라가 봅시다. 판별 기준은 미디어 플레이리스트에 EXT-X-ENDLIST가 있는지입니다. 끝 표시가 없으면 아직 세그먼트가 추가될 라이브라는 뜻이죠.
반대 방향도 있습니다. 라이브 방송이 끝나 모든 스트림에 EXT-X-ENDLIST가 붙으면, update()는 콘텐츠를 다시 VOD로 굳히고 갱신 타이머를 멈춥니다.
DASH가 MPD@type 하나로 처음부터 알았던 사실을, HLS는 미디어 플레이리스트를 들여다본 뒤에야, 그것도 재생 도중 바뀔 수 있는 값으로 다룹니다. 상태를 늦게, 그리고 동적으로 확정한다는 점이 HLS 파서의 또 다른 특징입니다.
마지막 차이는 라이브 갱신의 단위입니다. DASH는 MPD 한 장을 통째로 다시 받아 처리했습니다(onUpdate_ → requestManifest_). 하지만 HLS는 갱신할 매니페스트가 한 장이 아닙니다. 활성화된 스트림마다 자기 미디어 플레이리스트를 따로 가지고 있으므로, 각자 따로 reload합니다.
그리고 그 스트림별 reload에는 HLS 특유의 세밀한 최적화가 들어 있습니다.
_HLS_msn/_HLS_part) — 저지연 HLS(LL-HLS)에서, 클라이언트가 다음 세그먼트 번호를 미리 알려 주면 서버는 그 세그먼트가 준비될 때까지 응답을 붙잡고 있다가 내려줍니다. 폴링 지연을 없애는 기법입니다._HLS_skip=v2) — 이미 가진 오래된 세그먼트는 다시 받지 않고, 서버가 EXT-X-SKIP으로 그 부분을 생략한 가벼운 플레이리스트만 내려줍니다.갱신 주기를 정하는 getUpdatePlaylistDelay_는 단순히 lastTargetDuration_(타깃 세그먼트 길이)을 돌려줍니다. RFC 8216의 *"target duration 이상 기다린 뒤 플레이리스트를 다시 받으라"*는 규칙을 그대로 따른 것입니다.
두 파서를 나란히 놓으면 차이가 또렷해집니다.
| 항목 | DASH | HLS |
|---|---|---|
| 입력 형식 | 단일 MPD (XML) | M3U8 (텍스트 태그) |
| 문서 구조 | 단일 문서 | 2단 (마스터 + 미디어 플레이리스트) |
| 파싱 방식 | XML 트리 + 계층 상속(자식 || 부모) | 평평한 태그 목록을 이름으로 필터 |
| Period | 다중 Period → 평탄화 | 개념 없음 (periodCount 항상 1) |
| 세그먼트 로딩 | 즉시 (한 문서에 다 있음) | lazy-load (재생할 것만) |
| DRM 정보 위치 | MPD의 ContentProtection | 미디어 플레이리스트의 EXT-X-KEY |
| VOD/LIVE 판별 | MPD@type으로 즉시 | EXT-X-ENDLIST 유무로 늦게 |
| 라이브 갱신 | MPD 한 장을 통째로 | 활성 스트림마다 따로 reload |
그런데 이 모든 차이에도 불구하고, 두 파서가 돌려주는 것은 똑같습니다.
variants, textStreams, presentationTimeline으로 이루어진 포맷 중립적인 공통 모델. MPD든 M3U8이든, 일단 이 모델로 번역되고 나면 그 위의 StreamingEngine·AbrManager·DrmEngine은 원본이 DASH였는지 HLS였는지 알 필요도, 알 이유도 없습니다. 파서가 포맷의 차이를 전부 흡수해 버리기 때문입니다.
HLS 파서는 2단 구조의 M3U8을, 태그 필터링과 미디어 플레이리스트 lazy-load를 거쳐 DASH와 똑같은 manifest_ 모델로 바꿔 냅니다.
| 단계 | 메서드 | 하는 일 |
|---|---|---|
| 진입 | start | 마스터 플레이리스트 수신 → parseManifest_ → manifest_ 반환 |
| 분류 | parseManifest_ | EXT-X-* 태그를 이름별로 수집, MEDIA/MASTER 분기 |
| 조립 | createVariantsForTags_ 등 | 태그를 variant·텍스트·이미지 스트림으로 조립 |
| 지연 | createStreamInfo_ → downloadSegmentIndex | 재생할 variant의 미디어 플레이리스트만 lazy-load |
| 갱신 | update → updateStream_ | 활성 스트림마다 따로 reload (LL-HLS 블로킹·델타) |
핵심을 다시 모으면 다음과 같습니다.
start()는 DASH와 판박이로 얇고, 무게는 parseManifest_가 짊어집니다.EXT-X-* 태그 목록을 이름으로 긁어 모아 조립합니다.createSegmentIndex() 시점에야 받습니다. 이것이 "HLS DRM은 미디어 플레이리스트에 있다"는 떡밥의 정체입니다.한 줄로 요약하면 다음과 같습니다.
"DASH가 한 장의 XML을 상속으로 풀어낸다면, HLS는 마스터의 태그 목록으로 골격만 세우고 미디어 플레이리스트는 필요할 때 받는다. 길은 달라도 도착지는 같은 manifest 모델이다."
이로써 매니페스트 파서 2부작이 끝났습니다. DASH든 HLS든, 파싱이 끝나면 우리 손에는 동일한 manifest_ 모델이 쥐어집니다. 다음 편부터는 드디어 이 모델을 소비하는 쪽으로 넘어가겠습니다. 파싱된 variant 중 하나를 골라 세그먼트를 실제로 내려받아 MediaSource로 흘려보내는 StreamingEngine, 그리고 대역폭을 추정해 화질을 갈아 끼우는 AbrManager — PreloadManager 편에서 예고했던 그 "구동 단계"입니다.