PPI-1167 — 사라진 '듣기 ON' 사건 원인 확정 수정 완료 QA 대기

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

작성일: 2026-07-29 대상: Web 게스트 · Socket/SFU PR: #941 (재랜딩) / 원본 #935 · revert #940
🧪 포맷 파일럿 문서원본 분석 문서와 동일한 내용을 결론 선공개 → 비개발자 요약(접이식) → 웹툰 스트립 → 수사 일지 → 증거 블록 구조로 재구성했다. index 미등록. 원본과 비교해 읽고 채택 여부를 결정한다.

결론 — 범인부터 공개한다

PPI-1167(진행자 부재 시 AI 듣기 자동 ON)은 새 기능은 정상 동작했고, 기존에 잘 되던 동작을 부러뜨려 revert 됐다. 범인은 진행자 '듣기' 의도(configured)가 두 곳에 존재하는 이중 장부 구조 — 1167이 예약 마이크 ON의 판정 기준을 세션마다 리셋되는 사본 쪽으로 옮긴 것이다. 재랜딩 과정에서 이 사이드 이펙트와 presence 동기화 race 2건(여죄)을 추가로 검거했다.

기능의 대표 시나리오(진행자 부재)만 확인하면 통과하는 형태의 결함이었다. fallback은 별도 축이라 정상이었고, 깨진 것은 진행자가 있는 일반 수업의 듣기 복원이다.

비개발자용 3줄 요약 — "본부 장부와 교실 메모장" 비유로 읽기

동작 원리선생님이 "AI야, 아이 말을 들어줘" 스위치를 켜면 본부 장부에 기록되고, 수업 활동이 바뀔 때마다 새 담당자가 그 설정을 이어받아 다시 켜줘야 한다.

버그이번 업데이트에서 담당자가 본부 장부 대신 활동이 바뀔 때마다 백지가 되는 자기 메모장을 보고 판단하게 바뀌었다. 그래서 활동을 옮기면 "켜라는 기록이 없네?" 하고 스위치를 안 켰고, 선생님 화면에는 켜진 걸로 보이는데 AI는 아이 말을 못 듣는 상태가 됐다.

수정이제 활동을 옮길 때 본부 장부의 값을 쪽지에 적어 담당자에게 직접 전달한다. 담당자는 백지 메모장이 아니라 그 쪽지를 보고 스위치를 켠다.

사건의 재구성 — 6컷

🧑‍🏫 진행자
📔 본부 장부 (page-session ref · 안 지워짐)
📝 교실 메모장 (ai-session ref · 세션마다 백지)
🧹 stopSession
📋 예약 접수원 (deferred listening)
🤖 핑퐁이(AI)
1
진행자듣기 ON!
📔본부 장부true ✓
🚧비-AI 스텝 문지기
📝교실 메모장false
문지기지금은 영상 스텝. 교실엔 전달 금지!
본부 장부엔 true가 적혔지만, 교실 메모장은 false인 채 — 장부가 어긋나기 시작한다.
2
— 활동 전환! —
🧹stopSession
📝교실 메모장백지화
stopSession세션 종료! 메모장은 규정대로 백지로 되돌린다.
stopSession은 활동 전환마다 무조건 교실 메모장을 false로 리셋한다 (use-ai-session.ts:2186).
3
— 새 세션 시작 —
📋예약 접수원
📔본부 장부true ✓
접수원본부 장부는 true네요. 첫 AI 발화 후에 마이크 켜드리는 걸로 예약 접수!
예약 판정(hasListeningIntent)은 본부 장부를 본다 — 여기까진 정상.
4
— 예약 적용의 순간 —
🙍적용 담당자
(applyDeferredListening)
📝교실 메모장false
담당자내 메모장엔 false… fallback도 아니고… 켤 이유가 없는데?
담당자는 자기 메모장만 본다. 백지가 된 메모장 → no-op. 예약은 빈손으로 소멸.
5
🧑‍🏫진행자화면: ON 🟢
🤖핑퐁이마이크: 닫힘 🔇
🧒아동
아동핑퐁아, 내 말 들려?
핑퐁이……
세션 시작 시 트랙은 일부러 닫고 출발하므로, 예약 적용이 유일한 개방 기회였다. 스텝 내내 닫힌 채 유지 — 이것이 신고된 증상.
6
— 수정 후 —
📋접수원✉️ configuredIntent: true
🙍담당자
🎤마이크ON!
접수원예약 쪽지에 본부 장부 값을 동봉했습니다. 이걸 보고 켜세요!
상위가 예약 시점에 값을 인자로 내려보낸다 — 백지 메모장 대신 쪽지가 판정 기준이 된다.

수사 일지 ① — 사건 발생과 신고

7/28 — 평화로운 머지

PPI-1167이 머지됐다(e4dd7d14, #935). "진행자가 자리를 비우면 AI가 알아서 아동의 말을 듣는다" — 기능은 시연에서 멀쩡히 동작했고, 아무도 의심하지 않았다.

7/29 — 신고 접수, 그리고 반전

이튿날 신고가 들어왔다. "활동 전환 후 진행자 '듣기 ON'이 복원되지 않는다." 즉시 revert(cec1777c, #940). 그런데 이상했다 — 죽은 것은 새 기능이 아니라, 새 기능과 무관해 보이는 기존 동작이었다. 진행자 부재 fallback은 멀쩡했고, 진행자가 있는 일반 수업의 듣기 복원만 죽었다. 대표 시나리오만 검증하면 절대 잡히지 않는 위치에 범인이 숨어 있다는 뜻이다.

용의자 선상 — 의도(configured)의 이중 장부

수사망은 곧 한 가지 구조적 사실로 좁혀졌다. 진행자의 '듣기' 의도가 코드베이스에 두 곳 존재한다:

장부위치수명
📔 본부 장부 (진짜 SSOT)use-guest-page-session의 microphoneEnabledRef세션 재시작을 넘어 살아남음
📝 교실 메모장 (사본)use-ai-session의 isMicrophoneEnabledRefstopSession마다 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가 없으면 확진. "예약은 걸렸고 시각도 맞는데 적용만 빈손"이라는 서명이다.

왜 no-op이 곧 OFF인가 — 단 한 번의 기회

세션 시작 시 트랙 초기값도 resolveListeningIntent() = configured(false) || fallback(false) = 닫힘이다. AEC 피드백 방지를 위해 일부러 닫고 출발하므로 예약 적용이 유일한 개방 기회이고, 그 한 번이 no-op이면 해당 스텝 내내 닫힌 채 남는다.

범행 경로 — 사본이 false로 리셋되는 길 3개

#경로비고
1stopSession()use-ai-session.ts:2186활동 전환마다 무조건. 실제 신고 경로
2비-AI 스텝 mute — use-step-transitiontoggleMicrophone(false)직후 page ref만 previousMicState로 되돌림
3비-AI 스텝 중 진행자 ON 차단 — use-guest-page-session:1836ai-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만
fallback 축은 무사했다listeningFallbackActiveRefstopSession이 리셋하지 않고, shouldActivate 조건에 configured가 없어 presence만 부재면 계속 켠다. 백로그의 핵심 기능은 깨지지 않았고, 깨진 것은 기존 동작이다.

수사 일지 ② — 여죄: 늦게 도착한 옛 편지 (presence snapshot race)

7/29 — Hermes 1차 리뷰, 여죄 발견

재랜딩 리뷰 중 Hermes가 별건의 범행을 지목했다(medium). applyMonitorPresence가 revision 단조 비교를 source === "push"일 때만 적용해, snapshot 응답은 아무리 낡았어도 항상 덮어쓰고 있었다. 늦게 도착한 옛 편지가 방금 받은 새 소식을 덮어쓰는 구조다.

게스트 requestMonitorPresence 발신
   → 진행자 입장 push (revision 7, present) 먼저 적용
   → 지연된 snapshot 응답 (revision 6, absent) 나중 도착 → 무조건 덮어씀
   → presence 가 absent 로 되돌아감
presence는 변화 시에만 push되므로 한번 뒤집히면 다음 입·퇴장까지 유지된다. 진행자가 있는데 fallback이 켜져 AI가 아동 음성을 직접 듣거나, 부재인데 입력이 닫힌 상태가 남는다.

수정: source 구분 없이 revision <= applied면 드롭. 드롭 시 Monitor presence ignored (stale revision) 로그를 남긴다(이 race는 로그 없이 사후 판별 불가). 유효 응답 도착 시 재시도 예산은 staleness 판정과 무관하게 리셋.

함께 넣은 방어 — 재연결 시 revision baseline 리셋. Redis 재기동·키 유실로 서버 revision이 1부터 다시 시작하면 이전 연결의 큰 revision 때문에 이후 presence가 전부 stale로 버려져 진행자 판정이 얼어붙는다. 이 구멍은 snapshot의 무조건 덮어쓰기에 가려져 있었을 뿐 push에는 원래 존재했다. JOIN_ROOM 하이드레이션 push가 새 공간을 다시 채운다.

수사 일지 ③ — 수사관의 오판: snapshot revision 캡처 순서

7/29 — Hermes 2차 리뷰, 수사관의 판단이 뒤집히다

②를 수정하며 수사관은 "서버의 snapshot 생성 순서는 현행 유지해도 된다"고 판단했다. 이 판단은 틀렸다. Hermes 2차 리뷰가 반대 방향의 사고를 지적했다 — 이번엔 서버 쪽이 공범이었다.

getRoomMonitorPresenceSnapshot() 기존 순서
  ① presence 계산 → (이 사이 publish 가 revision 을 7로 올림) → ② currentRevision 읽기 = 7
  응답 = { 진행자 없음(낡음), revision 7(최신) }

이 응답이 진짜 push 보다 먼저 도착하면
  snapshot {없음, 7} 적용 → push {있음, 7} 은 7 <= 7 이라 무시
  → 낡은 상태가 최신 revision 으로 고정
오판의 근거: "push가 항상 ack보다 먼저 도착한다"고 봤다. 같은 인스턴스면 맞지만 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)이었다.

검증 상태

커밋내용검증
d017f9ae듣기 ON 복원실기기 확인 완료
6e2bac2bpresence snapshot race + 재연결 revision 리셋tsc만. 타이밍 역전·Redis 재기동 필요 → QA
a44f37f4snapshot revision 선-캡처socket vitest 4/4 + tsc. 인스턴스 간 순서 역전 필요 → QA
QA 권장 범위 — ① 진행자 듣기 ON 상태에서 full:video/full:image 후 다른 활동의 AI 스텝 복귀(활동 전환이어야 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.tsconfigured SSOT(microphoneEnabledRef), applyMonitorPresence, 재연결 revision 리셋
apps/socket/src/sfu-socket/monitor-presence.tspresence 판정·revision·snapshot 생성 순서
apps/socket/src/sfu-socket/handlers/connection-handlers.tsJOIN_ROOM presence 하이드레이션 push