마지막 업데이트 2026-07-22
| 발생 일시 | 2026-05-22 10:00:11 ~ 10:11:16 UTC (KST 19:00 ~ 19:11) |
| roomId | 574dd7b3-a406-46d8-bb8a-45fa677c7061_7 |
| 게스트 환경 | 아동146 / 마이크 Internal Microphone (Realtek), 출력 SKY Boom A1 (Bluetooth), 카메라 LG HD Webcam |
| 호스트 환경 | 모니터 포커스뷰 /monitor-dashboard/미지정/.../oneOnOne |
| 증상 | 수업 시작 1분 38초 후부터 호스트에 게스트 마이크 + AI 음성이 들리지 않음. 호스트가 강퇴/재접속을 3회 반복. |
| 관련 시스템 | V2 (FSD, mediasoup SFU) |
| 분석 소스 | LogRocket — 게스트 1개 / 호스트 1개 |
근본 원인
게스트 use-guest-page-session.ts:904 의 aiSession.guestMicStream ?? localMedia.audioStream
fallback 패턴이 AI 세션 시작/종료/재시작 때마다 micStream 참조를 바꿔,
use-mediasoup-producer.ts:463-536 mic-audio useEffect 가 producer 를 close → recreate 함 (producer churn).
직접 원인
호스트 use-mediasoup-consumer.ts:322-341 의 resume-consumer 응답이 Consumer not found 일 때 재시도 없이 그냥 catch 에서 warn 만 찍고 종료. cascade close race 를 흡수할 방어선이 없음.
관전 포인트
"ping 저하" 가설은 로그에서 확인되지 않음 — 게스트 send transport, 호스트 recv transport 모두 disconnected/failed 한 번도 안 됨.
자동 reconnect 로직 발동 흔적 없음. 네트워크 문제가 아니라 코드 race 문제.
BT 무관 BT 는 출력(헤드폰) 쪽이었고 입력(마이크)은 Realtek 내장. producer churn 은 입력 측 race 라서 마이크가 어떤 디바이스든 동일하게 발생함.
| 시각 | 주체 | 이벤트 |
|---|---|---|
| 09:45:39 | 호스트 | 모니터 포커스뷰 진입, mediasoup consumer device ready |
| 10:00:03 | 호스트 | peer-joined 아동146, guestInfo 세팅 |
| 10:00:11 | 호스트 | Recv transport connecting → connected, camera-video / mic-audio / ai-audio consumer 정상 생성 |
| 10:00:13 | 게스트 | Send transport connecting → connected, mic producer #1 (localMedia.audioStream) |
| 10:00:34 | 게스트 | AI 세션 시작 → setGuestMicStream(relayStream) → mic producer #2 생성 (1차 churn, 정상 path) |
| 10:00:35 ~ 10:02:11 | — | 정상 대화 ~1분 38초. 호스트 측 [MFCC] discarding N stale events on ai_speaking_start 누적 (1→5→14→...→35→21) |
| 10:02:13.435 | 게스트 | AI audio track ended + DataChannel closed (wasActive=false) — AI 세션 재시작 트리거 |
| 10:02:13.763 | 게스트 | mic producer id-034 생성 (localMedia.audioStream fallback) |
| 10:02:14.003 | 게스트 | mic producer id-034 close → id-035 생성 (guestMicStream 복귀, 240ms 갭) |
| 10:02:10.807 (host clock) | 호스트 | Failed to resume consumer id-040: Consumer not found → enqueueConsume catch 가 warn 만 찍고 종료 |
| 10:02:10.858 | 호스트 | 대체 producer id-035 에 대해 consumer id-052 생성 + resume 성공 → Added mic audio track |
| 10:02:11~10:03:11 | 호스트 | MFCC stale 32 → 22 → 66 누적 (UI 인디케이터 lag) |
| 10:03:33.765 | 호스트 | [CLIENT_HOST_SOCKET] Force kicking guest from room — 명시적 강퇴 ① |
| 10:03:57 ~ 10:04:05 | — | 게스트 재진입 → 사이클 반복 (producer churn 동일하게 재현) |
| 10:04:37.629 | 호스트 | Force kicking ② |
| 10:05:49.773 | 호스트 | Force kicking ③ |
| 10:07:44 ~ 10:11:16 | — | 이후 게스트-disconnected 3회 추가 (호스트의 명시적 kick 없음 → 게스트 측 자발 종료/네트워크) |
Producer Churn = mediasoup producer 가 짧은 시간 안에 close → recreate 를 반복하는 현상.
이번 케이스에선 240ms 안에 id-034 가 생성→close→id-035 생성됨.
발화 코드 — use-mediasoup-producer.ts:463-536:
useEffect(() => {
if (!isReady || !sendTransportRef.current || !micStream) return;
produceMicAudio(); // 새 producer 생성
return () => { // cleanup
const producer = producersRef.current.get("mic-audio");
if (producer) producer.close(); // ← producer 닫음
};
}, [isReady, micStream, roomId, peerId]); // ← micStream 변경 시 cleanup→재실행
그리고 그 micStream 을 만드는 use-guest-page-session.ts:904:
micStream: aiSession.guestMicStream ?? localMedia.audioStream
use-ai-session.ts 의 guestMicStream 라이프사이클:
null → fallback localMedia.audioStream 사용startSession() 안 처리 체인(gain → gate → EQ → compressor → limiter) 통과 후 setGuestMicStream(relayStream) (line 1599-1601)stopSession() 또는 재시작 정리 시 setGuestMicStream(null) (line 1333)→ AI 세션이 한 번 끊겼다 재시작될 때 null → 새 stream 또는 stream → null → 새 stream 으로 prop 참조가 바뀜 → useEffect cleanup→재실행 → producer churn.
호스트의 enqueueConsume 가 new-producer 이벤트를 직렬화 처리 — use-mediasoup-consumer.ts:477-491.
실패 흐름:
consume(id-034) 호출 → 서버: producer 살아있어서 consumer id-040 생성, success 응답recvTransport.consume(opts) 로 클라이언트 측 Consumer 객체 생성 ✅resume-consumer 보내는 사이 (수십 ms) 게스트가 producer id-034 를 closeid-040 삭제Consumer id-040 not found 응답if (response.success) {
resolve();
} else {
console.error(`Failed to resume consumer ${consumer.id}:`, response.error);
reject(new Error(response.error)); // ← throw
}
throw 는 enqueueConsume:482-487 의 catch 로 흘러감:
.catch((error) => {
console.warn(`Failed to consume producer ${producerId}:`, error.message);
}); // ← warn 만 찍고 끝, retry 없음
Consumer not found 는 일시적 race 신호인데도 영구 실패로 취급. 이번에는 게스트가 곧바로 다음 producer 를 만들어 server 의 별도 new-producer 이벤트가 호스트 queue 에 들어와 id-052 가 성공한 덕에 트랙은 붙었지만, 만약 대체 producer 가 없었으면 영구 무음.
mediasoup 서버는 producer 가 close 되면 그 producer 를 consume 중인 모든 consumer 를 자동으로 close 합니다. 이는 mediasoup 표준 동작 으로 코드 문제가 아닙니다. 문제는 클라이언트가 이 cascade 와 충돌하는 짧은 race 창문을 만들어내고, 그 race 를 흡수할 retry 가 없는 것.
호스트 로그상 대체 consumer id-052 는 정상 처리됐는데도 사용자는 그 이후 마이크 입력을 못 들었다고 함. 가능 원인:
| ① 무음 트랙 | 새 producer id-035 의 source track 이 AI 세션 재시작 중간의 transient 상태에서 clone 됨 → RTP 는 흐르나 무음 payload. 호스트의 trackended 도 안 뜨고 transport 도 살아있어서 "정상" 으로 보임. 유력 |
| ② AudioElement 바인딩 실패 | existing.audioStream = new MediaStream([consumer.track]) (PPI-943 fix) 로 React deps 는 갱신되지만, downstream 컴포넌트의 audio.srcObject 재바인딩이 race 로 누락될 가능성. |
| ③ AudioContext suspend | 호스트 브라우저의 AudioContext 가 mfcc 부하로 suspend 되거나 autoplay policy 로 차단. |
| ④ MFCC 워커 정체 | discarding 66 stale events 시점에 호스트 메인스레드가 일시 멈춤 → UI 인디케이터가 게스트의 발화를 못 표시 → 호스트가 "무음" 으로 오인. (실제 audio 는 흐르지만 UX 상 무음.) |
id-035 의 RTP send stats — bytesSent, packetCount 가 증가했는지. 무음 가설 ① 검증.id-052 의 RTP receive stats — bytesReceived, packetsLost, jitter. 가설 ① 검증.Added mic audio track 이후 실제로 audio.srcObject 가 새 MediaStream 으로 갱신됐는지. 가설 ② 검증.state === "running" 인지, 또는 suspend 됐는지. 가설 ③ 검증.wasActive=false 의 진짜 의미. step 전환 vs OpenAI Realtime drop vs 다른 원인.방안 A. replaceTrack 으로 교체
producer 를 close 하지 않고 같은 producer 의 track 만 바꿈. RTP 흐름 유지, 서버 consumer 유지 → 호스트 입장에서 끊김 zero.
// 기존: micStream 변경 시 producer close+recreate
// 개선: producer 살려두고 track 만 교체
const newTrack = newMicStream.getAudioTracks()[0];
await existingProducer.replaceTrack({ track: newTrack });
방안 B. AI 세션 준비 후 단일 produce
aiSession.guestMicStream 이 채워질 때까지 produce 를 보류. 그 동안 호스트는 게스트 마이크 못 들음(trade-off).
방안 A 권장 — 끊김 없음.
use-mediasoup-consumer.ts:322-341 에 짧은 백오프 retry 추가:
// resume 실패 응답이 "Consumer not found" 류면
// 50~200ms 백오프 후 consume() 처음부터 재시도, max 2~3회
// 단, server 의 별도 new-producer 이벤트와 중복되지 않게
// producerId 기준 idempotency 보장 필요
근본 race 자체는 제거 안 되지만 일시적 race 대한 방어선 확보.
현재는 mediasoup transport state 만 보고 "정상" 판단함. 다음 추가:
bytesReceived 가 일정 시간(예: 3~5초) 증가하지 않으면 alarmAnalyserNode 로 inbound stream 의 RMS 측정 — 일정 threshold 이하 지속 시 무음 alarmproducer.replaceTrack 으로 churn 자체를 제거하는 것이며, 호스트의 retry/inbound 무음 감지는 방어선 보강.