마지막 업데이트 2026-07-22
최초 단서가 된 로그 ([MEET_VIDEO] Video waiting [DECODE_OVERLOAD]):
| 필드 | 값 | 의미 |
|---|---|---|
bufferedAheadSec | 33.5 (duration 65s) | 파일 전체가 이미 버퍼됨 → 네트워크 stall 아님 |
droppedVideoFrames | 9 / 913 (~1%) | 정상 범위 — 진짜 과부하 아님 |
[LowPerf] result | 16코어 / 8GB / smooth+powerEfficient | 고사양 기기로 판정됨 |
DECODE_OVERLOAD 라벨이 찍혔다 → 라벨 자체가 오분류일 가능성이 첫 단서였다.bufferedAheadSec가 차 있다는 건 인코딩된(압축된) 비트스트림이 메모리에 있다는 뜻일 뿐. 화면에 그리려면 재생 내내 프레임 단위로 실시간 디코딩이 계속 돌아간다.
<video>는 waiting에 빠진다. → 끊김의 무대는 네트워크/버퍼가 아니라 디코드 단계.| 활동 영상 720p (디코드) | 아동 카메라 720p (인코드) | |
|---|---|---|
| 작업 | 만들어진 프레임을 푸는 것 | 매 프레임을 실시간으로 압축 |
| 비용 | 가벼움 | 훨씬 무거움 (브라우저가 하는 가장 무거운 작업 중 하나) |
diagnoseWaitingCase catch-all BUGapps/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 같은 스트리밍용 잔여 파라미터는 위생 정리 대상이지만 끊김 원인은 아님)"화면+오디오 동시 끊김"은 미디어 파이프라인 전체가 굶고 있다는 신호. 활동 영상을 보는 그 순간 아동 기기가 동시에 돌리는 작업 (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 |
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가 같은 시점에 튀는지 → 동시 부하 확정 |