마지막 업데이트 2026-07-29
recordingManager가 닫힌 producer를 참조하면서 발생한 침묵 실패가 누적되어 이후 모든 녹음 segment가 빈 파일이 되었고, 빈 segment 자동 폐기 로직과 결합되어 최종 mp3에 activity-0/1/2 분량(7:55)만 남았음.
범인은 한 명이 아니다 — stale producer 참조로 인한 throw 무시, consumer.resume() 실패의 무음 swallow, 빈 segment 자동 폐기라는 세 개의 침묵 실패가 공범이고, activity-3의 19초 인트로 영상(그마저 ended 이벤트 누락으로 fallback 타이머까지 대기)이 범행 기회를 만들었다. 아래는 로그로 재구성한 수사 전말이다.
activity-1779843076660-0,1,2(체크인 ~ 빙글빙글 지우)까지만 녹음에 포함되고, activity-1779843076660-3(빙글빙글 주원) 이후 모든 활동(이서, 지우, 체크아웃)이 mp3에서 누락됨.
| 활동 | 제목 | 녹음 포함 |
|---|---|---|
activity-…-0 | 공통_공통_체크인_2_0_지우 | ✅ 포함 |
activity-…-1 | 공통_공통_이거알아_1_0_지우 | ✅ 포함 |
activity-…-2 | 공통_공통_빙글빙글2분토크_2_1_지우 | ✅ 포함 |
activity-…-3 | 공통_공통_빙글빙글2분토크_2_2_주원 | ❌ 누락 |
activity-…-4 | 공통_공통_빙글빙글2분토크_2_3_이서 | ❌ 누락 |
activity-…-5 | 공통_공통_빙글빙글2분토크_2_4_지우 | ❌ 누락 |
activity-…-9 | 공통_공통_체크아웃_2_0_지우 | ❌ 누락 |
녹음이 "중간에 잘렸다"가 아니라 activity-2와 3 사이에서 딱 끊겼고 이후로는 한 번도 회복되지 않았다. 그렇다면 범행 현장은 activity-2 → 3 전환 경계다. 그 구간의 로그를 초 단위로 복원했다.
녹음이 끊긴 경계는 09:38:24 ~ 09:38:45 구간(약 21초). 다른 활동 전환들이 0.8초 만에 끝난 것과 대조됨.
id-030 클라이언트가 close() 호출
09:38:25.073 [WARN] AI_SESSION DataChannel closed
09:38:25.205 mic producer 4002b864 생성 → replaceChildConsumer → transitionFfmpeg THROW (stale aiProducer)
09:38:25.~ ~ 09:38:44 activity-3 인트로 영상 재생 (주원 등장영상, 19초)
09:38:44.561 [WARN] MEET_VIDEO Video duration timeout fallback (ended event 누락)
09:38:44.564 STEP_TRANSITION Starting session for activity-3
09:38:44.921 mic producer 9fc85101 → transitionFfmpeg THROW (stale aiProducer)
09:38:45.337 AI producer id-013 생성 → replaceAiConsumer 진입
09:38:45.~ success path 진입했으나 consumer.resume 침묵 실패 추정 → 빈 OGG segment 시작
타임라인에 THROW가 두 번, 그리고 마지막에 "success path에 들어갔는데도 빈 OGG"라는 수상한 흔적이 남았다. 그런데 같은 THROW는 다른 전환에서도 있었을 텐데 왜 여기서만 치명상이 됐는가 — 전환들끼리 비교해 봤다.
| 전환 | AI 세션 정지 → 새 AI producer 생성 | 결과 |
|---|---|---|
| 0 → 1 | 0.8초 | 정상 회복 (activity-1 녹음됨) |
| 1 → 2 | 0.8초 | 정상 회복 (activity-2 녹음됨) |
| 2 → 3 | 20.9초 | 회복 실패 (이후 전체 누락) |
| 3 → 4 | 12초 | 이미 끊긴 상태 유지 |
video_room_jiwoo1_intro_title_juwon_biggle_bridge_0)이라 AI 세션이 영상 종료 후에야 시작됨. 영상의 ended 이벤트가 발화되지 않아 fallback 타이머(19초)까지 대기 → stale window가 비정상적으로 길어짐.
stale window가 길어진 이유는 밝혀졌다. 그런데 애초에 그 공백 동안 mic producer는 왜 재생성되고 있었나? 타임아웃 같은 주기 로직을 의심했지만, 추적 결과는 달랐다.
타임아웃이나 폴링 로직이 아니라 AI 세션 상태가 바뀔 때 React state guestMicStream이 null ↔ MediaStream으로 토글되는 것이 트리거입니다.
use-guest-page-session.ts:956 의 nullish coalescing:
const mediasoupProducer = useMediasoupProducer({ ... micStream: aiSession.guestMicStream ?? localMedia.audioStream, ... });
| AI 세션 상태 | guestMicStream | ?? 평가 결과 |
|---|---|---|
| Active | relayStream (처리체인 적용된 MediaStream) | relayStream |
| Inactive | null | localMedia.audioStream (raw mic) |
use-ai-session.ts 에서 토글이 일어나는 두 지점:
// line 1387 — AI 세션 stop 시 (cleanup 함수 내부) setGuestMicStream(null); // line 1655 — AI 세션 start 시 (relay stream 생성 직후) setGuestMicStream(relayStream);
use-mediasoup-producer.ts:536 의 useEffect는 [isReady, micStream, roomId, peerId] 에 의존합니다. 참조 동등성(reference equality)으로 변경을 감지하므로 null → MediaStream 또는 MediaStream A → MediaStream B 변경 모두 cleanup(옛 producer close) + 본문(새 producer 생성)이 순서대로 실행됩니다.
transitionFfmpeg가 곧 회복함. activity-3은 stop → 19초 인트로 영상 → start 사이에 시간이 벌어지면서 두 번의 mic 재생성이 19초 간격으로 분리되고, 그 사이 session.aiProducer가 stale로 머무는 시간이 길어진 것.
트리거 경로가 밝혀졌으니 이제 서버측 recordingManager의 transitionFfmpeg 코드를 열어 범행 수법을 확인할 차례다. 침묵 실패 지점이 정확히 세 군데에서 발견됐다.
recordingManager.ts의 transitionFfmpeg에 두 개의 침묵 실패 지점이 있고, 활동 전환의 AI 세션 재시작 지연이 길어질 때 이 둘이 누적되어 녹음이 영구히 비게 됨.
if (session.childProducer) { newChildPair = await this.createPlainTransportConsumer(router, session.childProducer, true); } if (session.aiProducer) { newAiPair = await this.createPlainTransportConsumer(router, session.aiProducer, true); // ← 닫힌 producer면 throw }
session.aiProducer가 클라이언트가 이미 닫은 producer를 가리키는 상태로 남아 있어 transport.consume({ producerId: closedProducer.id })가 throw → enqueueSessionOp의 .catch(line 533)가 로그만 찍고 무시.
try { await newChildPair.consumer.resume(); } catch {} // ← 에러 무음 삼킴 try { await newAiPair.consumer.resume(); } catch {} // ← 에러 무음 삼킴
resume이 실패하면 consumer가 paused 상태로 남아 새 ffmpeg는 spawn되었지만 RTP가 흐르지 않아 빈 OGG 파일(헤더만)이 생성됨.
if (!valid) { const idx = session.segmentPaths.indexOf(oldRecordingPath); if (idx >= 0) session.segmentPaths.splice(idx, 1); // ← 빈 segment 제거 }
빈 파일이 자동 제거되어 최종 concat 대상에서 빠지므로, mp3에는 activity-0/1/2 분량만 남음.
MEET_VIDEO Video duration timeout fallback으로 AI 세션 재시작이 20.9초 지연 → stale window 누적 → 회복 불능.수법이 맞다면 남은 mp3 길이는 살아남은 세 segment의 합과 일치해야 한다. 계산해 보면 사용자 신고(7분 55초)와 정확히 맞아떨어진다 — 수법 재구성이 검증됐다.
seg1 (activity-0, 09:30:22 → 09:32:33) ≈ 2:11
seg2 (activity-1, 09:32:33 → 09:36:56) ≈ 4:23
seg3 (activity-2, 09:36:56 → 09:38:25, tail silence 포함) ≈ 1:21
합계 ≈ 7:55 (사용자 보고와 일치)
stale 참조 자체를 원천 차단. 이 한 가지만으로 이번 케이스의 직접 원인은 막힘.
producer.observer.once("close", () => { for (const session of recordingManager.getAllSessions()) { if (session.aiProducer === producer) session.aiProducer = null; if (session.childProducer === producer) session.childProducer = null; } });
transitionFfmpeg에 producer.closed 가드 필수if (session.childProducer && !session.childProducer.closed) { newChildPair = await this.createPlainTransportConsumer(router, session.childProducer, true); } if (session.aiProducer && !session.aiProducer.closed) { // ← closed 가드 newAiPair = await this.createPlainTransportConsumer(router, session.aiProducer, true); }
consumer.resume() 침묵 실패 가시화 필수try { await newChildPair.consumer.resume(); } catch (err) { logger.error("Failed to resume new child consumer", { roomId: session.roomId, err }); session.status = "error"; // 또는 restartFfmpeg 트리거 }
startFfmpeg의 ffmpeg.on("exit", ...)에서 비정상 코드/시그널이면 ERROR 로그 + 자동 restartFfmpeg 트리거. 현재는 lastFfmpegExit만 기록하고 침묵.
recording_transition_failed_total Prometheus 카운터 추가.[CAPTION-DIAG] OGG segment registered와 짝을 이루는 audioDurationMs=0 경고 로그 추가.MEET_VIDEO Video duration timeout 사전 방지 권장인트로 영상의 ended 이벤트 누락 fallback이 19초까지 늦어지지 않도록, currentTime >= duration * 0.99일 때 즉시 onEnd 호출하도록 임계값을 낮춤. stale window 자체를 줄여 동일 부류 사고 재발 확률을 낮춤.
roomId 09:36 ~ 09:50 구간의 다음 신호 카운트로 가설 1차 확인:
Recording session op failedffmpeg transitioned for roomlastFfmpegExit code/signal[CAPTION-DIAG] OGG segment registered 개수와 audioDurationMs