마지막 업데이트 2026-07-29
기능의 대표 시나리오(진행자 부재)만 확인하면 통과하는 형태의 결함이었다. fallback은 별도 축이라 정상이었고, 깨진 것은 진행자가 있는 일반 수업의 듣기 복원이다.
동작 원리선생님이 "AI야, 아이 말을 들어줘" 스위치를 켜면 본부 장부에 기록되고, 수업 활동이 바뀔 때마다 새 담당자가 그 설정을 이어받아 다시 켜줘야 한다.
버그이번 업데이트에서 담당자가 본부 장부 대신 활동이 바뀔 때마다 백지가 되는 자기 메모장을 보고 판단하게 바뀌었다. 그래서 활동을 옮기면 "켜라는 기록이 없네?" 하고 스위치를 안 켰고, 선생님 화면에는 켜진 걸로 보이는데 AI는 아이 말을 못 듣는 상태가 됐다.
수정이제 활동을 옮길 때 본부 장부의 값을 쪽지에 적어 담당자에게 직접 전달한다. 담당자는 백지 메모장이 아니라 그 쪽지를 보고 스위치를 켠다.
PPI-1167이 머지됐다(e4dd7d14, #935). "진행자가 자리를 비우면 AI가 알아서 아동의 말을 듣는다" — 기능은 시연에서 멀쩡히 동작했고, 아무도 의심하지 않았다.
이튿날 신고가 들어왔다. "활동 전환 후 진행자 '듣기 ON'이 복원되지 않는다." 즉시 revert(cec1777c, #940). 그런데 이상했다 — 죽은 것은 새 기능이 아니라, 새 기능과 무관해 보이는 기존 동작이었다. 진행자 부재 fallback은 멀쩡했고, 진행자가 있는 일반 수업의 듣기 복원만 죽었다. 대표 시나리오만 검증하면 절대 잡히지 않는 위치에 범인이 숨어 있다는 뜻이다.
수사망은 곧 한 가지 구조적 사실로 좁혀졌다. 진행자의 '듣기' 의도가 코드베이스에 두 곳 존재한다:
| 장부 | 위치 | 수명 |
|---|---|---|
| 📔 본부 장부 (진짜 SSOT) | use-guest-page-session의 microphoneEnabledRef | 세션 재시작을 넘어 살아남음 |
| 📝 교실 메모장 (사본) | use-ai-session의 isMicrophoneEnabledRef | stopSession마다 false로 리셋 |
그리고 1167의 변경 지점이 정확히 이 갈림길 위에 있었다:
- toggleMicrophone(true); // 무조건 ON — 사본이 false여도 강제로 true로 만들며 켜짐 + applyDeferredListening(); // 자기 사본을 읽어 판정 → false면 no-op
축 분리(configured / fallback) 자체는 옳았다. 죄목은 읽도록 만들면서 세션 재시작 후 그 값을 채워줄 경로를 만들지 않은 것이다. 이전 코드는 값을 읽지 않았으므로 사본이 stale이어도 무해했고 — 그래서 이 이중 장부 구조는 오래 방치될 수 있었다.
재현 로그를 시간순으로 복원하자 범행의 전모가 드러났다. 웹툰 6컷이 그대로 이 타임라인이다:
[19.084] 진행자 '듣기 ON' — 아동은 full:video 스텝 ← 컷 1
├─ page-session microphoneEnabledRef = true configured 진짜 SSOT
└─ "Blocked microphone enable during non-AI step"
setAgentListeningEnabled 호출 안 함
→ ai-session isMicrophoneEnabledRef = false 유지 [사본 어긋남 시작]
|
[36.276] 영상 → AI 활동 전환 ← 컷 2
└─ "Listening fallback synced" presence="present", shouldActivate=false
→ 진행자 부재 fallback 아님. 순수 configured 경로
|
[36.279] "Stopping session for new activity" → "Session stopped"
└─ stopSession 이 isMicrophoneEnabledRef = false 로 강제 리셋
(use-ai-session.ts:2186 — 활동 전환마다 무조건)
|
[36.896] 새 세션 시작 ← 컷 3
└─ hasListeningIntent() 는 page ref(true)를 보고 통과 → 예약 성립
"Defer mic: will apply after first AI response or fallback"
|
[39.073] 첫 AI 발화 → AEC 안정화 300ms 대기
|
[39.370] "Defer mic: applied after AEC stabilization delay" ← 컷 4
└─ applyDeferredListening():
isMicrophoneEnabledRef = false (리셋된 stale 사본)
listeningFallbackActive = false (진행자 present)
→ 두 분기 모두 빠져나가 no-op
|
"Agent listening enabled" 로그 없음 = 듣기 실제로 안 켜짐 [증상] ← 컷 5
Defer mic: applied after AEC stabilization delay는 찍히는데 그 직후 Agent listening enabled가 없으면 확진. "예약은 걸렸고 시각도 맞는데 적용만 빈손"이라는 서명이다.
세션 시작 시 트랙 초기값도 resolveListeningIntent() = configured(false) || fallback(false) = 닫힘이다. AEC 피드백 방지를 위해 일부러 닫고 출발하므로 예약 적용이 유일한 개방 기회이고, 그 한 번이 no-op이면 해당 스텝 내내 닫힌 채 남는다.
| # | 경로 | 비고 |
|---|---|---|
| 1 | stopSession() — use-ai-session.ts:2186 | 활동 전환마다 무조건. 실제 신고 경로 |
| 2 | 비-AI 스텝 mute — use-step-transition의 toggleMicrophone(false) | 직후 page ref만 previousMicState로 되돌림 |
| 3 | 비-AI 스텝 중 진행자 ON 차단 — use-guest-page-session:1836 | ai-session 호출을 건너뛰어 초기값 false가 유지 |
하위 훅(use-ai-session)은 상위(use-guest-page-session)를 import하지 않아 SSOT를 직접 읽을 수 없다. 그래서 상위가 예약 시점에 값을 인자로 내려보내는 통로를 만들었다 (컷 6).
// 상위 (use-step-transition, restartCurrentSession)
scheduleMicOnAfterFirstResponse({ configuredIntent: !!microphoneEnabledRef.current })
// 하위 (use-ai-session)
pendingConfiguredListeningRef.current = options?.configuredIntent ?? isMicrophoneEnabledRef.current;
// 적용
const configuredIntent = pendingConfiguredListeningRef.current || isMicrophoneEnabledRef.current;
if (configuredIntent) { toggleMicrophone(true); return; } // 모니터 동기화까지 복구
if (listeningFallbackActiveRef.current) applyListeningIntent(...); // fallback은 gate만
|| isMicrophoneEnabledRef.current는 백스톱 — 예약~적용 사이 진행자가 ON 했는데 mediaStream이 아직 없어 "intent stored"만 남은 좁은 창을 건진다.cancelPendingMicTimers)·세션 종료 시 스냅샷도 정리. 진행자 OFF는 취소 경로가 존중한다.configuredIntent 필드를 추가해 동일 증상을 로그만으로 판별 가능하게 했다.listeningFallbackActiveRef는 stopSession이 리셋하지 않고, shouldActivate 조건에 configured가 없어 presence만 부재면 계속 켠다. 백로그의 핵심 기능은 깨지지 않았고, 깨진 것은 기존 동작이다.
재랜딩 리뷰 중 Hermes가 별건의 범행을 지목했다(medium). applyMonitorPresence가 revision 단조 비교를 source === "push"일 때만 적용해, snapshot 응답은 아무리 낡았어도 항상 덮어쓰고 있었다. 늦게 도착한 옛 편지가 방금 받은 새 소식을 덮어쓰는 구조다.
게스트 requestMonitorPresence 발신 → 진행자 입장 push (revision 7, present) 먼저 적용 → 지연된 snapshot 응답 (revision 6, absent) 나중 도착 → 무조건 덮어씀 → presence 가 absent 로 되돌아감
수정: source 구분 없이 revision <= applied면 드롭. 드롭 시 Monitor presence ignored (stale revision) 로그를 남긴다(이 race는 로그 없이 사후 판별 불가). 유효 응답 도착 시 재시도 예산은 staleness 판정과 무관하게 리셋.
함께 넣은 방어 — 재연결 시 revision baseline 리셋. Redis 재기동·키 유실로 서버 revision이 1부터 다시 시작하면 이전 연결의 큰 revision 때문에 이후 presence가 전부 stale로 버려져 진행자 판정이 얼어붙는다. 이 구멍은 snapshot의 무조건 덮어쓰기에 가려져 있었을 뿐 push에는 원래 존재했다. JOIN_ROOM 하이드레이션 push가 새 공간을 다시 채운다.
②를 수정하며 수사관은 "서버의 snapshot 생성 순서는 현행 유지해도 된다"고 판단했다. 이 판단은 틀렸다. Hermes 2차 리뷰가 반대 방향의 사고를 지적했다 — 이번엔 서버 쪽이 공범이었다.
getRoomMonitorPresenceSnapshot() 기존 순서
① presence 계산 → (이 사이 publish 가 revision 을 7로 올림) → ② currentRevision 읽기 = 7
응답 = { 진행자 없음(낡음), revision 7(최신) }
이 응답이 진짜 push 보다 먼저 도착하면
snapshot {없음, 7} 적용 → push {있음, 7} 은 7 <= 7 이라 무시
→ 낡은 상태가 최신 revision 으로 고정
server.ts가 @socket.io/redis-adapter를 쓰므로 다른 인스턴스의 push는 pub/sub을 거쳐 로컬 ack보다 늦게 도착할 수 있다. 도착 순서 역전이 실제로 가능하다.
수정: currentRevision()을 presence 계산 앞으로 이동. 불변식이 뒤집힌다.
| 순서 | 불변식 | 최악의 결과 |
|---|---|---|
| 계산 → 읽기 (기존) | revision ≥ 상태 | 낡은 상태가 최신 revision으로 고정되고 맞는 push가 버려짐 (능동적 손상) |
| 읽기 → 계산 (수정) | 상태 ≥ revision | 신선한 snapshot이 드롭될 수 있음 — 단 그때 게스트는 이미 그 revision 이후 상태를 보유 (보수적 무시) |
"push 유실 후 복구 경로가 막힌다"는 초기 우려도 성립하지 않는다. push를 놓쳤다면 게스트 revision이 서버보다 뒤처져 있어, 선-읽은 revision도 게스트 값보다 크고 정상 적용된다. 이 순서 의존성은 클라이언트 주석에도 명시해 한쪽만 바뀌는 것을 막았다.
isMicrophoneEnabledRef를 갱신하니 그곳이 SSOT처럼 보이지만, 같은 파일의 stopSession이 그 값을 지운다. 정본은 세션 재시작을 넘어 살아남는 쪽(page-session ref)이었다.
effective = (configured || fallback) && !nonAiStepBlocked로 통일하고 stopSession이 configured를 건드리지 않게 하는 것. PPI-907 방어와 얽혀 revert 직후 동시 수행은 위험하므로 별도 티켓 권장.| 커밋 | 내용 | 검증 |
|---|---|---|
| d017f9ae | 듣기 ON 복원 | 실기기 확인 완료 |
| 6e2bac2b | presence snapshot race + 재연결 revision 리셋 | tsc만. 타이밍 역전·Redis 재기동 필요 → QA |
| a44f37f4 | snapshot revision 선-캡처 | socket vitest 4/4 + tsc. 인스턴스 간 순서 역전 필요 → QA |
stopSession을 탄다) ② 진행자 부재 입장·중간 입장·이탈 시 fallback ON/OFF와 '자동' 배지 ③ 아동 입장과 진행자 입장이 겹치는 타이밍에서 배지와 실제 입력 일치 ④ 아동 소켓 재연결 후 presence 재동기화(정상 동작 중엔 Monitor presence ignored (stale revision)가 뜨지 않아야 한다) ⑤ 음성 수업에서 fallback 미동작
| 파일 | 역할 |
|---|---|
| apps/web/entities/guest-session/model/use-ai-session.ts | 예약 마이크 ON 적용, configured/fallback 축, pendingConfiguredListeningRef |
| apps/web/entities/guest-page-session/model/use-step-transition.ts | 스텝·활동 전환 시 복원 판단, configuredIntent 전달 |
| apps/web/entities/guest-page-session/model/use-guest-page-session.ts | configured SSOT(microphoneEnabledRef), applyMonitorPresence, 재연결 revision 리셋 |
| apps/socket/src/sfu-socket/monitor-presence.ts | presence 판정·revision·snapshot 생성 순서 |
| apps/socket/src/sfu-socket/handlers/connection-handlers.ts | JOIN_ROOM presence 하이드레이션 push |