마지막 업데이트 2026-09-21
PR #1122 (PPI-1326). 게스트 수업의 두 조립 훅 use-guest-page-session(3,861줄)과 use-ai-session(5,514줄)은 단위 테스트가 없고 콜백 하나가 ref 수십 개를 공유해 국소 이해가 불가능했다. 이번 변경은 결정 로직을 순수 함수로, 상태·타이머·부수효과를 관심사별 자매 훅으로 옮기고 조립 훅은 연결만 맡게 한다. 두 훅의 props·return 계약, 로그 문구·태그, OpenAI data channel 메시지는 그대로이며 호출처 파일은 수정하지 않았다. 리뷰 시 가장 먼저 볼 지점은 자매 훅 결과 객체를 useCallback deps에 넣지 않았는지(구현 중 잡힌 P1)와 startSession 안에서 순서가 한두 줄 바뀐 세 곳이다. 실기기 검증은 아직 하지 않았다.
9/18 리팩터링 우선순위 분석이 지목한 1순위를 실행한 것이다. 그 문서는 use-ai-session 성장의 3분의 1만 런타임 3종(realtime·tts·livekit) 탓이고, 듣기·마이크 제어가 코드 16%인데 변경 빈도 30%를 차지한다고 봤다. 이번 분해도 같은 결론을 따라 듣기·마이크(use-listening-control, 약 900줄)를 가장 큰 단위로 떼어냈다.
GUEST_PAGE_SESSION/AI_SESSION 유지 — 현장 판별법이 로그 문자열에 의존), 의존 방향 lib ← 자매 훅 ← 조립 훅, 자매 훅끼리 import 금지.prompt-test, guest-layout) 변경.use-session-audio·use-response-gate·use-auto-start-finish·use-transcript-pipeline을 제안했다. 실제로는 오디오 출력·Realtime 세션 설정·끼어들기·듣기·대화 로그 5개 훅과 이벤트 핸들러 디스패처로 나눴고, 응답 게이트(setResponseBlocked·full 영상 전환 락)와 자동종료 연결은 useSessionAutoFinish와 자매 훅 여러 개를 동시에 건드려 조립 훅에 남겼다.순수 함수 (결정만)
entities/*/lib/
입력을 받아 결정 객체를 돌려준다. React·로그·소켓 없음. 자동종료 분기, VAD 리듀서, early 윈도우, data channel 메시지 빌더.
자매 훅 (상태·타이머 소유)
entities/*/model/use-*.ts
자기 관심사의 ref·state·effect만 만든다. 공유 ref는 조립 훅에서 RefObject로 받는다. 결정을 적용하고 로그를 원래 위치에서 남긴다.
조립 훅 (연결·오케스트레이션)
use-guest-page-session.ts · use-ai-session.ts
공유 ref(transport 11개 등) 소유, 훅 간 콜백 연결, startSession·stopSession 순서, return 객체.
| 옮긴 코드 | 새 모듈 | 소유 |
|---|---|---|
| 수업 데이터 로드 effect 300줄, 캐싱·리소스 실패 state 9개 | use-lesson-data-loader.ts | user·lesson·activities·isLoading·cachingProgress·resourcesReady·resourceLoadFailed. 초기 스텝은 콜백으로 조립 훅에 반환 |
| handleAutoFinish 375줄, goToNextStep, skipCurrentActivity, 진행자 종료 요청, 이동 증거 저장 | lib/step-navigation.ts + use-step-navigation.ts | 종료 예약(ref 3개 → 객체 1개), full 영상 전환 락, goToNextStepRef |
| 음성제어 prepare/commit/abort, 스냅샷 적용, join 컨텍스트, 영수증 보류 | use-guest-voice-control.ts | 전환 상태, 스냅샷 관찰자, hasRevisioned 플래그 |
| 외부 STT용 AI 오디오 캡처(ScriptProcessor·프리버퍼·flush) | use-ai-audio-stt-capture.ts | AudioContext·노드 ref, 프리버퍼, 타이머 |
| 환경소음 알림 state·ref·리스너·미러 emit | use-ambient-noise-alert.ts | 표시 여부, 템플릿, 15초 타이머, 지연 스냅샷 버퍼 |
| 모니터 presence revision·재시도, 자동듣기 복구 요청 | use-monitor-presence.ts | revision, 재시도 예산, presence 상태, 설정 스위치 |
| 친구 선택 뷰 계산, selectFriendChoice | lib/friend-selection.ts + use-friend-selection.ts | 없음 (순수 계산 + 소켓·로그 호출) |
| 옮긴 코드 | 새 모듈 | 비고 |
|---|---|---|
| 콜백 ref 17개 + 동기화 effect 17개 | shared/lib/use-latest-ref.ts | 하나의 callbacksRef. 갱신 시점은 기존과 같이 커밋 뒤 effect |
| data channel 메시지 인라인 객체 6곳 이상 | lib/realtime-session-messages.ts | 키 순서 유지, JSON 형태 테스트 |
| waitForStt·VAD·noise reduction·turnDetectionRef | lib/turn-detection-settings.ts + use-realtime-session-config.ts | Realtime은 session.update, LiveKit은 setVadOptions RPC |
| 끼어들기 mode, early 윈도우 상태머신, LiveKit admission | lib/early-window.ts + use-interrupt-control.ts | 모드 전환 5분기를 순수 함수로 |
| 마이크 의도, 입력 게이트, AEC 지연 복원, relay 뮤트 (약 900줄) | use-listening-control.ts | AI 발화 시작/종료 시 마이크 처리는 handleAssistantAudioStarted·applyAssistantAudioEndPolicy·scheduleMicRestoreAfterAssistantAudio로 노출 |
| audio element, sink, 공유 AudioContext, warmUp, 헬스 로그 | use-ai-audio-output.ts + lib/audio-element-health-log.ts | ensureAudioElement(deviceId)는 startSession이 캡처한 deviceId를 그대로 받음 |
| 응답 트랜스크립트 맵, 아이템 메타, pending 텍스트 로그, 재생 시간 | lib/conversation-item-log.ts + use-conversation-log.ts | TTL·상한 20 큐 관리는 순수 함수 |
| handleVoiceSessionEvent switch 1,090줄 (case 16개) | lib/voice-session-event-handlers/ 9파일 + index.ts 디스패처 | 핸들러는 VoiceSessionEventContext만 받음. 조립 훅이 컨텍스트 객체를 만들어 넘김 |
| startSession의 참여자 조회·라우팅, 마이크 획득, TTS 플레이어 생성, PC 핸들러 | lib/session-start/ 4파일 | 오케스트레이션(abort·generation·LiveKit 콜백)은 훅에 남음 (약 600줄) |
| 대화시작 멘트 스킵 판정·인스트럭션 해석, inbound stats 선택 | lib/auto-start-message.ts, lib/ai-audio-inbound-stats.ts |
콜백 대부분이 dataChannel·liveKitSession·isActive 같은 ref 11개를 함께 읽는다. 개별 전달 대신 useRef({...}).current로 한 번 만든 묶음을 넘겨 자매 훅 시그니처를 줄였다. 묶음 안의 ref는 모두 안정된 객체이므로 identity 문제가 없다.
끼어들기 훅의 applyEffectiveInterrupt는 듣기 훅의 runAgentInputPolicyEvent를 부르고, 듣기 훅의 runLiveKitInputGateEvent는 끼어들기 훅의 interruptModeRef를 읽는다. 기존 코드의 cancelResponseRef 패턴을 그대로 써서 조립 훅이 빈 ref를 먼저 만들고 듣기 훅 정의 직후 채운다. 첫 렌더 중에는 no-op이지만 이 함수는 이벤트·effect에서만 호출된다.
자매 훅이 돌려주는 객체는 렌더마다 새 리터럴이다. 이를 stopSession deps에 넣으면 stopSession identity가 매 렌더 바뀌고, useEffect(() => () => stopSession(...), [stopSession])인 언마운트 정리가 매 렌더 재실행돼 활성 세션을 끊는다. 구현 중간에 넣었다가 리뷰에서 걷어냈다. 이후 이 두 훅을 수정할 때 같은 함정을 반복하기 쉽다.
조립 훅의 handleVoiceSessionEvent는 필요한 ref·함수를 그룹별 객체로 묶어 넘기기만 한다. deps 배열은 기존 11개를 그대로 유지했다. 핸들러 파일은 ctx.listening.applyAssistantAudioEndPolicy()처럼 자매 훅 API를 통해서만 상태를 바꾼다.
페이지 세션의 handleAutoFinish는 "trigger 로그 → notifyAutoFinish → 전환 감시" 뒤에 리졸버를 부르고, 결정 종류별로 기존 문구를 그대로 찍는다. 종료 예약 무효 시 폴백 로그도 invalidPending 값으로 재현한다.
| 레이어 | 파일 | 핵심 변경 |
|---|---|---|
| 조립 훅 | entities/guest-page-session/model/use-guest-page-session.ts | −1,850줄. 자매 훅 7개 호출과 콜백 연결, 공유 ref·소켓·미디어 훅 조립, return 객체 유지 |
| 조립 훅 | entities/guest-session/model/use-ai-session.ts | −2,557줄. transport 묶음 소유, 자매 훅 5개 조립, startSession·stopSession 오케스트레이션, 디스패처 컨텍스트 조립 |
| 자매 훅 | entities/guest-page-session/model/use-*.ts 7개 | 각 관심사의 state·ref·effect·소켓 리스너 소유 |
| 자매 훅 | entities/guest-session/model/use-*.ts 5개 | 오디오 출력·세션 설정·끼어들기·듣기·대화 로그 |
| 순수 함수 | entities/guest-page-session/lib/ 2개, entities/guest-session/lib/ 6개 | 결정·빌더·리듀서. React 의존 없음 |
| 이벤트 핸들러 | entities/guest-session/lib/voice-session-event-handlers/ 10개 | context 타입·헬퍼, 이벤트별 핸들러 9파일, 디스패처 |
| 세션 시작 | entities/guest-session/lib/session-start/ 4개 | 참여자 조회·라우팅, 마이크 획득, TTS 플레이어, PC 핸들러 |
| 공용 | shared/lib/use-latest-ref.ts | 콜백 최신값 ref |
| 테스트 | *.test.ts(x) 29개, __tests__/ 픽스처 2개 | vitest + renderHook. test:guest-page-session(94개), test:ai-session(72개) |
| 문서 | docs/superpowers/specs/ 1개, docs/superpowers/plans/ 2개 | 설계서와 체크포인트 계획 |
| 항목 | 방법 | 결과 |
|---|---|---|
| return 객체 키 | 두 훅의 return {} 키를 추출해 정렬 비교 | 68개 / 46개 동일 |
| props 인터페이스 | UseAiSessionProps 본문 해시 비교 | 동일 |
| 로그 문구 | logger.*(“…”) 단일행·다중행·템플릿 문자열을 HEAD 훅 vs 새 모듈 집합으로 집계 | 동일 |
| socket emit 이벤트 | 페이지 세션의 socket.emit( 이벤트명 집계 | 동일 |
| data channel 메시지 | 빌더 출력 JSON을 기존 인라인 객체와 toEqual | 테스트 4개 통과 |
| 호출처 | git diff 4개 파일 | 변경 없음 |
| 타입·서식 | tsc --noEmit, prettier | 에러 수 베이스라인과 동일(사전 존재 1개), 서식 통과 |
deps 함정. 자매 훅 결과 객체(listeningControl 등)는 렌더마다 새 객체다. 조립 훅의 useCallback deps에는 구조분해한 안정 콜백만 들어가야 한다. 이번 PR은 git grep으로 deps 배열에 훅 객체가 없음을 확인했지만, 이후 수정에서 가장 재발하기 쉬운 지점이다.
startSession 안 순서 변경 3곳. ① checkAborted()가 라우팅 진단 생성(순수 계산) 뒤로 한 줄 이동. ② presetVarsRef 대입이 resolveVoiceSessionForProfile(예외 가능) 뒤로 이동 — 실패 경로는 stopSession()이 presetVarsRef를 비우므로 관찰 결과 같음. ③ PeerConnection의 onconnectionstatechange 등록이 TTS 플레이어 준비 뒤로 이동 — 그 사이에 await가 없어 상태 변화 이벤트가 끼어들 수 없다. 세 곳 모두 코드 리뷰로 근거를 확인할 것.
실기기 미검증. 단위 테스트는 훅을 따로 검증한 것이라 조립 상태의 타이밍은 iPad에서 확인해야 한다. 입장·초기 스텝·대화시작 멘트, 활동 전환 후 듣기 ON 복원과 마이크 토글, 끼어들기 off/early/on 전환 중 AI 발화, 시간종료·대화종료 자동전환과 환경소음 알림, Realtime·LiveKit·TTS 세 런타임의 시작·종료.
미분리 잔여. startSession 약 600줄(LiveKit 연결 콜백 10여 개가 클로저로 결합), handleExternalSttTranscript 174줄(블로킹 모드 분기), dc.onopen 본문은 계획과 달리 분리하지 않았다. 다음 후보다.
미세 의미 변화 2건(동작 동일). STT 캡처 훅의 sendAIAudio와 로더의 cacheResources를 effect 캡처값 대신 최신 ref로 읽는다. 둘 다 deps가 빈 useCallback이라 값이 같다. 1차의 aiSessionRef는 첫 렌더에 null로 시작하고 useAiSession 직후 effect로 채워지며, 읽는 곳이 모두 콜백이거나 뒤에 선언된 effect다.
__tests__/ 픽스처 폴더는 저장소 선례가 없다. 소켓 mock·transport ref 생성기·LiveKit 세션 mock을 11개 테스트가 공유하기 위해 뒀다. 기존 테스트는 파일마다 mock을 인라인 선언한다. 컨벤션으로 채택할지 결정이 필요하다.
web 단독 변경. 소켓 계약·API·DB 무관. 호출처 4개 파일과 use-guest-socket은 diff가 없다. 로그·emit·data channel 메시지가 동일하므로 기존 판별법 문서와 트리아지 도구는 그대로 쓸 수 있다.
lib/session-start/로 나뉜다.use-listening-control이 소유하게 된 입력 게이트·정책 리듀서.use-ai-audio-output)과 STT 캡처 훅이 다루는 경로.