Skip to content

fix: WebRTC answer SDP가 offer의 rtcp-fb를 누락해 송출 초반 화면이 깨짐 #90

Description

@hjbin-25

문제

송출 시작 직후 몇 초간 클라이언트 미리보기 영상이 깨진다. 백그라운드 복귀 시에도 동일하게 재현되고, 프로세스는 죽지 않으며 일정 시간 후 저절로 정상화된다.

깨짐의 양상이 원인을 특정해준다.

  • 정지 영역(천장, 벽, 캐비닛)만 파괴되고 움직이는 피사체(인물, 책상)는 정상이다
  • 클라이언트의 로컬 카메라 화면은 깨끗하고 서버 처리 화면만 깨진다
  • 얼굴 블러는 정확한 위치에 정상 동작한다

정지 영역은 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:176webrtc.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.goofferedVideoCodecs 가 offer SDP의 a=rtcp-fb 라인을 읽어 RTPCodecCapability.RTCPFeedback 에 넣는다. 이 한 곳의 수정으로 NACK · PLI · FIR · transport-cc 가 모두 복구된다.

대부분의 초반 손실은 NACK 재전송 선에서 정리되므로, 이 단계만으로 증상이 크게 완화될 것으로 본다.

2. 회귀 테스트 (작성 완료, 현재 RED)

internal/session/video_codec_test.goTestAnswerRetainsOfferedRTCPFeedback 를 추가해 두었다. 위 실측 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 → "송출 초반 몇 초간 영상이 깨짐"

Metadata

Metadata

Assignees

Labels

fix버그 수정

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions