텍스팅 수업 진행자 부재 시 AI 듣기 OFF 원인 확정 해결 설계 구현 완료 QA 대기

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

작성일: 2026-07-28 개정: 2026-07-28 (3차 · 구현 반영: 2단계 UI · 수동 OFF 우선 · 인증 경로) 상태: 구현 완료 — PR #935 리뷰·QA 중 대상: Web 게스트 · Socket/SFU

결론

미디어 서버를 monitor presence의 SSOT로 둔다. presence는 포커스뷰 claim · 카드뷰(그룹) claim 두 authoritative source의 OR로 판정한다. room의 monitor socket 멤버십은 claim 누락·정리 지연을 찾는 진단 신호로만 사용한다. 게스트는 수업 전체에서 이 값을 유지하고, 세션 시작 시 한 번만 서버 snapshot을 조회하며 재연결 하이드레이션은 서버가 JOIN_ROOM 시점에 push 한다. 그 외에는 presence 변경 push로 갱신한다. 텍스팅 수업인데 진행자가 없으면 진행자 설정을 바꾸지 않고 AI 입력만 임시로 듣기 ON 한다.
effectiveListeningEnabled =
  configuredListeningEnabled ||
  (fallbackFeatureEnabled
     && isTextingLesson
     && !manualListeningOff            // 진행자 수동 OFF 가 presence 판정을 이긴다
     && monitorPresence !== "present")
이 문서는 원인과 구현 계약을 고정한 설계 문서로 시작했고, PR #935에서 구현되었다. 구현 과정에서 계약이 바뀐 항목은 본문에 구현 변경 으로 표시했다. 실기기·실제 수업 흐름 검증은 QA 단계에 남아 있다(needs-qa).
개정 (2026-07-28 · 코드 대조 검토 반영) — 초안 대비 6곳을 수정했다.
  1. presence 정의 확장 — 초안의 peerKind === "monitoring-claim" 단독 판정은 카드뷰로만 진입한 진행자를 absent로 오판한다. 포커스뷰·카드뷰 claim의 2소스 OR로 교체하고 room socket 멤버십은 진단 신호로 분리. (영향 범위는 아래 오판 조건의 실제 범위 참조 — 초안 표현이 과장이었다.)
  2. unknown 즉시 fail-open 조건화 — 재시도 후 확정 + fallback 가시화 + 메트릭을 전제로만 ON.
  3. snapshot 조회 트리거 축소 — 모든 startSession 직전 동기 조회 → 입장·재연결 시에만. 대신 중앙 발행 helper와 room별 monotonic revision을 필수 전제로 추가.
  4. effective 값을 읽어야 하는 기존 코드 경로 명시 — 스텝/활동 전환의 마이크 복원이 configured intent만 읽기 때문에, 이 경로를 함께 바꾸지 않으면 fallback이 전환 한 번에 소실된다. 구현 성패를 가르는 항목이다.
  5. revision 복구 경로 — counter 리셋·Redis 미연결 시 push가 영구 무시되는 경우 방지.
  6. 발행 fan-out 비용 — 그룹 발행과 진단 계산의 빈도 상한을 명시.

3차 개정 (구현 반영) — 구현 중 계약이 바뀐 3곳:

  1. 모니터 UI 를 ON/OFF 2단계로 유지fallbackActive 배지(3단계 표시)를 폐기하고 실효 상태만 노출한다. 상세는 진행자 UI 표시 계약 절.
  2. 진행자 수동 OFF 가 fallback 을 이긴다 — 버튼 조작이 presence 보다 강한 신호다. 상세는 진행자 수동 조작 우선 절.
  3. 전역 스위치는 인증된 API 경로에서만 변경 — 소켓 핸드셰이크에 인증이 없어 소켓 이벤트로는 권한을 보장할 수 없다. 상세는 전역 스위치 절.

문제 상황

텍스팅 수업에서는 AI의 아동 음성 입력을 막기 위해 기본 듣기 OFF를 사용한다. 진행자는 별도 host relay로 아동 음성을 듣고, 들은 내용을 텍스트 메시지로 AI 세션에 대신 주입한다.

진행자가 접속하지 않았거나 수업 도중 모니터링을 이탈하면 텍스트를 대신 입력할 주체가 없다. 이때 AI 듣기까지 OFF이면 아동 음성도 AI에 전달되지 않아 대화 입력 경로가 완전히 사라진다.
  1. 아동이 활동을 시작하며 AI 세션을 생성한다.
  2. 텍스팅 수업 기본값에 따라 AI 듣기는 OFF다.
  3. 진행자가 해당 room을 모니터링하지 않는다.
  4. 아동이 말해도 AI 음성 입력은 차단되고, 진행자 텍스트 입력도 없다.
  5. AI가 응답할 사용자 입력을 받지 못한다.

현재 코드에서 확인되는 원인

현재 동작한계
듣기는 AI/agent input gate만 제어host relay와 분리된 것은 올바르지만 진행자 부재 fallback이 없음
게스트 입장 시 서버가 해당 room의 host monitoringRoomId를 조회진행자가 없으면 모호한 RoomNotFound로만 응답하고 명시적 presence 상태를 노출하지 않음
AI 세션은 활동 전환에 따라 반복적으로 start/stop최초 입장 때 한 번만 판정하면 이후 세션 시작과 진행자 이탈을 반영할 수 없음
existingProducers 제공producer 존재는 모니터링 claim과 다르므로 진행자 presence 판정에 사용할 수 없음
진행자의 실제 모니터링 claim이 두 갈래로 나뉘어 있음 (포커스뷰 claim / 카드뷰 group claim). 별도로 JOIN_ROOM role:"monitor" socket 멤버십이 존재한 claim만 보는 판정식은 카드뷰 진행자를 미접속으로 오판한다. 반대로 socket 멤버십을 단독 presence로 인정하면 UI 전환·cleanup 지연 중 false positive가 생길 수 있음

presence의 정확한 정의

present는 단순 로그인이나 대시보드 Socket 접속이 아니다. 해당 아동의 화면·전사를 실제로 보고 있어 텍스트를 대신 주입할 수 있는 진행자가 한 명 이상 있는 상태다. 판정 기준은 "텍스트 전달 주체가 존재하는가"이며, 이를 권위적으로 표현하는 claim 경로는 두 가지다.
hasActiveMonitor =
     // ① 포커스뷰 — HOST_START_MONITORING claim
     claims.some(c => c.role === "host"
                  && c.peerKind === "monitoring-claim"
                  && c.monitoringRoomId === roomId)
     // ② 통합 모니터링(카드뷰) — HOST_START_GROUP_MONITORING claim
  || claims.some(c => c.role === "host"
                  && c.peerKind === "group-monitoring-claim"
                  && c.monitoringGroupId === routerManager.getGroupId(roomId))
// 진단 전용 — authoritative presence 계산에는 포함하지 않음
hasMonitorSocketMembership =
  socketsInRoom.some(s => roleOf(s) === "host")

monitorPresenceMismatch =
  hasMonitorSocketMembership !== hasActiveMonitor
초안의 ① 단독 판정은 카드뷰로만 진입한 진행자를 미접속으로 오판한다. 카드뷰 진입 시 생성되는 claim은 peerKind: "group-monitoring-claim", monitoringRoomId: null, monitoringGroupId: groupId로 저장되므로 (monitoring-handlers.ts:289-307) ①의 조건에 걸리지 않는다. 그런데 카드뷰에서도 monitorSession.sendMessage로 AI에 텍스트를 주입할 수 있다 (session-card.tsx:688, :1222). 즉 전달 주체가 멀쩡히 있는데 fallback이 켜진다.

오판 조건의 실제 범위 — 초안 표현은 과장이었다

"통합 모니터링 수업 전반에서 상시 무력화"라는 초안 문장은 코드 경로를 더 따라가 보면 성립하지 않는다. ②가 실제로 판정을 좌우하는 조건은 하나로 좁혀진다.

확인 사항코드 근거
그룹 모니터링 아동은 음성 수업 필수 → 텍스팅(non-voice)일 수 없다user-form.tsx:786-791 ("그룹 수업은 음성 기반이므로 수업 유형 '음성'이 필수입니다") + 수업유형 버튼이 monitoringType === "group"일 때 disabled
카드뷰 페이지는 room.groupId === targetGroupId인 방만 렌더한다monitor-dashboard/[group]/page.tsx:149
대시보드에서 1:1 세션은 카드뷰를 거치지 않고 포커스뷰로 직행한다 → 진행자는 항상 ① claim을 얻는다live-sessions.tsx:168-171 (/{group}/{roomId}?type=oneOnOne) vs 그룹은 :78 (카드뷰)
세션 1:1/그룹 분류 기준은 아동 monitoringType이 아니라 room.groupId 유무다 → non-voice 아동이 수업 그룹에 편성되는 것이 구조적으로 배제되지는 않는다monitor-dashboard/page.tsx:161 (type = room.groupId ? Multi : OneOnOne)
따라서 ②가 load-bearing이 되는 경우는 "non-voice 아동이 class_groups에 편성된 수업" 하나다. 대다수 텍스팅 수업(1:1)에서는 ①만으로 커버된다.
TODO(운영 데이터 확인): non-voice 아동이 실제로 수업 그룹에 편성되는 사례가 있는지 확인한다. 없다면 ②는 방어적 코드이며 Phase 1의 group index 선행 보완은 blocking이 아니다. 있다면 해당 수업 전체에서 fallback이 오작동하므로 선행 보완이 필수다.

구현 함정 — 현재 Redis에는 카드뷰 group claim 인덱싱이 누락돼 있다

진행자 상태저장 형태Redis monitorKey(roomId)
포커스뷰 진입monitoringRoomId = roomId포함
카드뷰 진입monitoringGroupId만 설정, monitoringRoomId: null구현에서 groupMonitorKey 인덱싱을 추가(초안 시점에는 어느 monitor index에도 없었다) — cross-instance 조회 가능 구현 변경
JOIN_ROOM role:"monitor" (카드·포커스뷰 공통)roomId: null, monitoringRoomId: null (connection-handlers.ts:113-115)미포함 — socket.io 룸 멤버십으로만 진단 가능
현재 peer-store.ts의 monitor index 등록·삭제 조건은 role === "monitor" || peerKind === "monitoring-claim"이다. group claim은 role: "host", peerKind: "group-monitoring-claim"이므로 groupMonitorKey(groupId)에 들어가지 않는다 (peer-store.ts:127-133, :166-170). cross-instance presence 구현 전에 upsert/remove 양쪽 조건에 group-monitoring-claim을 포함하고 getMonitorsInGroup(groupId) 조회 helper를 추가해야 한다.
구현 완료peer-store.ts upsert/remove 조건과 정리 로직에 group claim 을 포함하고 getMonitorsInGroup 을 추가했다. collectClaims 가 room·group 두 인덱스에서 remote claim 을 보강한다.
주의 — groupMonitorKey는 비어 있는 게 아니라 "포커스뷰 claim만" 들어 있다. 포커스뷰 claim은 monitoringGroupId를 세팅하고(monitoring-handlers.ts:202) peerKindmonitoring-claim이라 위 조건을 통과해 peer-store.ts:132에서 그룹 인덱스에 등록된다. 즉 "그룹 인덱스에 있는 peer = 카드뷰 진행자"가 아니다. getMonitorsInGroup을 만들 때 peerKind로 필터할지, 포커스 claim 혼입을 의도적으로 group presence로 인정할지 명시적으로 정해야 한다. 이 구분 없이 인덱스만 읽으면 판정이 조용히 틀어진다.
기존 자산 재사용 + 신규 group 경로resolveOtherMonitor(monitoring-handlers.ts:62-103)가 memory/Redis 이중 조회와 mismatch 메트릭을, isMonitorAlive(:105-129)가 좀비 판정(로컬 socket → instance heartbeat EXISTS)을 이미 수행한다. 다만 두 함수는 room용 getMonitorsInRoom(roomId)에 결합돼 있어 group claim에 그대로 쓸 수 없다. 공통 생존 판정은 재사용하되 group Redis 인덱스·조회 helper와 group별 resolver는 새로 추가한다.

해결 계약

1. snapshot 조회 — 입장과 소켓 재연결 시에만

presence를 push 이벤트(2번)로 유지하므로 모든 startSession 직전에 동기 조회할 필요가 없다. 조회 시점은 두 곳으로 한정한다.

socket.emit("get-room-monitor-presence", { roomId }, response => {
  // { roomId, hasActiveMonitor, monitorCount, revision }
})

// 호출 지점 (구현)
//   (i)  세션 시작(confirmReady) — 이 시점에 guest peer 가 존재한다
//   (ii) 조회 실패 시 bounded retry
// 재연결 복구는 클라이언트가 조회하지 않는다 → 서버가 JOIN_ROOM(role guest) 시점에
//   해당 소켓으로 snapshot push (아래 authz 항목 참조)
// 그 외 세션 start 는 push 로 유지한 ref 최신값을 읽어
//   startSession({ initialListeningEnabled }) 로 전달

// 권한: 클라이언트가 보낸 roomId 를 신뢰하지 않는다
//   게스트  → 자기 peer 의 roomId 와 일치할 때만
//   진행자  → monitoringRoomId / joinedRoomId 와 일치할 때만
//   그 외   → FORBIDDEN
왜 축소하는가 — 활동 전환마다 AI 세션이 재생성되므로 수업당 수십 회 start가 발생한다. 조회 왕복이 세션 생성 경로에 직렬로 붙으면 첫 발화 지연 리스크가 생긴다(활동 전환 지점은 이미 스톨 민감 구간이다). push로 유지되는 값을 쓰면 세션 시작 경로의 왕복을 제거할 수 있다. stale 방지는 아래 중앙 발행 helper와 revision 계약이 담당한다.

조회 결과는 present | absent로 저장한다. 응답 전·timeout·오류는 unknown이다(3번 참조).

텍스팅 수업 판정 소유권: presence 조회 API는 모니터 존재만 반환한다. 게스트는 이미 로드한 사용자 프로필의 수업 타입으로 isTextingLesson을 계산한다. 서버 판정을 재사용해야 한다면 입장 시 계산한 값을 Redis room metadata에 명시적으로 저장해야 하며, GUEST_REQUEST_ENTRY 지역 변수만 믿고 이후 조회 응답에 싣지 않는다.

2. 수업 전체의 실시간 변경 알림

socket.on("room-monitor-presence-changed", payload => {
  // HOST_START_MONITORING / HOST_STOP_MONITORING /
  // HOST_START_GROUP_MONITORING / HOST_STOP_GROUP_MONITORING /
  // disconnect / zombie cleanup 뒤 서버가 발행
})

AI 세션이 정지 상태면 presence만 갱신하고, 활성 상태면 effective listening을 즉시 다시 계산한다. 이 채널이 정상 동작하는 것이 1번 축소의 전제이므로, 발행 지점 누락은 곧 stale presence로 이어진다.

push 정합성 계약 — 모든 claim mutation은 하나의 publishRoomMonitorPresence(roomId) helper를 통과한다. 서버는 Redis의 room별 counter를 INCR해 monotonic revision을 만들고 snapshot 응답과 push payload에 함께 넣는다. 게스트는 현재 revision 이하 이벤트를 무시한다.
type RoomMonitorPresence = {
  roomId: string
  hasActiveMonitor: boolean
  monitorCount: number
  revision: number
}

// push: 단조 비교로 순서 역전·중복 방어
if (payload.revision <= presenceRevisionRef.current) return
presenceRevisionRef.current = payload.revision
applyMonitorPresence(payload)

// snapshot(입장·재연결 응답): 무조건 authoritative — ref 를 덮어쓴다
presenceRevisionRef.current = snapshot.revision
applyMonitorPresence(snapshot)
revision 복구 경로 3건을 반드시 정의한다.
  1. counter 리셋 — eviction·flush·키 정책 변경으로 Redis counter가 되돌아가면 게스트는 revision <= ref 조건으로 이후 모든 push를 영구히 버린다. 그래서 snapshot 응답의 revision은 단조 비교 대상이 아니라 무조건 채택(ref 덮어쓰기)해야 한다. snapshot은 입장·재연결에서만 오므로 이 경로가 유일한 복구 수단이다.
  2. Redis 미연결 배포isRedisEnabled()가 false면 전역 단조 counter를 만들 수 없다. 로컬 counter로 대체하면 인스턴스마다 값이 달라 다른 인스턴스의 낮은 revision을 무시한다. 이 경우 revision 비교를 끄고 항상 적용하는 경로를 명시한다(순서 역전보다 stale 고착이 더 위험).
  3. fan-out 비용 — revision 스코프가 room이므로 그룹 발행은 룸별 INCR N회를 의미한다. 아래 발행 지점의 비용 상한에 포함해 계산한다.

3. unknown 처리 — 즉시 fail-open 금지

조회 실패를 곧바로 듣기 ON으로 처리하면 오작동이 조용히 누적된다. 진행자가 있는데 켜져도 모니터 UI에는 아무 표시가 없어(설정 오염 금지 원칙 때문) 진행자는 "듣기 OFF인데 AI가 반응한다"만 보고 원인을 추적할 수 없다. 조회 실패율이 높은 환경에서는 상시 ON으로 degrade한다.

아래 세 항목은 기능 출시 조건이다. 배포된 기능의 런타임에서는 bounded retry가 끝난 unknown을 fail-open(듣기 ON)으로 처리한다. 킬스위치가 OFF인 경우에만 기존 fail-closed 동작을 유지한다.
  1. bounded retry — 짧은 timeout으로 1~2회 재조회한 뒤에도 실패면 unknown 확정, 그 시점에 ON. 구현에서는 ack 오류뿐 아니라 ack 무응답(3초)과 소켓 미연결도 실패로 처리한다. 무응답을 방치하면 presence 가 영구 unknown 으로 남아 fail-open 이 아니라 기능이 조용히 죽는다.
  2. fallback 가시화fallbackActive를 read-only 필드로 모니터 카드·포커스뷰에 노출. configured 값은 건드리지 않으므로 "설정 오염 금지" 원칙과 충돌하지 않는다.
  3. 메트릭unknown 유입률과 fallback 발동률을 계측해 오탐 여부를 운영 데이터로 판단.

4. 초기 입장 응답 명시화

{
  status: "waiting-approval" | "joined-room-standalone",
  hasActiveMonitor: boolean,
  monitorPresenceRevision: number
}

기존 RoomNotFound는 실제로 room이 없는 뜻이 아니라 “활성 모니터 없음”으로 사용되고 있다. 신규 계약은 이를 도메인 상태로 명확하게 분리한다.

이 항목은 Phase 2로 분리한다. 게스트 입장 분기의 폴백 처리가 RoomNotFound 문자열에 직접 걸려 있어(use-guest-socket.ts:429-452 — standalone 진입 vs "조금 일찍 오셨네요" alert) 함께 바꾸면 입장 자체가 회귀할 위험이 있다. Phase 1에서는 기존 응답에 hasActiveMonitormonitorPresenceRevision추가하고 에러 코드 의미는 그대로 둔다.

게스트 상태와 반복 start/stop

type MonitorPresence = "unknown" | "present" | "absent";

configuredListeningEnabled // 진행자가 정한 원래 값
monitorPresence            // 수업 생명주기 동안 유지
fallbackActive =
  fallbackFeatureEnabled &&
  isTextingLesson &&
  monitorPresence !== "present"
effectiveListeningEnabled =
  configuredListeningEnabled || fallbackActive
상황결과
텍스팅 + 진행자 있음 (포커스뷰 / 카드뷰 claim 중 하나라도)원래 설정대로 듣기 OFF
텍스팅 + 진행자 없음 (두 claim 모두 없음)AI 입력만 임시 듣기 ON
텍스팅 + presence unknown/조회 실패bounded retry 후에도 실패면 unknown 확정 → 듣기 ON. 기능 출시 전 가시화·메트릭·킬스위치를 함께 준비
AI 세션 STOPpresence와 configured listening은 유지
다음 AI 세션 START재조회 없이 push로 유지된 ref 최신값으로 initialListeningEnabled 계산
Socket 재연결서버가 JOIN_ROOM 시점에 snapshot 을 push (클라이언트 조회 없음). revision 은 INCR 하지 않으므로 변화 없으면 무시됨 구현 변경
활동·스텝 전환 (AI 스텝 복귀)configured가 아니라 effective 기준으로 마이크 복원 — 미적용 시 fallback 소실
비-AI 스텝 (full:video / full:image)fallback 상태에서도 AI 입력 mute 유지 (PPI-907 가드)
활성 세션 중 진행자 이탈presence 이벤트로 즉시 듣기 ON
진행자 재입장fallback 해제, 원래 configured listening 복원
진행자 수동 듣기 OFFfallback 억제 → AI 입력 닫힘. 다음 활동에서 억제 해제 구현 변경
전역 스위치 OFF (main/message)진행 중 수업도 즉시 해제, 기존 fail-closed 동작
음성 수업fallback 조건 밖이므로 기존 동작 유지

부작용 방지 원칙

기존 추상화와의 정합apps/web/lib/voice-agent/voice-input-control.ts:43-56에 이미 agentListeningEnabled(진행자 intent)와 effectiveAgentInputEnabled(임시 gate 반영)의 분리가 있다. 본 설계의 fallback은 temporaryAgentInputBlocked의 반대 방향(temporary allow)을 추가하는 형태로 들어간다. 게스트에 내려가는 reason은 "auto-listening-no-monitor"로 두어 진행자 수동 조작(host-toggle-listening)과 로그상 구분한다(VoiceInputControlReason이 문자열 확장형이라 타입 변경 불필요).

진행자 UI 표시 계약 구현 변경

초안은 configured 값을 그대로 보여주고 fallback 은 "자동" 배지로 덧붙이는 3단계 표시를 요구했다. 구현에서는 모니터 UI 가 실효 상태만 ON/OFF 두 단계로 표시한다.

표시값 = configured || fallbackActive     // "지금 AI 가 듣고 있는가"
토글값 = !표시값                          // ON 으로 보일 때 누르면 실제로 OFF 된다

// 서버 guestState 는 두 값을 분리해 그대로 보존한다
guestState.isMicrophoneEnabled   // 진행자가 정한 configured (fallback 이 덮어쓰지 않음)
guestState.listeningFallbackActive // 실제 입력이 열린 동안만 true
왜 배지를 버렸나 — 배지의 목적은 "진행자가 상황을 오해해 오조작하는 것"을 막는 것이었다. 그런데 3단계 표시는 Off 로 보이는데 AI 는 듣고 있는 상태를 남기고, 그 상태에서 누른 OFF 가 아무 효과도 없어(아래 절) 오히려 혼란을 키웠다. 2단계 표시 + 수동 OFF 우선 규칙이면 보이는 대로 눌러서 보이는 대로 동작하므로 같은 목적을 더 단순하게 달성한다.
대가: 진행자는 ON 이 자기 설정 때문인지 fallback 때문인지 UI 만으로 구분할 수 없다. 구분이 필요할 때는 /main/debug/roomsisMicrophoneEnabled vs listeningFallbackActive, 또는 LogRocket ListeningFallback:* 이벤트로 판별한다. configured 원본이 서버에 보존되므로 진행자 복귀 시 원래 값으로 자동 복원되는 계약은 그대로다.

진행자 수동 조작 우선 구현 변경

초안 계약의 구멍effective = configured || fallback 만으로는 진행자가 듣기를 직접 OFF 해도 fallback 이 살아 있어 AI 입력이 유지된다. 진행자 입장에서는 "껐는데 계속 듣는다"가 되고, 텍스팅 수업에서 아동 발화를 AI 에 넣지 않겠다는 치료적 판단이 무시된다. presence push 도달 전 창이나 presence 오판 시 실제로 발생한다.

버튼 조작 자체가 진행자 존재의 가장 강한 신호다. 따라서 수동 OFF 는 presence 판정을 이긴다.

이벤트처리
진행자 수동 듣기 OFFmanualListeningOff = true → fallback 즉시 해제 → AI 입력 닫힘. 표시도 OFF
진행자 수동 듣기 ONmanualListeningOff = false (configured 로 커버되므로 억제 불필요)
다음 활동(새 AI 세션) 진입억제 해제 후 presence 기준으로 재판정
억제 범위를 활동 단위로 둔 이유 — "진행자 이탈까지 영구 억제"로 하면, presence 가 이미 absent 로 오판된 상태에서 누른 OFF 가 영구히 남아 진행자가 실제로 없는데도 fallback 이 죽는다(원 문제로 회귀). 활동 단위면 진행자가 남아 있는 경우엔 presence 가 present 라 어차피 켜지지 않고, 이탈한 경우엔 다음 활동에서 되살아난다. 발동 여부는 LogRocket payload 의 suppressedByManualOff 로 추적한다.

전역 스위치 구현 변경

운영 메뉴 main/message 최하단에 "진행자 부재 시 AI 듣기 자동 ON" 전역 ON/OFF 를 둔다. 기본값 ON 이며 OFF 면 텍스팅 수업은 기존 동작(듣기 OFF 유지 = fail-closed)으로 돌아간다.

브라우저 → PUT /api/settings/listening-fallback        (멤버 세션 인증)
            ↳ withAuthAdminOrDeveloper — admin/developer 외 401/403
         → PUT {socket}/internal/settings/listening-fallback  (X-Internal-Api-Key)
         → Redis 글로벌 키 기록 → sfuNamespace.emit(LISTENING_FALLBACK_SETTING_UPDATED)
         → 게스트가 수신해 fallback 재계산 (진행 중 수업도 즉시 반영)
소켓 이벤트로 두면 안 된다. /sfu 핸드셰이크에는 인증이 없고 (io.use() 미들웨어 없음, 클라이언트 auth 는 인스턴스 핀 용도뿐) "guest peer 가 아니면 허용" 같은 가드는 peer 등록 전 소켓이나 별도 연결로 그대로 우회된다. 아동 보호 성격의 안전장치를 끄는 스위치이므로 멤버 인증이 걸린 API 경로만 쓴다.
기본값 ON 을 세 겹으로 보장한다 — Redis 키 미설정 시 true, Redis 조회 실패 시 true(fail-open), 게스트가 설정 이벤트를 아직 못 받았을 때도 true. 소켓 URL 은 요청 x-forwarded-host 기준으로 기존 매핑(socket-config.js)을 재사용해 서버 전용 환경변수를 새로 만들지 않았다.

effective 값을 읽어야 하는 기존 코드 경로 구현 성패

"configured를 덮어쓰지 않는다"는 원칙만 지키면 끝나지 않는다. 현재 코드에는 configured intent를 유일한 소스로 읽어 마이크를 복원하는 경로가 이미 존재하고, 이 경로를 함께 바꾸지 않으면 fallback이 조용히 사라진다.

1. 스텝·활동 전환의 마이크 복원 필수

// use-step-transition.ts — 모두 configured intent(microphoneEnabledRef)만 읽는다
:78  / :102   세션 시작 후   if (microphoneEnabledRef.current) scheduleMicOnAfterFirstResponse()
:116          AI 스텝 복귀   if (microphoneEnabledRef.current) toggleMicrophone(true)
:130-137      비-AI 스텝     toggleMicrophone(false) 후 previousMicState 로 ref 원복
configured가 OFF인 텍스팅 수업에서는 위 조건이 모두 false다. 따라서 활동이 바뀌거나 영상·이미지 스텝을 한 번 지나면 fallback으로 켜둔 AI 입력이 복원되지 않는다. 텍스팅 수업은 활동 전환이 반복되므로 실사용에서 거의 확실히 발생하며, 증상은 "첫 활동에서는 AI가 반응했는데 다음 활동부터 무응답"으로 나타난다.

2. external STT가 configured를 오염시키는 기존 경로

use-guest-page-session.ts:2566  setAgentListeningEnabled(true, "external-stt-enabled")
room-handlers.ts:534-540        STT ON → mic 강제 ON
                                "Disabling external STT must not force listening mode off"
external STT를 켰다 끄면 configured가 ON으로 남는다(기존 동작). 새 설계에서 "진행자 재입장 시 configured 복원"을 하면 복원 대상이 STT가 켜놓은 값일 수 있다. STT 강제 ON을 configured로 볼지, fallback과 같은 별도 temporary allow gate로 볼지 정의해야 한다. 정의하지 않으면 "진행자가 OFF로 뒀는데 복원 후 ON"이라는 설명 불가능한 상태가 생긴다.

3. VAD 프로필이 시딩되지 않은 채 AI와 직접 대화하게 된다

VAD 프로필 시딩은 isVoiceUser 게이트 안에 있다(room-handlers.ts:166). 텍스팅 아동은 시딩 대상이 아니므로 fallback으로 듣기가 켜지면 기본 VAD 설정으로 AI와 대화한다. 끼어들기·오탐 여지가 음성 수업보다 크다.
권고: Phase 1에서는 시딩하지 않는다. 목적은 입력 경로 복구이고, VAD까지 건드리면 실패 모드가 늘어난다. fallback이 발동한 세션의 끼어들기·오인식 로그를 확인한 뒤 별건으로 판단한다.

서버 발행 지점

경로presence 처리
HOST_START_MONITORING포커스뷰 claim 등록 후 room에 최신 snapshot 발행
HOST_STOP_MONITORINGclaim 삭제 후 남은 유효 claim 수로 재계산
HOST_START_GROUP_MONITORING 추가카드뷰 그룹 claim 등록. 해당 groupId에 속한 모든 room에 재계산 발행 (monitoring-handlers.ts:277)
HOST_STOP_GROUP_MONITORING 추가그룹 claim 삭제 후 같은 범위로 재계산
JOIN_ROOM role:"monitor" / leaveauthoritative presence는 변경하지 않고 claim과 socket 멤버십 mismatch 진단 메트릭만 갱신 (connection-handlers.ts:26)
Socket disconnect / explicit leave해당 socket의 claim cleanup 후 영향받은 room/group을 중앙 helper로 재계산
zombie claim cleanup삭제가 완료된 뒤 재계산
GUEST_REQUEST_ENTRY초기 hasActiveMonitor snapshot 반환 (기존 응답에 필드 추가)
GET_ROOM_MONITOR_PRESENCE세션 시작 시 authoritative snapshot 반환. self peer 기준 room 접근 권한 검증 후에만 응답(불일치 시 FORBIDDEN) 구현 변경
JOIN_ROOM role:"guest" 추가조인 완료 시 해당 소켓에만 현재 snapshot push(재연결 하이드레이션). currentRevision 사용 — INCR 하지 않는다
그룹 claim은 room 단위가 아니라 group 단위이므로, 발행 대상 room 집합을 routerManagergroupId 매핑으로 역산해야 한다. 이 발행이 누락되면 카드뷰 진행자 입·퇴장이 게스트에 반영되지 않아 1번(조회 축소)의 전제가 깨진다.
중앙화 원칙: start/stop 핸들러마다 emit을 복제하지 않는다. setPeer/deletePeer 이후 영향받은 room 목록을 계산해 publishRoomMonitorPresence를 호출하는 공통 경로를 둔다. disconnect, REST peer 제거, reaper/zombie cleanup도 같은 helper를 사용해야 발행 누락을 막을 수 있다.
발행 빈도 상한을 함께 설계한다.
  • 그룹 claim start/stop은 그룹 내 모든 room에 발행 + 룸별 revision INCR → 비용이 (진행자 수 × 룸 수)로 늘어난다. 대시보드 진입·새로고침마다 발생한다.
  • JOIN_ROOM role:"monitor"는 authoritative 계산에서 제외했지만, mismatch 진단을 매 join마다 계산하면 카드뷰 진입 시 룸 수만큼 claim+socket 조회가 발생한다.
  • 대응: 같은 room에 대한 발행을 짧은 창(예 200~500ms)으로 coalesce하고, 진단 계산은 샘플링 또는 주기 배치로 분리한다. 게스트는 revision 단조 비교로 중복을 흡수하므로 coalesce가 정합성을 깨지 않는다.

구현 단계

Phase범위비고
Phase 1 구현 완료(PR #935) — 서버 presence 조회·변경 이벤트(2 claim source) · room revision(+복구 경로) · 발행 coalesce · group claim Redis 인덱싱 · 게스트 fallback(입장·재연결 조회 + push 반영) · 기존 마이크 복원 경로를 effective 기준으로 전환 · 모니터 2단계 표시 · 수동 OFF 우선 · 운영 메뉴 전역 스위치(인증 API) · LogRocket 이벤트 기존 응답/이벤트에 필드 추가만. 에러 코드 의미 변경 없음. VAD 시딩과 Prometheus 메트릭은 범위에서 제외(로그·LogRocket 으로 대체)
Phase 2 RoomNotFound → 도메인 상태(joined-room-standalone 등) 분리 게스트 입장 분기 회귀 위험이 있어 단독 PR + 입장 시나리오 회귀 테스트 동반
Phase 1의 메트릭으로 fallback 발동률, claim/socket mismatch, 발동 시 룸 내 monitor socket 수를 남긴다. 오탐(진행자가 있었는데 발동)이 잦으면 claim 인덱스·cleanup·push 누락을 추적할 근거가 되고, 발동이 0에 가까우면 애초에 문제 빈도가 낮았다는 뜻이므로 범위를 축소할 근거가 된다.

검증 시나리오

  1. 진행자 없이 텍스팅 수업에 입장해 첫 AI 세션이 듣기 ON으로 생성되는지 확인한다.
  2. AI 세션을 여러 번 start/stop해도 재조회 없이 ON이 유지되는지 확인한다(push 유지값 사용).
  3. 진행자가 포커스뷰에 있는 텍스팅 수업은 기존처럼 듣기 OFF로 생성되는지 확인한다.
  4. ★ 활동을 2개 이상 진행해 스텝·활동 전환 후에도 fallback ON이 유지되는지 확인한다. 영상/이미지 스텝을 지나 AI 스텝으로 복귀했을 때 AI가 아동 음성에 계속 반응해야 한다. 가장 실패하기 쉬운 항목 — configured intent만 읽는 복원 경로가 남아 있으면 두 번째 활동부터 무응답이 된다.
  5. 비-AI 스텝(full:video / full:image) 재생 중에는 fallback 상태에서도 AI 입력이 mute이고 영상 위로 AI 발화가 흐르지 않는지 확인한다(PPI-907 회귀 방지). 이때 모니터 듣기 표시도 OFF 로 내려간다.
  6. ★ 진행자 수동 조작 우선 — fallback 으로 ON 표시된 상태에서 듣기 버튼을 누르면 실제로 OFF 되고 AI 가 아동 음성에 반응하지 않는지 확인한다. 다시 누르면 ON 이 된다.
    초안 계약에서는 이 클릭이 무시됐다 — configured 만 false 가 되고 fallback 이 유지되어 계속 듣는 상태였다.
  7. 수동 OFF 후 다음 활동으로 넘어가면 억제가 풀려 presence 기준으로 재판정되는지 확인한다 (진행자가 여전히 있으면 OFF 유지, 이탈했으면 다시 ON).
  8. ★ 진행자가 통합 모니터링(카드뷰)만 열어둔 텍스팅 수업도 듣기 OFF로 생성되는지 확인한다.
    재현 전제: 텍스팅 수업은 1:1 진입 시 포커스뷰로 직행하므로, 이 케이스를 만들려면 non-voice 아동을 class_groups에 편성해 카드뷰(그룹 페이지)에 노출시켜야 한다. 편성이 불가능하면 이 시나리오는 해당 없음으로 표기하고, ②는 방어적 코드로 남긴다.
  9. 카드뷰 진행자가 대시보드를 닫으면(그룹 claim 삭제) 듣기 ON으로 전환되는지 확인한다.
  10. external STT를 켰다 끈 뒤 진행자가 재입장했을 때 복원되는 configured 값이 의도와 일치하는지 확인한다.
  11. 활성 AI 세션 중 포커스뷰 진행자가 이탈하면 듣기 ON으로 전환되는지 확인한다.
  12. 진행자가 재입장하면 자동 fallback만 해제되고 원래 OFF가 복원되는지 확인한다.
  13. AI 세션 정지 중 진행자 상태가 바뀐 뒤 다음 start가 최신값(push로 갱신된 ref)을 쓰는지 확인한다.
  14. Socket 재연결(네트워크 순단·새로고침) 후 진행자 상태가 바뀌어 있었다면 서버 join push 로 복구되는지 확인한다. 클라이언트가 별도로 조회하지 않아도 반영돼야 한다.
  15. presence 조회 권한 — 다른 수업의 roomIdget-room-monitor-presence 를 호출하면 FORBIDDEN 이 반환되는지 확인한다(자기 방·자기가 모니터링하는 방만 허용).
  16. presence 조회 timeout(ack 무응답 3초·소켓 미연결 포함) 시 재시도 후 unknown 확정 → ON 되는지, 게스트 콘솔·LogRocket ListeningFallback:Activatedpresence: "unknown" 으로 그 경로가 구분되는지 확인한다.
  17. 음성 수업, host relay, 녹음 producer, 진행자 UI의 configured 듣기 값이 변경되지 않는지 회귀 확인한다.
  18. room socket 멤버십만 남고 claim이 없는 전환·cleanup 구간에서 presence는 absent를 유지하고 mismatch 메트릭만 증가하는지 확인한다.
  19. 반대 방향 — claim은 있으나 room socket 멤버십이 없는 구간(카드 언마운트 등)도 presence는 present를 유지하고 mismatch만 증가하는지 확인한다.
  20. 동일 room의 오래된 revision 이벤트가 늦게 도착해도 게스트가 무시하는지 확인한다.
  21. revision counter가 리셋된 상황(Redis 키 삭제 후)에서 재연결 snapshot으로 presence가 복구되는지, 그리고 Redis 미연결 배포에서 revision 비교가 꺼진 채 push가 정상 적용되는지 확인한다.
  22. 다중 Socket 인스턴스에서 진행자와 게스트가 다른 인스턴스에 연결되어도 focus/group claim 판정 결과가 같은지 확인한다.
  23. 전역 스위치(main/message) OFF 시 진행 중 수업까지 즉시 비활성되고 기존 동작으로 복귀하는지, ON 으로 되돌리면 다시 활성되는지, 기본값이 ON 인지 확인한다.
  24. 전역 스위치 권한 — 일반 진행자(manager) 계정에서는 변경이 차단되는지(403) 확인한다. admin/developer 만 변경할 수 있어야 한다.

LLM용 구현 요약

Socket/SFU 서버에 room monitor presence 조회·변경 이벤트를 추가한다. presence는 두 authoritative claim source의 OR로 정의한다 — ① monitoring-claim(monitoringRoomId === roomId, 포커스뷰), ② group-monitoring-claim(monitoringGroupId === room.groupId, 카드뷰). room socket 멤버십의 host role은 claim과의 mismatch 진단에만 사용한다. ①만 쓰면 카드뷰로만 진입한 진행자를 absent로 오판하므로 ②를 둔다. 다만 그룹 모니터링 아동은 음성 필수이고 1:1 세션은 포커스뷰로 직행하므로, ②가 판정을 좌우하는 경우는 non-voice 아동이 class_groups에 편성된 수업뿐이다 (해당 편성의 실존은 운영 데이터로 확인해야 하며, 없으면 ②는 방어적 코드다). 현재 peer-store.ts는 group claim을 groupMonitorKey에 등록·삭제하지 않으므로 이 인덱스를 먼저 보완한다. 단 그룹 인덱스에는 포커스뷰 claim이 이미 섞여 들어가므로 peerKind 필터 정책을 함께 정한다. 모든 claim mutation과 cleanup은 중앙 presence 발행 helper를 호출하고, Redis INCR 기반 room revision을 snapshot/push에 포함한다. push는 revision 단조 비교로 걸러내되 snapshot revision은 무조건 채택해 counter 리셋에서 복구하고, Redis 미연결 배포에서는 revision 비교를 끈다. 그룹 발행과 mismatch 진단은 room 단위로 coalesce한다. 게스트는 presence를 AI 세션 바깥에서 수업 전체에 걸쳐 유지한다. configured intent만 읽는 기존 마이크 복원 경로(use-step-transition.ts:78/102/116/130-137)를 effective 기준으로 함께 바꿔야 한다 — 바꾸지 않으면 활동 전환 한 번에 fallback이 소실된다. 비-AI 스텝에서는 fallback 상태에서도 mute를 유지한다(PPI-907). external STT의 기존 강제 ON 경로가 configured를 오염시키므로 복원 대상 정의를 명시한다. VAD 프로필 시딩은 Phase 1 범위에서 제외한다. snapshot 조회는 세션 시작 시에만 하고(클라이언트 roomId 를 신뢰하지 않고 self peer 기준으로 권한 검증), 재연결 하이드레이션은 서버가 JOIN_ROOM 시점에 push 한다. 그 외 세션 start는 push로 갱신된 ref 값을 읽어 startSession({ initialListeningEnabled })로 전달한다. 텍스팅 수업에서 presence가 absent면 설정값을 바꾸지 않은 채 agent input만 ON한다. unknown은 bounded retry(ack 오류·무응답·소켓 미연결 포함) 후 확정해 fail-open으로 처리한다. 모니터 UI 는 configured || fallbackActive 실효 상태만 ON/OFF 2단계로 표시하고 토글도 그 값을 기준으로 계산한다. 진행자가 수동으로 듣기를 OFF 하면 fallback 을 억제하며(presence 보다 우선), 억제는 다음 활동에서 해제한다. 전역 스위치는 소켓 이벤트가 아니라 멤버(admin/developer) 인증이 걸린 /api/settings/listening-fallback → 소켓 내부 REST 경로로만 변경한다(소켓 핸드셰이크 무인증). 활성 세션 중 presence 변경도 같은 계산식으로 반영하고, 진행자가 돌아오면 fallback만 해제해 configured listening을 복원한다. 발행 지점에 그룹 모니터링 start/stop과 모든 claim cleanup 경로를 반드시 포함한다.

관련 코드 · 문서

대상역할
apps/socket/src/sfu-socket/handlers/monitoring-handlers.tsmonitoring claim(포커스뷰 :180~) · group claim(카드뷰 :277~) 생성·삭제, resolveOtherMonitor(:62) · isMonitorAlive(:105) 좀비 정리
apps/socket/src/sfu-socket/handlers/connection-handlers.tsJOIN_ROOM — monitor role peer가 roomId: null로 저장되는 지점(:113). authoritative claim과 비교할 socket membership 진단 근거
apps/socket/src/redis/peer-store.tsgetMonitorsInRoom(:199) · getPeerIdsForSocket(:188) · getPeer(:229). 현재 group claim index 등록·삭제가 누락되어 조건 보완과 getMonitorsInGroup 신규 추가 필요
apps/web/features/session/ui/session-card.tsx카드뷰 — role: "monitor" 룸 조인(:196)과 sendMessage 텍스트 주입(:688). 카드뷰가 전달 주체임을 보여주는 근거
apps/web/lib/voice-agent/voice-input-control.tsconfigured/effective 분리 지점(:43-56). fallback은 temporary allow로 추가
apps/socket/src/sfu-socket/handlers/room-handlers.ts게스트 입장, 프로필 기준 isVoiceUser 판정(:143)과 룸 기본값 권위적 리셋(:155)
apps/web/entities/guest-socket/model/use-guest-socket.ts입장 응답·presence snapshot/event 수신
apps/web/entities/guest-page-session/model/use-guest-page-session.ts수업 생명주기 presence 유지와 반복 세션 start/stop 조정. external STT 강제 ON 경로(:2566)
apps/web/entities/guest-page-session/model/use-step-transition.tsconfigured intent만 읽는 마이크 복원 경로(:78 / :102 / :116 / :130-137)와 비-AI 스텝 가드(:124-128). effective 전환 대상
apps/web/entities/guest-session/model/use-ai-session.tsagent input의 initial/effective listening 적용. setListeningFallbackActive(temporary allow gate)
apps/socket/src/sfu-socket/monitor-presence.ts구현 — presence 판정(claim 2소스)·revision·coalesce·중앙 발행 helper. 판정 규칙은 isFocusClaim/isGroupClaim 으로 export 되어 테스트로 고정
apps/socket/src/sfu-api/settingsHandlers.ts구현 — 전역 스위치 내부 REST(X-Internal-Api-Key)
apps/web/app/api/settings/listening-fallback/route.ts구현 — 멤버(admin/developer) 인증 게이트 + 소켓 프록시
apps/web/components/sections/listening-fallback-section.tsx구현 — 운영 메뉴(main/message) ON/OFF UI
apps/socket/test/monitor-presence.test.ts구현 — claim 판정 회귀 테스트(group 인덱스에 섞인 포커스 claim 오인 방지 포함)