마지막 업데이트 2026-09-18
리팩터링 1순위는 게스트 세션 갓훅 2개(use-ai-session 5,514줄 · use-guest-page-session 3,861줄)이고, 그 전에 테스트 CI 게이트와 V1 사체 삭제를 끝내야 한다. 코드가 계속 커지는 이유는 "에이전트가 분리를 안 해서"가 아니라 이미 큰 파일을 건드릴 때 추출 대신 추가를 선택하게 만드는 지침 공백이다. 파일 크기 기준(300/500/1,000 래칫)과 리뷰 게이트 한 줄이 빠져 있다.
이전 감사 전체 코드 감사 (2026-07-02)가 V1 제거·보안·잠재버그를 다뤘고, 이 문서는 그 뒤 두 달 반 동안의 구조 부채와 성장 메커니즘만 본다.
| 순위 | 대상 | 측정 근거 (사실) | 조치 (추정) |
|---|---|---|---|
| P0-1 | apps/web/entities/guest-session/model/use-ai-session.ts | 5,514줄 · useRef 110 · useEffect 32 · useCallback 56 · 단위테스트 0 · 6개월 변경 93회 | 세션 라이프사이클 / 듣기 의도 / 응답 게이트 / 전사 / 자동 진행 / 오디오로 분할 (§4 설계) |
| P0-2 | apps/web/entities/guest-page-session/model/use-guest-page-session.ts | 3,861줄 · useEffect 41 · useState 31 · return 객체 143줄 · 변경 105회 | 오케스트레이터가 갓훅화. use-step-sync·use-step-transition처럼 이미 분리된 패턴을 나머지에도 적용 |
| P0-3 | apps/web/app/monitor-dashboard/[group]/[roomId]/page.tsx | 2,332줄 · import 50 · useEffect 20 · 인라인 핸들러 15 · 변경 106회 (전체 1위) | 페이지는 조립만. 로직은 widgets/monitor-dashboard/*로 |
| P1-1 | 테스트 CI 게이트 부재 | 테스트 파일 391개 존재. 워크플로우에 vitest/tsc 없음. apps/web/package.json에 test:* 스크립트 12개 산재 | P0 리팩터링의 안전망. 통합 test·check-types + PR 게이트 먼저 |
| P1-2 | apps/web/lib/db-queries.ts | 5,488줄 · export 132 · API 라우트 86개 의존 · 한 달 +1,379 | 도메인별 DAO 분리. lib/api/dao/voice-pipeline.dao.ts 패턴 존재 |
| P1-3 | V1 죽은 코드 | 미참조 훅 6개: use-session-manager(1,371) use-screen-share(313) use-ice-restart use-camera-stream use-network-recovery use-auto-start-on-guest-ready + 체인 끝 use-web-rtc(969) | 삭제만으로 약 3,000줄 감소. 위험 최저 |
| P1-4 | apps/socket/src/sfu-socket/handlers/avatar-handlers.ts | 2,647줄에 socket.on 11개 (이벤트당 약 240줄) · connection-handlers.ts 1,943줄 | 이벤트 핸들러 → 서비스 함수 추출. §6의 VP 통합 커밋이 붙인 7.5k가 여기 |
| P2-1 | today-class-list.tsx / all-class-list.tsx | 2,158 + 1,177줄 · 동일 라인 289개 · 공유 핸들러 26개 | 공통 리스트 + 날짜 범위 prop |
| P2-2 | any 사용 | 138건 · use-monitor-session.ts 21건 집중 (소켓 이벤트 payload) | 소켓 이벤트 타입을 packages/shared로 |
| P2-3 | 디렉터리 이중 구조 | FSD(entities/features/widgets) vs 레거시 components/sections 105개 · hooks/ 99개 | 신규는 FSD만, 레거시는 터치할 때 이동 |
| P3 | apps/web/lib/tts/typecast-voices.ts | 13,416줄 순수 데이터 | JSON 분리 |
| 순위 | 대상 | 측정 근거 (사실) | 조치 (추정) |
|---|---|---|---|
| P0 | 미참조 컴포넌트 10개 삭제 | 어디서도 import 안 됨: performance-monitor(537) manual-voucher-modal(480) host-video(367) lesson-date-time-picker(234) guest-entry-popup(196) card-end-menu(186) webrtc-status-indicator(156) monitor-settings-modal(154) issue-input-panel(142) interrupt-mode-button(134) | 약 2,600줄 + 대응 CSS 1,500줄 제거. 동작 변경 0 |
| P0 | 모니터 룸 페이지 분해 | §1 P0-3과 동일 | 동일 |
| P1 | 모달 31개 → useOverlay 일원화 | modal/dialog 이름 31개 중 useOverlay 사용 7개, if (!isOpen) return null 자체 구현 12개, createPortal 직접 7개. CSS .cancelButton 15회·.confirmButton 14회·.closeButton 10회 중복 선언 | CLAUDE.md 규칙 위반 상태. 공통 Confirm/Form 모달 셸 + 버튼 스타일 하나로 |
| P1 | alert()/confirm() 직접 호출 | 156회 (37 / 26 파일) | 위 모달 통합과 같은 작업 |
| P1 | 테이블 34개 인라인 구현 | <table 직접 작성 34파일 · CSS .td 14회·.th 11회 중복 | shared/ui/data-table. debug 페이지 3개(redis 880·rooms 603·peers 427, useState 9~10·fetch·table 구조 동일)가 첫 대상 |
| P1 | 대형 폼 4개 | avatar-form 1,330(useState 28) · lesson-template-form 1,033 · activity-form 905(useState 25) · lesson-form 855. 공통 식별자 5개뿐 | 통합 아니라 각 폼을 섹션으로 분할. useState 25~28은 useReducer 대상 |
| P2 | features/session/ui/session-card.tsx | 1,237줄 · import 48 · useCallback 13 · 변경 90회 | 상태·액션을 features/session/model로 |
| P2 | widgets/guest/guest-layout/ui/guest-layout.tsx | 662줄 (앱 레이아웃 4개 합계 135줄의 5배) | 디버그 패널·라우팅 상태는 stories 있는 하위 위젯으로 |
| P2 | shared/ui/meet-video.tsx / friend-choice-screen.tsx | 1,154줄(useEffect 18, 참조 6곳) / 840줄 | 미디어 element 관리 훅과 UI 분리 |
| P2 | CSS 모듈 6,897줄 | chat 781 · assignment-editor 740 · avatar-form 647 · .container 15회 중복 | 버튼·테이블·모달 셸 토큰화 후 자연 감소 |
| P3 | Storybook 커버리지 | stories 33개 · components/sections 76개 중 12개 | 위 통합 시 규칙대로 동반 추가 |
2026-01-15 생성 741줄 → 02월 853 → 04월 1,831 → 06월 2,618 → 07/13 3,222
→ 07/15 4,007 (+785, LiveKit self-hosted 도입) → 08월 4,362 → 09/01 5,206 → 09/18 5,514
8개월간 커밋 124개, 티켓 61개의 수정이 한 훅에 누적됐다. 원인은 네 가지다.
| 원인 | 측정 (사실) |
|---|---|
| 전송 계층 교체를 "추가"로 처리 | LiveKit self-hosted 커밋 +783 (d8933ee2, 리버트 후 재적용으로 두 번 기록), VP 대화 모드 +419 (b7db1a7c). Realtime 경로를 지우지 않고 옆에 붙였다 |
| 로그가 코드의 11% | logger 호출 145개, 다중행 포함 589줄. "Realtime 로그 정렬 메타데이터" 커밋 2건만 +343, "세션 시작 진단 로그 보강" +221 |
| 버그 수정마다 가드 ref와 타이머 하나씩 | useRef 110개, setTimeout/clearTimeout 42회. PPI-1219 재응답 차단 +139, PPI-1252 후속 발화 차단 +130, PLC 오탐 방지 +142, PPI-1101 EC/NC/AGC 전환 +122 |
| 이벤트 디스패처가 한 함수 | handleVoiceSessionEvent 4020행부터 1,150줄, case 16개. startSession 2833행부터 1,187줄 |
startSession 1,187줄 중 LiveKit 언급 80줄, Realtime PC/DC 67줄, TTS 37줄. 나머지 약 1,000줄은 마이크 획득·오디오 엘리먼트·generation 가드·로그로 세 런타임 공통.lib/voice-agent/session-events.ts가 3 런타임을 18종 공통 이벤트로 정규화해서 1,150줄 핸들러 안의 런타임 분기는 44줄뿐.| 구분 | 줄수 | 위치 |
|---|---|---|
| 전용 콜백 17개 (applyActualInputState 139, updateVadSettings 115, runLiveKitInputGateEvent 92 …) | 726 | 1114~2170, 2526~2582 |
| 이벤트 핸들러 안의 듣기 분기 | 90 | 4020~5170 내 |
| startSession 안의 마이크 획득·relay | 65 | 2833~4020 내 |
| 합계 | 약 880 / 5,514 |
isMicrophoneEnabledRef pendingConfiguredListeningRef listeningCommandSinceStartRef liveKitInputGateRef agentInputPolicyRef hostRelayMutedRef가 각각 "듣기 의도"의 일부다. readiness 재시도 후 듣기 ON 미인식이 이 이중소스에서 나왔다.lib/voice-agent/* 22개 전부, tts-player, mic-relay-stream, session-events 등). 훅 밖으로 빼낸 것은 테스트되고, 훅 안에 남은 것은 테스트되지 않는 패턴이 명확하다. 저장소 전체로도 훅 147개 중 테스트 동반 22개(15%)이고, 500줄 이상 훅 16개 중 테스트 있는 7개도 대부분 훅 밖 순수 함수만 검증한다 (use-host-socket hydration 테스트 669줄이 가장 가까운 선례). 1,000줄 넘는 훅을 renderHook으로 통째 검증한 사례는 없다.프로젝트 brainstorming 스킬의 architectural 경로로 잡았다. 런타임 3종이 모두 살아 있어(lib/voice-agent/resolver.ts:11 VoiceRuntime = "realtime" | "tts" | "livekit") 삭제가 아닌 분리다. 승인 후 docs/superpowers/specs/ 스펙 → writing-plans → code-quality-guardian 순서.
| 안 | 내용 | 판정 |
|---|---|---|
| A. 런타임별 수직 분리 | realtime / tts / livekit 훅 3개 | 기각 — 이벤트 처리·가드가 공통이라 중복 3배. §3에서 런타임 코드가 3분의 1임을 확인 |
| B. 책임별 수평 분리 + 트랜스포트 커넥터 함수화 | 서브훅 5개 + 순수 리듀서 + 커넥터 3개 | 채택 — 현재 코드의 자연 경계와 일치 |
| C. 상태 머신 재작성 | Zustand/XState 기반 재구현 | 기각 — 테스트 0인 상태에서 위험 최대 |
apps/web/entities/guest-session/ 기준| 파일 | 예상 줄수 | 가져갈 코드 (현재 줄) | 형태 |
|---|---|---|---|
| model/use-ai-session.ts | ~650 | 아래 골격 + 남는 effect·래퍼 | 오케스트레이터. 조합만, 구현 없음 |
| lib/voice-session-event-reducer.ts | ~1,000 | handleVoiceSessionEvent 4020~5170, case 16개 | 순수 리듀서 (event, snapshot) → effects[]. 콜백·로그·TTS 명령을 effects로 반환 |
| lib/transport/{realtime,tts,livekit}-connector.ts + transport-handle.ts | ~750 | startSession 3463~3990 런타임 블록, stopSession dispose | 기존 lib/voice-agent/voice-session-transport.ts 인터페이스 구현 함수 3개. generation/abort 가드는 오케스트레이터 유지 |
| model/use-listening-control.ts | ~900 | 1114~2170, 2526~2582, ref 21개 | 서브훅. configured 듣기 의도의 단일 소스 |
| model/use-response-gate.ts | ~450 | cancelResponse, setResponseBlocked, fullVideo 게이트 4개, setInterruptMode/early window (1056~1460, 1969~2330) | 서브훅 |
| model/use-transcript-pipeline.ts + lib/realtime-item-meta.ts | ~600 | sendTextMessage, handleExternalStt*, partial transcript (815~1056, 1462~1720), 상단 순수함수 215~407 | 서브훅 + 순수 함수 |
| model/use-auto-start-finish.ts | ~350 | handleSendAutoStartMessage, handleAutoFinish, resetAutoFinish, updateStartMent (2335~2526, 5181~5245) | 서브훅 |
| model/use-session-audio.ts | ~300 | audioElement/output sink/shared TTS ctx/warmUpAudio/health log (2526~2650, 5224~5330, 상단 182~207) | 서브훅 |
| model/use-ai-session.types.ts | ~150 | Props 49개, StartSessionParams | 타입 |
공통 정리: 콜백 동기화 effect 17개(onXRef.current = onX)는 useLatestRef 유틸 하나로. 도메인 간 공유 ref(liveKitSessionRef, peerConnectionRef 등 7개)는 오케스트레이터가 소유하는 transportHandleRef 하나로 묶어 서브훅에 주입한다. 서브훅은 트랜스포트를 소유하지 않는다.
export function useAiSession(props: UseAiSessionProps) { // 1) 콜백 props 17개 → useLatestRef 하나로 (effect 17개 제거) const callbacks = useLatestRef(props); // 2) useState 10개는 그대로 (소비자 인터페이스 불변) const [isActive, setIsActive] = useState(false); /* … 9개 */ // 3) 트랜스포트 핸들: pc / dataChannel / liveKitSession / ttsPlayer / generation / abort const transport = useRef(createTransportHandle()); // 4) 서브훅: 자기 ref만 소유, 트랜스포트는 주입받아 읽기·명령만 const audio = useSessionAudio({ transport, audioOutputDeviceId: props.audioOutputDeviceId }); const gate = useResponseGate({ transport, callbacks }); const listening = useListeningControl({ transport, callbacks, setGuestMicStream, setIsUserTalking, externalMicStream: props.externalMicStream }); const transcript = useTranscriptPipeline({ transport, callbacks, setCurrentTranscript }); const auto = useAutoStartFinish({ transport, callbacks, gate, transcript }); // 5) 이벤트: 리듀서가 effects를 돌려주고, 여기서만 실행 const handleVoiceSessionEvent = useCallback((event: VoiceSessionEvent) => { const effects = reduceVoiceSessionEvent(event, { gate: gate.snapshot(), listening: listening.snapshot(), transcript: transcript.snapshot(), auto: auto.snapshot(), }); for (const fx of effects) { switch (fx.kind) { case "setPeerTalking": setIsPeerTalking(fx.value); break; case "gate": gate.apply(fx); break; case "listening": listening.apply(fx); break; case "transcript": transcript.apply(fx); break; case "auto": auto.apply(fx); break; case "audio": audio.apply(fx); break; case "callback": callbacks.current[fx.name]?.(...fx.args); break; case "log": logger[fx.level](fx.message, fx.fields); break; } } }, [gate, listening, transcript, auto, audio, callbacks]); // 6) 시작/종료: generation·abort 가드는 여기, 런타임별 연결은 커넥터 3개로 const startSession = useCallback(async (params: StartSessionParams) => { const generation = transport.current.nextGeneration(); const signal = transport.current.arm(); const voiceSession = resolveVoiceSessionForProfile(params.profile); const mic = await listening.acquireMic(params.audioDeviceIdOrStream, signal); const audioEl = audio.prepareElement(); if (!transport.current.isCurrent(generation, signal)) return; const connector = voiceSession.runtime === "livekit" ? connectLiveKit : voiceSession.runtime === "tts" ? connectTts : connectRealtime; // 런타임 분기는 여기 한 곳 const connected = await connector({ voiceSession, mic, audioEl, signal, onEvent: (e) => handleVoiceSessionEventRef.current(e), onRemoteStream: setAiAudioStream }); if (!transport.current.isCurrent(generation, signal)) { connected.dispose(); return; } transport.current.attach(connected); listening.applyPendingAfterConnect(); // pendingVadSettings, configured 듣기 의도 auto.sendStartMessageIfNeeded(); setIsActive(true); }, [listening, audio, auto]); // 8) return: 키 48개 그대로, 구현만 서브훅으로 위임 return { isSessionActive: isActive, isActive, isPeerTalking, /* … 상태 */ handleStartSession, handleStopSession, ...gate.api, ...listening.api, ...transcript.api, ...auto.api, ...audio.api }; }
useAiSession renderHook 스모크 1개. vitest jsdom 환경 존재. 이 저장소 관행("훅은 얇게, 로직은 순수 함수로 뽑아 테스트")과 일치.useLatestRef 추출 (무위험)tsc + vitest + 소비자 2곳(use-guest-page-session.ts:864, use-toggle-headset-mode.ts) 인터페이스 diff 0.결론 — 총량 증가의 주범은 기능 볼륨이고, 지침 부재가 만든 문제는 "핫파일에 덧붙이기" 한 패턴에 국한된다. 분리 지침은 실제로 없다. 다만 그것이 전체 증가의 원인은 아니다.
[한 달 순증 +5.2만 줄, 런타임 ts/tsx · 2026-08-18~09-18 · 커밋 103개]
├─ 신규 파일 +3.7만 (261개) ← 기능 추가. 에이전트는 새 모듈을 잘 만듦
├─ 300줄 미만 파일 +1.1만 ← 정상 성장
├─ 1000~3000줄 파일 −0.4만 ← 레거시 제거(−5,608)·MFCC 제거(−3,586)가 상쇄
└─ 3000줄+ 파일 +0.3만 ← 문제 구간. 파일 4개에 집중
use-ai-session +954 / use-guest-page-session +715 / db-queries +1,379 / livekit-client-session +2,159
[핫파일 덧붙이기 메커니즘]
이슈 티켓(PPI-1219 재응답 차단, 1252 후속발화 차단, 1102 듣기 복원 …)
→ brainstorming "bounded" 경로 = "기존 흐름을 수정" 으로 분류
→ 가드 ref + effect를 기존 훅 안에 추가 (+20~140줄/커밋, use-ai-session 11커밋 중 9개가 순증)
→ 검토 게이트에 파일 크기·책임 분리 기준 없음 → 통과
→ 5,514줄
CLAUDE.md, .claude/CLAUDE.md, AGENTS.md에 파일 크기·책임 분리·새 모듈 배치 규칙 0건. ESLint에 max-lines·complexity 룰 없음. 8/25 도입된 code-quality-guardian은 "복잡도 회귀 방지"를 표방하지만 실제 12단계는 동작 보존·하네스·리뷰이고 크기 기준은 없다. code-review-expert의 SOLID 체크리스트에도 크기·추출 항목이 없다.lib/voice-agent에만 27개. 순증의 70%가 신규 파일. "분리 자체를 안 한다"는 가설은 기각.livekit-client-session.ts는 4,131줄이 됐는데 export function 40개가 한 파일에 계속 추가됐다.한 달 순증의 절반 이상을 차지하는 커밋이다. "VP 모드"가 3만 줄이 아니고, 154개 커밋을 하나로 squash한 통합 PR이 16.6만 줄이다.
b7db1a7c (#1041) 633 files, +166,076 / −8,046 PR 생성→머지 57초, PR #1006(PPI-1176) 통합 ├─ apps/livekit-agent/vendor/* +86.8k ← LiveKit Agents 1.7.1 파이썬 런타임 vendoring (작성 코드 아님) ├─ 테스트 +37.2k ← socket 19.9k, agent 9.3k, web 8k ├─ 런타임 ts/tsx +31.3k ← 기능 4개 묶음 (신규 438파일 +13.3만 / 기존 192파일 +2.5만 전체 기준) └─ 설정·문서·기타 +2.7k 런타임 3.1만의 구성 (PR 본문 4개 섹션 ↔ 디렉터리 순증 대조) SFU 인증·재연결 + 음성 제어 전달(Redis) 약 13k avatar-handlers +2,430 · voice-control-delivery-store +1,720(신규) · sfu-socket +9.4k · redis +3.6k LiveKit VP 대화 처리(클라이언트) 약 9k livekit-client-session +1,802 · lib/voice-agent 신규 다수 모니터·게스트 UI 상태 복구 약 6k use-guest-page-session +2,500 · features/monitor +2,505 · host-socket +1,430 관측성·API·공유 타입 약 3k lib/api +1,720 · shared/lib +1,403
socket 런타임 +14,239
├─ 기존 핸들러에 추가 +7.5k avatar-handlers +2,430 · connection-handlers +1,386 · monitoring-handlers +851
│ pending-disconnect-tracker +826 · session-relay +636 · room-store +585
└─ 신규 모듈 +6.7k voice-control-delivery-store 1,720 · livekit-input-diagnostics 575
voice-control-transition-store 467 · monitor-recipient-auth 355 · monitor-control-auth 266
credential-refresh/lifecycle 318 · admission·principal·claim 스토어 6개
avatar-handlers.ts 추가 2,411줄 중 generation/fence/lease 311줄, voiceControl 219줄, 함수 선언 158개, 로그 30줄. 디버그 로그가 아니라 음성 제어 명령의 전달 보장(ACK·재전달·generation fence·소유권 lease) 로직이다.voice-control-delivery-store.ts 1,720줄은 export 23개, Redis multi/eval 52곳. Redis 기반 전달 상태 머신.런타임 ts/tsx 1,184개, 총 23.5만 줄 · 중앙값 82 · p75 197 · p90 433 · p95 693 >300줄 188개(16%) → 전체 코드의 64% >500줄 100개( 8%) → 49% >1000줄 30개(2.5%) → 29% 훅(use-*) 147개 · 중앙값 130 · p75 245 · p90 535 · 300 초과 28개(19%) · eslint max-lines/complexity 룰 없음
| 기준 | 적용 | 이유 |
|---|---|---|
| 300줄 | 신규 파일 lint 경고 (max-lines) | 업계 기본값(ESLint 기본 300, max-lines-per-function 50, complexity 20, SonarQube 인지복잡도 15)이고 이 저장소 p85와 일치. 기존 파일엔 미적용 — 188개가 걸려 소음 |
| 500줄 | 래칫: 이 위의 파일에 순증하는 PR은 추출 체크포인트를 포함해야 통과 | 파일 8%가 코드 절반을 차지하는 꼬리의 시작점. 여기서 막으면 성장이 멈춘다 |
| 1,000줄 | 분할 백로그 등록, 수정 시 bounded 경로 금지 | 30개 파일. 지난달 순증이 실제로 몰린 구간 |
useRef 20개 또는 useEffect 10개 초과 시 서브훅 분리 (use-ai-session 110/32, use-guest-page-session 62/41). 소켓 핸들러: socket.on 하나당 평균 100줄 초과 시 서비스 함수 추출 (avatar-handlers 240). 함수: 100줄 초과 시 분리 (handleVoiceSessionEvent 1,150, startSession 1,187).max-lines: 300(신규만) · guardian 1단계 bounded 분류 조건에 "대상 파일 500줄 초과 시 추출 체크포인트 필수" · code-review-expert 게이트에 "파일 줄 수 delta 보고". 지침 두 줄과 lint 룰 하나로 끝난다.| 항목 | 상태 |
|---|---|
| 파일·훅 줄수, ref/effect/callback 개수, 커밋별 순증, 성장 곡선, 지침 문서 매칭 0건, lint 룰 부재 | 사실 develop e34637e5 기준 직접 측정 |
| 미참조 컴포넌트·훅 목록 | 사실 import 경로·컴포넌트명 grep 기준. 동적 import·문자열 참조는 삭제 전 재확인 필요 |
| 에이전트 작업 비중 86% | 추정 PR 본문 템플릿 프록시. 사람이 같은 템플릿을 쓴 경우 과대 |
| 모듈 맵 예상 줄수, 리듀서/서브훅 인터페이스 | 추정 writing-plans 단계에서 확정 |
| VP 통합 커밋 설계 적정성 | 판단 불가 — 리뷰 기록 없음. Redis 스토어 3개 상태 전이 문서화 후 대조 필요 |
| "에이전트가 갓파일을 키운다"는 인과 | 추정 시기 상관(7~9월 가속)과 커밋 성격(ref/로그 추가)에서 추론. 사람 커밋과의 대조군 없음 |