영상 압축의 본질 — I/P/B-frame, GOP, 그리고 HLS가 같이 죽는 이유
라이브 송출 중 화면이 멈추고 로그에 EXTINF:18이 찍혔다면, 진짜 범인은 HLS 패키저가 아니라 인코딩 파이프라인 위쪽일 가능성이 큽니다. 이 글은 영상이 왜 압축되어야 하는지부터 I·P·B 프레임의 역할, keyint이 시간이 아니라 프레임 개수라는 사실, 그리고 CPU 과부하로 fps가 무너지면 같은 설정인데도 GOP가 늘어나 HLS까지 같이 죽는 인과 사슬을 설명합니다. 끝까지 읽으면 segment 길이 한 줄만 보고 어디가 깨졌는지 거꾸로 추적하는 법을 알게 됩니다.
목차(36개 항목)
- 0. 핵심 명제 — `#EXTINF:18`은 인코더 파이프라인이 보낸 비명이다
- 1. 영상의 본질 — 사진 × N장
2. I-frame vs P-frame vs B-frame — 3종류의 프레임
3. GOP — Group Of Pictures
- 4. 정상 상태 🟢 — 30fps + keyint=60
- 5. CPU 터짐 🔴 — fps가 무너지는 순간
- 6. 사고 연쇄 반응 💥 — 5단계 인과 사슬
6-1. 같은 증상, 다른 원인 — `#EXTINF:18` 대안 경로
7. HLS가 같이 죽는 이유 — I-frame 위치 강제
- 8. iOS 플레이어가 더 터지는 이유
9. B-frame Deep Dive — 왜 라이브에서는 끄는가
- 10. 실무 디버깅 역추적 🔍
- 11. 한 장 요약 — 최종 암기 카드
- 12. SA 의사결정 체크리스트
- 13. 한 줄 결론
- 관련 글
- 참고 자료
"라이브 송출 중에 화면이 멈춰요. 로그 보니 #EXTINF:18이 찍혀있는데, 이게 뭔가요?". HLS로 송출하다 보면 종종 마주치는 증상입니다. 표면적으로는 "HLS segment가 18초"인데, 진짜 원인은 인코딩 파이프라인 한참 위에 있습니다. 이 글은 영상 압축의 본질부터, I/P/B-frame의 역할, GOP과 keyint, 그리고 CPU 과부하가 HLS까지 어떻게 끌고 내려가는지를 한 번에 정리합니다.
0. 핵심 명제 — #EXTINF:18은 인코더 파이프라인이 보낸 비명이다
HLS segment가 이상하게 길다는 건 인코더가 입력 페이스를 못 따라가거나 GOP 정책이 깨졌다는 신호다. 가장 흔한 원인은 CPU 과부하지만 유일한 원인은 아니다 (frame drop, packager 설정 오류, 입력 burst, clock drift 등). 영상의 압축 구조(I/P/B-frame)와 GOP, 그리고 HLS의 split 규칙(I-frame 위치에서만 자름)을 알면 segment 길이 한 줄로 인코딩 파이프라인 어디가 깨졌는지 역추적할 수 있다. keyint은 시간이 아니라 프레임 개수다.
흔한 오해와 정정:
| 오해 | 정확한 이해 |
|---|---|
keyint=2 = 2초마다 keyframe | ❌ "2 프레임마다 keyframe". 시간 아니라 프레임 개수 |
keyint=60 = 정확히 60프레임마다 I-frame | ❌ keyint은 최대값. scenecut이 켜져 있으면 더 일찍 삽입 가능 |
| HLS segment 길이는 플레이리스트 옵션이 정한다 | ❌ I-frame이 어디 박히느냐에 따라 결정 — 인코더가 결정 |
| B-frame은 무조건 켜야 한다 | ❌ 실시간/라이브는 끄는 게 표준. VOD에서만 켬 |
#EXTINF:18은 HLS 패키저 버그 | ⚠️ 대개는 인코더 단의 GOP 폭증이지만, packager 자체 설정 오류 가능성도 배제하면 안 됨 |
1. 영상의 본질 — 사진 × N장
| 항목 | 수치 |
|---|---|
| 1초 | 30장 (30fps) |
| 사진 1장 (raw) | ~2MB |
| 1초 원본 | 60MB → 💀 미침 |
| 1시간 원본 | 216GB → 💀💀 |
결론: 압축 없이는 인터넷으로 영상을 보낼 방법이 없습니다. 영상 코덱(H.264/H.265/AV1)이 푸는 문제가 정확히 이것 — "사진 × N장을 어떻게 작게 만들 것인가".
2. I-frame vs P-frame vs B-frame — 3종류의 프레임
정의
| 종류 | 풀네임 | 저장 내용 | 참조 | 크기 |
|---|---|---|---|---|
| I-frame | Intra frame (Keyframe) | 전체 사진 통째로 | 없음 (독립) | 큼 |
| P-frame | Predicted frame | 앞 프레임과의 변화 | 앞만 참조 | 중간 |
| B-frame | Bidirectional frame | 앞뒤 양방향 보간 | 앞 + 뒤 모두 참조 | 가장 작음 |
직관 — 공이 날아가는 장면
P-frame은 앞만 봐서 변화량을 적고, B-frame은 앞뒤를 다 봐서 더 작게 줄입니다.
압축 효율
B-frame이 가장 효율적이지만, 공짜는 아닙니다 (뒤에서 다룸).
3. GOP — Group Of Pictures
정의
GOP = I-frame부터 다음 I-frame 직전까지의 묶음.
B-frame을 포함한 전형적인 GOP는 이렇게 생깁니다:
keyint — 가장 헷갈리는 파라미터
⚠️ 흔한 착각:
keyint=60= "60초마다 keyframe" 실제: "60 프레임마다 keyframe (최대값)"
정확히 말하면 — keyint은 "최대" GOP 길이
x264에서 --keyint N은 maximum GOP length입니다. 두 가지 옵션이 함께 영향을 줍니다:
| 옵션 | 기본값 (x264) | 동작 |
|---|---|---|
--keyint | 250 | 최대 GOP 길이 — 이 값 도달 시 무조건 I-frame |
--min-keyint | auto | 최소 GOP 길이 — 이 값 이전엔 I-frame 안 박힘 |
--scenecut | 40 | 장면 전환 감지 임계값 — 감지 시 keyint 도달 전에도 I-frame 삽입 |
이 글의 시나리오는 --scenecut 0 전제입니다. 라이브 HLS에서는 segment 정렬을 위해 scenecut을 끄는 것이 표준이며 (#37 참조), 이 전제 위에서만 "keyint=60 = 정확히 60프레임마다 I-frame"이 성립합니다.
학교 출석부 비유 🏫
선생님이 말합니다: "60번째 학생마다 출석 불러".
근데 학생이 하루에 몇 명 오느냐에 따라 시간이 달라집니다.
- 하루에 30명씩 오면 → 2일 만에 60번째
- 하루에 5명씩 오면 → 12일 만에 60번째
keyint은 시간이 아니라 "몇 번째 프레임마다" 라는 카운트 규칙입니다.
fps에 따라 실제 시간이 달라진다
🟢 30fps 정상 케이스
🔴 fps 떨어진 케이스
택배 비유 📦
keyint=60= "60번째 택배마다 도장 찍어"정상: 하루에 30개 택배 옴 → 60개 = 2일 만에 도장 CPU 터짐: 하루에 5개밖에 못 처리 → 60개 = 12일 만에 도장
도장 찍는 규칙(
keyint=60)은 안 바뀝니다. 그냥 택배가 느리게 오는 것뿐.
시간이 아니라 프레임 개수. fps가 바뀌면 GOP의 실제 시간 길이도 같이 바뀝니다. 이게 뒤에 나올 "CPU 폭주 → segment 폭증"의 핵심 메커니즘 — 인코더에 설정된 규칙은 그대로지만, fps가 떨어지면 같은 keyint이 더 긴 시간으로 펼쳐집니다.
"GOP=2초"는 사람 표현, 인코더는 프레임으로 처리
이 헷갈림의 진짜 원인은 사람이 설정하는 단위와 인코더 내부 단위가 다르다는 점입니다.
사람이 설정하는 값
사람 입장에서는 "2초마다 keyframe 넣어"가 가장 직관적입니다.
인코더 내부 변환
x264/x265 같은 인코더는 시간 개념이 없습니다. frame 카운터로만 동작합니다. 그래서 사람이 입력한 "2초"를 자동으로 "60 frame"으로 변환.
정상일 땐 둘이 같아 보임
CPU 망하면 차이 발생
진실 한 장 정리
| 사람이 말하는 것 | 실제 인코더 내부 |
|---|---|
| GOP = 2초 | keyint = 60 |
| 시간 단위 | frame 개수 단위 |
한 줄: "2초 GOP는 설정값이고, 인코더는 종종 그걸 60 프레임으로 변환해서 처리한다."
평소엔 둘이 같아 보여서 헷갈리지만, fps가 흔들리는 순간 정체가 드러납니다.
Open GOP vs Closed GOP
| 종류 | B-frame이 이전 GOP 참조 가능? | 용도 |
|---|---|---|
| Open GOP | 가능 | VOD, 압축률 우선 |
| Closed GOP | 불가 (GOP 경계에서 차단) | 라이브/HLS 필수 |
HLS segment를 깨끗하게 자르려면 반드시 Closed GOP. Open GOP에서는 segment 경계의 B-frame이 이전 segment의 프레임을 참조해서 깨집니다 (#37 참조).
4. 정상 상태 🟢 — 30fps + keyint=60
깔끔하게 2초마다 I-frame이 박히고, HLS도 2초마다 자릅니다. #EXT-X-TARGETDURATION:2 약속과 실제가 일치.
5. CPU 터짐 🔴 — fps가 무너지는 순간
CPU 부하가 100%에 도달하면 인코더가 fps를 못 채웁니다.
같은 keyint=60 설정인데도, fps가 떨어지면 GOP의 실제 시간이 6배로 늘어납니다. 이게 사고의 시작점.
6. 사고 연쇄 반응 💥 — 5단계 인과 사슬
#EXTINF:18이 찍힌 시점에는 이미 5단계 전부터 무너진 상태. 패키저는 무죄, 인코더가 범인.
⚠️ 전제 조건: 이 인과 사슬은 인코더가 프레임을 드롭하지 않고 큐잉하는 경우에 발현됩니다. OBS의 "Frames missed due to rendering lag"이 늘어나는 모드, 또는 ffmpeg에서
-re없이 캡처 디바이스를 직접 받는 케이스. 인코더가 frame drop 정책이면 GOP 시간은 유지되지만 화질이 떨어집니다 (다음 섹션 참조).
6-1. 같은 증상, 다른 원인 — #EXTINF:18 대안 경로
CPU 과부하가 가장 흔한 원인이지만, 동일 증상이 다른 경로로도 발생합니다. 디버깅 시 반드시 함께 확인해야 할 4가지:
A. Frame drop 모드 — fps는 유지, 화질이 떨어짐
B. Packager 설정 오류 — 인코더는 무죄
C. 입력 소스 burst / PTS 불연속
D. Clock drift — 인코더와 시스템 시계 불일치
진단 우선순위
| 순서 | 확인 항목 | 명령 |
|---|---|---|
| 1 | Frame drop 카운터 | OBS UI, ffmpeg -progress drop_frames |
| 2 | CPU 사용률 | top, htop, nvidia-smi (HW 인코더) |
| 3 | 인코더 설정 vs packager 설정 | 양쪽 config diff |
| 4 | 입력 소스 PTS 연속성 | ffprobe -show_packets |
| 5 | 시스템 시계 | chronyc tracking, timedatectl |
핵심:
#EXTINF:18을 보면 frame drop 카운터를 먼저 보세요. drop이 많으면 §6-1 A 시나리오 (Frame drop → fps 유지, 화질 저하), drop이 적은데 segment가 길면 §6 본문 시나리오 (CPU/큐잉 → GOP 폭증).
7. HLS가 같이 죽는 이유 — I-frame 위치 강제
HLS Split 규칙
HLS segment는 반드시 I-frame 위치에서만 split 가능.
이유는 디코딩 의존성 때문:
정상 vs 사고
| 상태 | I-frame 주기 | HLS segment 길이 | 로그 |
|---|---|---|---|
| 🟢 정상 | 2초 | 2초 | #EXTINF:2.0 |
| 🟡 살짝 부하 | 4~6초 | 4~6초 | #EXTINF:4.5 |
| 🔴 CPU 터짐 | 12~18초 | 12~18초 | #EXTINF:18 |
#EXTINF의 값이 #EXT-X-TARGETDURATION보다 크면 RFC 8216 위반이며, 거의 대부분 인코더/파이프라인 단의 문제입니다.
8. iOS 플레이어가 더 터지는 이유
iOS HLS 플레이어는 EXT-X-TARGETDURATION을 신뢰합니다. 그게 어긋나면 줄줄이 무너집니다.
| 증상 | 원인 |
|---|---|
| seek 이상 | 타임라인 계산이 segment 길이 기반인데 빗나감 |
| 버퍼 꼬임 | 예상 크기의 9배 segment 도착 → 버퍼 overflow/underflow |
| 화면 멈춤 | 다음 segment 못 받음 (또는 받았는데 디코딩 지연) |
| AV sync 이상 | 오디오/비디오 PTS 계산 꼬임 (특히 B-frame 환경) |
RFC 8216:
EXT-X-TARGETDURATION은 모든 segment의 최대 길이 상한값이어야 함. 위반 시 클라이언트 동작은 implementation-defined → iOS는 보수적으로 처리하다 멈춤.
9. B-frame Deep Dive — 왜 라이브에서는 끄는가
동작 원리
P-frame은 과거만 봅니다:
B-frame은 과거 + 미래를 동시에 봅니다:
디코딩 순서 ≠ 재생 순서
B-frame의 핵심 함정: 저장/전송 순서와 화면 재생 순서가 다릅니다. 이유를 단계별로 풀어봅시다.
Step 1 — 문제 제기
B-frame은 미래 프레임을 참조합니다.
B(1번)을 디코딩하려면?
- ⬅️ 과거 I(0번) 필요 → ✅ 이미 있음
- ➡️ 미래 P(3번) 필요 → ❌ 아직 안 왔음
💡 B-frame을 재생하려면 미래의 P-frame을 먼저 디코딩해놔야 한다.
Step 2 — 그래서 저장 순서를 바꾼다
Step 3 — 디코더 내부 흐름
디코딩 시점과 화면 출력 시점이 분리되어 있다는 게 핵심. 디코더는 미래 프레임을 먼저 디코딩한 뒤 버퍼에 저장해두고, 재생 타이밍이 되면 꺼내서 출력합니다.
비유 — 영화 자막 번역가 🎯
영화 자막 번역가가 있다고 해봅시다. B-자막은 앞뒤 문맥을 모두 봐야 번역할 수 있습니다. 그래서 뒤 장면을 먼저 읽고, 다시 돌아와서 번역합니다.
→ 파일에는 "뒤 장면"이 먼저 저장되어 있어야 한다.
한 줄 요약
B-frame은 미래를 참조하므로, 그 미래(P)를 먼저 디코딩해야 한다. 그래서 파일 저장 순서 ≠ 화면 재생 순서가 된다.
이걸 처리하기 위해 디코더는 B-frame 직전의 P-frame을 미리 받아둘 만큼의 버퍼가 필요하고, 그 버퍼가 곧 지연(latency)으로 이어집니다. 실시간 통화에서 B-frame을 끄는 이유가 여기 있습니다.
PTS vs DTS — 컨테이너가 두 순서를 관리하는 법
디코딩 순서와 재생 순서가 다르다는 건 컨테이너 레벨에서 두 개의 타임스탬프를 따로 들고 있어야 한다는 뜻입니다.
| 타임스탬프 | 풀네임 | 의미 |
|---|---|---|
| PTS | Presentation Time Stamp | 화면에 보여줄 시각 |
| DTS | Decode Time Stamp | 디코더가 처리할 시각 |
실무 영향:
- AV sync 디버깅:
ffprobe -show_frames -select_streams v출력에서pts와pkt_dts가 같으면 B-frame 없음, 다르면 B-frame 있음 (FFmpeg 5.0 미만 구버전은pkt_pts필드 사용). PTS/DTS 불연속 발견 시 segment 경계 이슈 의심. - HLS segment 경계: B-frame이 포함된 GOP를 segment로 자를 때, 마지막 B-frame의 DTS가 다음 segment의 첫 프레임 DTS보다 클 수 있어 sample dependency가 꼬일 위험이 있음 → Closed GOP 필수의 또 다른 이유.
- iOS AV sync 증상: 비정상 segment에서 PTS/DTS 정렬이 깨지면 오디오 PTS와 비디오 PTS 매칭이 어긋남 → 입술 동기 오차.
단점 3가지
| 단점 | 메커니즘 | 영향 |
|---|---|---|
| 디코더 지연 | 뒤 P-frame 먼저 받아 버퍼링 | 실시간 스트리밍에서 치명적 |
| 디코딩 복잡도 ↑ | 앞뒤 둘 다 참조해서 합성 | CPU/GPU 부하 증가 |
| 에러 전파 | 앞뒤 frame 중 하나만 손상돼도 B 깨짐 | 손실 환경에서 화질 불안 |
실무 설정 — 라이브 OFF, VOD ON
🔴 B-frame 끄는 경우 (라이브)
🟢 B-frame 켜는 경우 (VOD)
WebRTC는 사양상 B-frame 미사용이 일반적. LL-HLS도 part 단위가 워낙 짧아서 B-frame 효용이 적습니다 (#40).
HLS Segment와 B-frame의 상호작용
라이브 + HLS + B-frame 조합은 디버깅이 가장 어렵습니다. 그래서 라이브에서는 끄는 게 표준.
10. 실무 디버깅 역추적 🔍
#EXTINF:13 한 줄에서 시작해 CPU 모니터까지 거꾸로 따라갑니다.
한 줄 로그에서 8단계 인과 추적이 가능하다는 게 핵심. 영상 압축 구조를 모르면 "HLS 버그"로 오인하고 패키저만 뒤집니다.
11. 한 장 요약 — 최종 암기 카드
| 개념 | 한 줄 요약 |
|---|---|
| I-frame | 완전한 사진 한 장 (큼, 독립) |
| P-frame | "앞이랑 이만큼 달라" (중간) |
| B-frame | "앞아 뒤야, 나 너네 사이 어딘가야" (가장 작음) |
| GOP | I-frame ~ 다음 I-frame 직전 묶음 |
| keyint=60 | 60 프레임마다 I-frame (≠ 60초!) |
| Closed GOP | HLS 필수 — GOP 경계에서 참조 차단 |
| HLS split | 반드시 I-frame 위치에서만 |
#EXTINF:18 | 인코딩 파이프라인 이상 증거 (주로 CPU, §6-1 대안 참조) |
| B-frame 라이브 | OFF (-bf 0) |
| B-frame VOD | ON (-bf 2) |
12. SA 의사결정 체크리스트
라이브 송출 셋업 전에 답해야 할 8가지:
| 질문 | 영향 |
|---|---|
| 1. 목표 fps와 keyint은? | GOP의 실제 시간 = keyint ÷ fps |
| 2. Closed GOP인가? | HLS segment 깨짐 방지 |
| 3. scene-cut detection은 OFF인가? | GOP 경계 무작위 삽입 방지 (#37) |
| 4. CBR인가 VBR인가? | 라이브는 CBR (#37) |
| 5. 인코더 선택은? (SW/HW) | SW(x264): 화질↑ CPU↑ / HW(NVENC/QSV/VideoToolbox): 화질△ CPU↓↓ — CPU 부하 자체를 차단 |
| 6. 인코더 preset과 CPU 여유는? | preset이 너무 무거우면 fps 미달 |
| 7. B-frame 사용 여부? | 라이브 OFF / VOD ON |
8. HLS EXT-X-TARGETDURATION 설정값? | segment 길이 ÷ GOP의 정수배여야 깔끔 |
이 8가지를 사전에 확정하면 #EXTINF:18 로그를 만날 일이 거의 없습니다.
13. 한 줄 결론
HLS segment 길이는 인코더의 I-frame 박힘에 의해 결정되고, I-frame은 GOP 설정과 fps에 의해 결정되며, fps는 주로 CPU 여유에 의해 결정된다. 그래서
#EXTINF:18한 줄로 인코딩 파이프라인 전체를 역추적할 수 있다 (단, frame drop / packager 설정 / PTS 불연속 / clock drift 같은 대안 경로도 함께 점검 — §6-1 참조). keyint은 시간이 아니라 프레임 개수다.
관련 글
- #37 GOP-세그먼트 정렬의 산수 — Agora Media Push에서 YouTube/Twitch까지 — GOP 설정의 산수
- #38 ABR Ladder의 Layer B 정렬 — 화질 전환 시 키프레임 동기화 — 멀티 ladder 정렬
- #40 라이브 스트리밍 프로토콜 선택 — HLS / LL-HLS / WebRTC 의사결정 — 프로토콜 비교
- #15 M3U8과 TS 파일의 모든 것 — HLS Deep Dive — HLS 컨테이너 구조
참고 자료
- RFC 8216 — HTTP Live Streaming —
EXT-X-TARGETDURATION규약 - x264 settings — keyint, scenecut, bframes — x264 인코더 파라미터
- Apple HLS Authoring Specification — Apple 권장 인코딩 가이드
- ITU-T H.264 / ISO 14496-10 — I/P/B-frame, NAL, GOP 표준 정의