영상 파일을 공부하게 될 줄은 몰랐다.
AI 써서 공부하고 공부해서 내가 이해한 것들을 옮김.
=========================================
0. 영상 파일
영상 파일은 비디오 코덱, 오디오 코덱, 컨테이너(MP4, fMP4, MPEG-TS, WebM)로 구성
1. 컨테이너
| 구분 | MP4 | fMP4 | MPEG-TS | WebM |
| 정식 계열 | ISO BMFF | ISO BMFF | MPEG-2 TS | Matroska 계열 |
| 파일 확장자 | .mp4 | 보통 .mp4, .m4s | .ts | .webm |
| 대표 코덱 | H.264, HEVC, AAC 등 | H.264, HEVC, AAC 등 | H.264, HEVC, AAC, MPEG-2 등 | VP8/VP9, AV1, Opus/Vorbis |
| 구조 | 전체 파일 중심 | 여러 조각(fragment) | 작은 패킷들의 연속 | EBML 기반 |
| 스트리밍 | 가능하지만 전통적으로 파일 중심 | 매우 적합 | 매우 적합 | 적합 |
| DASH | 사용 가능 | 주력 | 가능 | 가능 |
| HLS | 가능 | 현대 HLS의 주요 방식 | 전통적인 방식 | 일반적이지 않음 |
| 브라우저 지원 | 매우 높음 | 매우 높음 | 재생 환경에 따라 다름 | 높음 |
| 랜덤 접근 | 좋음 | fragment 단위 | 상대적으로 불편 | 좋음 |
컨테이너는 코덱 데이터를 샘플 단위로 담고, 각 샘플에 "크기, 위치, DTS, PTS, 키프레임 여부" 다섯 가지를 붙여 관리한다고 보면 됩니다. DTS는 디코더에 넣는 순서를, PTS는 보여주는 시각을 정하고, 둘의 차이는 B프레임 재정렬 때문에 생깁니다. 키프레임은 이 흐름에 들어갈 수 있는 입구입니다. MP4, fMP4, TS, WebM은 이 정보를 적는 위치와 형식만 다를 뿐, 담는 내용은 본질적으로 같습니다.
MP4
MP4는 박스(box, atom) 라는 단위로 구성. 핵심 박스는 세 가지.
- ftyp: 파일 종류 선언
- moov: 영상 전체의 메타데이터와 목차. 각 프레임이 파일의 몇 번째 바이트에 있고 재생 시점이 언제인지
- mdat: 실제 인코딩된 영상·음성 데이터
moov에 전체 목차가 한꺼번에 들어 있어서 탐색(seek)이 빠르고 효율적입니다.
대신 인코딩이 끝나야 목차를 완성할 수 있기 때문에, 보통 moov가 파일 끝에 기록됩니다. 그 결과 다음과 같은 특징이 생깁니다.
- 웹에서 바로 재생하려면 moov를 앞으로 옮겨야 합니다. ffmpeg의 -movflags +faststart 옵션이 이 작업을 합니다.
- 녹화 중에 프로그램이 죽어 moov가 기록되지 못하면 파일 전체가 재생되지 않습니다.
- 끝이 정해지지 않은 라이브 스트리밍에는 맞지 않습니다.
그래서 다운로드용 파일, VOD, 편집용 파일처럼 완성된 파일에 가장 잘 맞습니다.
fMP4 (Fragmented MP4)
fMP4는 MP4를 라이브와 스트리밍에 맞게 조각(fragment) 단위로 쪼갠 형태입니다. 구조는 다음과 같습니다.
- 초기화 세그먼트: ftyp 다음에 moov가 옵니다. 여기에는 코덱 정보만 있고 프레임 목차는 없습니다.
- 그 뒤로 moof 다음에 mdat이 오는 묶음이 반복됩니다. moof는 해당 조각만의 작은 목차입니다.
각 조각이 자기 목차를 들고 다니니까 전체 길이를 몰라도 계속 이어 붙일 수 있습니다. 중간에 끊겨도 이미 기록된 조각은 살아 있습니다. 이런 특성 때문에 현대 스트리밍의 표준이 되었습니다.
- MPEG-DASH, 최신 HLS, CMAF(HLS와 DASH가 세그먼트를 공유하는 규격)에서 씁니다.
- 브라우저의 MSE(Media Source Extensions)가 기본으로 받아들이는 형식이라 웹 플레이어와 궁합이 좋습니다.


MPEG-TS (Transport Stream)
MPEG-TS는 1990년대 디지털 방송을 위해 만들어졌습니다. 설계 전제는 "전파나 네트워크로 흘려보내는데, 수신기가 아무 때나 켜질 수 있고 데이터가 일부 손실될 수 있다"는 것입니다.
- 모든 데이터를 188바이트 고정 크기 패킷으로 잘게 쪼갭니다.
- 각 패킷에는 PID(어느 스트림 소속인지)가 붙습니다.
- 프로그램 구성표(PAT, PMT)를 주기적으로 반복해서 보냅니다. 그래서 스트림 중간 어디서 받기 시작해도 곧 재생할 수 있습니다.
- 패킷 일부가 깨져도 그 부분만 손상되고 나머지는 계속 재생됩니다.
단점은 오버헤드입니다. 패킷마다 헤더가 붙고 표를 반복해서 보내므로 MP4보다 용량이 조금 더 큽니다. 또 브라우저가 기본으로 재생하지 못합니다(Safari의 HLS 재생은 예외). 그래서 hls.js 같은 플레이어는 TS를 받아서 fMP4로 변환(transmux)한 뒤 MSE에 넣습니다.
주로 쓰이는 곳은 방송(DVB, ATSC, IPTV), 전통적인 HLS의 .ts 세그먼트, 그리고 SRT나 UDP 기반 실시간 전송입니다.
WebM
WebM은 Google이 만든 형식으로, Matroska(MKV)의 부분집합입니다. EBML이라는 구조를 씁니다. 가장 큰 특징은 로열티 없는 코덱만 허용한다는 점입니다.
- 영상: VP8, VP9, AV1
- 음성: Vorbis, Opus
H.264나 AAC는 WebM에 넣을 수 없습니다. 특허료 걱정이 없는 웹 영상을 목표로 만들어졌고, YouTube, 브라우저의 MediaRecorder(Chrome에서 녹화하면 기본으로 WebM이 나옴), WebRTC 녹화 등에서 많이 쓰입니다. 예전에는 Safari 지원이 약했지만 지금은 많이 나아졌습니다.
2. 샘플과 타임스탬프
타임스케일(timescale)
컨테이너는 시간을 초 단위 소수로 적지 않고, 정수 틱(tick) 으로 적습니다. 1초를 몇 틱으로 나눌지 정한 값이 타임스케일입니다.
- MP4는 트랙마다 mdhd 박스에 타임스케일이 있습니다. 비디오는 90000이나 프레임레이트의 배수(예: 30fps면 15360, 30000 등)를 많이 쓰고, 오디오는 보통 샘플레이트(48000 등)를 그대로 씁니다.
- MPEG-TS는 90kHz로 고정입니다. PES 패킷 헤더에 PTS/DTS가 33비트 정수로 들어갑니다.
- WebM은 기본 1ms 단위(TimecodeScale = 1,000,000ns)입니다.
실제 시각은 틱 값 ÷ 타임스케일로 계산합니다. 타임스케일 90000에서 PTS가 180000이면 2초입니다. 정수를 쓰는 이유는 부동소수점 오차가 쌓이지 않게 하려는 것입니다. 29.97fps처럼 딱 떨어지지 않는 프레임레이트도 타임스케일 30000, 프레임 길이 1001로 정확하게 표현할 수 있습니다.
샘플(sample)
샘플은 컨테이너 입장에서 타임스탬프 하나가 붙는 최소 데이터 단위입니다. 컨테이너는 샘플 내부를 해석하지 않고, "이 바이트 덩어리는 크기가 몇이고, 언제 디코딩하고, 언제 보여주고, 키프레임인가"만 관리합니다.
코덱별로 보면 다음과 같습니다.
- H.264/H.265: 프레임 1개가 샘플 1개입니다. 프레임 하나는 여러 NAL 유닛(슬라이스, SEI 등)으로 이루어질 수 있는데, 이것들을 묶어 한 샘플로 넣습니다. MP4에서는 NAL 앞에 시작 코드(00 00 00 01) 대신 길이 필드를 붙이는 방식(AVCC)을 씁니다. TS는 시작 코드 방식(Annex B)을 씁니다. 같은 H.264라도 MP4와 TS 사이를 옮길 때 이 변환이 필요한 이유가 여기 있습니다.
- AAC: AAC 프레임 1개(보통 1024 오디오 샘플, 48kHz 기준 약 21.3ms)가 컨테이너 샘플 1개입니다. 여기서 "오디오 샘플"(PCM 값 하나)과 "컨테이너 샘플"(AAC 프레임 하나)은 다른 개념이라 헷갈리기 쉽습니다.
- 자막: 자막 한 줄(표시 구간 하나)이 샘플 하나입니다.
MP4에서는 샘플 정보가 moov 안의 샘플 테이블(stbl)에 흩어져 기록됩니다.
| stsz | 각 샘플의 크기(바이트) |
| stco / co64 | 청크(샘플 묶음)의 파일 내 위치 |
| stsc | 어느 청크에 샘플이 몇 개 들어 있는지 |
| stts | 각 샘플의 디코딩 길이 → DTS 계산 |
| ctts | DTS와 PTS의 차이 |
| stss | 키프레임인 샘플 번호 목록 |
플레이어는 이 표들을 조합해서 "N번 샘플은 파일 몇 바이트 위치에 몇 바이트 크기로 있고, DTS는 얼마, PTS는 얼마"를 계산합니다.
fMP4에서는 이 정보가 조각마다 moof 안에 들어갑니다. tfdt에는 조각의 시작 DTS가, trun에는 샘플별 길이, 크기, 플래그(키프레임 여부), PTS 오프셋이 들어갑니다.
DTS와 PTS
왜 두 개가 필요한가
B프레임은 앞 프레임과 뒤 프레임을 모두 참조합니다. 그래서 뒤에 보여줄 프레임을 먼저 디코딩해 둬야 B프레임을 풀 수 있습니다. 표시 순서가 I B B P라면 실제 디코딩 순서는 I P B B가 됩니다.
컨테이너 안에서 샘플은 디코딩 순서대로 저장됩니다. 디코더에 들어가는 순서가 그래야 하기 때문입니다. 각 샘플에는 두 시각이 붙습니다.
- DTS(Decoding Time Stamp): 디코더에 넣을 시각. 저장 순서대로 단조 증가합니다.
- PTS(Presentation Time Stamp): 화면에 표시할 시각. 저장 순서 기준으로는 오르락내리락합니다.
엄밀히는 DTS도 "순서"가 아니라 "시각"이지만, 실무에서는 사실상 디코딩 순서를 표현하는 역할을 합니다.
구체적인 예
30fps, 타임스케일 30(프레임 1개 = 1틱)으로 단순화해 보겠습니다. 표시 순서는 I0 B1 B2 P3입니다.
| 1 | I0 | 0 | 1 | 1 |
| 2 | P3 | 1 | 4 | 3 |
| 3 | B1 | 2 | 2 | 0 |
| 4 | B2 | 3 | 3 | 0 |
PTS가 전부 1씩 밀려 있는 점에 주목하세요. 원래 표시 시각대로 PTS를 0, 3, 1, 2로 적으면 B1은 DTS 2에 디코딩되는데 PTS 1에 보여줘야 합니다. 디코딩도 안 된 프레임을 먼저 보여줄 수는 없습니다. 그래서 PTS ≥ DTS 조건을 지키도록 표시 시각 전체를 뒤로 밀어 둡니다. 이 밀린 양을 디코더 지연(reorder delay)이라고 하고, B프레임을 연속으로 많이 쓸수록 커집니다.
MP4에서의 기록 방식
- stts에는 각 샘플의 길이만 적습니다. DTS는 누적합으로 계산됩니다(0, 1, 2, 3…). 길이가 같은 샘플이 연속되면 "길이 1인 샘플 4개"처럼 압축해서 적습니다.
- ctts에는 PTS−DTS 오프셋을 적습니다(1, 3, 0, 0). B프레임이 없으면 PTS = DTS이므로 ctts 박스 자체가 없습니다.
밀린 시작 시각의 처리
위 예에서 첫 프레임의 PTS가 0이 아니라 1이므로, 그대로 두면 영상이 1프레임 늦게 시작하고 오디오와 어긋납니다. 해결 방법은 두 가지입니다.
- 편집 목록(elst): "이 트랙은 미디어 시각 1부터 재생하라"고 적어서 앞부분을 잘라냅니다. ffmpeg가 만드는 MP4의 대부분이 이 방식입니다.
- 음수 오프셋: ctts 버전 1은 음수를 허용합니다. 오프셋을 −1씩 조정해 첫 PTS를 0으로 맞출 수 있습니다. fMP4/CMAF에서 자주 씁니다.
플레이어가 elst를 무시하면 오디오와 비디오가 미세하게 어긋나는 문제가 생기는데, 실무에서 싱크 버그의 흔한 원인 중 하나입니다.
오디오는 보통 단순하다
AAC 같은 오디오 코덱은 프레임 재정렬이 없으므로 PTS = DTS입니다. 대신 인코더 지연(priming)이라는 비슷한 문제가 있습니다. AAC 인코더는 앞부분에 약 1024~2112 오디오 샘플의 무음 구간을 만들어 냅니다. 이것 역시 elst로 잘라내야 정확한 싱크와 끊김 없는 재생(gapless)이 됩니다.
키프레임(sync sample)
키프레임은 다른 프레임을 참조하지 않고 단독으로 디코딩할 수 있는 프레임.
이 프레임 이후의 프레임들이 이 프레임 이전 프레임을 참조하지 않아야 진짜 "여기서부터 시작 가능한 지점"이 됩니다. 이 두 번째 조건이 중요합니다.
- IDR 프레임(H.264): 디코더의 참조 버퍼를 비웁니다. 이후 프레임은 절대 이전 프레임을 참조하지 않으므로 완전한 시작점입니다. Closed GOP라고 합니다.
- IDR이 아닌 I 프레임: 그 자체는 단독으로 풀 수 있지만, 뒤따르는 B프레임이 이 I 앞의 프레임을 참조할 수 있습니다. 여기서 시작하면 첫 몇 개 B프레임이 깨집니다. Open GOP라고 합니다. H.265의 CRA 프레임이 대표적이고, 이런 경우 앞의 깨지는 프레임을 버리는 규칙(RASL 프레임 폐기)이 코덱에 정의돼 있습니다.
컨테이너에서의 표시
- MP4: stss에 키프레임 샘플 번호 목록이 있습니다. stss가 아예 없으면 "모든 샘플이 키프레임"이라는 뜻이고, 오디오 트랙이 보통 이렇습니다.
- fMP4: trun의 샘플 플래그에 sample_is_non_sync_sample 비트로 표시합니다.
- MPEG-TS: 적응 필드(adaptation field)의 random_access_indicator 비트로 표시합니다. 그러나 이 비트가 누락되는 경우가 많아서, 실무에서는 NAL 유닛을 직접 읽어 IDR인지 확인하기도 합니다.
- WebM: SimpleBlock의 키프레임 플래그와 Cues(탐색용 색인)로 표시합니다.
탐색(seek)이 실제로 동작하는 방식
사용자가 12.5초로 이동하면 플레이어는 이렇게 합니다.
- 12.5초에 해당하는 샘플을 찾습니다.
- 키프레임 목록에서 그 샘플 이전에 있는 가장 가까운 키프레임을 찾습니다. 예를 들어 10초에 있다고 합시다.
- 10초 키프레임부터 디코딩을 시작합니다.
- 여기서 두 가지 선택이 있습니다. 빠른 탐색은 그냥 10초부터 보여줍니다. 정확한 탐색은 10~12.5초 구간을 디코딩만 하고 화면에는 버린 뒤 12.5초부터 보여줍니다.
키프레임 간격(GOP 길이)이 길면 압축 효율은 좋아지지만, 탐색이 부정확하거나 느려집니다. 그래서 목적에 따라 간격을 다르게 잡습니다.
- 스트리밍(HLS/DASH): 세그먼트가 반드시 키프레임으로 시작해야 합니다. 그래야 세그먼트 단위로 화질을 바꾸거나 중간부터 재생할 수 있습니다. 보통 2초 간격 고정 GOP를 씁니다.
- 저지연 라이브: 1초 이하로 짧게 잡습니다.
- 보관용 파일: 5~10초로 길게 잡기도 합니다.
ffmpeg에서 -ss로 자를 때 -c copy(재인코딩 없음)를 쓰면 시작점이 키프레임에 맞춰 당겨지거나 앞부분이 깨지는 현상도 같은 원리입니다. 키프레임이 아닌 곳에서는 스트림을 시작할 수 없기 때문입니다.
3. MP4 & fMP4
MP4의 핵심, 박스: size(4byte) + type(4byte) + box contents
예제: 00 00 00 1C 66 74 79 70 ...
00 00 00 1C <- size: 28
66 74 79 70 <- type: ftyp
| 박스 | 내용 | 이번 진단에서 본 것 |
| ftyp | 파일 종류 | 맨 앞에 있어야 함 |
| mdat | 실제 데이터(모든 샘플의 바이트) | 2GB 영상이면 대부분이 여기 |
| moov | 색인(트랙별 샘플 표) | MediaMuxer는 파일 끝에 씀. 로컬 파일은 문제 없음 |
| mdhd | 트랙 시간 단위(timescale)·길이 | 비디오 90000, 오디오 48000 등 |
| stts | 샘플별 길이(시간) | AAC는 샘플이 1024여야 정상. |
| stsz | 샘플별 크기·개수 | 샘플 수 = 길이×샘플레이트÷1024여야 함 |
| stco/co64 | 청크(샘플 묶음)의 파일 위치 | 비디오·오디오가 섞여 있는지(인터리브) |
| stss | 키프레임 목록 | 첫 항목이 1이어야 처음부터 재생 가능 |
| ctts | 표시·디코딩 순서 차이 | B프레임이 있는 H.264에만 |
| elst | 트랙 시작 보정(편집 목록) | 비디오 mediaTime 9040(0.1초)은 B프레임용 정상값 |
| esds | AAC 설정(AudioSpecificConfig) | 샘플은 "헤더 없는 AAC 프레임"이라는 약속 |
3.1 ftyp — File Type Box
가장 먼저 자주 등장하는 것이 ftyp이다.
ftyp
├── major_brand
├── minor_version
└── compatible_brands[]
예를 들어:
ftyp
major_brand = isom
minor_version = 512
compatible_brands =
isom
iso2
avc1
mp41
이것은 코덱 정보가 아니다.
"이 파일이 어떤 ISO Base Media File Format 계열과 호환되는가"를 나타내는 정보다.
그래서:
ftyp = H.264
가 아니다.
H.264인지 여부는 뒤쪽의 stsd 등을 봐야 한다.
3.2 moov — MP4의 핵심
moov가 사실 MP4에서 가장 중요하다.
moov
├── mvhd
├── trak
├── trak
└── ...
moov는 쉽게 말하면:
"이 영상 파일을 어떻게 해석해야 하는지 알려주는 인덱스/메타데이터 영역"
이다.
예를 들어 영상이
1920 × 1080
30 fps
H.264
10분
이라면 이런 정보를 알아야 실제 mdat를 해석할 수 있다.
3.3 mvhd — Movie Header
moov
└── mvhd
전체 movie에 대한 정보가 들어간다.
대표적으로:
- movie timescale
- duration
- creation/modification time
- playback rate
- volume
- transformation matrix
등이 있다.
여기서 timescale이 중요하다.
예를 들어:
timescale = 1000
duration = 600000
이면
600000 / 1000 = 600초
즉 10분이다.
MP4에서는 시간을 단순히 "초"로 저장하지 않고 정수 단위의 시간 tick으로 표현하는 경우가 많다.
3.4 trak — Track
그 다음이 trak이다.
moov
├── trak ← video
└── trak ← audio
즉 하나의 MP4 안에 여러 track이 있을 수 있다.
예를 들어:
trak #1
└─ video
└─ H.264
trak #2
└─ audio
└─ AAC
trak #3
└─ subtitle
이런 구조가 가능하다.
3.5 tkhd — Track Header
trak
└── tkhd
각 track 자체의 정보가 들어간다.
예를 들어 video track이라면:
track ID = 1
width = 1920
height = 1080
등이 들어간다.
여기서 중요한 점은:
MP4의 width/height가 한 군데만 있는 것은 아니다.
여러 레벨에 관련 정보가 존재할 수 있다.
3.6 mdia — Media
trak
└── mdia
├── mdhd
├── hdlr
└── minf
여기부터 실제 media 자체를 설명한다.
mdhd
Media의 시간 정보.
mdhd
├── timescale
└── duration
hdlr
이 track이 무엇인지 알려준다.
예:
hdlr
handler_type = vide
이면 video.
hdlr
handler_type = soun
이면 audio.
즉:
vide → video
soun → sound
이다.
3.7 minf — Media Information
mdia
└── minf
└── stbl
여기에는 해당 media track을 실제로 처리하기 위한 정보가 들어간다.
Video라면 vmhd, Audio라면 smhd 등이 있다.
하지만 실제로 중요한 것은 그 아래의 **stbl**이다.
3.8 stbl — Sample Table
여기가 MP4를 이해하는 데 가장 중요한 부분 중 하나다.
stbl
├── stsd
├── stts
├── ctts
├── stsc
├── stsz
├── stco / co64
└── stss
이것들을 조합하면:
"mdat 안에서 어떤 데이터가 어느 위치에 있고, 얼마나 크고, 언제 재생되고, 어떤 순서로 디코딩되는가?"
를 알 수 있다.
3.9 stsd — Sample Description
stsd
는 이 track의 데이터가 어떤 형식인지 알려준다.
예를 들어 H.264라면:
stsd
└── avc1
└── avcC
HEVC라면:
stsd
└── hvc1 / hev1
└── hvcC
AAC라면:
stsd
└── mp4a
└── esds
같은 식이다.
그래서 MP4를 보고:
avc1
이 발견되면
"아, 이 track은 H.264구나."
라고 알 수 있다.
3.10 avcC — H.264 설정 정보
예를 들어:
stsd
└── avc1
└── avcC
가 있다면 avcC 안에 H.264 decoder configuration이 들어간다.
대표적으로:
- SPS
- PPS
- profile
- level
- NAL length size
등이다.
즉 디코더가 H.264를 디코딩하기 시작할 때 필요한 정보다.
3.11 stts — 시간 정보
이것도 상당히 중요하다.
stts
는 Decoding Time에 대한 정보를 담는다.
예를 들어:
sample 1 → 0
sample 2 → 33
sample 3 → 66
sample 4 → 99
처럼 sample이 언제 디코딩되어야 하는지 알 수 있다.
30fps라면 대략:
timescale = 1000
sample duration ≈ 33
같은 식으로 표현될 수 있다.
3.12 ctts — Composition Time
영상에서는 DTS와 PTS가 다를 수 있다.
특히 B-frame 때문이다.
예를 들어:
Decode order:
I → B → B → P
인데 화면에 보여주는 순서는:
I → B → B → P
처럼 보이지만 내부적으로 decode 순서와 presentation 순서가 달라질 수 있다.
그래서:
stts = decoding timestamp
ctts = composition/presentation offset
라고 생각하면 된다.
Chromium/WebKit의 미디어 파이프라인을 볼 때 이 부분이 상당히 중요하다.
3.13 stsz — Sample Size
각 sample의 크기를 알려준다.
예를 들어:
stsz
sample 1 = 12000 bytes
sample 2 = 3500 bytes
sample 3 = 2800 bytes
sample 4 = 14000 bytes
...
이런 식이다.
영상에서는 frame 하나가 하나의 sample이 되는 경우가 많다.
3.14 stco / co64 — 데이터 위치
그리고 실제 데이터가 mdat의 어디에 있는지 알아야 한다.
그 역할을 하는 것이:
stco
또는
co64
다.
예를 들어:
sample 1 → offset 100000
sample 2 → offset 112000
sample 3 → offset 115500
이런 정보를 갖는다.
그러면 parser가:
stco + stsz
를 이용해서
"sample 2는 파일의 이 위치에서 이 크기만큼 읽으면 되는구나."
라고 알 수 있다.
3.15 mdat — 진짜 영상 데이터
그리고 드디어:
mdat
이다.
여기에 실제 encoded media data가 들어간다.
예를 들어 H.264라면:
mdat
│
├── H.264 sample 1
├── H.264 sample 2
├── H.264 sample 3
├── H.264 sample 4
└── ...
중요한 것은:
mdat 자체에는 "이게 몇 번째 frame인지" 같은 고수준 정보가 거의 없다.
그 정보를 moov의 sample table이 설명한다.
즉 둘을 합쳐야 한다.
moov
│
┌────────┴─────────┐
│ │
sample table codec config
│
↓
"sample #23은
offset X,
size Y,
DTS Z"
│
↓
mdat
│
↓
실제 H.264 데이터
3.16 그래서 MP4 parser의 핵심은 이런 관계다
개념적으로 보면:
MP4
│
┌────────┴────────┐
↓ ↓
moov mdat
│ │
metadata encoded data
│
↓
trak
│
mdia
│
minf
│
stbl
│
┌─────┼─────┬─────┬─────┐
↓ ↓ ↓ ↓ ↓
stsd stts stsz stco ctts
│
↓
avc1
│
↓
avcC
이렇게 연결된다.
3.17 fMP4가 되면 구조가 달라진다
여기서 앞에서 이야기한 fMP4가 등장한다.
일반 MP4:
ftyp
moov
mdat
fMP4:
ftyp
moov
moof
mdat
moof
mdat
moof
mdat
가 된다.
moof는 Movie Fragment이고,
mdat는 그 fragment의 실제 데이터다.
대략:
moof
├── mfhd
└── traf
├── tfhd
├── tfdt
└── trun
mdat
└── encoded samples
여기서 특히:
tfhd
tfdt
trun
이 중요하다.
trun에는 fragment 안의 sample들에 대한:
- size
- duration
- flags
- composition time offset
등의 정보가 들어갈 수 있다.
즉 일반 MP4에서 stbl이 하던 역할의 일부를 fragment별로 moof 안에서 나눠 가지고 있는 것이라고 이해하면 된다.
3.18 왜 fMP4가 스트리밍에 좋은가?
일반 MP4는:
ftyp
moov ────────────────┐
│
mdat │
█████████████████████│
처럼 전체 파일에 대한 index가 필요하다.
반면 fMP4는:
ftyp
moov
moof + mdat ← 0~2초
moof + mdat ← 2~4초
moof + mdat ← 4~6초
moof + mdat ← 6~8초
처럼 각 fragment가 독립적으로 필요한 정보를 가지고 있다.
그래서 HTTP로:
Range request
↓
fragment 23
↓
moof + mdat
↓
decode
같은 방식으로 다루기가 좋다.
3.19 마지막으로 가장 중요한 그림
MP4를 "코덱 데이터가 들어 있는 상자"라고만 생각하면 조금 부족하고, 다음처럼 생각하면 정확하다.
MP4
│
┌────────────┴────────────┐
│ │
설명/인덱스 데이터
│ │
moov mdat
│ │
┌────┴────┐ │
│ │ │
video audio │
│ │ │
trak trak │
│ │
mdia │
│ │
minf │
│ │
stbl │
│ │
┌─┼──────┬──────┬──────┐ │
│ │ │ │ │ │
stsd stts stsz stco ctts │
│ │
↓ ↓
H.264 실제 NAL/sample
config bytes
**한마디로 moov가 "설명서/색인"이고 mdat가 "실제 영상 창고"**라고 생각하면 된다.
4. 스트리밍
웹에서 영상을 틀어 주는 방식은 크게 네 가지
| 방식 | 페이지가 쓰는 주소 | 실제 데이터 |
| progressive | https://…/video.mp4 | 파일 하나 |
| HLS | …/index.m3u8 | 재생목록 + 짧은 세그먼트 수백~수천 개 |
| DASH | …/manifest.mpd | XML 매니페스트 + 세그먼트 |
| MSE(blob) | blob:https://… | JS가 받은 데이터를 브라우저에 밀어 넣음 |
4.1 Progressive
가장 단순하다.
<video src="https://example.com/video.mp4">
실제 데이터는 그냥 하나의 MP4 파일
video.mp4
┌────────────────────────────────────────────┐
│ ftyp │ moov │ mdat │
│ │ │ H264 + AAC │
└────────────────────────────────────────────┘
여기서 "progressive"라고 하는 이유는 파일 전체를 다운로드할 때까지 기다리지 않고 받은 데이터부터 재생할 수 있기 때문이다.
예를 들어:
다운로드
████████████████████████████████
↑
현재 다운로드 위치
재생
████████
↑
현재 재생 위치
다운로드와 재생이 동시에 진행된다.
중요한 점
Progressive라고 해서 반드시 "처음부터 끝까지 전부 다운로드한다"는 것은 아니다.
브라우저가 HTTP Range Request를 사용하면:
Range: bytes=10000000-20000000
처럼 MP4의 특정 부분을 요청할 수도 있다.
따라서:
Progressive = 단일 미디어 파일을 HTTP를 통해 재생
이라고 이해하는 게 정확.
4.2 HLS — "파일 하나"가 아니라 "재생목록을 따라간다"
HLS에서는 <video>가 MP4를 직접 받는 것이 아니다.
처음에 playlist인, m3u8 을 받는다.
https://example.com/video/index.m3u8
예를 들어:
index.m3u8
1080p.m3u8
720p.m3u8
480p.m3u8
같은 정보를 가지고 있을 수 있다.
즉:
Browser
│
│ GET index.m3u8
↓
Server
│
│ playlist
↓
Browser
그리고 playlist를 보고 실제 영상을 찾는다.
예를 들어 720p playlist가:
720p.m3u8
이고 내부적으로:
segment001.ts
segment002.ts
segment003.ts
segment004.ts
...
즉 m3u8 자체에는 보통 영상 데이터가 없다.
m3u8은 사실상 "다음에 이 URL들을 받아서 재생해." 라는 목록/설명서다.
예를 들어 2시간짜리 영상이고 segment 하나가 6초라면:
7200 / 6 = 1200개
정도가 된다.
그래서:
video
│
├── segment0001
├── segment0002
├── segment0003
├── ...
└── segment1200
이런 구조가 된다.
그리고 여기서 엄청 중요한 기능이 하나 생긴다.
화질을 바꿀 수 있다.
서버가:
1080p
├─ 001
├─ 002
├─ 003
...
720p
├─ 001
├─ 002
├─ 003
...
480p
├─ 001
├─ 002
├─ 003
...
를 갖고 있다면 플레이어가:
1080p segment 1
1080p segment 2
720p segment 3
720p segment 4
1080p segment 5
처럼 선택할 수 있다.
이것이 Adaptive Bitrate Streaming (ABR)이다.
4.3 DASH도 거의 같은 철학이다
DASH에서는 시작 주소가:
https://example.com/manifest.mpd
HLS의 m3u8에 대응하는 것이 DASH의 mpd다.
HLS DASH
──── ────
m3u8 mpd
playlist manifest
segment segment
DASH의 manifest는 XML이다.
개념적으로:
<MPD>
<Period>
<AdaptationSet>
<Representation bandwidth="800000">
...
</Representation>
<Representation bandwidth="4000000">
...
</Representation>
</AdaptationSet>
</Period>
</MPD>
이런 정보를 가지고 있다.
즉:
Browser / Player
│
│ GET manifest.mpd
↓
MPD
│
├── 1080p
├── 720p
└── 480p
│
↓
각 segment URL
이라는 구조다.
4.4 HLS와 DASH의 본질적인 차이
둘의 핵심 구조는 사실 굉장히 비슷하다.
Streaming
│
┌──────────┴──────────┐
↓ ↓
HLS DASH
↓ ↓
m3u8 mpd
↓ ↓
segments segments
↓ ↓
ABR ABR
차이는 manifest 형식과 프로토콜/생태계의 역사적 차이가 크다.
그리고 컨테이너도 과거와 현재가 조금 다르다.
과거 HLS:
m3u8
↓
.ts
현대 HLS:
m3u8
↓
fMP4/CMAF
DASH:
mpd
↓
fMP4/CMAF
가 흔하다.
그래서 요즘은:
HLS와 DASH가 서로 다른 영상 포맷이라기보다, 같은 fMP4/CMAF 미디어를 서로 다른 manifest/스트리밍 규칙으로 전달할 수 있다
고 이해하면 좋다.
4.5 MSE가 완전히 다른 이유
네 표에서 가장 헷갈릴 수 있는 부분이 이거다.
MSE(blob)
blob:https://…
JS가 받은 데이터를 브라우저에 밀어 넣음
여기서는 blob URL이 실제 서버의 영상 URL이 아니다.
예를 들어 페이지에:
<video src="blob:https://example.com/abc-123">
가 있다고 하자.
이걸 보고:
"abc-123이라는 파일이 서버에 있나?"
라고 생각하면 안 된다.
MSE에서는 JavaScript가 중간에 있다
구조가 다음과 같이 된다.
Server
↑
│ HTTP
│
JavaScript
│
│ appendBuffer()
↓
MediaSource
↓
SourceBuffer
↓
<video>
예를 들어 JS가:
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
하면 브라우저가:
blob:https://example.com/...
같은 URL을 만든다.
이것은 MediaSource 객체를 <video>에 연결하기 위한 브라우저 내부적인 URL이다.
그리고 JS가 직접 데이터를 가져온다
예를 들어 다음과 같이 한다.
fetch("/video/segment001.m4s")
서버가:
moof + mdat
를 반환하면, JS가 다음과 같이 한다.
sourceBuffer.appendBuffer(data);
그래서 다음과 같이 된다.
segment001.m4s
↓
ArrayBuffer
↓
appendBuffer()
↓
SourceBuffer
↓
<video>
즉 브라우저에게 데이터를 공급하는 주체가 JavaScript다.
5. HLS
5.1 HLS 전체 구조
HLS는 크게 Master Playlist → Media Playlist → Segment 3단계로 생각하면 된다.
HLS
│
master.m3u8
│
┌────────────┼────────────┐
↓ ↓ ↓
1080p 720p 480p
│ │ │
media.m3u8 media.m3u8 media.m3u8
│ │ │
┌────┼────┐ ┌───┼────┐ ┌───┼────┐
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
seg seg seg seg seg seg seg seg seg
그리고 오디오가 별도로 있다면:
master.m3u8
│
┌───────────┴───────────┐
↓ ↓
VIDEO AUDIO
↓ ↓
1080p.m3u8 audio.m3u8
↓ ↓
video segments audio segments
이제 각각을 보자.
5.2 Master Playlist
처음 요청하는 것이 보통 이것이다.
https://example.com/hls/master.m3u8
내용은 대략:
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=854x480
480p/index.m3u8
여기서 중요한 것은 영상 데이터가 없다는 것이다.
Master Playlist는:
"이 영상에는 이런 버전들이 있습니다."
라고 알려주는 메뉴판이다.
5.2.1 #EXT-X-STREAM-INF
이 줄이 variant를 정의한다.
예를 들어:
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
1080p/index.m3u8
의 의미는 다음과 같다.
variant
├── 예상 bitrate: 5 Mbps
├── 해상도: 1920×1080
└── media playlist:
1080p/index.m3u8
여기서 variant라는 말은 "동일한 콘텐츠의 다른 품질 버전"이라는 뜻이다.
따라서:
1080p
720p
480p
가 각각 하나의 variant다.
5.2.2 BANDWIDTH
예:
#EXT-X-STREAM-INF:BANDWIDTH=5000000
이것은 단순히:
"현재 파일이 5 Mbps다."
라는 의미라기보다 해당 variant를 재생하는 데 필요한 평균적인/최대 네트워크 대역폭 정보를 제공하는 값으로 이해하는 게 좋다.
플레이어가:
현재 네트워크 ≈ 3 Mbps
라면:
1080p = 5 Mbps
720p = 2.5 Mbps
480p = 1 Mbps
중에서 720p나 480p를 선택할 수 있다.
이것이 ABR의 출발점이다.
5.2.3 Master Playlist에는 오디오도 등장할 수 있다
여기가 조금 복잡해진다.
단순한 HLS라면:
1080p.m3u8 → video + audio
처럼 한 segment 안에 영상과 음성이 같이 들어갈 수 있다.
하지만 오디오를 별도로 분리할 수도 있다.
예를 들어:
Master
│
├── 1080p video
├── 720p video
├── 480p video
│
└── audio
├── Korean
├── English
└── Japanese
이런 구조다.
Master Playlist에 이런 것이 나올 수 있다.
#EXT-X-MEDIA:TYPE=AUDIO,
GROUP-ID="audio",
NAME="Korean",
LANGUAGE="ko",
DEFAULT=YES,
URI="audio/ko.m3u8"
그리고 video variant:
#EXT-X-STREAM-INF:
BANDWIDTH=5000000,
RESOLUTION=1920x1080,
AUDIO="audio"
video/1080p.m3u8
이것은 다음과 같은 뜻이다.
"1080p 영상은 audio라는 오디오 그룹을 사용하고, 그 그룹의 기본 오디오는 Korean이다."
왜 오디오를 분리할까? 대표적인 이유가 언어 선택이다.
VIDEO
│
┌───────┼───────┐
↓ ↓ ↓
1080p 720p 480p
AUDIO
│
┌───────┼───────┐
↓ ↓ ↓
Korean English Japanese
영상은 하나인데:
한국어 음성
영어 음성
일본어 음성
을 선택할 수 있다.
또 다른 이유는 영상과 음성의 bitrate를 독립적으로 조절하기 위해서다.
5.3 Media Playlist
Master가:
1080p/index.m3u8
을 가리켰다고 가정해 보자.
#EXTM3U
#EXT-X-TARGETDURATION:6
#EXTINF:5.96,
segment001.m4s
#EXTINF:6.02,
segment002.m4s
#EXTINF:5.98,
segment003.m4s
#EXT-X-ENDLIST
이것이 Media Playlist다.
Media Playlist에는 실제 segment 목록이 들어 있다.
5.3.1 #EXTINF
예:
#EXTINF:5.96,
segment001.m4s
의 의미:
segment001.m4s
↑
약 5.96초의 미디어
이다.
즉:
segment001.m4s → 0~5.96초
segment002.m4s → 5.96~11.98초
segment003.m4s → 11.98~17.96초
처럼 플레이어가 시간축을 구성할 수 있다.
5.3.2 Segment 하나가 꼭 정확히 6초일 필요는 없다
중요한 점이다.
#EXTINF:6.00
#EXTINF:5.96
#EXTINF:6.03
#EXTINF:5.97
처럼 약간씩 다를 수 있다.
#EXTINF는 실제 segment duration을 설명하는 정보다.
5.3.3 #EXT-X-ENDLIST
이것도 매우 중요하다.
#EXT-X-ENDLIST
가 있다면:
"이 playlist는 더 이상 추가 segment가 없다."
라는 의미다.
그래서 일반적으로 VOD에서는:
segment1
segment2
segment3
...
segment100
#EXT-X-ENDLIST
가 된다.
반면 Live HLS는:
segment98
segment99
segment100
까지만 있고 ENDLIST가 없다.
조금 뒤 다시 playlist를 요청하면:
segment99
segment100
segment101
처럼 바뀐다.
Live HLS는 playlist 자체가 움직인다
이것이 VOD와 Live의 큰 차이다.
VOD
playlist
├─ 1
├─ 2
├─ 3
├─ ...
└─ 100
ENDLIST
Live
시간 T:
playlist
├─ 98
├─ 99
└─ 100
몇 초 후:
playlist
├─ 99
├─ 100
└─ 101
다시:
playlist
├─ 100
├─ 101
└─ 102
즉 플레이어가 playlist를 주기적으로 다시 받아서 새로운 segment를 발견한다.
5.4 #EXT-X-MAP
이제 fMP4로 들어간다.
전통적인 HLS는 다음과 같다.
m3u8
↓
segment.ts
segment.ts
segment.ts
그런데 fMP4를 쓰면 다음과 같이 된다.
#EXT-X-MAP:URI="init.mp4"
segment001.m4s
segment002.m4s
segment003.m4s
#EXT-X-MAP은 다음 의미이다.
"이 segment들을 해석하기 위한 initialization segment는 여기 있다."
앞에서 MP4 구조를 봤기 때문에 이제 쉽게 이해할 수 있다.
일반 MP4:
ftyp
moov
mdat
fMP4에서는 다음과 같다.
init segment
↓
ftyp + moov
media segments
↓
moof + mdat
moof + mdat
moof + mdat
즉:
init.mp4
↓
"이 미디어는 H.264이고,
timescale은 이것이고,
track은 이것이고,
decoder configuration은 이것이다."
를 알려준다.
그리고 실제 segment는 다음과 같다.
segment1.m4s
↓
moof + mdat
↓
실제 sample
따라서 #EXT-X-MAP이 보이면 거의 확실하게:
HLS
↓
fMP4
↓
CMAF 계열
을 의심할 수 있다.
반대로:
#EXTINF:6,
segment001.ts
처럼 .ts를 사용하면 전통적인 MPEG-TS 기반 HLS일 가능성이 높다.
다만 확장자만으로 100% 판단하는 것보다는 실제 파일의 MIME/type과 box/TS 구조를 보는 것이 확실하다.
5.5 이제 암호화
예를 들어:
#EXT-X-KEY:METHOD=AES-128,
URI="https://example.com/key"
라면:
"이후 segment는 AES-128 방식으로 암호화되어 있으니 이 key를 가져와 복호화해서 사용해라."
라는 의미다.
구조를 보면:
playlist
│
├── #EXT-X-KEY
│ ↓
│ key URL
│
↓
encrypted segment
│
↓
AES-128
│
↓
clear media
다만, AES-128 HLS와 DRM은 구별해야 한다
HLS AES-128
#EXT-X-KEY:METHOD=AES-128
HLS AES-128는 콘텐츠를 암호화해서 전송하는 방식이다.
일반적으로 플레이어가 key를 가져와서 segment를 복호화할 수 있다.
즉 개념적으로 다음과 같다.
encrypted segment
+
key
↓
decrypted media
DRM
반면:
SAMPLE-AES
Widevine
FairPlay
PlayReady
같은 DRM은 단순히:
"URL에서 key 하나 받아서 AES 복호화"
하는 구조와 다르다.
특히 브라우저에서는:
Encrypted Media Extensions
↓
CDM
↓
license server
↓
decryption
같은 별도의 DRM 시스템이 들어간다.
따라서 HLS의 AES-128과 DRM을 같은 것으로 생각하면 안 된다.
5.6 HLS 예제
예를 들어 서버에 다음이 있다고 하자.
/master.m3u8
/1080p.m3u8
/720p.m3u8
/audio/ko.m3u8
/1080/init.mp4
/1080/001.m4s
/1080/002.m4s
...
Master:
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,
GROUP-ID="audio",
NAME="Korean",
DEFAULT=YES,
URI="audio/ko.m3u8"
#EXT-X-STREAM-INF:
BANDWIDTH=5000000,
RESOLUTION=1920x1080,
AUDIO="audio"
1080p.m3u8
#EXT-X-STREAM-INF:
BANDWIDTH=2500000,
RESOLUTION=1280x720,
AUDIO="audio"
720p.m3u8
플레이어는:
master.m3u8
│
├───────────────┐
↓ ↓
1080p.m3u8 audio/ko.m3u8
│ │
↓ ↓
init.mp4 audio init
│ │
↓ ↓
001.m4s audio001.m4s
002.m4s audio002.m4s
003.m4s audio003.m4s
를 받아서 재생한다.
ABR은 여기서 어떻게 작동하나?
처음에는 네트워크가 좋다고 판단해서:
1080p
↓
001.m4s
002.m4s
를 받고 있었는데 다운로드 속도가 떨어진다.
플레이어가:
현재 buffer = 4초
네트워크 = 불안정
이라고 판단하면:
1080p
↓
001
002
에서
720p
↓
003
004
로 바꿀 수 있다.
즉 variant를 바꾸는 것은 master playlist를 다시 받는 것이 아니라, 다른 variant의 media playlist/segment를 선택하는 것이다.
5.7 HLS 전체 구조를 하나의 그림으로 보면
HLS
│
↓
master.m3u8
│
#EXT-X-STREAM-INF
│
┌─────────────────┼─────────────────┐
↓ ↓ ↓
1080p 720p 480p
│ │ │
↓ ↓ ↓
media.m3u8 media.m3u8 media.m3u8
│
├── #EXT-X-MAP ──→ init.mp4
│
├── #EXTINF ─────→ 001.m4s
├── #EXTINF ─────→ 002.m4s
├── #EXTINF ─────→ 003.m4s
└── #EXT-X-ENDLIST
오디오가 별도라면:
master.m3u8
│
┌───────────┴───────────┐
↓ ↓
VIDEO AUDIO
│ │
1080/720/480 Korean
│ English
│ Japanese
↓ ↓
video.m3u8 audio.m3u8
│ │
↓ ↓
video.m4s audio.m4s
│ │
└───────────┬───────────┘
↓
Playback
결국 HLS의 핵심은 m3u8이 영상을 담고 있는 게 아니라, "어떤 미디어를 어떤 순서로 가져와야 하는지 알려주는 제어 파일"이라는 것이다.
MP4와 연결해 보면,
HLS = m3u8이라는 재생 지침 + (TS 또는 fMP4)라는 실제 미디어 segment들의 조합
특히 #EXT-X-STREAM-INF → variant 선택 → #EXT-X-MAP → moof/mdat → #EXTINF → segment → MSE/디코더까지 연결해서 보면, 실제 Chrome DevTools에서 HLS 하나를 잡았을 때 무슨 일이 일어나는지 거의 그대로 읽을 수 있다.
'개발' 카테고리의 다른 글
| [스크랩] 좋은 개발자의 특징 (0) | 2025.04.14 |
|---|---|
| 워드프레스에서 우편번호 오류창 해결하기 (1) | 2023.09.19 |
| ninja 빌드시 반복적으로 gn이 호출됨 (0) | 2022.11.08 |
| [Crawling] BeautifulSoup 예제 (0) | 2022.08.11 |
| flutter 따라해보기 - 첫번째 앱 1 (0) | 2021.05.27 |