PPI-1167 — revert 후 재랜딩과 3연쇄 수정 원인 확정 수정 완료 QA 대기

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

PPI-1167 — revert 후 재랜딩과 3연쇄 수정 원인 확정 수정 …: 증상: 이 문서는 이렇게 읽으면 됩니다, 원인: 사고 ② presence snapshot이 최신 push를 덮어쓰는 race, 수정·검증: 검증 상태 흐름
동작 흐름 요약
  1. 증상: 이 문서는 이렇게 읽으면 됩니다
  2. 원인: 사고 ② presence snapshot이 최신 push를 덮어쓰는 race
  3. 수정·검증: 검증 상태
문서 읽는 법 · 수사반장식

이 문서는 이렇게 읽으면 됩니다

PPI-1167 duplicated — revert 후 재랜딩과 3연쇄 수정 (듣기 복원 실패 · presence race)의 핵심을 수사반장식으로 먼저 안내합니다. 기술적 결론과 원문 근거는 아래 본문에 보존되어 있습니다.

핵심 흐름 펼쳐 보기
  1. 결론부터 확인한 뒤 증상과 영향을 읽습니다.
  2. 시간순 수사 흐름에서 가설과 반증을 따라갑니다.
  3. 마지막으로 로그·코드·검증 섹션에서 근거를 확인합니다.
  • 결론
  • 사건 타임라인
  • 사고 ① 활동 전환 후 진행자 '듣기 ON' 복원 실패
작성일: 2026-07-29 대상: Web 게스트 · Socket/SFU PR: #941 (재랜딩) / 원본 #935 · revert #940

결론

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

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

사건 타임라인

시점사건커밋/PR
7/28PPI-1167 머지 — 진행자 부재 시 AI 듣기 자동 ONe4dd7d14 (#935)
7/29revert — 활동 전환 후 진행자 '듣기 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

사고 ① 활동 전환 후 진행자 '듣기 ON' 복원 실패

인과 체인 (재현 로그 기준)

[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가 없으면 확진. "예약은 걸렸고 시각도 맞는데 적용만 빈손"이라는 서명이다.

왜 no-op이 곧 OFF인가

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

1167이 바꾼 지점

- toggleMicrophone(true);      // 무조건 ON — 사본이 false여도 강제로 true로 만들며 켜짐
+ applyDeferredListening();    // 자기 사본을 읽어 판정 → false면 no-op

축 분리(configured / fallback) 자체는 옳았다. 문제는 읽도록 만들면서 세션 재시작 후 그 값을 채워줄 경로를 만들지 않은 것이다. 이전 코드는 값을 읽지 않았으므로 사본이 stale이어도 무해했고, 그래서 이중 소스 구조가 오래 방치될 수 있었다.

사본이 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를 직접 읽을 수 없다. 그래서 상위가 예약 시점에 값을 인자로 내려보내는 통로를 만들었다.

// 상위 (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이 최신 push를 덮어쓰는 race

Hermes 1차 리뷰 지적(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 캡처 순서 — 판단 정정

②를 수정할 때 "서버 생성 순서는 현행 유지"로 판단했으나 틀렸다. 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