마지막 업데이트 2026-07-29
DECODE_OVERLOAD 라벨은 diagnoseWaitingCase의 catch-all 오분류였고(고사양·풀버퍼·드롭 1%인데도 라벨링),
용량 축소·코덱 변경·플레이어 교체·MSE/HLS 분할·인코딩 옵션 변경은 전부 검토 후 기각됐다.
해결 1순위는 RTCRtpSender.setParameters로 high(720p) 레이어만 끄는 360p 상한(재협상 불필요·가역적), 적용 시점은 B(활동 영상 재생 중) + C(저사양 게이트) 병행 권장. 상태: 🟡 원인 규명 완료 · 구현 전.
고객 VOC가 들어왔다 — 활동 영상 재생 중 아동측에서 화면과 오디오가 같이 끊긴다. 현장에 남은 흔적은 [MEET_VIDEO] Video waiting [DECODE_OVERLOAD] 로그 한 줄. 그런데 로그를 뜯어보니 진술이 앞뒤가 맞지 않았다.
최초 단서가 된 로그 ([MEET_VIDEO] Video waiting [DECODE_OVERLOAD]):
| 필드 | 값 | 의미 |
|---|---|---|
bufferedAheadSec | 33.5 (duration 65s) | 파일 전체가 이미 버퍼됨 → 네트워크 stall 아님 |
droppedVideoFrames | 9 / 913 (~1%) | 정상 범위 — 진짜 과부하 아님 |
[LowPerf] result | 16코어 / 8GB / smooth+powerEfficient | 고사양 기기로 판정됨 |
DECODE_OVERLOAD 라벨이 찍혔다 → 라벨 자체가 오분류일 가능성이 첫 단서였다."라벨이 오분류일 수 있다"는 단서를 쫓기 전에, 이 사건을 헛짚게 만드는 흔한 오해 두 개를 먼저 걷어내야 했다. 버퍼가 차 있다는 것과 디코드가 돌아간다는 것은 다른 얘기고, 같은 720p라도 보는 쪽과 찍어 보내는 쪽의 비용은 전혀 다르다.
bufferedAheadSec가 차 있다는 건 인코딩된(압축된) 비트스트림이 메모리에 있다는 뜻일 뿐. 화면에 그리려면 재생 내내 프레임 단위로 실시간 디코딩이 계속 돌아간다.
<video>는 waiting에 빠진다. → 끊김의 무대는 네트워크/버퍼가 아니라 디코드 단계.| 활동 영상 720p (디코드) | 아동 카메라 720p (인코드) | |
|---|---|---|
| 작업 | 만들어진 프레임을 푸는 것 | 매 프레임을 실시간으로 압축 |
| 비용 | 가벼움 | 훨씬 무거움 (브라우저가 하는 가장 무거운 작업 중 하나) |
diagnoseWaitingCase catch-all BUG고사양 기기·풀버퍼·드롭 1%가 "디코드 과부하"를 증언한다? 목격자(로그) 진술부터 검증했다. 코드를 열어보니 라벨을 붙이는 분류기 자체가 위증을 하고 있었다 — 버퍼만 차 있으면 무조건 DECODE_OVERLOAD로 떨어지는 catch-all.
apps/web/shared/ui/meet-video.tsx (라인 534–557)
// 라인 548–553: 진짜 디코드 과부하 조건 if (bufferedAheadSec > 1 && droppedVideoFrames > 10) return "DECODE_OVERLOAD"; // 라인 554: 버퍼만 차 있으면 무조건 DECODE_OVERLOAD로 떨어지는 catch-all ⚠️ if (bufferedAheadSec > 1) return "DECODE_OVERLOAD";
droppedVideoFrames=9(≤10)라 548행에 안 걸리고, bufferedAheadSec=33.5(>1)라 554행 fallback으로 떨어져 DECODE_OVERLOAD로 라벨링됨. 실제로는 BLOB_READ_STALL에 가까운 양성 stall까지 모두 같은 라벨로 뭉뚱그려진다.
목격자 진술이 무너졌으니 원점이다. 통상 이런 사건에서 먼저 지목되는 용의자들 — 파일 용량, 코덱, 플레이어, 전달 방식, 인코딩 옵션 — 을 하나씩 대질했다. 전원 알리바이가 성립했다.
| 제안 | 판정 | 이유 |
|---|---|---|
| 파일 용량 더 줄이기 / 길이 단축 | 기각 | 이미 720p 20MB / 2분, 풀버퍼. 다 받아진 상태에서 멈춤 → 용량·길이 무관 |
| 코덱/포맷 변경 (WebM·AV1) | 기각 | H.264가 HW 가속 최선. 다른 코덱은 약한 기기에서 SW 디코드로 떨어져 역효과 |
| 플레이어 교체 (video.js 등) | 기각 | JS 래퍼일 뿐 디코드는 동일한 네이티브 파이프라인. 효과 0 |
| MSE 슬라이싱 / HLS(m3u8) 분할 | 기각 | 전달 방식만 바꿈. 통짜든 세그먼트든 매 순간 디코드량은 동일. "쪼개기"는 디코드 오버로드와 무관. ABR 저화질 전환 이득은 기존 480p로 더 싸게 달성 |
| 인코딩 옵션 변경 (GOP·faststart·maxrate) | 기각 | Lambda 트랜스코딩 config가 이미 최적 (아래) |
트랜스코딩 Lambda (SQS 트리거, raw→720p/480p)의 ffmpeg 옵션:
# 720p: scale 1280x720, maxrate 1800k / 480p: 854x480, maxrate 1200k -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -crf 22 -maxrate 1800k -bufsize 3600k -preset veryfast -x264-params cabac=0:keyint=60:min-keyint=60:scenecut=0:open_gop=0: ref=1:bframes=0:weightp=0:aud=1:repeat-headers=1:nal-hrd=cbr:... -movflags +faststart
| 의심했던 원인 | 실제 설정 | 판정 |
|---|---|---|
| 긴 GOP → seek 시 디코드 버스트 | keyint=60@30fps = 고정 2초 closed GOP | 이미 짧고 규칙적 |
| VBR 비트레이트 스파이크 | maxrate+nal-hrd=cbr = 사실상 CBR, 평탄 | 스파이크 없음 |
| faststart 누락 | +faststart | 있음 |
| 무거운 디코드 프로파일 | baseline + bframes=0 + ref=1 + cabac=0 | H.264 최저 디코드 비용 |
crf+nal-hrd=cbr 혼용으로 filler 낭비, slice-max-size·aud·repeat-headers 같은 스트리밍용 잔여 파라미터는 위생 정리 대상이지만 끊김 원인은 아님)영상 파일 쪽 용의자가 전원 배제되고, 인코딩 config마저 이미 디코드 최저 비용으로 튜닝돼 있었다면 — 남는 것은 재생 현장 자체다. 활동 영상을 보는 바로 그 순간, 아동 기기가 뒤에서 동시에 돌리고 있는 작업 목록을 전부 나열해 봤다.
"화면+오디오 동시 끊김"은 미디어 파이프라인 전체가 굶고 있다는 신호. 활동 영상을 보는 그 순간 아동 기기가 동시에 돌리는 작업 (ENCODE DECODE DSP):
| # | 작업 | 유형 | 파일 |
|---|---|---|---|
| ① | 카메라 비디오 — 720p+360p+180p 3레이어 simulcast | ENCODE 최대 | use-mediasoup-producer.ts:412 |
| ② | mediasoup 마이크 오디오 (진행자가 아동 목소리 청취) | ENCODE | use-mediasoup-producer.ts:483 |
| ③ | OpenAI Realtime 마이크 (핑퐁이, WebRTC, 서버 VAD) | ENCODE | use-ai-session.ts |
| ④ | 외부 STT 송출 (48k캡처→HPF/리미터→16k 다운샘플→WS) | ENCODE | external-stt-observer.ts:304 |
| ⑤ | 활동 영상 디코드 (로컬 blob H.264) — 끊기는 그것 | DECODE | meet-video.tsx |
| ⑥ | 핑퐁이 AI 오디오 수신 + Web Audio 체인(Gate/EQ/Comp/Limiter) | DECODE | use-ai-session.ts |
| ⑦ | 진행자 영상/오디오 수신 (P2P 경로일 때) | DECODE | use-guest-media-manager.ts:270 |
| ⑧ | 화면 공유 수신 (진행자 공유 시, 최대 1080p) | DECODE | guest.tsx:135 |
| ⑨ | STT 폴백 (Web Speech API, ko-KR) | 처리 | use-stt-fallback.ts |
| ⑩ | 오디오 처리 체인 DSP (20ms 간격 RMS + 게이트/EQ/컴프) | DSP | use-audio-processing-chain.ts |
| ⑪ | peer 오디오 녹음 (AI 발화 중 500ms 청크) | ENCODE | use-guest-audio-capture.ts |
부하 더미의 가장 큰 단일 덩어리로 지목된 3레이어 simulcast를 감정했다. 결정적인 사실 — 가장 무거운 720p high 레이어를, 정작 받는 쪽(모니터)은 평소에 보지도 않는다.
use-mediasoup-producer.ts:412–423 / packages/shared/.../constants.ts:97–112
| 레이어 (rid) | 해상도 | maxBitrate | scaleResolutionDownBy | 인코드 비용 |
|---|---|---|---|---|
| high | 720p (1280×720) | 900 kbps | 1 | 가장 무거움 (지배적) |
| medium | 360p (640×360) | 300 kbps | 2 | 중간 |
| low | 180p (320×180) | 100 kbps | 4 | 가벼움 |
핵심: 모니터 대시보드는 기본적으로 spatialLayer 0(180p 썸네일)만 구독한다 (use-mediasoup-consumer.ts:304). 진행자가 특정 아동을 확대했을 때만 layer 2(720p)를 요청 (use-video-expansion.ts:20).
→ 활동 영상 재생 중 진행자는 대개 180p 썸네일로 보고 있어, 아동이 보내는 720p high 레이어는 사실상 아무도 안 보면서 CPU만 잡아먹는 비용이다.
encodings[2].active=false로 high 레이어만 끈다 → 송신 상한 360p, 썸네일/medium 사다리는 그대로.
// RTCRtpSender.setParameters — 재협상 불필요 · 가역적 const params = cameraSender.getParameters(); params.encodings[2].active = false; // 720p high 끄기 // (더 필요하면) params.encodings[1].active = false; // 180p 단일 // (더 필요하면) params.encodings[0].maxFramerate = 15; await cameraSender.setParameters(params); // 활동 영상 종료(ended/canplay) 시 active = true 로 복구
가장 단순. 단 확대해도 영원히 720p 못 보고, 멀쩡한 기기 화질까지 손해. → 단순함이 최우선이고 아동 기기가 대부분 약할 때만.
끊김 구간만 타겟, 가역적. 영상 없을 땐 720p 유지. 활동 영상 재생 라이프사이클에 토글을 연결.
isLowPerformanceDevice 게이트) ✅ 권장약한 기기만 보호, 좋은 기기는 720p 유지. B와 병행 가능.
isLowPerformanceDevice 메커니즘)meet-video.tsx:554 오분류 catch-all 수정 → 로그 신뢰도 회복(정량화의 전제조건)crf+nal-hrd=cbr 혼용 정리, 통짜 blob 재생에 불필요한 slice-max-size/aud/repeat-headers 검토| 파일 | 변경 내용 |
|---|---|
hooks/mediasoup/use-mediasoup-producer.ts | 카메라 sender high 레이어 토글 API (setParameters) |
| 활동 영상 재생 라이프사이클 (guest) | 재생 시작→레이어 하향, 종료→복구 (옵션 B) |
lib/utils.ts / 진입 훅 | 저사양 게이트 연동 (옵션 C) |
shared/ui/meet-video.tsx | diagnoseWaitingCase 554행 오분류 수정 |
| 지표 | 확인 내용 |
|---|---|
waitDurationMs (meet-video.tsx:417, "Video canplay event fired") | 끊김 길이/빈도 — 사용자 체감 규모. 수정 전/후 비교 |
DECODE_OVERLOAD 재집계 (드롭 비율 기준) | 진짜 과부하 vs 양성 stall 분리 |
| 발생 기기 프로필 집계 | hardwareConcurrency/deviceMemory/isLowPerformanceDevice로 grouping — 저사양 편중 여부 |
| WebRTC stats 상관 | packetsLost/jitter/freezeCount가 같은 시점에 튀는지 → 동시 부하 확정 |