마지막 업데이트 2026-07-28
듣기 ON 한다.
effectiveListeningEnabled =
configuredListeningEnabled ||
(fallbackFeatureEnabled
&& isTextingLesson
&& !manualListeningOff // 진행자 수동 OFF 가 presence 판정을 이긴다
&& monitorPresence !== "present")
needs-qa).
peerKind === "monitoring-claim" 단독 판정은
카드뷰로만 진입한 진행자를 absent로 오판한다.
포커스뷰·카드뷰 claim의 2소스 OR로 교체하고 room socket 멤버십은 진단 신호로 분리.
(영향 범위는 아래 오판 조건의 실제 범위 참조 — 초안 표현이 과장이었다.)unknown 즉시 fail-open 조건화 — 재시도 후 확정 + fallback 가시화 + 메트릭을 전제로만 ON.startSession 직전 동기 조회 → 입장·재연결 시에만.
대신 중앙 발행 helper와 room별 monotonic revision을 필수 전제로 추가.3차 개정 (구현 반영) — 구현 중 계약이 바뀐 3곳:
fallbackActive 배지(3단계 표시)를 폐기하고
실효 상태만 노출한다. 상세는 진행자 UI 표시 계약 절.텍스팅 수업에서는 AI의 아동 음성 입력을 막기 위해 기본 듣기 OFF를 사용한다. 진행자는 별도 host relay로 아동 음성을 듣고, 들은 내용을 텍스트 메시지로 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가 생길 수 있음 |
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
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) |
class_groups에 편성된 수업" 하나다.
대다수 텍스팅 수업(1:1)에서는 ①만으로 커버된다.
| 진행자 상태 | 저장 형태 | 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를 추가해야 한다.
getMonitorsInGroup 을 추가했다. collectClaims 가 room·group 두 인덱스에서 remote claim 을 보강한다.
groupMonitorKey는 비어 있는 게 아니라 "포커스뷰 claim만" 들어 있다.
포커스뷰 claim은 monitoringGroupId를 세팅하고(monitoring-handlers.ts:202)
peerKind가 monitoring-claim이라 위 조건을 통과해
peer-store.ts:132에서 그룹 인덱스에 등록된다.
즉 "그룹 인덱스에 있는 peer = 카드뷰 진행자"가 아니다.
getMonitorsInGroup을 만들 때 peerKind로 필터할지, 포커스 claim 혼입을 의도적으로
group presence로 인정할지 명시적으로 정해야 한다. 이 구분 없이 인덱스만 읽으면 판정이 조용히 틀어진다.
monitor-presence: 진행자가 대시보드에 접속한 상태일 뿐이므로 판정에서 제외한다.monitoring-claim / group-monitoring-claim: 특정 room·group을 실제로 점유·모니터링하므로 authoritative 판정에 포함한다.existingProducers: producer 존재는 모니터링 claim과 무관하므로 판정에 쓰지 않는다.peers Map만 보지 않고 Redis claim과 생존 확인을 사용한다.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는 새로 추가한다.
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
조회 결과는 present | absent로 저장한다. 응답 전·timeout·오류는 unknown이다(3번 참조).
isTextingLesson을 계산한다.
서버 판정을 재사용해야 한다면 입장 시 계산한 값을 Redis room metadata에 명시적으로 저장해야 하며,
GUEST_REQUEST_ENTRY 지역 변수만 믿고 이후 조회 응답에 싣지 않는다.
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로 이어진다.
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 <= ref 조건으로 이후 모든 push를 영구히 버린다.
그래서 snapshot 응답의 revision은 단조 비교 대상이 아니라 무조건 채택(ref 덮어쓰기)해야 한다.
snapshot은 입장·재연결에서만 오므로 이 경로가 유일한 복구 수단이다.isRedisEnabled()가 false면 전역 단조 counter를 만들 수 없다.
로컬 counter로 대체하면 인스턴스마다 값이 달라 다른 인스턴스의 낮은 revision을 무시한다.
이 경우 revision 비교를 끄고 항상 적용하는 경로를 명시한다(순서 역전보다 stale 고착이 더 위험).INCR N회를 의미한다.
아래 발행 지점의 비용 상한에 포함해 계산한다.unknown 처리 — 즉시 fail-open 금지조회 실패를 곧바로 듣기 ON으로 처리하면 오작동이 조용히 누적된다. 진행자가 있는데 켜져도 모니터 UI에는 아무 표시가 없어(설정 오염 금지 원칙 때문) 진행자는 "듣기 OFF인데 AI가 반응한다"만 보고 원인을 추적할 수 없다. 조회 실패율이 높은 환경에서는 상시 ON으로 degrade한다.
unknown을 fail-open(듣기 ON)으로 처리한다. 킬스위치가 OFF인 경우에만 기존 fail-closed 동작을 유지한다.
unknown 확정, 그 시점에 ON.
구현에서는 ack 오류뿐 아니라 ack 무응답(3초)과 소켓 미연결도 실패로 처리한다.
무응답을 방치하면 presence 가 영구 unknown 으로 남아 fail-open 이 아니라 기능이 조용히 죽는다.fallbackActive를 read-only 필드로 모니터 카드·포커스뷰에 노출.
configured 값은 건드리지 않으므로 "설정 오염 금지" 원칙과 충돌하지 않는다.unknown 유입률과 fallback 발동률을 계측해 오탐 여부를 운영 데이터로 판단.{
status: "waiting-approval" | "joined-room-standalone",
hasActiveMonitor: boolean,
monitorPresenceRevision: number
}
기존 RoomNotFound는 실제로 room이 없는 뜻이 아니라 “활성 모니터 없음”으로 사용되고 있다. 신규 계약은 이를 도메인 상태로 명확하게 분리한다.
RoomNotFound 문자열에
직접 걸려 있어(use-guest-socket.ts:429-452 — standalone 진입 vs
"조금 일찍 오셨네요" alert) 함께 바꾸면 입장 자체가 회귀할 위험이 있다.
Phase 1에서는 기존 응답에 hasActiveMonitor와 monitorPresenceRevision만
추가하고 에러 코드 의미는 그대로 둔다.
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 세션 STOP | presence와 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 복원 |
| 진행자 수동 듣기 OFF | fallback 억제 → AI 입력 닫힘. 다음 활동에서 억제 해제 구현 변경 |
전역 스위치 OFF (main/message) | 진행 중 수업도 즉시 해제, 기존 fail-closed 동작 |
| 음성 수업 | fallback 조건 밖이므로 기존 동작 유지 |
routerManager.isMicrophoneEnabled나 진행자 UI의 원래 듣기 값을 변경하지 않는다.host-toggle-microphone: true를 재사용하지 않는다. 이 이벤트는 persisted room state와 모니터 UI까지 ON으로 오염시킬 수 있다.stopSession이 presence를 false/unknown으로 초기화하지 않게 한다.startSession({ initialListeningEnabled })로 전달하여 세션 생성 뒤 ON하는 짧은 입력 유실 구간을 없앤다.fallbackActive 배지를 별도 표시(3단계)로 요구했으나,
구현에서는 실효 상태만 ON/OFF 2단계로 노출한다. 상세는 아래 진행자 UI 표시 계약 절.
어느 방식이든 목적은 같다 — 늦게 들어온 진행자가 "듣기 OFF인데 AI가 반응"하는 상황을 오해해
임의 조작하고 결국 텍스트 전달도 없는 원래 상태로 되돌아가는 것을 막는 것이다.main/message) 전역 스위치로 구현했다.
배포 없이 끌 수 있고 진행 중 수업까지 즉시 반영된다. 상세는 아래 전역 스위치 절.agentListeningEnabled(진행자 intent)와 effectiveAgentInputEnabled(임시 gate 반영)의 분리가 있다.
본 설계의 fallback은 temporaryAgentInputBlocked의 반대 방향(temporary allow)을 추가하는 형태로 들어간다.
게스트에 내려가는 reason은 "auto-listening-no-monitor"로 두어 진행자 수동 조작(host-toggle-listening)과
로그상 구분한다(VoiceInputControlReason이 문자열 확장형이라 타입 변경 불필요).
초안은 configured 값을 그대로 보여주고 fallback 은 "자동" 배지로 덧붙이는 3단계 표시를 요구했다.
구현에서는 모니터 UI 가 실효 상태만 ON/OFF 두 단계로 표시한다.
표시값 = configured || fallbackActive // "지금 AI 가 듣고 있는가" 토글값 = !표시값 // ON 으로 보일 때 누르면 실제로 OFF 된다 // 서버 guestState 는 두 값을 분리해 그대로 보존한다 guestState.isMicrophoneEnabled // 진행자가 정한 configured (fallback 이 덮어쓰지 않음) guestState.listeningFallbackActive // 실제 입력이 열린 동안만 true
Off 로 보이는데 AI 는 듣고 있는 상태를 남기고, 그 상태에서 누른 OFF 가
아무 효과도 없어(아래 절) 오히려 혼란을 키웠다.
2단계 표시 + 수동 OFF 우선 규칙이면 보이는 대로 눌러서 보이는 대로 동작하므로
같은 목적을 더 단순하게 달성한다.
/main/debug/rooms 의 isMicrophoneEnabled vs
listeningFallbackActive, 또는 LogRocket ListeningFallback:* 이벤트로 판별한다.
configured 원본이 서버에 보존되므로 진행자 복귀 시 원래 값으로 자동 복원되는 계약은 그대로다.
effective = configured || fallback 만으로는
진행자가 듣기를 직접 OFF 해도 fallback 이 살아 있어 AI 입력이 유지된다.
진행자 입장에서는 "껐는데 계속 듣는다"가 되고, 텍스팅 수업에서 아동 발화를 AI 에 넣지 않겠다는
치료적 판단이 무시된다. presence push 도달 전 창이나 presence 오판 시 실제로 발생한다.
버튼 조작 자체가 진행자 존재의 가장 강한 신호다. 따라서 수동 OFF 는 presence 판정을 이긴다.
| 이벤트 | 처리 |
|---|---|
진행자 수동 듣기 OFF | manualListeningOff = true → fallback 즉시 해제 → AI 입력 닫힘. 표시도 OFF |
진행자 수동 듣기 ON | manualListeningOff = false (configured 로 커버되므로 억제 불필요) |
| 다음 활동(새 AI 세션) 진입 | 억제 해제 후 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 경로만 쓴다.
true,
Redis 조회 실패 시 true(fail-open), 게스트가 설정 이벤트를 아직 못 받았을 때도 true.
소켓 URL 은 요청 x-forwarded-host 기준으로 기존 매핑(socket-config.js)을
재사용해 서버 전용 환경변수를 새로 만들지 않았다.
"configured를 덮어쓰지 않는다"는 원칙만 지키면 끝나지 않는다. 현재 코드에는 configured intent를 유일한 소스로 읽어 마이크를 복원하는 경로가 이미 존재하고, 이 경로를 함께 바꾸지 않으면 fallback이 조용히 사라진다.
// 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 원복
scheduleMicOnAfterFirstResponse 게이트를
effectiveListeningEnabled 기준으로 바꾼다.full:video / full:image)에서는 fallback도 mute를 유지한다.
이 가드를 우회하면 영상·이미지 위로 AI 발화가 흐르는 문제가 재발한다
(use-step-transition.ts:124-128 — PPI-907 revert 사유).toggleMicrophone(false)가 ref를 덮어쓰고 원복하는 패턴이 있으므로,
fallback을 별도 상태로 두고 계산된 effective를 적용하는 구조여야 한다.
ref를 직접 true로 써서 해결하려 하면 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"
isVoiceUser 게이트 안에 있다(room-handlers.ts:166).
텍스팅 아동은 시딩 대상이 아니므로 fallback으로 듣기가 켜지면 기본 VAD 설정으로 AI와 대화한다.
끼어들기·오탐 여지가 음성 수업보다 크다.
| 경로 | presence 처리 |
|---|---|
HOST_START_MONITORING | 포커스뷰 claim 등록 후 room에 최신 snapshot 발행 |
HOST_STOP_MONITORING | claim 삭제 후 남은 유효 claim 수로 재계산 |
HOST_START_GROUP_MONITORING 추가 | 카드뷰 그룹 claim 등록. 해당 groupId에 속한 모든 room에 재계산 발행 (monitoring-handlers.ts:277) |
HOST_STOP_GROUP_MONITORING 추가 | 그룹 claim 삭제 후 같은 범위로 재계산 |
JOIN_ROOM role:"monitor" / leave | authoritative 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 하지 않는다 |
routerManager의
groupId 매핑으로 역산해야 한다. 이 발행이 누락되면 카드뷰 진행자 입·퇴장이 게스트에 반영되지 않아
1번(조회 축소)의 전제가 깨진다.
setPeer/deletePeer 이후 영향받은 room 목록을 계산해
publishRoomMonitorPresence를 호출하는 공통 경로를 둔다.
disconnect, REST peer 제거, reaper/zombie cleanup도 같은 helper를 사용해야 발행 누락을 막을 수 있다.
INCR →
비용이 (진행자 수 × 룸 수)로 늘어난다. 대시보드 진입·새로고침마다 발생한다.JOIN_ROOM role:"monitor"는 authoritative 계산에서 제외했지만,
mismatch 진단을 매 join마다 계산하면 카드뷰 진입 시 룸 수만큼 claim+socket 조회가 발생한다.| 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 + 입장 시나리오 회귀 테스트 동반 |
full:video / full:image) 재생 중에는 fallback 상태에서도 AI 입력이 mute이고
영상 위로 AI 발화가 흐르지 않는지 확인한다(PPI-907 회귀 방지). 이때 모니터 듣기 표시도 OFF 로 내려간다.class_groups에 편성해 카드뷰(그룹 페이지)에 노출시켜야 한다.
편성이 불가능하면 이 시나리오는 해당 없음으로 표기하고, ②는 방어적 코드로 남긴다.roomId 로 get-room-monitor-presence 를
호출하면 FORBIDDEN 이 반환되는지 확인한다(자기 방·자기가 모니터링하는 방만 허용).unknown 확정 → ON 되는지,
게스트 콘솔·LogRocket ListeningFallback:Activated 의 presence: "unknown" 으로
그 경로가 구분되는지 확인한다.main/message) OFF 시 진행 중 수업까지 즉시 비활성되고 기존 동작으로 복귀하는지,
ON 으로 되돌리면 다시 활성되는지, 기본값이 ON 인지 확인한다.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.ts | monitoring claim(포커스뷰 :180~) · group claim(카드뷰 :277~) 생성·삭제, resolveOtherMonitor(:62) · isMonitorAlive(:105) 좀비 정리 |
| apps/socket/src/sfu-socket/handlers/connection-handlers.ts | JOIN_ROOM — monitor role peer가 roomId: null로 저장되는 지점(:113). authoritative claim과 비교할 socket membership 진단 근거 |
| apps/socket/src/redis/peer-store.ts | getMonitorsInRoom(: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.ts | configured/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.ts | configured intent만 읽는 마이크 복원 경로(:78 / :102 / :116 / :130-137)와 비-AI 스텝 가드(:124-128). effective 전환 대상 |
| apps/web/entities/guest-session/model/use-ai-session.ts | agent 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 오인 방지 포함) |