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

M3U8과 TS 구조 — HLS 녹화 파일 읽기

Cloud Recording 결과에 포함된 M3U8 재생목록과 MPEG-TS 세그먼트를 읽는 방법을 설명합니다. MP4의 `moov` 위치와 fragmented MP4를 구분하고, TS packet·PES·codec frame의 관계, HLS 태그, target duration 규칙을 살펴봅니다. Agora의 track event와 slice 파일명은 공식 형식에 맞춰 해석합니다.

HLSM3U8TSI-frameCloud Recording

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

HLS 조각과 playlist 구조는 녹화 중 파일을 순차 업로드하고 장애 뒤 남은 구간을 처리하는 데 유리합니다.

단일 일반 MP4와 HLS가 장애·재생·후처리 측면에서 어떻게 다른지부터 살펴봅니다.


MP4의 moov atom

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

MP4 파일 구조:
┌──────────────────────────────────┐
│  ftyp (파일 타입)                 │
│  mdat (미디어 데이터 — 거대함)     │
│  ...                             │
│  ...                             │
│  moov (메타데이터, 위치는 생성 방식에 따라 다름) │
└──────────────────────────────────┘

비정상 종료 시:
  일반 MP4에서 moov 마감 전 종료 → 일반 player가 열지 못할 수 있음

moov atom은 MP4의 track 정보, timestamp, sample 위치 같은 metadata를 담습니다. moov가 파일 끝에 놓이는 파일도 있지만 항상 그런 것은 아닙니다. faststart는 완성된 일반 MP4의 moov를 앞으로 옮기고, fragmented MP4는 여러 fragment의 metadata를 나눠 기록할 수 있습니다.

일반 MP4 writer가 metadata를 마감하기 전에 종료되면 player가 파일을 열지 못할 수 있습니다. 복구 가능성은 writer와 fragment 구조, 남은 metadata에 따라 달라지므로 전체 녹화가 항상 손실된다고 단정할 수는 없습니다.

-movflags +faststart는 완성된 일반 MP4의 index를 앞쪽으로 재배치해 progressive download를 돕는 옵션입니다. 녹화 중 장애 복구 기능 자체는 아닙니다.


HLS — HTTP Live Streaming

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

하나의 긴 영상을 통째로 저장하는 대신:
recording.mp4 → moov 위치와 range request 지원에 따라 점진 재생 가능

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

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

녹화 관점에서는 업로드가 완료된 segment를 개별 객체로 보존할 수 있다는 장점이 있습니다. 다만 녹화 서버 failover가 발생하면 bak<n> playlist가 추가될 수 있어 복구 시 여러 playlist를 함께 처리해야 합니다. 저장 흐름은 녹화 파일이 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) │
│  ├─ optional adaptation field                  │
│  └─ 남은 payload (PES 데이터 일부)              │
│                                                │
│  PID로 오디오/비디오 스트림 구분                   │
│  → continuity counter 등으로 손실을 감지           │
└────────────────────────────────────────────────┘

TS packet은 188 bytes이고 header 첫 byte는 0x47 sync byte입니다. 파일 시작점이 packet 경계로 정렬되었다는 보장이 없거나 앞부분이 손상되었다면 여러 packet 간격에서 반복되는 sync byte를 확인해 경계를 찾아야 합니다.

PID(Packet Identifier)는 13-bit 값으로 어느 stream에 속하는지 식별합니다. PMT(Program Map Table)에 audio/video PID가 정의됩니다. 하나의 PES packet이나 video frame은 여러 TS packet에 걸칠 수 있으므로 TS packet 하나가 완결된 media unit은 아니며, 손실 영향도 codec의 참조 구조에 따라 뒤 구간으로 이어질 수 있습니다.

MP4TS
구조일반/fragmented 구성 가능188-byte packet과 playlist segment
중간 손상metadata·fragment 구조에 따라 영향이 다름손실 감지가 쉽지만 media dependency 영향은 남음
실시간 녹화writer와 fragmentation 방식에 따라 다름완료·업로드된 segment를 개별 보존 가능
스트리밍range request/progressive/fragmented 방식 지원playlist와 segment 단위 재생
파일 크기효율적헤더 오버헤드 (~2%)

M3U8 파일 구조

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

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:34
#EXT-X-DISCONTINUITY
#EXT-X-AGORA-ROTATE:WIDTH=640,HEIGHT=480,ROTATE=0,TIME=20190920125142485
#EXT-X-AGORA-TRACK-EVENT:EVENT=START,TRACK_TYPE=VIDEO,TIME=20190920125142485
#EXTINF:6.332000
sid_mychannel__uid_s_1001__uid_e_video_20190920125142485.ts
#EXT-X-AGORA-ROTATE:WIDTH=1280,HEIGHT=720,ROTATE=0,TIME=20190920125149174
#EXT-X-DISCONTINUITY
#EXTINF:17.442000
sid_mychannel__uid_s_1001__uid_e_video_20190920125149174.ts
#EXT-X-ENDLIST

표준 HLS 태그:

  • #EXTM3U — M3U8 파일임을 선언하는 시작 태그. 반드시 첫 줄에 있어야 합니다.
  • #EXT-X-VERSION:3 — 위 Agora 예제 playlist가 요구하는 HLS compatibility version입니다.
  • #EXT-X-TARGETDURATION:34 — RFC 8216의 target duration입니다. 각 EXTINF duration을 가장 가까운 정수로 반올림한 값이 이를 넘지 않아야 합니다.
  • #EXTINF:6.332000 — 바로 다음 TS 파일의 실제 길이입니다. RFC 문법은 뒤에 comma를 요구하지만 Agora 출력은 comma를 생략할 수 있습니다. player 호환 문제가 있으면 privateParams의 correctEXTINF를 사용합니다.
  • #EXT-X-ENDLIST — 녹화 완료를 나타냅니다. 이 태그가 없으면 플레이어는 세그먼트가 계속 추가될 것으로 기대합니다 (라이브 스트리밍 모드).

Agora 전용 확장 태그:

  • #EXT-X-AGORA-TRACK-EVENT — EVENT=START, TRACK_TYPE=AUDIO|VIDEO, TIME=<17자리 UTC 시각>. 최초 시작과 중단 후 재시작에 기록되므로 한 track의 재시작 횟수는 START 수에서 최초 시작을 뺀 값으로 추정합니다.
  • #EXT-X-AGORA-ROTATE — WIDTH, HEIGHT, ROTATE(0/90/180/270), TIME=<17자리 UTC 시각>을 기록합니다.

예제처럼 video segment 길이는 고정 15초가 아닙니다. I-frame, codec·해상도 변경, stream 중단과 강제 분할 조건에 따라 달라집니다.


Agora 모드별 출력 파일 트리

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

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

cloud-storage-bucket/
├── sid_mychannel__uid_s_1001__uid_e_audio.m3u8
├── sid_mychannel__uid_s_1001__uid_e_audio_20190920125142289.ts
├── sid_mychannel__uid_s_1001__uid_e_audio_20190920125157307.ts
├── sid_mychannel__uid_s_1001__uid_e_video.m3u8
├── sid_mychannel__uid_s_1001__uid_e_video_20190920125142485.ts
├── sid_mychannel__uid_s_1001__uid_e_video_20190920125149174.ts
├── sid_mychannel__uid_s_2002__uid_e_audio.m3u8
├── sid_mychannel__uid_s_2002__uid_e_audio_20190920125142289.ts
└── ...

streamMode=default에서는 UID마다 audio/video M3U8이 생성됩니다. streamMode=standard에서 audio와 video를 함께 녹화하면 UID별 merged M3U8도 추가되므로 파일 수가 달라집니다.

Composite 모드 — 합성 후 단일 output

cloud-storage-bucket/
├── sid_mychannel.m3u8
├── sid_mychannel_20190920125142485.ts
├── sid_mychannel_20190920125149174.ts
└── ...

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

파일명 컨벤션 해부:

sid_mychannel__uid_s_1001__uid_e_video_20190920125142485.ts
│    │          │    │         │    │       │
│    │          │    │         │    │       └─ 17자리 UTC 시각 YYYYMMDDHHmmssSSS
│    │          │    │         │    └─ uid_e (end 구분자)
│    │          │    │         └─ 트랙 타입 (audio / video)
│    │          │    └─ UID (1001)
│    │          └─ uid_s (start 구분자)
│    └─ 채널 이름 (cname)
└─ sid (Recording 세션 ID)

동일한 prefix 안에서는 17자리 UTC 시각을 문자열로 정렬해 segment 시작 순서를 확인할 수 있습니다. 이 값은 Unix epoch가 아닙니다.


avFileType 설정

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

HLS만 요청하는 설정입니다.

{ "recordingFileConfig": { "avFileType": ["hls"] } }

Composite/Web page recording에서 HLS와 MP4를 함께 요청하는 설정입니다.

{ "recordingFileConfig": { "avFileType": ["hls", "mp4"] } }

avFileType에 mp4를 쓰려면 hls와 함께 지정해야 합니다. mp4 단독 설정은 유효하지 않습니다. MP4는 Composite와 Web page recording에서만 지원되며 Individual recording에서는 사용할 수 없습니다.

Composite/Web page recording에서 현재 MP4 길이가 약 3시간 또는 크기가 약 2GB에 도달하면 새 MP4가 생성됩니다. Web page recording은 maxVideoDuration으로 길이 기준을 별도 설정할 수 있습니다.

HA 전환으로 여러 bak<n> playlist가 생긴 경우 Agora의 HA transcoder로 원본과 failover 이후 playlist를 합칠 수 있습니다. 일반적인 HLS→MP4 remux도 가능하지만 codec·timestamp·segment 존재 여부를 먼저 확인해야 합니다.


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

TS 바이트를 임의 위치에서 잘라도 독립 재생 가능한 segment가 된다고 보장할 수 없습니다. 비디오 frame의 참조 관계를 고려해야 합니다.

I-frame (Intra)     P-frame (Predicted)    B-frame (Bidirectional)
┌──────────────┐    ┌──────────────┐       ┌──────────────┐
│ 전체 이미지   │    │ 이전 프레임과 │       │ 앞뒤 프레임과 │
│ 완전한 정보   │    │ 차이만 저장   │       │ 차이만 저장   │
│ (독립 재생 O) │    │ (독립 재생 X) │       │ (독립 재생 X) │
└──────────────┘    └──────────────┘       └──────────────┘
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: 이전·이후 reference frame을 사용할 수 있습니다. 구체적인 참조 구조와 압축 효율은 encoder 설정에 따라 달라집니다.

segment를 독립 재생하려면 시작 frame이 필요한 참조 정보를 갖춰야 하므로 I-frame 경계가 유리합니다. 그러나 Agora의 browser H.264 강제 분할에서는 새 segment가 I-frame으로 시작하지 않을 수 있고, 이 경우 해당 segment를 단독 decode·재생할 수 없습니다.


TS 슬라이싱 규칙

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

비디오 슬라이싱 트리거:

[정상 케이스]
  segment가 15초에 도달한 뒤 I-frame 도착
      → 세그먼트 종료, 새 세그먼트 시작

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

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

[강제 슬라이싱]
  5.5분(330초) 경과 또는 50MB 초과
      → browser H.264 recording에서 강제 슬라이싱
      → 새 segment가 I-frame으로 시작하지 않을 수 있음

오디오 슬라이싱:

오디오 slice는 15초에 도달하면 분할됩니다. 비디오 slice와 항상 같은 경계로 잘린다고 가정하면 안 됩니다.

비디오와 오디오의 타임스탬프가 어긋나는 경우, 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, encoding='utf-8') 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('=', 1) 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'):
            # 17자리 UTC 시각(YYYYMMDDHHmmssSSS) 추출
            utc_match = re.search(r'_(\d{17})\.ts$', line)
            utc = 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)}개")
    starts = [e for e in events if e.get('EVENT') == 'START']
    print(f"추정 재시작: {max(0, len(starts) - 1)}회")
    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분)
# 이벤트: 1개
#   START — VIDEO @ 20190920125142485

이 스크립트는 playlist 기준 총 길이와 START event를 확인합니다. 최초 START 이후 추가 START는 stream 재시작을 나타내지만, 원인이 반드시 사용자 네트워크 단절이라고 단정할 수는 없습니다.


핵심 요약

  • 일반 MP4는 metadata 마감 전 종료 시 재생이 어려울 수 있지만 moov 위치와 fragmentation 방식은 구현에 따라 다릅니다.
  • HLS는 media를 segment와 playlist로 나눕니다. 업로드된 객체는 보존되지만 failover 시 여러 playlist를 함께 처리해야 할 수 있습니다.
  • TS packet은 188 bytes이지만 하나의 완결된 frame은 아니며 손실 영향은 media dependency에 따라 이어질 수 있습니다.
  • M3U8은 플레인 텍스트 목차입니다. Agora 전용 태그(#EXT-X-AGORA-TRACK-EVENT, #EXT-X-AGORA-ROTATE)를 통해 스트림 이벤트와 해상도 변경을 기록합니다.
  • 비디오 slice는 15초 도달 후 I-frame, codec·해상도 변경, stream 중단 등에 분할됩니다. browser H.264 강제 분할은 새 slice가 I-frame으로 시작하지 않을 수 있습니다.
  • MP4 출력은 hls와 함께 지정하고 Composite/Web page recording에서 사용합니다. 약 3시간 또는 약 2GB에 도달하면 다음 MP4로 분할됩니다.

시리즈 네비게이션

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

  1. 실시간 통화, 왜 녹화해야 하는가 — Cloud Recording Architecture
  2. 내 서버에서 직접 녹화한다면 — On-Premise vs Cloud Recording
  3. Agora 녹화 모드 비교 — Individual, Composite, Web page
  4. M3U8과 TS 파일의 모든 것 ← 현재 글
  5. FFmpeg 미디어 처리 — 컨테이너, 코덱, 스트림
  6. FFmpeg 실전 파이프라인 — Agora 녹화에서 프로덕션 VOD까지

관련 글


참고 자료

© 2026 Frank Kim. All rights reserved.