오디오 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
defget_utc_from_m3u8(m3u8_path):
"""M3U8에서 첫 번째 TS 파일의 UTC 타임스탬프 추출"""withopen(m3u8_path) as f:
for line in f:
if line.strip().endswith('.ts'):
match = re.search(r'_(\d{10,})\.ts', line)
ifmatch:
returnint(match.group(1))
returnNone# 각 유저의 시작 시점 확인
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가 이 작업의 핵심입니다.
[입력]필터=파라미터[출력]
[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개 오디오를 믹싱
빈 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는 무조건 먼저 시도: 재인코딩 없이 컨테이너 변환만 하므로 속도가 압도적으로 빠르고 품질 손실이 없다. 합성이나 리사이즈가 필요할 때만 인코딩한다.
2-pass는 마지막 최적화 수단: 비트레이트 상한이 정해진 상황에서 품질을 극대화할 때 쓴다. 2배 시간이 걸리므로 비동기 파이프라인이 전제다.
Individual 대량 사용 시 후처리 파이프라인 투자가 맞다: Mix 단가 대비 Individual 단가 차이 × 일일 세션 수가 서버 비용을 초과하는 순간부터 ROI가 발생한다.
시리즈를 마치며
6편에 걸쳐 실시간 통화 녹화의 전체 스택을 다뤘습니다. Cloud Recording의 아키텍처에서 시작해 On-Premise 대안을 살펴보고, Individual/Mix/Web 모드의 내부 파이프라인을 해부했습니다. HLS가 왜 .m3u8과 .ts 구조를 택했는지 이해한 뒤, FFmpeg의 기초 원리를 짚고, 마지막으로 그 모든 지식을 실전 파이프라인으로 연결했습니다. "녹화 파일이 S3에 있는데 이제 뭘 하지?"라는 질문에 이 시리즈 전체가 하나의 답입니다.