아동측 영상·오디오 끊김 — 동시 인코딩 부하 분석 및 simulcast 360p 계획 분석계획

마지막 업데이트 2026-07-22

작성일: 2026-06-12 상태: 🟡 원인 규명 완료 · 구현 전 관련 영역: guest(아동) · mediasoup producer · meet-video

증상 VOC

고객 VOC: 활동 영상 재생 중 아동측에서 화면과 오디오가 같이 끊김.

최초 단서가 된 로그 ([MEET_VIDEO] Video waiting [DECODE_OVERLOAD]):

필드의미
bufferedAheadSec33.5 (duration 65s)파일 전체가 이미 버퍼됨 → 네트워크 stall 아님
droppedVideoFrames9 / 913 (~1%)정상 범위 — 진짜 과부하 아님
[LowPerf] result16코어 / 8GB / smooth+powerEfficient고사양 기기로 판정됨
고사양 기기 · 풀버퍼 · 드롭 1%인데도 DECODE_OVERLOAD 라벨이 찍혔다 → 라벨 자체가 오분류일 가능성이 첫 단서였다.

핵심 개념 — 두 가지 오해 정정

① 버퍼 ≠ 디코드

bufferedAheadSec가 차 있다는 건 인코딩된(압축된) 비트스트림이 메모리에 있다는 뜻일 뿐. 화면에 그리려면 재생 내내 프레임 단위로 실시간 디코딩이 계속 돌아간다.

버퍼가 가득해도 디코더가 다음 프레임을 제때 못 내놓으면 <video>waiting에 빠진다. → 끊김의 무대는 네트워크/버퍼가 아니라 디코드 단계.

② 인코드 ≫ 디코드 (같은 720p라도 비대칭)

활동 영상 720p (디코드)아동 카메라 720p (인코드)
작업만들어진 프레임을 푸는 것매 프레임을 실시간으로 압축
비용가벼움훨씬 무거움 (브라우저가 하는 가장 무거운 작업 중 하나)
720p라는 숫자 자체는 고사양이 아니다. 하지만 약한 아동 기기에서 720p를 ×3 레이어로 실시간 인코딩하는 것은 무겁다. "보는 720p"는 싸도 "찍어 보내는 720p"는 비싸다.

로그 오분류 — diagnoseWaitingCase catch-all BUG

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";
VOC 로그 대입: droppedVideoFrames=9(≤10)라 548행에 안 걸리고, bufferedAheadSec=33.5(>1)라 554행 fallback으로 떨어져 DECODE_OVERLOAD로 라벨링됨. 실제로는 BLOB_READ_STALL에 가까운 양성 stall까지 모두 같은 라벨로 뭉뚱그려진다.
수정 방향: 드롭 비율(droppedVideoFrames/totalVideoFrames) 기준 임계치로 바꾸고, 그 외 버퍼 충분 케이스는 blob/네트워크 여부에 따라 분류 → 진짜 성능 문제와 양성 stall을 분리.

해결책이 아닌 것들 (검토 후 기각)

제안판정이유
파일 용량 더 줄이기 / 길이 단축기각이미 720p 20MB / 2분, 풀버퍼. 다 받아진 상태에서 멈춤 → 용량·길이 무관
코덱/포맷 변경 (WebM·AV1)기각H.264가 HW 가속 최선. 다른 코덱은 약한 기기에서 SW 디코드로 떨어져 역효과
플레이어 교체 (video.js 등)기각JS 래퍼일 뿐 디코드는 동일한 네이티브 파이프라인. 효과 0
MSE 슬라이싱 / HLS(m3u8) 분할기각전달 방식만 바꿈. 통짜든 세그먼트든 매 순간 디코드량은 동일. "쪼개기"는 디코드 오버로드와 무관. ABR 저화질 전환 이득은 기존 480p로 더 싸게 달성
인코딩 옵션 변경 (GOP·faststart·maxrate)기각Lambda 트랜스코딩 config가 이미 최적 (아래)

인코딩 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=0H.264 최저 디코드 비용
인코딩이 이렇게까지 가벼운데도 끊긴다면 → 범인은 영상 파일이 아니라 아동 기기의 동시 부하다. (crf+nal-hrd=cbr 혼용으로 filler 낭비, slice-max-size·aud·repeat-headers 같은 스트리밍용 잔여 파라미터는 위생 정리 대상이지만 끊김 원인은 아님)

진짜 원인 — 아동 기기 동시 부하 (starvation)

"화면+오디오 동시 끊김"은 미디어 파이프라인 전체가 굶고 있다는 신호. 활동 영상을 보는 그 순간 아동 기기가 동시에 돌리는 작업 (ENCODE DECODE DSP):

#작업유형파일
카메라 비디오 — 720p+360p+180p 3레이어 simulcastENCODE 최대use-mediasoup-producer.ts:412
mediasoup 마이크 오디오 (진행자가 아동 목소리 청취)ENCODEuse-mediasoup-producer.ts:483
OpenAI Realtime 마이크 (핑퐁이, WebRTC, 서버 VAD)ENCODEuse-ai-session.ts
외부 STT 송출 (48k캡처→HPF/리미터→16k 다운샘플→WS)ENCODEexternal-stt-observer.ts:304
활동 영상 디코드 (로컬 blob H.264) — 끊기는 그것DECODEmeet-video.tsx
핑퐁이 AI 오디오 수신 + Web Audio 체인(Gate/EQ/Comp/Limiter)DECODEuse-ai-session.ts
진행자 영상/오디오 수신 (P2P 경로일 때)DECODEuse-guest-media-manager.ts:270
화면 공유 수신 (진행자 공유 시, 최대 1080p)DECODEguest.tsx:135
STT 폴백 (Web Speech API, ko-KR)처리use-stt-fallback.ts
오디오 처리 체인 DSP (20ms 간격 RMS + 게이트/EQ/컴프)DSPuse-audio-processing-chain.ts
peer 오디오 녹음 (AI 발화 중 500ms 청크)ENCODEuse-guest-audio-capture.ts
마이크 하나가 ②③④ 세 갈래로 동시에 처리·전송되고, 그 위에 비디오 인코더(①, ×3레이어)와 여러 디코드가 겹친다. "3 레이어 simulcast"는 이 부하 더미의 가장 큰 단일 덩어리 하나일 뿐. 활동 영상 디코드(⑤)가 순간 밀리면 화면·오디오가 같이 끊긴다.

현재 아동 카메라 simulcast 설정

use-mediasoup-producer.ts:412–423 / packages/shared/.../constants.ts:97–112

레이어 (rid)해상도maxBitratescaleResolutionDownBy인코드 비용
high720p (1280×720)900 kbps1가장 무거움 (지배적)
medium360p (640×360)300 kbps2중간
low180p (320×180)100 kbps4가벼움

핵심: 모니터 대시보드는 기본적으로 spatialLayer 0(180p 썸네일)만 구독한다 (use-mediasoup-consumer.ts:304). 진행자가 특정 아동을 확대했을 때만 layer 2(720p)를 요청 (use-video-expansion.ts:20).

→ 활동 영상 재생 중 진행자는 대개 180p 썸네일로 보고 있어, 아동이 보내는 720p high 레이어는 사실상 아무도 안 보면서 CPU만 잡아먹는 비용이다.

해결 계획 PLAN

1순위 — 카메라 high(720p) 레이어 끄기 = 360p 상한

가장 무겁고 가장 안 쓰이는 인코딩 부담을 제거. 캡처를 360p로 바꾸지 않고(그러면 썸네일이 90p로 망가짐) 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 로 복구

적용 시점 — A/B/C 중 선택 (구현 전 결정 필요)

A. 항상 360p 상한

가장 단순. 단 확대해도 영원히 720p 못 보고, 멀쩡한 기기 화질까지 손해. → 단순함이 최우선이고 아동 기기가 대부분 약할 때만.

B. 활동 영상 재생 중에만 ✅ 권장

끊김 구간만 타겟, 가역적. 영상 없을 땐 720p 유지. 활동 영상 재생 라이프사이클에 토글을 연결.

C. 저사양 아동 기기만 (isLowPerformanceDevice 게이트) ✅ 권장

약한 기기만 보호, 좋은 기기는 720p 유지. B와 병행 가능.

단계적 추가 레버 (360p로도 부족할 때)

부수 작업

변경 예정 파일

파일변경 내용
hooks/mediasoup/use-mediasoup-producer.ts카메라 sender high 레이어 토글 API (setParameters)
활동 영상 재생 라이프사이클 (guest)재생 시작→레이어 하향, 종료→복구 (옵션 B)
lib/utils.ts / 진입 훅저사양 게이트 연동 (옵션 C)
shared/ui/meet-video.tsxdiagnoseWaitingCase 554행 오분류 수정

검증 · 모니터링

지표확인 내용
waitDurationMs (meet-video.tsx:417, "Video canplay event fired")끊김 길이/빈도 — 사용자 체감 규모. 수정 전/후 비교
DECODE_OVERLOAD 재집계 (드롭 비율 기준)진짜 과부하 vs 양성 stall 분리
발생 기기 프로필 집계hardwareConcurrency/deviceMemory/isLowPerformanceDevice로 grouping — 저사양 편중 여부
WebRTC stats 상관packetsLost/jitter/freezeCount가 같은 시점에 튀는지 → 동시 부하 확정

관련 문서