문제
송출 시작 직후 몇 초간 클라이언트 미리보기 영상이 깨진다. 백그라운드 복귀 시에도 동일하게 재현되고, 프로세스는 죽지 않으며 일정 시간 후 저절로 정상화된다.
깨짐의 양상이 원인을 특정해준다.
- 정지 영역(천장, 벽, 캐비닛)만 파괴되고 움직이는 피사체(인물, 책상)는 정상이다
- 클라이언트의 로컬 카메라 화면은 깨끗하고 서버 처리 화면만 깨진다
- 얼굴 블러는 정확한 위치에 정상 동작한다
정지 영역은 skip 매크로블록으로 처리되어 픽셀 데이터가 전송되지 않는다. 참조 프레임을 잃으면 그 자리에 초기화되지 않은 버퍼 값이 남고 다음 IDR까지 전파된다. 운동 영역은 매 프레임 잔차가 갱신되므로 참조 없이도 정상적으로 그려진다. 디코더 레퍼런스 손실의 지문이다.
원인
answer SDP가 a=rtcp-fb를 한 줄도 싣지 않는다.
internal/session/video_codec.go:82 에서 클라이언트 offer를 파싱해 코덱 파라미터를 재구성할 때 RTCPFeedback 필드를 채우지 않는다.
parameters: webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: string(videoCodec),
ClockRate: codec.ClockRate,
SDPFmtpLine: codec.Fmtp,
// RTCPFeedback 누락
},
PayloadType: webrtc.PayloadType(payloadType),
},
이 값이 SetCodecPreferences로 비디오 트랜시버에 덮어써진다. Pion은 answer의 a=rtcp-fb 라인을 협상된 코덱의 RTCPFeedback에서 생성하므로, 빈 리스트는 곧 피드백 라인 전무를 의미한다.
Pion 기본값은 정상이다. manager.go:176 의 webrtc.NewAPI(WithSettingEngine(...)) 는 MediaEngine을 넘기지 않으므로 기본 코덱(피드백 5종 포함)과 기본 인터셉터(NACK generator/responder, RTCP reports, TWCC)를 자동 등록한다. SetCodecPreferences 가 그 위를 덮어쓰는 것이 유일한 손실 지점이다.
비디오 m-line은 sendrecv 하나뿐이고 트랜시버 코덱 선호에 방향 구분이 없다. 따라서 인그레스·이그레스 양방향 모두 아래가 비활성이다.
| 기능 |
결과 |
| NACK |
손실 RTP 패킷의 재전송 복구 경로 없음 |
| PLI / FIR |
클라이언트가 키프레임을 요청할 수단 없음 (미협상이므로 송신조차 하지 않음) |
| transport-cc / goog-remb |
수신측 대역폭 피드백 없음 → 초반 손실 자체를 유발 |
발생 경로:
접속 직후 / 백그라운드 복귀 직후
→ 서버→클라이언트 RTP 패킷 손실
→ NACK 미협상: 재전송 복구 없음
→ 클라이언트 디코더 레퍼런스 파손
→ PLI 미협상: 키프레임 요청 불가
→ 인코더의 다음 자연 IDR까지 오염 전파
실측치
기준 커밋 e1dcb27.
1. answer SDP의 a=rtcp-fb 라인 수
브라우저 규격 offer로 CreateAnswer를 호출해 m=video 섹션을 집계했다.
| 피드백 종류 |
offer (VP8 선호) |
answer |
offer (H.264 선호) |
answer |
nack |
72 |
0 |
2 |
0 |
nack pli |
36 |
0 |
1 |
0 |
ccm fir |
12 |
0 |
1 |
0 |
goog-remb |
12 |
0 |
1 |
0 |
transport-cc |
24 |
0 |
1 |
0 |
| 합계 |
120 |
0 |
5 |
0 |
협상 코덱과 무관하게 answer는 항상 0이다. a=extmap:4 ...transport-wide-cc... 헤더 확장은 answer에 남아 있으나 대응하는 a=rtcp-fb transport-cc 가 없어 실제 피드백은 동작하지 않는다.
2. egress 인코더 실제 GOP 길이
h264.go:210-212 의 프로덕션 인자를 그대로 써서 150프레임을 인코딩한 결과, I 프레임이 0, 30, 60, 90, 120 프레임에 정확히 등장했다.
| 레그 |
설정값 |
실측 IDR 주기 |
| 클라이언트 미리보기 (WebRTC egress) |
-g 30, 입력 -framerate 30 고정 |
30프레임 = 1.000초 |
| YouTube RTMP egress |
-g fps*2 (fps는 런타임 실측) |
2.000초 |
미리보기 레그의 IDR 주기가 1초이므로 단발 손실은 1초 내에 자가복구되어야 한다. 증상이 "몇 초" 지속된다는 것은 초반 구간에서 손실이 반복 발생한다는 뜻이며, transport-cc/goog-remb 부재로 인한 대역폭 추정 실패와 연결된다.
작업 범위
1. offer의 rtcp-fb를 파싱해 RTCPFeedback에 채운다
internal/session/video_codec.go 의 offeredVideoCodecs 가 offer SDP의 a=rtcp-fb 라인을 읽어 RTPCodecCapability.RTCPFeedback 에 넣는다. 이 한 곳의 수정으로 NACK · PLI · FIR · transport-cc 가 모두 복구된다.
대부분의 초반 손실은 NACK 재전송 선에서 정리되므로, 이 단계만으로 증상이 크게 완화될 것으로 본다.
2. 회귀 테스트 (작성 완료, 현재 RED)
internal/session/video_codec_test.go 에 TestAnswerRetainsOfferedRTCPFeedback 를 추가해 두었다. 위 실측 1을 그대로 검사한다.
--- FAIL: TestAnswerRetainsOfferedRTCPFeedback
answer dropped "nack" feedback offered by the client
answer dropped "nack pli" feedback offered by the client
answer dropped "ccm fir" feedback offered by the client
answer dropped "goog-remb" feedback offered by the client
answer dropped "transport-cc" feedback offered by the client
1번이 들어가면 그대로 GREEN이 된다.
확정된 판단
rtcp-fb 파싱을 먼저 한다. 근본 원인 지점을 직접 제거하는 유일한 방법이다.
- PLI 핸들링(
signaling.go:123 drainRTCP)은 후속으로 미룬다. drainRTCP 가 sender RTCP를 통째로 버리는 것은 사실이지만, 1번 없이는 PLI가 애초에 도착하지 않으므로 지금 손대도 아무 변화가 없다.
- GOP 단축은 하지 않는다. 미리보기 레그는 실측 1초로 이미 짧아 추가로 얻는 것이 적고, 키프레임 빈도 증가로 비트레이트 비용만 발생한다.
범위 밖
PLI/FIR 수신 시 키프레임 강제 (별도 이슈로 분리)
1번 적용 후 잔여 증상이 있으면 착수한다. 선행 설계 결정이 필요하다 — 현재 egress 인코더는 stdin으로 raw/mjpeg를 받는 별도 ffmpeg 프로세스라 실행 중 온디맨드 IDR을 강제할 채널이 없다. 인코더 프로세스 재기동(구현 단순, 순간 지연) 또는 인프로세스 라이브러리 교체(정공법, 범위 큼) 중 선택해야 한다.
YouTube RTMP egress(#29)의 동일 문제 여부
RTMP 수신단은 일반적으로 연결 직후 키프레임을 기다리므로 증상이 나타나지 않거나 다른 형태(초기 몇 초 영상 미표시)로 나타날 수 있다. 별도 확인이 필요하다.
오진 방지 — 이미 정상인 항목
검증 과정에서 확인한, 원인이 아닌 것들이다.
-x264-params repeat-headers=1 이 켜져 있어 SPS/PPS가 매 IDR마다 반복 전송된다. 파라미터 셋 유실 문제가 아니다.
- H.264 디코더는
h264.go:23-33 에서 IDR 도착까지 대기하므로 서버 디코더의 콜드 스타트는 깨끗하다.
- 인그레스 해상도 중간 변경이 아니다. 인코더는
h264.go:119 에서 첫 프레임 크기로 1회만 기동되고 재기동 경로가 없어, 해상도가 바뀌면 validateEncoderInput(ffmpeg.go:292)이 에러를 반환하고 파이프라인이 cancel된다. 자가복구되는 열화가 아니라 즉시 종료다.
- "얼굴 블러가 제 위치에 걸리므로 서버 입력이 온전하다"는 추론은 성립하지 않는다. 얼굴은 운동 영역이라 레퍼런스가 손실돼도 정상 디코딩된다. 서버 디코더가 오염된 상태에서도 얼굴 탐지와 블러는 정확히 동작한다.
- 클라이언트의 로컬 카메라 화면이 깨끗한 것도 근거가 되지 않는다.
app.js:942 에서 그것은 getUserMedia 스트림을 그대로 물린 것이라 네트워크도 인코더도 거치지 않는다.
관련
- 전체 검증 과정:
docs/operations/streaming-startup-corruption-verification.md
- 운영 대응 절차:
docs/operations/TROUBLESHOOTING.md → "송출 초반 몇 초간 영상이 깨짐"
문제
송출 시작 직후 몇 초간 클라이언트 미리보기 영상이 깨진다. 백그라운드 복귀 시에도 동일하게 재현되고, 프로세스는 죽지 않으며 일정 시간 후 저절로 정상화된다.
깨짐의 양상이 원인을 특정해준다.
정지 영역은 skip 매크로블록으로 처리되어 픽셀 데이터가 전송되지 않는다. 참조 프레임을 잃으면 그 자리에 초기화되지 않은 버퍼 값이 남고 다음 IDR까지 전파된다. 운동 영역은 매 프레임 잔차가 갱신되므로 참조 없이도 정상적으로 그려진다. 디코더 레퍼런스 손실의 지문이다.
원인
answer SDP가
a=rtcp-fb를 한 줄도 싣지 않는다.internal/session/video_codec.go:82에서 클라이언트 offer를 파싱해 코덱 파라미터를 재구성할 때RTCPFeedback필드를 채우지 않는다.이 값이
SetCodecPreferences로 비디오 트랜시버에 덮어써진다. Pion은 answer의a=rtcp-fb라인을 협상된 코덱의RTCPFeedback에서 생성하므로, 빈 리스트는 곧 피드백 라인 전무를 의미한다.Pion 기본값은 정상이다.
manager.go:176의webrtc.NewAPI(WithSettingEngine(...))는 MediaEngine을 넘기지 않으므로 기본 코덱(피드백 5종 포함)과 기본 인터셉터(NACK generator/responder, RTCP reports, TWCC)를 자동 등록한다.SetCodecPreferences가 그 위를 덮어쓰는 것이 유일한 손실 지점이다.비디오 m-line은 sendrecv 하나뿐이고 트랜시버 코덱 선호에 방향 구분이 없다. 따라서 인그레스·이그레스 양방향 모두 아래가 비활성이다.
발생 경로:
실측치
기준 커밋
e1dcb27.1. answer SDP의
a=rtcp-fb라인 수브라우저 규격 offer로
CreateAnswer를 호출해m=video섹션을 집계했다.nacknack pliccm firgoog-rembtransport-cc협상 코덱과 무관하게 answer는 항상 0이다.
a=extmap:4 ...transport-wide-cc...헤더 확장은 answer에 남아 있으나 대응하는a=rtcp-fb transport-cc가 없어 실제 피드백은 동작하지 않는다.2. egress 인코더 실제 GOP 길이
h264.go:210-212의 프로덕션 인자를 그대로 써서 150프레임을 인코딩한 결과, I 프레임이 0, 30, 60, 90, 120 프레임에 정확히 등장했다.-g 30, 입력-framerate 30고정-g fps*2(fps는 런타임 실측)미리보기 레그의 IDR 주기가 1초이므로 단발 손실은 1초 내에 자가복구되어야 한다. 증상이 "몇 초" 지속된다는 것은 초반 구간에서 손실이 반복 발생한다는 뜻이며, transport-cc/goog-remb 부재로 인한 대역폭 추정 실패와 연결된다.
작업 범위
1. offer의
rtcp-fb를 파싱해RTCPFeedback에 채운다internal/session/video_codec.go의offeredVideoCodecs가 offer SDP의a=rtcp-fb라인을 읽어RTPCodecCapability.RTCPFeedback에 넣는다. 이 한 곳의 수정으로 NACK · PLI · FIR · transport-cc 가 모두 복구된다.대부분의 초반 손실은 NACK 재전송 선에서 정리되므로, 이 단계만으로 증상이 크게 완화될 것으로 본다.
2. 회귀 테스트 (작성 완료, 현재 RED)
internal/session/video_codec_test.go에TestAnswerRetainsOfferedRTCPFeedback를 추가해 두었다. 위 실측 1을 그대로 검사한다.1번이 들어가면 그대로 GREEN이 된다.
확정된 판단
rtcp-fb파싱을 먼저 한다. 근본 원인 지점을 직접 제거하는 유일한 방법이다.signaling.go:123drainRTCP)은 후속으로 미룬다.drainRTCP가 sender RTCP를 통째로 버리는 것은 사실이지만, 1번 없이는 PLI가 애초에 도착하지 않으므로 지금 손대도 아무 변화가 없다.범위 밖
PLI/FIR 수신 시 키프레임 강제 (별도 이슈로 분리)
1번 적용 후 잔여 증상이 있으면 착수한다. 선행 설계 결정이 필요하다 — 현재 egress 인코더는 stdin으로 raw/mjpeg를 받는 별도 ffmpeg 프로세스라 실행 중 온디맨드 IDR을 강제할 채널이 없다. 인코더 프로세스 재기동(구현 단순, 순간 지연) 또는 인프로세스 라이브러리 교체(정공법, 범위 큼) 중 선택해야 한다.
YouTube RTMP egress(#29)의 동일 문제 여부
RTMP 수신단은 일반적으로 연결 직후 키프레임을 기다리므로 증상이 나타나지 않거나 다른 형태(초기 몇 초 영상 미표시)로 나타날 수 있다. 별도 확인이 필요하다.
오진 방지 — 이미 정상인 항목
검증 과정에서 확인한, 원인이 아닌 것들이다.
-x264-params repeat-headers=1이 켜져 있어 SPS/PPS가 매 IDR마다 반복 전송된다. 파라미터 셋 유실 문제가 아니다.h264.go:23-33에서 IDR 도착까지 대기하므로 서버 디코더의 콜드 스타트는 깨끗하다.h264.go:119에서 첫 프레임 크기로 1회만 기동되고 재기동 경로가 없어, 해상도가 바뀌면validateEncoderInput(ffmpeg.go:292)이 에러를 반환하고 파이프라인이 cancel된다. 자가복구되는 열화가 아니라 즉시 종료다.app.js:942에서 그것은getUserMedia스트림을 그대로 물린 것이라 네트워크도 인코더도 거치지 않는다.관련
docs/operations/streaming-startup-corruption-verification.mddocs/operations/TROUBLESHOOTING.md→ "송출 초반 몇 초간 영상이 깨짐"