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

FFmpeg 실전 파이프라인 — Agora 녹화에서 프로덕션 VOD까지

Individual 녹화 파일을 프로덕션 VOD로 만드는 전체 여정. M3U8→MP4 변환, 타임스탬프 동기화, 멀티유저 그리드 합성, 트랜스코딩 품질 제어, 자동화 스크립트까지.

FFmpegCloud Recordingfilter_complexCRF자동화

Individual 녹화 파일 100개가 S3에 올라왔다. 이제 뭘 하지?

Agora Cloud Recording의 Individual 모드를 쓰면 녹화 비용은 아낄 수 있습니다. 그런데 S3에는 유저별로 쪼개진 .m3u8 파일과 수백 개의 .ts 세그먼트가 쌓여 있을 뿐, 사용자에게 바로 보여줄 수 있는 VOD 파일은 없습니다.

이 글은 그 원자재를 프로덕션 VOD로 만드는 전체 여정을 다룹니다. FFmpeg 명령어 하나하나를 해부하고, 타임스탬프 동기화부터 멀티유저 그리드 합성, 트랜스코딩 품질 제어까지 실전에서 쓸 수 있는 파이프라인을 완성합니다.


전체 파이프라인 아키텍처

┌─ Cloud Storage (S3/GCS) ────────────────────────────────┐
│  Individual 녹화 파일들                                   │
│  ├── sid_ch__uid_s_1001__uid_e_audio.m3u8               │
│  ├── sid_ch__uid_s_1001__uid_e_video.m3u8               │
│  ├── sid_ch__uid_s_2002__uid_e_audio.m3u8               │
│  ├── sid_ch__uid_s_2002__uid_e_video.m3u8               │
│  └── ... (TS 세그먼트 수백 개)                            │
└──────────────────────┬───────────────────────────────────┘
                       │ ① 다운로드
                       ▼
┌─ Post-Processing Server ────────────────────────────────┐
│                                                          │
│  ② M3U8 → MP4 변환         (ffmpeg -c copy)             │
│  ③ 오디오/비디오 합치기      (-map 0:a -map 1:v)          │
│  ④ 타임스탬프 동기화         (-itsoffset)                 │
│  ⑤ 멀티유저 그리드 합성      (filter_complex)             │
│  ⑥ 트랜스코딩 + 품질 제어   (CRF, 2-pass)               │
│                                                          │
└──────────────────────┬───────────────────────────────────┘
                       │ ⑦ 업로드
                       ▼
┌─ CDN / VOD Service ─────────────────────────────────────┐
│  최종 MP4 → 사용자에게 다시보기 서비스                      │
└──────────────────────────────────────────────────────────┘

각 단계는 독립적입니다. ②③은 병렬로 돌릴 수 있고, ④는 선택 사항이며, ⑤는 유저 수에 따라 동적으로 결정됩니다. 순서대로 하나씩 뜯어봅니다.


Step 1: M3U8 → MP4 변환

가장 먼저 할 일은 HLS 포맷을 MP4로 바꾸는 것입니다.

# 가장 기본: M3U8 목록을 읽어서 하나의 MP4로 합침
ffmpeg -i sid_ch__uid_s_1001__uid_e_video.m3u8 -c copy user_1001_video.mp4

내부에서 무슨 일이 일어나는지 시각화하면 이렇습니다.

recording.m3u8 ──→ FFmpeg가 목차 파싱
    │                    │
    ├── seg_001.ts ─────→│
    ├── seg_002.ts ─────→├──→ TS 패킷 demux
    ├── seg_003.ts ─────→│    → 스트림 추출
    └── seg_004.ts ─────→│    → MP4 mux
                         │
                    output.mp4
                    (재인코딩 없이 컨테이너만 변환)

-c copy가 핵심입니다. 디코딩/인코딩 없이 스트림을 그대로 새 컨테이너에 담기 때문에 속도가 매우 빠르고 품질 손실이 0입니다. 1시간 분량의 영상도 수십 초 안에 변환됩니다.


Step 2: 유저별 오디오+비디오 합치기

M3U8 → MP4 변환을 마치면 유저별로 오디오 MP4와 비디오 MP4가 따로 존재합니다. 이걸 하나로 합칩니다.

# User 1001의 오디오/비디오를 하나의 MP4로
ffmpeg \
  -i user_1001_audio.mp4 \
  -i user_1001_video.mp4 \
  -c copy \
  -map 0:a -map 1:v \
  user_1001_combined.mp4
오디오 MP4 ──→ ┌──────────┐
               │          │ -map 0:a (첫 번째 입력의 오디오)
               │  FFmpeg   │ -map 1:v (두 번째 입력의 비디오)
               │  Muxer    │──→ user_1001_combined.mp4
               │          │
비디오 MP4 ──→ └──────────┘

왜 처음부터 분리되어 있을까요? Individual 모드는 오디오와 비디오를 별도 M3U8/TS 스트림으로 저장하기 때문입니다. 오디오는 15초 고정 주기로 슬라이싱되고, 비디오는 I-frame(키프레임) 기반으로 슬라이싱됩니다. 주기가 달라서 동기화가 깔끔하지 않으므로 별도로 관리하는 것이 Agora의 설계 방침입니다.


Step 3: 타임스탬프 동기화

가장 까다로운 단계입니다. 유저마다 채널에 입장한 시점이 다르기 때문에 그냥 합치면 어긋납니다.

User A: ────────────────────────────── (00:00부터 입장)
User B:       ──────────────────────── (00:05부터 입장)
User C:            ─────────────────── (00:10부터 입장)

그냥 합치면? → 모두 00:00부터 시작해서 5초, 10초 어긋남

해결 방법은 Agora TS 파일명에 박혀 있는 UTC 타임스탬프를 활용하는 것입니다.

# 파일명에서 UTC 추출
# sid_ch__uid_s_1001__uid_e_video_1690000100.ts
#                                 ^^^^^^^^^^
#                                 UTC 시작 시간 (초 단위 Unix timestamp)

Python으로 각 유저의 입장 시점을 계산하고, 가장 빠른 시점 대비 offset을 구합니다.

import re
import subprocess

def get_utc_from_m3u8(m3u8_path):
    """M3U8에서 첫 번째 TS 파일의 UTC 타임스탬프 추출"""
    with open(m3u8_path) as f:
        for line in f:
            if line.strip().endswith('.ts'):
                match = re.search(r'_(\d{10,})\.ts', line)
                if match:
                    return int(match.group(1))
    return None

# 각 유저의 시작 시점 확인
users = {
    'user_1001': get_utc_from_m3u8('sid_ch__uid_s_1001__uid_e_video.m3u8'),
    'user_2002': get_utc_from_m3u8('sid_ch__uid_s_2002__uid_e_video.m3u8'),
    'user_3003': get_utc_from_m3u8('sid_ch__uid_s_3003__uid_e_video.m3u8'),
}

# 가장 빠른 입장 시점 기준으로 offset 계산
earliest = min(users.values())
for uid, utc in users.items():
    offset = utc - earliest
    print(f"{uid}: offset = {offset}초")

# 출력 예:
# user_1001: offset = 0초
# user_2002: offset = 5초
# user_3003: offset = 10초

계산된 offset을 -itsoffset 옵션으로 적용합니다.

# offset 적용하여 동기화
ffmpeg \
  -i user_1001_combined.mp4 \
  -itsoffset 5 -i user_2002_combined.mp4 \
  -itsoffset 10 -i user_3003_combined.mp4 \
  -filter_complex "..." \
  synced_output.mp4

# -itsoffset 5: 두 번째 입력을 5초 뒤로 밀어서 시간 축 정렬

-itsoffset은 해당 입력 바로 앞에 위치해야 합니다. 그 뒤에 오는 -i에만 적용됩니다.

빈 구간 처리

User B가 5초 후 입장했으면, 앞 5초는 검은 화면과 무음으로 채워야 합니다.

# 무음 오디오 생성 (5초)
ffmpeg -f lavfi -i anullsrc=r=48000:cl=stereo -t 5 silence_5s.m4a

# 검은 화면 비디오 생성 (5초)
ffmpeg -f lavfi -i color=c=black:s=640x360:r=30 -t 5 -c:v libx264 black_5s.mp4

-f lavfi는 FFmpeg의 가상 소스 필터를 활성화합니다. anullsrc는 무음 오디오, color=c=black은 단색 비디오를 생성합니다. 실제 파일이 없어도 원하는 길이의 빈 미디어를 만들 수 있습니다.


Step 4: 멀티유저 그리드 합성

동기화된 유저별 파일을 하나의 화면으로 합칩니다. FFmpeg의 filter_complex가 이 작업의 핵심입니다.

filter_complex 상세 해부

# 4명을 2x2 그리드로 합성
ffmpeg \
  -i user_1001.mp4 \
  -i user_2002.mp4 \
  -i user_3003.mp4 \
  -i user_4004.mp4 \
  -filter_complex "
    [0:v]scale=640:360[v0];
    [1:v]scale=640:360[v1];
    [2:v]scale=640:360[v2];
    [3:v]scale=640:360[v3];
    [v0][v1]hstack[top];
    [v2][v3]hstack[bottom];
    [top][bottom]vstack[outv];
    [0:a][1:a][2:a][3:a]amix=inputs=4[outa]
  " \
  -map "[outv]" -map "[outa]" \
  -c:v libx264 -crf 23 \
  -c:a aac -b:a 128k \
  grid_output.mp4

문법이 낯설어 보이지만 규칙은 단순합니다.

[입력]필터=파라미터[출력]

[0:v]          → 입력 0번의 비디오 스트림 참조
scale=640:360  → 640x360으로 리사이즈
[v0]           → 결과에 "v0"이라는 레이블 부여

[v0][v1]hstack[top]       → v0과 v1을 수평으로 붙여서 "top"
[top][bottom]vstack[outv] → top과 bottom을 수직으로 붙여서 최종 비디오
[0:a][1:a][2:a][3:a]amix=inputs=4[outa] → 4개 오디오를 믹싱

결과 레이아웃은 이렇습니다.

┌──────────┬──────────┐
│ User1001 │ User2002 │  ← hstack (수평 결합)
│  640x360 │  640x360 │
├──────────┼──────────┤
│ User3003 │ User4004 │  ← hstack
│  640x360 │  640x360 │
└──────────┴──────────┘
        vstack (수직 결합)
     → 1280x720 최종 출력

다른 레이아웃들

서비스 형태에 따라 레이아웃을 다르게 가져갈 수 있습니다. 1:N 발표자 레이아웃:

ffmpeg \
  -i speaker.mp4 -i viewer1.mp4 -i viewer2.mp4 \
  -filter_complex "
    [0:v]scale=960:720[main];
    [1:v]scale=320:360[s1];
    [2:v]scale=320:360[s2];
    [s1][s2]vstack[side];
    [main][side]hstack[outv];
    [0:a][1:a][2:a]amix=inputs=3[outa]
  " \
  -map "[outv]" -map "[outa]" \
  -c:v libx264 -crf 23 -c:a aac \
  speaker_layout.mp4
┌──────────────┬─────────┐
│              │ viewer1 │  ← vstack
│   speaker    │  320x360│
│   960x720    ├─────────┤
│              │ viewer2 │
│              │  320x360│
└──────────────┴─────────┘
       hstack → 1280x720

PIP (Picture-in-Picture):

ffmpeg \
  -i main.mp4 -i pip.mp4 \
  -filter_complex "
    [0:v]scale=1280:720[bg];
    [1:v]scale=320:240[small];
    [bg][small]overlay=W-w-20:H-h-20[outv];
    [0:a][1:a]amix=inputs=2[outa]
  " \
  -map "[outv]" -map "[outa]" \
  -c:v libx264 -crf 23 -c:a aac \
  pip_output.mp4

overlay=W-w-20:H-h-20에서 W, H는 배경 해상도, w, h는 오버레이 해상도입니다. 우하단에서 각각 20px 안쪽에 배치합니다.

┌────────────────────────────┐
│                            │
│         main video         │
│         1280x720           │
│                   ┌──────┐ │
│                   │ pip  │ │
│                   │320x24│ │
└───────────────────┴──────┘─┘
                   ↑ overlay=W-w-20:H-h-20

Step 5: 트랜스코딩과 품질 제어

합성이 끝나면 최종 인코딩 단계입니다. 품질과 파일 크기의 균형을 어떻게 잡느냐가 핵심입니다.

CRF (Constant Rate Factor)

libx264의 CRF는 0~51 범위로 낮을수록 고품질입니다.

CRF 값:  0 ──────── 18 ──── 23 ──── 28 ──────── 51
         │          │       │       │           │
      무손실     시각적    기본값   적당한      최저
      (거대함)   무손실           압축       품질
  • CRF 0: 무손실 (lossless). 용량이 극단적으로 큼
  • CRF 18: 시각적으로 무손실 (visually lossless). 아카이브 용도
  • CRF 23: libx264 기본값. 품질/용량 밸런스가 잘 맞음
  • CRF 28: 눈에 띄는 품질 저하 시작. 모바일 전용이 아니면 비추천
  • CRF 51: 최저 품질. 거의 쓸 일 없음

CBR vs VBR

CBR (Constant Bitrate)VBR (Variable Bitrate)
동작항상 고정 비트레이트 유지장면 복잡도에 따라 자동 조절
장점파일 크기 예측 가능같은 크기에서 더 나은 품질
단점단순 장면에서 용량 낭비파일 크기 예측 어려움
용도라이브 스트리밍VOD, 아카이브
# VBR (CRF 기반 — VOD 추천)
ffmpeg -i input.mp4 -c:v libx264 -crf 23 output.mp4

# CBR (고정 비트레이트 — 스트리밍 추천)
ffmpeg -i input.mp4 -c:v libx264 -b:v 2M -maxrate 2M -bufsize 4M output.mp4

후처리 VOD에는 VBR이 맞습니다. 실시간 제약이 없으니 파일 크기보다 품질을 우선시하면 됩니다.

해상도별 권장 설정

해상도CRF예상 비트레이트용도
1920x108020-234-6 Mbps고화질 VOD
1280x72023-252-3 Mbps일반 VOD
640x36025-280.5-1 Mbps모바일, 저대역

2-pass 인코딩

같은 비트레이트에서 최상의 품질을 뽑아내야 할 때 씁니다.

# 1st pass: 영상 분석 (실제 인코딩 없이 통계만 수집)
ffmpeg -i input.mp4 -c:v libx264 -b:v 3M -pass 1 -f null /dev/null

# 2nd pass: 통계 기반으로 최적 인코딩
ffmpeg -i input.mp4 -c:v libx264 -b:v 3M -pass 2 output.mp4
1st pass:  영상 전체 분석
           → 장면별 복잡도 기록
           → ffmpeg2pass-0.log 생성

2nd pass:  log 읽어서
           → 복잡한 장면에 비트레이트 집중
           → 단순 장면에서 비트레이트 절약
           → 동일 평균 비트레이트로 더 나은 품질

1-pass 대비 처리 시간이 2배 걸립니다. 비동기 후처리 파이프라인이라면 감수할 만합니다.


Step 6: 자동화 파이프라인

앞의 모든 단계를 묶어 S3에서 받아 CDN으로 올리는 단일 스크립트를 만듭니다.

Shell 스크립트: S3 → FFmpeg → CDN

#!/bin/bash
# process_recording.sh — Agora Individual 녹화 → 프로덕션 VOD
set -euo pipefail

CHANNEL=$1
SID=$2
WORK_DIR="/tmp/recordings/${SID}"
OUTPUT_DIR="/tmp/output/${SID}"

mkdir -p "$WORK_DIR" "$OUTPUT_DIR"

echo "=== Step 1: S3에서 다운로드 ==="
aws s3 sync "s3://my-bucket/recordings/${SID}/" "$WORK_DIR/"

echo "=== Step 2: M3U8 → MP4 변환 ==="
for m3u8 in "$WORK_DIR"/*.m3u8; do
  base=$(basename "$m3u8" .m3u8)
  ffmpeg -y -i "$m3u8" -c copy "$WORK_DIR/${base}.mp4" 2>/dev/null || {
    echo "변환 실패: $m3u8 (빈 파일이거나 깨진 세그먼트)"
    continue
  }
done

echo "=== Step 3: 유저별 오디오+비디오 합치기 ==="
for audio_m3u8 in "$WORK_DIR"/*_audio.m3u8; do
  uid=$(echo "$audio_m3u8" | grep -oP 'uid_s_\K\d+')
  video_mp4="$WORK_DIR/${SID}_${CHANNEL}__uid_s_${uid}__uid_e_video.mp4"
  audio_mp4="$WORK_DIR/${SID}_${CHANNEL}__uid_s_${uid}__uid_e_audio.mp4"

  if [[ -f "$video_mp4" && -f "$audio_mp4" ]]; then
    ffmpeg -y -i "$audio_mp4" -i "$video_mp4" \
      -c copy -map 0:a -map 1:v \
      "$OUTPUT_DIR/user_${uid}.mp4" 2>/dev/null
    echo "user_${uid}.mp4 생성 완료"
  fi
done

echo "=== Step 4: 그리드 합성 (유저 수에 따라 분기) ==="
USER_FILES=("$OUTPUT_DIR"/user_*.mp4)
COUNT=${#USER_FILES[@]}

if [[ $COUNT -eq 1 ]]; then
  cp "${USER_FILES[0]}" "$OUTPUT_DIR/final.mp4"

elif [[ $COUNT -eq 2 ]]; then
  ffmpeg -y \
    -i "${USER_FILES[0]}" -i "${USER_FILES[1]}" \
    -filter_complex "
      [0:v]scale=640:360[v0];
      [1:v]scale=640:360[v1];
      [v0][v1]hstack[outv];
      [0:a][1:a]amix=inputs=2[outa]
    " \
    -map "[outv]" -map "[outa]" \
    -c:v libx264 -crf 23 -c:a aac \
    "$OUTPUT_DIR/final.mp4"

elif [[ $COUNT -ge 4 ]]; then
  ffmpeg -y \
    -i "${USER_FILES[0]}" -i "${USER_FILES[1]}" \
    -i "${USER_FILES[2]}" -i "${USER_FILES[3]}" \
    -filter_complex "
      [0:v]scale=640:360[v0];
      [1:v]scale=640:360[v1];
      [2:v]scale=640:360[v2];
      [3:v]scale=640:360[v3];
      [v0][v1]hstack[top];
      [v2][v3]hstack[bot];
      [top][bot]vstack[outv];
      [0:a][1:a][2:a][3:a]amix=inputs=4[outa]
    " \
    -map "[outv]" -map "[outa]" \
    -c:v libx264 -crf 23 -c:a aac \
    "$OUTPUT_DIR/final.mp4"
fi

echo "=== Step 5: CDN 업로드 ==="
aws s3 cp "$OUTPUT_DIR/final.mp4" "s3://my-cdn-bucket/vod/${SID}/final.mp4"

echo "완료: s3://my-cdn-bucket/vod/${SID}/final.mp4"

# 임시 파일 정리
rm -rf "$WORK_DIR" "$OUTPUT_DIR"

에러 핸들링

실전에서 자주 만나는 상황들입니다.

  • 깨진 세그먼트: set -e로 전체 중단 대신 || continue로 해당 파일만 스킵
  • 빈 M3U8: FFmpeg이 에러를 뱉지만 2>/dev/null로 로그를 억제하고 continue
  • 오디오 없는 유저: [[ -f "$audio_mp4" ]] 체크로 비디오만 있는 경우 처리
  • 디스크 부족: WORK_DIR/tmp에 두고 처리 즉시 삭제. 대용량이면 EBS 마운트 권장

병렬 처리

유저가 많을수록 Step 2~3은 병렬화할수록 이득입니다.

# GNU parallel로 유저별 변환 병렬화
ls "$WORK_DIR"/*_audio.m3u8 | parallel -j4 '
  uid=$(echo {} | grep -oP "uid_s_\K\d+")
  ffmpeg -y -i {} -c copy "${WORK_DIR}/audio_${uid}.mp4"
'

# FFmpeg 내부 멀티스레딩
ffmpeg -threads 0 -i input.mp4 -c:v libx264 output.mp4
# -threads 0 → CPU 코어 수 자동 감지 및 사용

filter_complex 단계는 입력이 많을수록 FFmpeg 내부에서 자동으로 스레드를 활용합니다. 별도 병렬화 없이도 멀티코어를 씁니다.


비용 분석: Individual + 후처리 vs Mix 실시간

실제로 어떤 모드가 더 경제적인지 정리합니다.

항목Mix 모드Individual + FFmpeg
Agora 녹화 비용높음 (Mix 단가)낮음 (Individual 단가)
서버 비용없음후처리 서버 필요
처리 시간실시간 (즉시 완성)녹화 후 수 분~수 시간
레이아웃 유연성녹화 시점에 결정나중에 자유롭게 변경
총 비용 (대량)높음낮음 (서버 비용 포함해도)

결론은 명확합니다. 소량이면 Mix가 간편합니다. 파이프라인 구축 비용이 없고 즉시 VOD가 나옵니다. 대량이면 Individual + FFmpeg이 경제적입니다. 서버 비용을 감안해도 Agora 단가 차이로 충분히 상쇄됩니다. 하루 수백 세션 이상이라면 Individual 모드 + 비동기 후처리 파이프라인이 표준 선택지입니다.


핵심 요약

  • -c copy는 무조건 먼저 시도: 재인코딩 없이 컨테이너 변환만 하므로 속도가 압도적으로 빠르고 품질 손실이 없다. 합성이나 리사이즈가 필요할 때만 인코딩한다.
  • 타임스탬프 동기화는 TS 파일명에서: Agora Individual 모드는 파일명에 UTC unix timestamp를 심어두므로, 파싱해서 -itsoffset으로 입장 시점을 맞춘다.
  • filter_complex가 합성의 전부: hstack, vstack, overlay, amix 네 가지 필터 조합만 알면 어떤 레이아웃이든 만들 수 있다.
  • VOD는 CRF 23이 출발점: libx264 기본값이지만 합리적인 기준점이다. 고화질 아카이브라면 1820, 모바일 전용이라면 2528로 조정한다.
  • 2-pass는 마지막 최적화 수단: 비트레이트 상한이 정해진 상황에서 품질을 극대화할 때 쓴다. 2배 시간이 걸리므로 비동기 파이프라인이 전제다.
  • Individual 대량 사용 시 후처리 파이프라인 투자가 맞다: Mix 단가 대비 Individual 단가 차이 × 일일 세션 수가 서버 비용을 초과하는 순간부터 ROI가 발생한다.

시리즈를 마치며

6편에 걸쳐 실시간 통화 녹화의 전체 스택을 다뤘습니다. Cloud Recording의 아키텍처에서 시작해 On-Premise 대안을 살펴보고, Individual/Mix/Web 모드의 내부 파이프라인을 해부했습니다. HLS가 왜 .m3u8.ts 구조를 택했는지 이해한 뒤, FFmpeg의 기초 원리를 짚고, 마지막으로 그 모든 지식을 실전 파이프라인으로 연결했습니다. "녹화 파일이 S3에 있는데 이제 뭘 하지?"라는 질문에 이 시리즈 전체가 하나의 답입니다.


시리즈 네비게이션

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

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

© 2026 Frank Kim. All rights reserved.