WebRTC 플레이어가 일시정지를 스트림 단절로 오판하는 이유
수신 통계(fps, bitrate)만 보고 스트림 생사를 판정하면, 사용자가 잠깐 누른 pause가 연결 종료와 프로토콜 폴백까지 끌고 갑니다.
증상
모바일 웹에서 저지연(WebRTC) 라이브를 보다가 일시정지를 몇 초 걸었다가 다시 재생하면, 화면은 돌아오는데 재생 경로가 WebRTC가 아니라 HLS로 바뀌어 있습니다. 지연이 1초대에서 갑자기 10초대가 되니까 사용자는 "왜 갑자기 느려졌지"라고 느끼고, 로그를 뒤지는 사람은 서버 문제를 의심하게 됩니다.
재현 조건이 아주 선명하게 갈렸습니다.
| 환경 | pause 중 패킷 수신 | 통계값 | 결과 |
|---|---|---|---|
| 모바일 브라우저 | 전력 절약을 위해 수신과 디코딩 파이프라인 정지 | fps 0, bitrate 0으로 수렴 | 연결 종료 후 HLS 폴백, 100% 재현 |
| 데스크톱 Chrome | pause 중에도 패킷 수신 유지 | bitrate가 0으로 떨어지지 않음 | 재현 안 됨 |
주의: "데스크톱에서는 재현이 안 됩니다"라는 말이 나오면 서버가 아니라 클라이언트 상태 머신을 봐야 한다는 강한 신호입니다. 서버는 어느 쪽 브라우저인지 모릅니다.
재생 엔진이 스트림 생사를 판정하는 방식
저지연 재생 엔진은 주기적으로 수신 통계 콜백을 돌리면서 스트림 상태를 판정합니다. 판정 기준이 딱 두 개였습니다.
onStats(stats):
# 조건 1) 버퍼링 판정
if stats.fps <= streamPlaying.threshold # 기본값 5
or stats.bitrate <= streamPlaying.threshold: # 기본값 5
emit WAITING_BEGIN # 버퍼링 시작 이벤트
# 조건 2) 단절 판정
if stats.bitrate:
streamReceiveFail.curNum = 0 # 수신 확인, 카운터 리셋
else:
streamReceiveFail.curNum += 1
if streamReceiveFail.curNum >= streamReceiveFail.maxNum: # 기본값 5
emit STREAM_EMPTY
disconnect(NEED_RECONNECT)두 조건 모두 왜 통계가 0인지를 묻지 않습니다. 네트워크가 끊겨서 0인 것과, 사용자가 pause를 눌러 브라우저가 파이프라인을 세워서 0인 것을 구분할 방법이 로직 안에 없습니다.
참고: pause는 두 조건을 동시에 만족시킵니다. fps와 bitrate가 함께 0이 되니까요. 그래서 버퍼링 이벤트가 먼저 뜨고, 그다음 실패 카운터가 임계값 5까지 쌓입니다. 통계 콜백 주기가 1초라면 pause 5초 = 스트림 사망 선고입니다.
폴백까지 이어지는 캐스케이드
6번 단계가 특히 고약합니다. pause 중에는 재연결도 성공할 수가 없습니다. 사용자가 재생을 누르지 않았으니 수신이 재개될 이유가 없죠. 그래서 자동 복구가 아니라 자동 악화가 됩니다.
레이어가 다르다는 게 핵심
이 구조에서 제일 중요한 통찰은 연결 종료와 프로토콜 폴백이 서로 다른 레이어에서 일어난다는 점입니다.
그래서 폴백 옵션을 끄면(fallback: false) 폴백 단계만 막힙니다. 연결 종료는 그대로 일어납니다. 결과는 "HLS로 안 넘어가는 대신 검은 화면"이 되고, 체감은 오히려 나빠질 수 있습니다.
주의: 튜닝으로 우회하려 해도 막힙니다. 공개 파라미터는
connectRetryCount,connectRetryDelay,receiveVideo,receiveAudio,showLog,receiveSEI,fallback,fallbackUrl8개뿐이고, 판정에 실제로 쓰이는streamPlaying.threshold와streamReceiveFail.maxNum은 내부 변수로 노출되지 않습니다. pause 중 연결 유지는 공개 API만으로는 불가능합니다. 결국 판정 함수에 pause 인식을 넣는 수정이 필요합니다.
그러면 어떻게 판정해야 하나
fps와 bitrate 두 개로는 부족합니다. getStats가 주는 값들을 조합하면 "화면이 멈춤"의 원인을 구분할 수 있습니다.
| 신호 | 어디서 | 무엇을 알려주나 |
|---|---|---|
bytesReceived 증가량 | inbound-rtp | transport가 살아있는가 |
packetsReceived, packetsLost | inbound-rtp | 경로 품질이 나쁜가 |
framesReceived 증가량 | inbound-rtp (video) | 인코딩된 프레임이 오고 있는가 |
framesDecoded 증가량 | inbound-rtp (video) | 디코더가 실제로 돌고 있는가 |
framesDropped 증가량 | inbound-rtp (video) | 디코딩은 되는데 버리고 있는가 |
freezeCount, totalFreezesDuration | inbound-rtp (video) | 사용자가 체감한 정지가 있었는가 |
jitterBufferDelay / jitterBufferEmittedCount | inbound-rtp | 지연이 버퍼에서 쌓이고 있는가 |
pliCount, firCount | inbound-rtp | 키프레임을 계속 요청하는 손상 상태인가 |
SSRC 변경 | inbound-rtp | 스트림이 교체되었는가 |
video.paused, video.ended | HTMLMediaElement | 사용자가 의도적으로 멈춘 상태인가 |
document.visibilityState | Document | 탭이 백그라운드로 갔는가 |
조합해서 표로 만들면 판정이 이렇게 갈립니다.
| bytesReceived | framesDecoded | video.paused | 판정 | 조치 |
|---|---|---|---|---|
| 증가 | 증가 | false | 정상 재생 | 없음 |
| 증가 | 정지 | false | 디코더 stall 또는 프레임 손상 | 키프레임 요청, 필요 시 재협상 |
| 정지 | 정지 | true | 사용자 pause (정상) | 판정과 카운터 모두 건너뜀 |
| 정지 | 정지 | false | 실제 수신 단절 | 실패 카운터 누적, 재연결 |
| 증가 | 증가(느림) + framesDropped 급증 | false | 성능 부족 | 해상도 하향 요청 |
수정 방향을 코드로 쓰면 이 정도입니다.
function judgeStreamHealth(prev, cur, videoEl) {
// 1) 사용자 의도로 멈춘 상태는 판정 대상이 아니다
if (videoEl.paused || videoEl.ended || document.visibilityState === 'hidden') {
failCounter = 0; // 누적 초기화가 핵심
return 'PAUSED'; // 상태 판정 자체를 건너뛴다
}
const dBytes = cur.bytesReceived - prev.bytesReceived;
const dFrames = cur.framesDecoded - prev.framesDecoded;
// 2) transport와 decoder를 분리해서 본다
if (dBytes === 0 && dFrames === 0) {
if (++failCounter >= MAX_FAIL) return 'DEAD'; // 실제 단절
return 'SUSPECT';
}
failCounter = 0;
if (dBytes > 0 && dFrames === 0) return 'DECODE_STALL'; // 재협상/PLI 대상
return 'PLAYING';
}참고: 포인트는 세 줄입니다. 첫째, pause와 백그라운드는 판정 이전에 걸러냅니다. 둘째, 실패 카운터는 판정을 건너뛸 때 0으로 리셋해야 합니다. 리셋을 빼먹으면 pause를 여러 번 반복하는 사용자에게서 카운터가 누적되어 같은 사고가 다시 납니다. 셋째, transport(
bytesReceived)와 decoder(framesDecoded)를 분리해서 봐야 조치가 갈립니다. 전자가 죽었으면 재연결이고, 후자만 죽었으면 키프레임 요청이나 재협상입니다.
추가로 임계값도 손볼 값이 있습니다. 실패 임계값 5회에 통계 주기 1초면 5초 만에 연결을 끊습니다. 모바일 네트워크 전환(Wi-Fi에서 셀룰러로) 같은 상황은 보통 그보다 오래 걸리니, 임계값을 늘리거나 재연결 백오프를 두는 편이 안전합니다.
원칙 정리
수신 통계가 0이라는 사실은 관측 결과일 뿐이고, 그 원인은 최소 네 가지입니다. 네트워크 단절, 송신 측 중단, 사용자 pause, 브라우저의 전력 절약 정책. 이 네 가지를 구분하지 않고 하나의 임계값으로 처리하면 오판은 시간문제입니다.
그래서 스트림 생사 판정의 원칙은 이렇습니다.
- 의도된 정지는 판정에서 제외합니다. 판정 로직의 첫 줄은 항상 클라이언트 상태(
paused,visibilityState) 확인입니다. - 레이어를 섞지 않습니다. transport 생사, decoder 상태, 사용자 의도는 각각 다른 신호로 판단합니다.
- 자동 복구는 조건부로 만듭니다. 재연결이 성공할 수 없는 상태(pause 중)에서 재연결을 시도하면 상태만 나빠집니다.
- 폴백 스위치는 안전장치가 아닙니다. 폴백을 끄는 것은 증상을 가리는 것이고, 연결을 끊는 결정 자체를 고쳐야 합니다.
정리
- 모바일 브라우저는 pause 시 수신과 디코딩을 정지하므로 fps와 bitrate가 0으로 수렴하고, 수신 통계만 보는 판정 로직은 이것을 단절로 읽습니다. 데스크톱 Chrome은 pause 중에도 수신이 유지되어 재현되지 않습니다.
- 버퍼링 임계값(기본 5)과 실패 누적 임계값(기본 5)을 연속으로 통과하면서 버퍼링 이벤트, STREAM_EMPTY, 연결 종료, 재연결 실패, 상위 레이어의 소스 전환까지 캐스케이드가 완성되고 HLS로 폴백됩니다.
- 연결 종료는 재생 엔진 레이어, 프로토콜 폴백은 상위 플레이어 레이어에서 일어납니다. 그래서 폴백 옵션을 끄면 폴백만 막히고 연결 종료는 남습니다.
- 판정 임계값들은 공개 파라미터가 아니라서 옵션만으로는 해결되지 않고, 판정 함수에
video.paused확인과 카운터 리셋을 추가하는 수정이 필요합니다. - 올바른 판정은
bytesReceived(transport)와framesDecoded(decoder)를 분리해서 보고,framesDropped,freezeCount,jitterBufferDelay,pliCount로 원인을 나누며, 의도된 pause와 백그라운드 상태를 판정 이전에 걸러내는 방식입니다.