마지막 업데이트 2026-07-29
PPI-1167 duplicated — revert 후 재랜딩과 3연쇄 수정 (듣기 복원 실패 · presence race)의 핵심을 수사반장식으로 먼저 안내합니다. 기술적 결론과 원문 근거는 아래 본문에 보존되어 있습니다.
기능의 대표 시나리오(진행자 부재)만 확인하면 통과하는 형태의 결함이었다. fallback은 별도 축이라 정상이었고, 깨진 것은 진행자가 있는 일반 수업의 듣기 복원이다.
| 시점 | 사건 | 커밋/PR |
|---|---|---|
| 7/28 | PPI-1167 머지 — 진행자 부재 시 AI 듣기 자동 ON | e4dd7d14 (#935) |
| 7/29 | revert — 활동 전환 후 진행자 '듣기 ON' 복원 실패 발견 | cec1777c (#940) |
| 7/29 | 재랜딩 브랜치 PPI-1167-fix 생성, revert 되돌림 | 3bd481e3 |
| 7/29 | 사고 ①: 듣기 ON 복원 실패 수정 — 실기기 검증 완료 | d017f9ae |
| 7/29 | 사고 ②: presence snapshot이 최신 push를 덮어쓰는 race (Hermes 1차) | 6e2bac2b |
| 7/29 | 사고 ③: snapshot revision 캡처 순서 (Hermes 2차, ②의 판단 정정) | a44f37f4 |
[19.084] 진행자 '듣기 ON' — 아동은 full:video 스텝
├─ page-session microphoneEnabledRef = true configured 진짜 SSOT
└─ "Blocked microphone enable during non-AI step"
setAgentListeningEnabled 호출 안 함
→ ai-session isMicrophoneEnabledRef = false 유지 [사본 어긋남 시작]
|
[36.276] 영상 → AI 활동 전환
└─ "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] 새 세션 시작
└─ 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"
└─ applyDeferredListening():
isMicrophoneEnabledRef = false (리셋된 stale 사본)
listeningFallbackActive = false (진행자 present)
→ 두 분기 모두 빠져나가 no-op
|
"Agent listening enabled" 로그 없음 = 듣기 실제로 안 켜짐 [증상]
Defer mic: applied after AEC stabilization delay는 찍히는데 그 직후 Agent listening enabled가 없으면 확진. "예약은 걸렸고 시각도 맞는데 적용만 빈손"이라는 서명이다.
세션 시작 시 트랙 초기값도 resolveListeningIntent() = configured(false) || fallback(false) = 닫힘이다. AEC 피드백 방지를 위해 일부러 닫고 출발하므로 예약 적용이 유일한 개방 기회이고, 그 한 번이 no-op이면 해당 스텝 내내 닫힌 채 남는다.
- toggleMicrophone(true); // 무조건 ON — 사본이 false여도 강제로 true로 만들며 켜짐 + applyDeferredListening(); // 자기 사본을 읽어 판정 → false면 no-op
축 분리(configured / fallback) 자체는 옳았다. 문제는 읽도록 만들면서 세션 재시작 후 그 값을 채워줄 경로를 만들지 않은 것이다. 이전 코드는 값을 읽지 않았으므로 사본이 stale이어도 무해했고, 그래서 이중 소스 구조가 오래 방치될 수 있었다.
| # | 경로 | 비고 |
|---|---|---|
| 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를 직접 읽을 수 없다. 그래서 상위가 예약 시점에 값을 인자로 내려보내는 통로를 만들었다.
// 상위 (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 1차 리뷰 지적(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가 새 공간을 다시 채운다.
②를 수정할 때 "서버 생성 순서는 현행 유지"로 판단했으나 틀렸다. 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 |