블로그 목록
시리즈: 실시간 통화, 어떻게 녹화하는가4/6
  1. 1.실시간 통화, 왜 녹화해야 하는가
  2. 2.내 서버에서 직접 녹화한다면
  3. 3.녹화 모드 해부
  4. 4.M3U8과 TS 파일의 모든 것 ← 현재 글
  5. 5.FFmpeg, 미디어의 스위스 아미 나이프
  6. 6.FFmpeg 실전 파이프라인
Media시리즈12분 읽기

M3U8과 TS 파일의 모든 것 — HLS Deep Dive

MP4의 moov atom 한계, TS 패킷 188바이트 구조, I/P/B-frame 슬라이싱 규칙, Agora 전용 M3U8 태그까지 HLS를 프로토콜 수준에서 해부합니다.

HLSM3U8TSI-frameCloud Recording

녹화 파일을 열어보면 두 가지가 나옵니다. .m3u8 파일과 .ts 파일들. 처음 보면 생소합니다. MP4나 WebM을 예상했는데 왜 이런 구조인가?

이유는 하나입니다. 실시간 녹화에서 장애 내성(Fault Tolerance)이 최우선이기 때문입니다.

"서버가 비정상 종료되어도 지금까지 녹화된 내용은 살아남아야 한다" — 이 요구사항을 충족하려면 MP4 단일 파일 구조는 근본적으로 적합하지 않습니다. 왜 그런지부터 시작합니다.


MP4의 치명적 약점 — moov atom

MP4 파일을 열면 내부는 이렇게 생겼습니다.

MP4 파일 구조:
┌──────────────────────────────────┐
│  ftyp (파일 타입)                 │
│  mdat (미디어 데이터 — 거대함)     │
│  ...                             │
│  ...                             │
│  moov (메타데이터 — 파일 끝!)      │  ← 이게 없으면 재생 불가
└──────────────────────────────────┘

비정상 종료 시:
  mdat 까지만 써짐 → moov 없음 → 파일 전체 재생 불가 ❌

moov atom은 MP4에서 사실상 목차(index)입니다. 트랙 정보, 타임스탬프, 각 샘플의 바이트 오프셋 — 플레이어가 영상을 재생하려면 이 정보가 반드시 필요합니다. 문제는 이 moov파일의 맨 끝에 위치한다는 것입니다.

녹화가 정상적으로 끝나면 moov가 파일 끝에 기록됩니다. 하지만 서버 크래시, 네트워크 중단, 강제 종료 — 이런 상황에서는 mdat만 쓰여진 채 moov가 없는 파일이 남습니다. 플레이어는 이 파일을 열지 못합니다. 1시간짜리 상담 녹화가 통째로 날아가는 겁니다.

ffmpeg -i input.mp4 -movflags faststart output.mp4moov를 파일 앞으로 옮길 수 있습니다. 하지만 이 작업은 녹화가 완전히 끝난 후에만 가능합니다. 진행 중인 녹화에는 적용할 수 없습니다.


HLS — HTTP Live Streaming

Apple이 2009년에 만든 스트리밍 프로토콜입니다. 핵심 아이디어는 단순합니다. 긴 영상을 통째로 다루는 대신, 짧은 조각들로 쪼개서 HTTP로 전달한다.

하나의 긴 영상을 통째로 저장하는 대신:
❌  recording.mp4 (2시간, 4GB) → 다운로드 완료까지 재생 불가

잘게 쪼개서 저장:
✅  recording.m3u8        ← 목차 (playlist)
    ├── segment_001.ts   ← 15초 분량 조각
    ├── segment_002.ts   ← 15초 분량 조각
    ├── segment_003.ts   ← 15초 분량 조각
    └── ...

M3U8은 목차(playlist) 역할을 하는 텍스트 파일이고, TS(Transport Stream)는 실제 미디어가 담긴 조각들입니다. 플레이어는 M3U8을 읽고, 필요한 TS 세그먼트를 순서대로 다운로드해서 재생합니다.

녹화 관점에서 장점이 명확합니다. 세그먼트 단위로 파일이 완성되기 때문에, 중간에 서버가 죽어도 지금까지 완성된 세그먼트들은 온전히 살아있습니다. MP4처럼 마지막에 한 번에 메타데이터를 쓰는 구조가 아닙니다. 녹화 파일이 S3에 저장되고 업로드되는 흐름은 녹화 파일이 S3에 저장되는 흐름에서 다뤘습니다.


TS (Transport Stream) 패킷 구조

TS는 원래 위성방송과 디지털 방송을 위해 설계된 컨테이너 포맷입니다. 핵심 설계 철학은 **손실 허용(loss-tolerant)**입니다. 신뢰할 수 없는 전송 채널을 가정하고 만들었습니다.

┌─TS 컨테이너 구조 ──────────────────────────────┐
│                                                │
│  ┌────────┐┌────────┐┌────────┐┌────────┐     │
│  │Packet 1││Packet 2││Packet 3││Packet 4│ ... │
│  │ 188B   ││ 188B   ││ 188B   ││ 188B   │     │
│  └────────┘└────────┘└────────┘└────────┘     │
│                                                │
│  각 패킷 = 고정 188 bytes                       │
│  ├─ 4B header (sync byte 0x47 + PID + flags)  │
│  └─ 184B payload (H.264 NAL units 또는         │
│                    AAC audio frames)           │
│                                                │
│  PID로 오디오/비디오 스트림 구분                   │
│  → 손상되어도 해당 패킷만 손실, 나머지 재생 가능    │
└────────────────────────────────────────────────┘

모든 패킷이 고정 188 bytes입니다. 첫 번째 바이트는 항상 0x47 (sync byte)입니다. 패킷 경계를 찾기 위해 파일 전체를 파싱할 필요가 없습니다. 파일 오프셋 mod 188 == 0인 위치를 찾으면 됩니다.

**PID (Packet Identifier)**는 13-bit 값으로 어느 스트림인지를 식별합니다. PMT(Program Map Table)에 오디오 PID와 비디오 PID가 정의되어 있고, 플레이어는 이를 참고해 패킷을 분류합니다. 패킷 하나가 손상되어도 해당 패킷만 스킵하면 됩니다. 파일 전체가 망가지지 않습니다.

MP4TS
구조moov atom이 파일 끝각 패킷이 자기완결적
중간 손상파일 전체 재생 불가손상된 패킷만 스킵
실시간 녹화비정상 종료 시 깨짐쓴 세그먼트는 안전
스트리밍전체 다운로드 필요세그먼트 단위 즉시 재생
파일 크기효율적헤더 오버헤드 (~2%)

M3U8 파일 해부

M3U8은 그냥 텍스트 파일입니다. #으로 시작하는 태그와 파일 경로로 구성됩니다. Agora Cloud Recording이 실제로 생성하는 M3U8은 이렇게 생겼습니다.

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:15
#EXT-X-MEDIA-SEQUENCE:0

#EXT-X-AGORA-TRACK-EVENT:EVENT=START,TRACK_TYPE=VIDEO,TIME=1690000100
#EXT-X-AGORA-ROTATE:WIDTH=640,HEIGHT=480,ROTATE=0
#EXTINF:15.0,
sid_mychannel__uid_s_1001__uid_e_video_1690000100.ts

#EXTINF:15.0,
sid_mychannel__uid_s_1001__uid_e_video_1690000115.ts

#EXTINF:12.3,
sid_mychannel__uid_s_1001__uid_e_video_1690000130.ts

#EXT-X-AGORA-TRACK-EVENT:EVENT=STOP,TRACK_TYPE=VIDEO,TIME=1690000142
#EXT-X-ENDLIST

표준 HLS 태그:

  • #EXTM3U — M3U8 파일임을 선언하는 시작 태그. 반드시 첫 줄에 있어야 합니다.
  • #EXT-X-VERSION:3 — HLS 스펙 버전. Agora는 v3를 사용합니다.
  • #EXT-X-TARGETDURATION:15 — 세그먼트 최대 길이(초). 실제 세그먼트는 이보다 짧을 수 있지만, 길어서는 안 됩니다.
  • #EXTINF:15.0, — 바로 다음에 오는 TS 파일의 실제 길이(초). 쉼표로 끝납니다.
  • #EXT-X-ENDLIST — 녹화 완료를 나타냅니다. 이 태그가 없으면 플레이어는 세그먼트가 계속 추가될 것으로 기대합니다 (라이브 스트리밍 모드).

Agora 전용 확장 태그:

  • #EXT-X-AGORA-TRACK-EVENTEVENT=START 또는 EVENT=STOP, TRACK_TYPE=AUDIO 또는 VIDEO, TIME=UTC초. 스트림이 언제 시작하고 중단됐는지를 기록합니다. 네트워크 중단으로 스트림이 잠깐 끊겼다 재개된 경우 START/STOP 쌍이 여러 번 나타납니다.
  • #EXT-X-AGORA-ROTATEWIDTH, HEIGHT, ROTATE(0/90/180/270). 해상도와 화면 회전 정보. 모바일에서 카메라 방향이 바뀌면 이 태그가 추가됩니다.

마지막 세그먼트의 길이가 12.3초인 것에 주목하세요. 15초 경계에서 녹화가 끝나지 않았기 때문입니다. 실제 녹화 데이터가 12.3초이면 그 길이 그대로 기록됩니다.


Agora 모드별 출력 파일 트리

모드에 따라 생성되는 파일 구조가 다릅니다.

Individual 모드 — 참여자별 분리 저장

cloud-storage-bucket/
├── sid_mychannel__uid_s_1001__uid_e_audio.m3u8
├── sid_mychannel__uid_s_1001__uid_e_audio_1690000100.ts
├── sid_mychannel__uid_s_1001__uid_e_audio_1690000115.ts
├── sid_mychannel__uid_s_1001__uid_e_video.m3u8
├── sid_mychannel__uid_s_1001__uid_e_video_1690000100.ts
├── sid_mychannel__uid_s_1001__uid_e_video_1690000115.ts
├── sid_mychannel__uid_s_2002__uid_e_audio.m3u8
├── sid_mychannel__uid_s_2002__uid_e_audio_1690000100.ts
└── ...

UID마다, 트랙(audio/video)마다 별도의 M3U8과 TS 세트가 생성됩니다. 참여자가 10명이면 최대 20개의 M3U8 파일이 나옵니다.

Mix (Composite) 모드 — 합성 후 단일 파일

cloud-storage-bucket/
├── sid_mychannel.m3u8
├── sid_mychannel_1690000100.ts
├── sid_mychannel_1690000115.ts
└── ...

서버 사이드에서 모든 참여자 영상을 믹싱한 결과물입니다. 파일이 훨씬 단순합니다.

파일명 컨벤션 해부:

sid_mychannel__uid_s_1001__uid_e_video_1690000100.ts
│    │          │    │         │    │       │
│    │          │    │         │    │       └─ UTC 타임스탬프 (세그먼트 시작 시점)
│    │          │    │         │    └─ uid_e (end 구분자)
│    │          │    │         └─ 트랙 타입 (audio / video)
│    │          │    └─ UID (1001)
│    │          └─ uid_s (start 구분자)
│    └─ 채널 이름 (cname)
└─ sid (Recording 세션 ID)

타임스탬프가 파일명에 들어가 있어서 파일 시스템 정렬만으로 시간순 순서를 알 수 있습니다.


avFileType 설정

Cloud Recording API 요청 시 출력 포맷을 지정할 수 있습니다.

// HLS만 (기본값) — M3U8 + TS
"recordingFileConfig": { "avFileType": ["hls"] }

// HLS + MP4 동시 생성 — M3U8/TS + MP4
"recordingFileConfig": { "avFileType": ["hls", "mp4"] }

// ⚠️ MP4만은 불가!
"recordingFileConfig": { "avFileType": ["mp4"] }  // ❌ 에러 발생

MP4만 단독으로 지정하면 에러가 납니다. 이유는 Agora의 내부 파이프라인 때문입니다. MP4는 HLS 녹화 결과물(TS 세그먼트)을 후처리로 합쳐서 만드는 방식입니다. HLS 없이 MP4를 만들 수 없습니다. MP4는 HLS에 의존합니다.

"hls", "mp4" 조합을 쓰면 녹화가 끝난 후 Agora가 TS 세그먼트들을 MP4로 합쳐서 추가로 업로드합니다. 즉시 재생 가능한 단일 MP4가 생기지만, 녹화 완료 후 추가 시간이 걸립니다.


I-frame / P-frame / B-frame — 세그먼트를 어디서 자르는가

TS 세그먼트를 임의로 자를 수는 없습니다. 비디오 압축의 기본 개념이 개입합니다.

I-frame (Intra)     P-frame (Predicted)    B-frame (Bidirectional)
┌──────────────┐    ┌──────────────┐       ┌──────────────┐
│ 전체 이미지   │    │ 이전 프레임과 │       │ 앞뒤 프레임과 │
│ 완전한 정보   │    │ 차이만 저장   │       │ 차이만 저장   │
│ (독립 재생 O) │    │ (독립 재생 X) │       │ (독립 재생 X) │
└──────────────┘    └──────────────┘       └──────────────┘
     ~50KB               ~5KB                   ~2KB

I ─ P ─ P ─ B ─ P ─ P ─ I ─ P ─ P ─ B ─ P ─ P ─ I
                         ↑                         ↑
                    세그먼트 경계 (여기서 잘라야 독립 재생 가능)
  • I-frame (Key Frame): 완전한 이미지 정보를 담습니다. 앞 프레임 없이도 독립 재생이 가능합니다. JPEG 한 장과 유사한 정보량을 가집니다.
  • P-frame: 이전 I-frame 또는 P-frame과의 차이(delta)만 저장합니다. I-frame 없이는 재생할 수 없습니다.
  • B-frame: 앞뒤 프레임 모두와의 차이를 저장합니다. 가장 압축률이 높지만 의존성이 가장 큽니다.

TS 세그먼트는 반드시 I-frame에서 시작해야 합니다. P-frame이나 B-frame에서 세그먼트가 시작되면, 참조할 이전 프레임이 없어서 화면이 깨집니다. 플레이어가 세그먼트를 독립적으로 재생할 수 없게 됩니다.


TS 슬라이싱 규칙

Agora가 TS 세그먼트를 자르는 조건은 다음과 같습니다.

비디오 슬라이싱 트리거:

[정상 케이스]
  I-frame 도착 후 15초 경과
      → 세그먼트 종료, 새 세그먼트 시작

[코덱/해상도 변경]
  H.264 → VP8 변경 감지
  640x480 → 1280x720 해상도 변경
      → 즉시 슬라이싱 (15초 미만이어도)

[스트림 이벤트]
  스트림 중단 / 재개
      → 중단 시점에서 슬라이싱
      → M3U8에 STOP/START 이벤트 태그 기록

[강제 슬라이싱]
  5.5분(330초) 경과 또는 50MB 초과
      → H.264 강제 I-frame 삽입 후 슬라이싱
      → 대역폭이 낮아 I-frame이 오래 안 오는 경우에 대한 안전장치

오디오 슬라이싱:

오디오는 단순합니다. 비디오 슬라이싱과 동기화되거나, 독립적으로 매 15초마다 일정하게 잘립니다.

비디오와 오디오의 타임스탬프가 어긋나는 경우, Individual 모드에서는 M3U8의 #EXTINF 값을 통해 플레이어가 싱크를 맞춥니다. 나중에 FFmpeg으로 합칠 때 이 타임스탬프 정렬이 중요해집니다.


M3U8 파싱 스크립트

Agora M3U8을 파싱해서 유용한 정보를 빠르게 추출하는 Python 스크립트입니다.

#!/usr/bin/env python3
"""Agora M3U8 파서 — 세그먼트 정보와 이벤트 추출"""
import re
import sys

def parse_agora_m3u8(filepath):
    segments = []
    events = []
    current_duration = 0

    with open(filepath) as f:
        lines = f.readlines()

    for i, line in enumerate(lines):
        line = line.strip()

        # Agora 트랙 이벤트 파싱
        if line.startswith('#EXT-X-AGORA-TRACK-EVENT:'):
            props = dict(kv.split('=') for kv in line.split(':',1)[1].split(','))
            events.append(props)

        # 세그먼트 Duration
        elif line.startswith('#EXTINF:'):
            current_duration = float(line.split(':')[1].rstrip(','))

        # TS 파일명
        elif line.endswith('.ts'):
            # UTC 타임스탬프 추출 (파일명 마지막 숫자)
            utc_match = re.search(r'_(\d{10,})\.ts$', line)
            utc = int(utc_match.group(1)) if utc_match else None
            segments.append({
                'file': line,
                'duration': current_duration,
                'utc': utc,
            })

    total = sum(s['duration'] for s in segments)
    print(f"총 세그먼트: {len(segments)}개")
    print(f"총 길이: {total:.1f}초 ({total/60:.1f}분)")
    print(f"이벤트: {len(events)}개")
    for e in events:
        print(f"  {e.get('EVENT')}{e.get('TRACK_TYPE')} @ {e.get('TIME')}")

if __name__ == '__main__':
    parse_agora_m3u8(sys.argv[1])
# 사용법
python3 parse_m3u8.py sid_mychannel__uid_s_1001__uid_e_video.m3u8

# 출력 예시:
# 총 세그먼트: 120개
# 총 길이: 1800.0초 (30.0분)
# 이벤트: 2개
#   START — VIDEO @ 1690000100
#   STOP — VIDEO @ 1690001900

녹화 파일을 받았을 때 가장 먼저 실행할 만한 스크립트입니다. 총 길이와 스트림 중단 여부를 빠르게 확인할 수 있습니다. 세그먼트 수가 예상보다 적거나, START/STOP 쌍이 여러 번 나타나면 네트워크 중단이 있었음을 의미합니다.


핵심 요약

  • MP4가 실시간 녹화에 부적합한 이유는 moov atom 구조 때문입니다. 비정상 종료 시 메타데이터가 기록되지 않아 파일 전체를 잃습니다.
  • HLS는 영상을 짧은 TS 세그먼트로 쪼개 저장합니다. 완성된 세그먼트는 서버가 죽어도 안전합니다.
  • TS 패킷은 고정 188 bytes로 자기완결적입니다. 중간 손상이 해당 패킷에만 영향을 미칩니다.
  • M3U8은 플레인 텍스트 목차입니다. Agora 전용 태그(#EXT-X-AGORA-TRACK-EVENT, #EXT-X-AGORA-ROTATE)를 통해 스트림 이벤트와 해상도 변경을 기록합니다.
  • TS 세그먼트는 반드시 I-frame에서 시작해야 합니다. Agora는 I-frame 도착 후 15초를 기준으로 슬라이싱하며, 코덱/해상도 변경, 스트림 중단, 강제 타임아웃 조건에서도 즉시 자릅니다.
  • MP4 출력은 HLS에 의존합니다. avFileType: ["mp4"] 단독은 에러이며, ["hls", "mp4"]로 지정하면 녹화 완료 후 TS → MP4 변환이 추가로 수행됩니다.

시리즈 네비게이션

실시간 통화, 어떻게 녹화하는가 시리즈

  1. 실시간 통화, 왜 녹화해야 하는가 — Cloud Recording Architecture
  2. 내 서버에서 직접 녹화한다면 — On-Premise vs Cloud
  3. 녹화 모드 해부 — Individual, Mix, Web의 내부 파이프라인
  4. M3U8과 TS 파일의 모든 것 ← 현재 글
  5. FFmpeg, 미디어의 스위스 아미 나이프
  6. FFmpeg 실전 파이프라인 — 녹화에서 VOD까지

© 2026 Frank Kim. All rights reserved.