mediasoup 녹음 activity 전환 끊김 원인 분석 BUG OPEN

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

로그: 6-019e68c3-…-541a96e0.json 세션 시각: 2026-05-27 09:30 ~ 09:52 KST 관련 파일: apps/socket/src/recording/recordingManager.ts

증상 BUG

전체 세션이 녹음되지 않고 mp3 파일이 7분 55초까지만 저장됨
약 20분 분량의 수업 중 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 전환

녹음이 끊긴 경계는 09:38:24 ~ 09:38:45 구간(약 21초). 다른 활동 전환들이 0.8초 만에 끝난 것과 대조됨.

09:38:24.931 AI_SESSION Stopping session (activity-2 종료, auto-finish) 09:38:24.942 AI audio capture stopped 09:38:24.943 guest-ai-session-active → false 09:38:24.~ AI producer 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 시작

왜 activity-3에서만 회복하지 못했는가

전환AI 세션 정지 → 새 AI producer 생성결과
0 → 10.8초정상 회복 (activity-1 녹음됨)
1 → 20.8초정상 회복 (activity-2 녹음됨)
2 → 320.9초회복 실패 (이후 전체 누락)
3 → 412초이미 끊긴 상태 유지
구조적 차이: activity-3는 다른 활동과 달리 첫 step이 인트로 영상(video_room_jiwoo1_intro_title_juwon_biggle_bridge_0)이라 AI 세션이 영상 종료 후에야 시작됨. 영상의 ended 이벤트가 발화되지 않아 fallback 타이머(19초)까지 대기 → 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?? 평가 결과
ActiverelayStream (처리체인 적용된 MediaStream)relayStream
InactivenulllocalMedia.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 생성)이 순서대로 실행됩니다.

09:38:24 ~ 09:38:45 추적

09:38:24.931 AI_SESSION Stopping session setGuestMicStream(null) 호출 └ React 리렌더 → micStream = localMedia.audioStream (객체 변경) └ use-mediasoup-producer useEffect 재실행 ├ cleanup: 옛 mic producer abc86a20 producer.close() └ 본문: 새 track으로 produce() 09:38:25.205 ── 새 mic producer 4002b864 생성 ── (인트로 영상 19초 재생 — micStream 변동 없음) 09:38:44.564 AI_SESSION Starting session (activity-3) 09:38:44.618 Using external mic stream for AI session 09:38:44.656 Audio processing chain applied setGuestMicStream(relayStream) 호출 └ React 리렌더 → micStream = relayStream (객체 변경) └ use-mediasoup-producer useEffect 재실행 ├ cleanup: 옛 mic producer 4002b864 producer.close() └ 본문: 새 track으로 produce() 09:38:44.921 ── 새 mic producer 9fc85101 생성 ── 09:38:45.337 ── 새 ai producer id-013 생성 ── (aiAudioStream 변화에 의한 ai-audio useEffect 재실행)
결론: 활동 전환 자체와 무관하게 AI 세션이 stop/start 될 때마다 mic producer는 항상 1번씩 재생성됨. 활동 0, 1, 2 전환도 마찬가지였지만 stop과 start 사이가 0.8초라서 두 번의 재생성이 거의 동시에 일어났고, AI producer 생성까지 짧게 따라붙어 transitionFfmpeg가 곧 회복함. activity-3은 stop → 19초 인트로 영상 → start 사이에 시간이 벌어지면서 두 번의 mic 재생성이 19초 간격으로 분리되고, 그 사이 session.aiProducer가 stale로 머무는 시간이 길어진 것.

문제 원인 CAUSE

recordingManager.tstransitionFfmpeg에 두 개의 침묵 실패 지점이 있고, 활동 전환의 AI 세션 재시작 지연이 길어질 때 이 둘이 누적되어 녹음이 영구히 비게 됨.

① stale producer 참조로 인한 throw 무시 — line 1001~1014

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)가 로그만 찍고 무시.

② consumer.resume() 실패 무음 swallow — line 1071~1076

try { await newChildPair.consumer.resume(); } catch {}  // ← 에러 무음 삼킴
try { await newAiPair.consumer.resume(); } catch {}    // ← 에러 무음 삼킴

resume이 실패하면 consumer가 paused 상태로 남아 새 ffmpeg는 spawn되었지만 RTP가 흐르지 않아 빈 OGG 파일(헤더만)이 생성됨.

③ 빈 segment 자동 폐기 — line 1086~1098

if (!valid) {
  const idx = session.segmentPaths.indexOf(oldRecordingPath);
  if (idx >= 0) session.segmentPaths.splice(idx, 1);  // ← 빈 segment 제거
}

빈 파일이 자동 제거되어 최종 concat 대상에서 빠지므로, mp3에는 activity-0/1/2 분량만 남음.

④ activity-3 트리거 조건

최종 mp3 길이 산식

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 (사용자 보고와 일치)

해결 방안 FIX

P0 producer close 시 session 캐시 자동 정리 필수

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;
  }
});

P0 transitionFfmpegproducer.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);
}

P0 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 트리거
}

P1 ffmpeg 비정상 exit 감지 및 자동 복구 권장

startFfmpegffmpeg.on("exit", ...)에서 비정상 코드/시그널이면 ERROR 로그 + 자동 restartFfmpeg 트리거. 현재는 lastFfmpegExit만 기록하고 침묵.

P1 메트릭/관측성 강화 권장

P2 MEET_VIDEO Video duration timeout 사전 방지 권장

인트로 영상의 ended 이벤트 누락 fallback이 19초까지 늦어지지 않도록, currentTime >= duration * 0.99일 때 즉시 onEnd 호출하도록 임계값을 낮춤. stale window 자체를 줄여 동일 부류 사고 재발 확률을 낮춤.

검증 방법

한 줄 결론

activity-3의 인트로 영상으로 AI 세션 재시작이 20초 지연된 사이, recordingManager가 닫힌 producer를 참조하면서 발생한 침묵 실패가 누적되어 이후 모든 녹음 segment가 빈 파일이 되었고, 빈 segment 자동 폐기 로직과 결합되어 최종 mp3에 activity-0/1/2 분량(7:55)만 남았음.