HLS 딥: 암호화, 멀티트랙, 기억해야 할 EXT-X 태그
HLS/M3U8 입문 가이드는 3분짜리 짧은 영상의 M3U8을 파싱하는 "나쁘지 않은" 케이스를 다뤘다. 이 글은 그 다음: 30개 체인 마스터 플레이리스트, 다중 오디오/자막 트랙, AES-128 암호화, 라이브의 EXT-X-DISCONTINUITY, 그리고 1000라인짜리 M3U8이 작성되는 이유를 알고 싶은 사람을 위한 것.
다운로더를 만들고 있거나, "이 사이트는 되는데 저 사이트는 안 되는" 이유를 알고 싶다면——이 글이 답이다.
재미있는 부분: 암호화
HLS의 암호화는 3단계로 이루어진다:
- 키 페치——
#EXT-X-KEY태그가 키 URL을 가리킴 - IV——초기화 벡터, 태그가 명시하거나 미디어 시퀀스 번호에서 유도
- 복호화——각 TS 세그먼트는 AES-128-CBC로 암호화, 키+IV로 해독
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin",IV=0x1234567890abcdef1234567890abcdef
#EXTINF:6.0,
segment-1.ts
#EXTINF:6.0,
segment-2.ts
#EXT-X-ENDLIST
METHOD=AES-128이 표준. NONE도 있고(#EXT-X-KEY:METHOD=NONE, "이후 세그먼트는 암호화 안 함"을 의미) SAMPLE-AES(Apple의 특별 포맷, FairPlay와 짝)도 있다.
키를 페치하려면:
const keyResponse = await fetch(keyUrl)
const keyBytes = new Uint8Array(await keyResponse.arrayBuffer())
// keyBytes는 정확히 16바이트 (AES-128)
그리고 세그먼트를 복호화:
async function decryptSegment(segmentData, keyBytes, iv) {
// WebCrypto API 사용
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-CBC' },
false,
['decrypt']
)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv },
cryptoKey,
segmentData
)
return new Uint8Array(decrypted)
}
IV 처리는 두 가지 패턴:
#EXT-X-KEY에IV=0x...가 있으면 그걸 사용- 없으면 미디어 시퀀스 번호를 16바이트로 패딩——
[0, 0, 0, ..., 0, seqNum]
function deriveIV(sequenceNumber) {
const iv = new Uint8Array(16)
const view = new DataView(iv.buffer)
view.setUint32(12, sequenceNumber)
return iv
}
핵심 포인트: 키 URL은 보통 토큰화되어 있고, 페치 시 쿠키나 헤더 인증이 필요하다. fetch(keyUrl, { credentials: 'include' })——include가 필수, 브라우저는 기본적으로 서드파티 쿠키를 보내지 않는다.
HLS의 "키 로테이션"
#EXT-X-KEY는 언제든 다시 나올 수 있다:
#EXT-X-KEY:METHOD=AES-128,URI="key1.bin",IV=0x...
#EXTINF:6.0,
segment-1.ts # key1로 암호화
#EXTINF:6.0,
segment-2.ts # key1로 암호화
#EXT-X-KEY:METHOD=AES-128,URI="key2.bin",IV=0x...
#EXTINF:6.0,
segment-3.ts # key2로 암호화
다운로더는 "현재 키" 상태를 추적해야 한다. 새 #EXT-X-KEY를 만나면 새 키를 페치, 이후 세그먼트는 새 키로 복호화. 파서를 쓸 때 이 상태 기계가 모든 곳에 들어있어야 한다.
SAMPLE-AES와 FairPlay
METHOD=SAMPLE-AES는 Apple의 FairPlay DRM과 짝. TS 패킷의 일부——보통 H.264 NAL unit의 슬라이스 데이터——만 암호화, 나머지는 평문. 키 획득은 skd:// URL을 통해 FairPlay의 CDM을 사용.
이건 DRM이다. FairPlay는 Apple 독점. 브라우저에서 복호화하려면 EME(Encrypted Media Extensions)를 통해, 그것도 Safari에서만. FlowPick은 SAMPLE-AES를 지원하지 않는다. 정당한 다운로더도 마찬가지다. 스트리밍 다운로드의 합법성에서 이 선을 더 논의.
멀티 오디오 트랙: #EXT-X-MEDIA
다중 언어 오디오, 디렉터 코멘터리, 청각 장애용 트랙 등:
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en",URI="en.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Japanese",AUTOSELECT=YES,LANGUAGE="ja",URI="ja.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Commentary",AUTOSELECT=NO,LANGUAGE="en",URI="commentary.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1920x1080,AUDIO="audio"
video.m3u8
마스터 플레이리스트는 비디오 변형(#EXT-X-STREAM-INF)과 독립 오디오 트랙(#EXT-X-MEDIA)을 나열. 각 AUDIO 속성은 같은 GROUP-ID를 공유하는 미디어 세트를 가리킨다.
다운로더의 흐름:
- 마스터 파싱
- 원하는 비디오 변형 선택
- 원하는 오디오 트랙 선택
- 두 URL을 따로 페치하여 세그먼트 리스트 획득
- 비디오와 오디오 각각 결합
- 최종 멀티플렉스 (MP4로 만드는 경우)
자막 트랙
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en",URI="subs-en.m3u8"
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Japanese",AUTOSELECT=YES,LANGUAGE="ja",URI="subs-ja.m3u8"
WebVTT 포맷. 각 자막 세그먼트는 .vtt 파일. 결합은 그냥 concat——단, 마지막 WebVTT 세그먼트의 X-TIMESTAMP-MAP을 주의:
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
이 맵은 WebVTT의 시간이 TS의 PTS와 어떻게 대응하는지. 결합 시 이 매핑이 정확히 유지되어야 자막과 비디오가 싱크된다.
#EXT-X-DISCONTINUITY: 그 다음은 뭘까
#EXTINF:6.0,
ad-1.ts
#EXTINF:6.0,
ad-2.ts
#EXT-X-DISCONTINUITY
#EXTINF:6.0,
main-1.ts
#EXT-X-DISCONTINUITY는 이전 세그먼트와 다음 세그먼트 사이에 "불연속"이 있음을 선언——codec, 타임스탬프, 해상도, 프레임 레이트가 바뀔 수 있음.
광고 삽입에서 흔하다. 본편은 1080p/30fps H.264, 광고는 720p/15fps VP9 등.
다운로더가 주의할 것:
- 타임스탬프 재설정. discontinuity 다음 세그먼트의 PTS는 0에서 시작할 수 있다. 단순히 "이전 세그먼트 끝 시간 + 길이"로 계산하면 안 된다.
- codec 호환성. 다른 codec을 그냥 결합하면 파일이 망가진다. 비디오 플레이어는 첫 번째 codec을 쓰다가 두 번째 부분에서 글리치.
- 광고 건너뛰기. 사용자는 광고를 원하지 않는다.
discontinuity사이의 세그먼트를 무시하고 본편만 결합——가장 일반적 전략, 타임스탬프를 재조정해야 한다.
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:6.0,
segment-1.ts
이 태그는 모든 세그먼트가 독립적으로 디코딩 가능함을 선언——각 세그먼트가 IDR 프레임으로 시작. 다운로더에게 중요: 임의 세그먼트부터 결합해도 올바른 MP4가 나온다.
이 태그가 없으면, 첫 번째 세그먼트가 IDR 프레임을 갖는다고 가정할 수 없다. "앞부분 몇 초가 손상된" 재생 파일이 나오는 원인.
#EXT-X-MAP: fMP4 세그먼트용
#EXT-X-MAP:URI="init.mp4"
#EXTINF:6.0,
segment-1.m4s
이 태그는 이후 세그먼트가 MPEG-TS가 아니라 fMP4임을 선언. init.mp4는 초기화 박스(ftyp + moov)를 가진다. 각 .m4s는 moof + mdat.
HLS-fMP4는 DASH와 구조적으로 동일——세그먼트를 결합하려면 init + 모든 미디어 세그먼트를 순서대로 붙이면 된다. 자세한 건 fMP4 결합 글에서.
어떤 사이트는 TS와 fMP4를 섞어 쓴다. 마스터에서는 어떤 변형은 TS, 어떤 변형은 fMP4. 다운로더는 양쪽 포맷을 다뤄야 한다.
#EXT-X-PROGRAM-DATE-TIME
#EXT-X-PROGRAM-DATE-TIME:2026-08-04T18:30:00.000Z
#EXTINF:6.0,
segment-1.ts
이 태그는 세그먼트의 절대 방송 시간. 라이브에서 중요: "이 세그먼트가 실제로 몇 시 몇 분에 방영되었는가"를 안다. 다운로더는 이것을 "사용자가 다운로드하는 부분이 최근인가?"를 판단하는 데 쓴다——minimum/maximum PROGRAM-DATE-TIME을 보고 "이 라이브가 언제부터 시작했고 지금은 어디쯤"을 안다.
EXT-X-PROGRAM-DATE-TIME은 optional이고 모든 M3U8에 있는 건 아니다. 하지만 있으면 동기화가 쉬워진다.
#EXT-X-PLAYLIST-TYPE
#EXT-X-PLAYLIST-TYPE:VOD
두 값: VOD 또는 EVENT. VOD는 플레이리스트가 정적——한 번 쓰고 끝, #EXT-X-ENDLIST로 닫힌다. EVENT는 플레이리스트가 자라나고 있다——라이브가 종료되어 VOD로 전환될 때까지 갱신. 태그가 없으면 알 수 없다.
VOD가 가장 쉽다: 한 번 파싱하고 다운로드, 끝. EVENT와 태그 없음은 재획득 로직이 필요.
#EXT-X-ENDLIST
#EXTINF:6.0,
segment-1000.ts
#EXT-X-ENDLIST
"스트림이 여기서 끝남, 이 이후 세그먼트는 없음"을 선언. 다운로더는 이것을 봐야 "전부 다운로드했는가?"를 안다. 라이브에서는 안 보일 수 있다——스트림이 아직 진행 중이므로. 이 경우, "스트림 종료를 기다린다" 또는 "현재까지의 세그먼트로 끝낸다" 선택.
마스터 플레이리스트 파싱
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MEDIA:TYPE=AUDIO,...,URI="audio-en.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=3840x2160,CODECS="avc1.640028,mp4a.40.2",AUDIO="audio"
4k.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2",AUDIO="audio"
1080p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2",AUDIO="audio"
720p.m3u8
CODECS 속성이 중요——avc1.640028은 H.264, hvc1.1.6.L120.B0은 H.265, vp09.00.10.08은 VP9. 이 문자열로 세그먼트를 디코딩할 codec을 안다.
다운로더의 흐름:
- 마스터 파싱
- 최고 비트레이트의
#EXT-X-STREAM-INF를 찾는다 (사용자가 최고 화질을 원한다면) - 해당 변형의 URL을 재귀적으로 페치하여 세그먼트 리스트 획득
CODECS문자열에서 codec을 알아내어 디코더를 설정
실제로 부딪힌 어려운 부분
어려움 1: 세그먼트 URL의 상대 경로. M3U8이 https://cdn.example.com/v/x.m3u8에 있고, 안에 segment-1.ts라고 쓰여 있으면, 절대 URL은 https://cdn.example.com/v/segment-1.ts. 하지만 https://cdn.example.com/v/ 안에 또 다른 M3U8이 있고, 그 안에 segment-1.ts라고 쓰여 있으면, 절대 URL은 https://cdn.example.com/v/segment-1.ts가 아닌 https://cdn.example.com/v/<subdir>/segment-1.ts가 될 수 있다.
RFC 3986의 상대 URL 해석 규칙을 따라야 한다. JS의 new URL(relative, base)가 해준다:
const segmentUrl = new URL('segment-1.ts', playlistUrl).href
어려움 2: 라이브 스트림의 다운로드 시점. 라이브는 새 세그먼트를 계속 만든다. "다운로드 시작"을 누른 순간의 스냅샷만 가질 수 있다——스트림이 아직 살아있으면, 스냅샷 이후에 추가되는 세그먼트는 놓친다. "라이브가 끝날 때까지 기다리기" 옵션이 있으면 좋지만, 스트림이 영원히 지속되는 라이브(24시간 뉴스 등)에는 안 통한다.
FlowPick의 접근: 다운로드 시작 시점의 스냅샷을 찍고, 사용자에게 "이후에 다시 클릭하여 새 세그먼트 추가"를 허용.
어려움 3: 청크 단위 인코딩. 일부 라이브는 CMAF(chunked CMAF)를 사용——세그먼트가 완성되기 전에 청크 단위로 전송. #EXT-X-KEY가 바뀌는 중간에 부분적 청크가 올 수 있어, 파서는 청크 경계를 정확히 처리해야 한다.
어려움 4: 토큰 기반 CDN. 일부 CDN은 서명된 URL을 사용——세그먼트 URL이 ?token=abc&expires=1234567890 형태. 이 토큰은 짧은 TTL(몇 초)을 가져서, M3U8 파싱 직후에 세그먼트를 페치해야 한다. 몇 시간 전에 파싱하고 지금 다운로드하는 배치 다운로더는 실패.
다운로더의 간단한 구조
async function downloadHls(masterUrl) {
const masterText = await fetch(masterUrl).then(r => r.text())
const masterPlaylist = parseM3u8(masterText)
// 최고 비트레이트 변형 선택
const variants = masterPlaylist.streamInf || [{ url: masterUrl }]
const best = variants.sort((a, b) => b.bandwidth - a.bandwidth)[0]
// 변형 플레이리스트 페치
const variantUrl = new URL(best.url, masterUrl).href
const variantText = await fetch(variantUrl).then(r => r.text())
const variantPlaylist = parseM3u8(variantText)
// fMP4인가 TS인가
const isFmp4 = variantPlaylist.map !== undefined
const initUrl = isFmp4
? new URL(variantPlaylist.map.uri, variantUrl).href
: null
// 키 페치
let keyBytes = null
let iv = null
if (variantPlaylist.key && variantPlaylist.key.method === 'AES-128') {
const keyUrl = new URL(variantPlaylist.key.uri, variantUrl).href
keyBytes = await fetch(keyUrl, { credentials: 'include' })
.then(r => r.arrayBuffer())
.then(b => new Uint8Array(b))
iv = variantPlaylist.key.iv
? hexToBytes(variantPlaylist.key.iv)
: deriveIV(variantPlaylist.mediaSequence)
}
// 세그먼트 페치
const segments = variantPlaylist.segments
const segmentUrls = segments.map(s => new URL(s.uri, variantUrl).href)
// 병렬 다운로드
const segmentData = await parallelFetch(segmentUrls, 6)
// 필요시 복호화
if (keyBytes) {
for (let i = 0; i < segmentData.length; i++) {
const segmentIv = variantPlaylist.key.iv
? hexToBytes(variantPlaylist.key.iv)
: deriveIV(variantPlaylist.mediaSequence + i)
segmentData[i] = await decryptSegment(segmentData[i], keyBytes, segmentIv)
}
}
// 결합
if (isFmp4) {
const initBytes = await fetch(initUrl).then(r => r.arrayBuffer())
return mergeFmp4(initBytes, segmentData)
} else {
return mergeTsSegments(segmentData) // TS 디먹싱 필요, FFmpeg WASM 또는 mux.js
}
}
parallelFetch는 Promise.all과 워커 풀 패턴——DASH 글에서 같은 패턴을 설명.
마치며
HLS는 "그냥 텍스트 파일 파싱"이라고 생각하기 쉽지만, 프로덕션 스트림에는 암호화, 멀티 트랙, discontinuity, 라이브 갱신이 섞여 있다. 이 모든 것을 다루는 견고한 다운로더는 몇백 줄 코드로는 안 된다. 하지만 핵심 상태 기계——현재 키, 현재 IV, 마지막 PTS, 마지막 discontinuity 시퀀스——를 올바르게 다루면 대부분의 스트림을 처리할 수 있다.
HLS 입문 가이드는 "이게 뭔가"를, 이 글은 "진짜로 동작시키려면 뭘 알아야 하나"를 다룬다. 다음은 fMP4 세그먼트를 결합하는 법 또는 WebCodecs로 더 빠른 경로.
추천 글
- HLS/M3U8 스트리밍이란?기본 가이드——이 글의 입문판
- DASH 딥: SegmentTemplate, ContentProtection, 시점 전환 MPD——DASH 쪽의 대응물. 두 포맷의 트레이드오프를 이해
- 브라우저에서 비디오 리먹스: fMP4, ISOBMFF, 트랜스코딩하지 않는 이유——fMP4 세그먼트를 결합하는 방법
- FlowPick은 어떻게 브라우저에서 수백 개의 비디오 세그먼트를 하나의 MP4로 합치는가——이 글의 패턴이 FlowPick에서 어떻게 구현되는가
- 스트리밍 다운로드는 합법인가?기술과 법적 체크리스트——SAMPLE-AES가 하드한 선인 이유
- 브라우저 비디오 처리 성능 벤치마크——FFmpeg WASM, WebCodecs, 네이티브의 풀 벤치마크