tips

HLS 딥: 암호화, 멀티트랙, 기억해야 할 EXT-X 태그

M3U8 입문을 지나 암호화, 멀티 오디오/자막 트랙, DISCONTINUITY와 Independent Segment, 그리고 스트리밍 다운로드를 실제로 지원하는 EXT-X 태그를 살펴본다.
FlowPick 팀
14분 분량
# hls # m3u8 # 암호화 # 멀티트랙 # 딥 # 스트리밍

HLS/M3U8 입문 가이드는 3분짜리 짧은 영상의 M3U8을 파싱하는 "나쁘지 않은" 케이스를 다뤘다. 이 글은 그 다음: 30개 체인 마스터 플레이리스트, 다중 오디오/자막 트랙, AES-128 암호화, 라이브의 EXT-X-DISCONTINUITY, 그리고 1000라인짜리 M3U8이 작성되는 이유를 알고 싶은 사람을 위한 것.

다운로더를 만들고 있거나, "이 사이트는 되는데 저 사이트는 안 되는" 이유를 알고 싶다면——이 글이 답이다.

재미있는 부분: 암호화

HLS의 암호화는 3단계로 이루어진다:

  1. 키 페치——#EXT-X-KEY 태그가 키 URL을 가리킴
  2. IV——초기화 벡터, 태그가 명시하거나 미디어 시퀀스 번호에서 유도
  3. 복호화——각 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 처리는 두 가지 패턴:

  1. #EXT-X-KEYIV=0x...가 있으면 그걸 사용
  2. 없으면 미디어 시퀀스 번호를 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를 공유하는 미디어 세트를 가리킨다.

다운로더의 흐름:

  1. 마스터 파싱
  2. 원하는 비디오 변형 선택
  3. 원하는 오디오 트랙 선택
  4. 두 URL을 따로 페치하여 세그먼트 리스트 획득
  5. 비디오와 오디오 각각 결합
  6. 최종 멀티플렉스 (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 등.

다운로더가 주의할 것:

  1. 타임스탬프 재설정. discontinuity 다음 세그먼트의 PTS는 0에서 시작할 수 있다. 단순히 "이전 세그먼트 끝 시간 + 길이"로 계산하면 안 된다.
  2. codec 호환성. 다른 codec을 그냥 결합하면 파일이 망가진다. 비디오 플레이어는 첫 번째 codec을 쓰다가 두 번째 부분에서 글리치.
  3. 광고 건너뛰기. 사용자는 광고를 원하지 않는다. 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)를 가진다. 각 .m4smoof + 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을 안다.

다운로더의 흐름:

  1. 마스터 파싱
  2. 최고 비트레이트의 #EXT-X-STREAM-INF를 찾는다 (사용자가 최고 화질을 원한다면)
  3. 해당 변형의 URL을 재귀적으로 페치하여 세그먼트 리스트 획득
  4. 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
  }
}

parallelFetchPromise.all과 워커 풀 패턴——DASH 글에서 같은 패턴을 설명.

마치며

HLS는 "그냥 텍스트 파일 파싱"이라고 생각하기 쉽지만, 프로덕션 스트림에는 암호화, 멀티 트랙, discontinuity, 라이브 갱신이 섞여 있다. 이 모든 것을 다루는 견고한 다운로더는 몇백 줄 코드로는 안 된다. 하지만 핵심 상태 기계——현재 키, 현재 IV, 마지막 PTS, 마지막 discontinuity 시퀀스——를 올바르게 다루면 대부분의 스트림을 처리할 수 있다.

HLS 입문 가이드는 "이게 뭔가"를, 이 글은 "진짜로 동작시키려면 뭘 알아야 하나"를 다룬다. 다음은 fMP4 세그먼트를 결합하는 법 또는 WebCodecs로 더 빠른 경로.


추천 글