대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -0,0 +1,161 @@
# Vignette 코드품질 연구 보고서 (2026-06-26)
> SSOT: `docs/dev_dashboard.html`. 이 문서는 코드품질 리팩토링 연구의 상세 근거다.
> 방법: 5개 차원 병렬 분석(데드코드·과다파라미터·중복·SSOT·추상화) + 방법론 연구 → 종합(워크플로우 code-quality-research).
> 주의: 분석의 'location/참조 0건'은 착수 전 코드 재확인 권고. 자기수정 포함(예: make_eval_hook은 sessions 경로 주입 완료, voice 경로만 누락).
---
# Vignette 코드품질 개선 연구 보고서
## 1. 총평 — 임팩트 큰 리팩터 Top 3
차원별 분석 5종(데드코드, 파라미터 객체화, 중복 로직, SSOT, 추상화/패턴)과 모노레포 실측 구조를 종합할 때, 투자 대비 효과가 가장 큰 작업은 다음 셋이다.
1. **REST/WebSocket 턴 처리 파이프라인 중복 제거 (구조적 최대 부채).** `routes/sessions.py``submit_turn`/`stream_turn``routes/voice.py``_run_turn_and_speak`가 동일한 도메인 절차(세션 로드→오너십 검증→`prepare_turn``run_turn_generate`→학습자/내담자 `TurnRecord` append→state update)를 Type-2/Type-3로 복제하고 있다. 단순 중복을 넘어 **실데이터 불일치**가 이미 존재한다: stage 표기가 채널마다 다르고(`sessions.py`는 한글 `_stage_label`, `voice.py`는 enum `.value` 원시값), 음성 경로는 `eval_hook` 미주입으로 평가가 누락되며, `voice.py`는 빈 `RecallContext()`를 매 턴 새로 만들어 회상 주입이 빠진다. 공통 `record_completed_turn`/`load_owned_session`/`SessionRepository`로 추출하면 중복·드리프트·기능 누락을 한 번에 해소한다.
2. **백엔드 Pydantic ↔ 프론트 `lib/api.ts` 계약 SSOT화 (드리프트 실증됨).** `routes/sessions.py`의 ~14개 응답 모델이 `api.ts`에 수기 미러되어 있고(주석에 "계약 미러" 명시), 이미 드리프트가 발생했다 — 백엔드 `TurnResponse.crisis_kind`, `SessionEndResponse.end_state`가 TS 타입에 없다. OpenAPI → openapi-typescript 단방향 자동 생성으로 전환하면 후속 모든 프론트 리팩토링의 토대가 되고, 타입만 다루므로 런타임 리스크가 낮다.
3. **죽은 확장점/계약 정리 (LogHook·eval_hook·tier 라우팅·Live2D 자산).** 잘 설계됐으나 미배선된 seam이 다수다: `LogHook`/`log_hook`은 caller 0건의 완전한 데드코드, `make_eval_hook`은 정의·export·테스트만 있고 호출 0건, 게이트웨이 `GenerateRequest.tier`(client=Sonnet/evaluator=Opus/fast=Haiku)는 항상 `DEFAULT_MODEL`로 무시되는 dead-contract, Live2D 모델 자산 트리는 fetch 주체가 없는 고아 자산이다. 각각 "배선" 또는 "삭제"로 결론지어 API 표면을 줄이면 이후 작업 비용이 줄어든다.
> 참고: 분석 JSON은 일부 항목에서 자기수정을 포함한다. 예컨대 "make_eval_hook 완전 미주입"은 부정확하며 정확한 결함은 **음성 경로 한정 `eval_hook` 누락**임을 명시하고 있다(`sessions.py:916`은 주입, `voice.py:307`은 미주입). 본 보고서는 이 수정된 판정을 따른다.
---
## 2. 방법론 요약 (차원별 접근·도구·프로세스·안전장치)
전제: 현재 레포에 `pyproject.toml`/`ruff`/`mypy`/`eslint`/`knip` 설정이 **없고** `requirements.txt`만 존재한다. 따라서 0순위는 **측정 도구를 레포에 고정(pin)하고 베이스라인을 스냅샷하는 것**이다.
**공통 안전 원칙:** ① 1 PR = 1 관심사, ② 순수 리팩토링 커밋과 동작 변경 커밋을 절대 섞지 않음, ③ 리팩토링 전 해당 모듈 테스트가 green인지 확인(없으면 characterization test 선작성), ④ `git mv`/IDE rename으로 diff 노이즈 최소화.
| 차원 | 권장 접근 | 핵심 도구 | 안전장치 |
|---|---|---|---|
| **0. 베이스라인** | `apps/api/pyproject.toml`·`apps/web/knip.json` 신설, 도구 버전 pin, `scripts/quality-baseline.ps1`로 수치 덤프 | ruff·vulture·mypy·coverage·knip | CI 게이트는 `--exit-zero`(경고만)로 시작, 정리된 모듈부터 strict 승격 |
| **1. 데드코드** | 자동수정→vulture 베이스라인→whitelist→knip 순. 정의-참조 grep 대조, 생산-소비 경계 추적, 자산-로더 대조 | ruff(F401/F811/F841 `--fix`), vulture(`--min-confidence 80`), knip, madge, coverage | 문자열 동적참조(`getattr`/라우트 문자열) 수동 재확인, FastAPI 핸들러·Pydantic 모델은 whitelist/`--ignore-decorators`로 오탐 차단, 모듈 단위 잘게 |
| **2. 파라미터 객체화** | 임계 초과 함수 AST 전수→데이터 클럼프 식별→호출부 출처 추적→불변값은 frozen dataclass(slots)·HTTP 경계는 pydantic·키 묶음은 TypedDict | ruff PLR0913/PLR0917, pylint R0913, AST 커스텀 스캐너, dataclasses, pydantic, radon | 시그니처 변경=광범위 diff → 순수 리팩토링 단독 PR, 기존 테스트(`test_orchestrator_masking` 등) 선갱신 |
| **3. 중복 통합** | 클론 유형(Type-1/2/3) 분류→호출 그래프 공통화→포맷/스토어 헬퍼 통합. 통합 중 숨은 의미 불일치(stage 라벨, seq base) 노출·일원화 | jscpd(`--min-tokens 50`), pylint duplicate-code, semgrep, radon | rule of three(3회 전 추출 보류), 우연한 유사는 건드리지 않음, 골든 테스트(`test_session_turn_persistence`, `test_voice_ws`)로 동등성 확인 |
| **4. SSOT화** | 계약-우선: 도메인 enum은 백엔드 Python을 SoT로, 프론트 TS는 OpenAPI에서 생성(수기 미러 금지). DTO 미러부터 자동생성 전환→enum 통합→상수 모듈 | openapi-typescript, datamodel-code-generator, StrEnum, ts-prune, schemathesis, pytest 스냅샷(openapi.json 해시) | 타입만 먼저(런타임 무변경)라 저위험, CI에 "재생성 후 git diff 없음" 게이트, 컴파일 에러=드리프트 리포트이므로 끄지 말 것 |
| **5. 추상화/패턴** | 결합도 핫스팟 식별→변하는 축(provider/tier/theory_mode) 분리(Strategy+Protocol)→영속성 이중화 Repository로 통합→파이프라인 Chain of Responsibility로 표면화 | radon cc/mi, import-linter/pydeps, mypy(Protocol 검증), xenon(복잡도 CI 게이트) | ABC보다 `typing.Protocol` 선호, "변경 잦고 복잡하고 테스트 있는" 모듈에만(over-engineering 경계), characterization test 선행 |
| **6. 서비스 최적화** | 정적 게이트(mypy strict 점진, ruff ASYNC/PERF) 후 측정 기반 튜닝. N+1 제거(JOIN/배치), 불변값 캐시, 이벤트루프 블로킹 오프로딩 | ruff `--select ASYNC`, py-spy/pyinstrument, asyncpg EXPLAIN ANALYZE, rollup-plugin-visualizer | before/after 벤치마크 필수(수치 없는 최적화 금지), e2e(`voice-success.spec.ts`)가 회귀망 |
---
## 3. 구체 타깃 목록 (차원별 우선순위)
### 3.1 데드코드 · 미사용 export · 고아 자산
| 우선 | Location | 이슈 | 심각도 | 권고 |
|---|---|---|---|---|
| 1 | `services/orchestrator.py:41,185,221-225,259,324-326,401` | `LogHook` 타입 + `log_hook` 파라미터 + 두 if 분기 + `__all__` export가 완전 데드코드. caller 0건(`grep 'log_hook='` → 0), 두 호출부(`sessions.py:916`, `voice.py:307`)가 모두 생략 → 항상 None 도달불가 | medium | 타입·파라미터·두 분기·export 제거. 턴 로깅이 필요하면 라우트에서 배선, 아니면 확장점 전체 삭제 |
| 2 | `routes/voice.py:307` | 음성 라우트가 `eval_hook` 미주입 → 음성 턴 항상 `evaluation=None`. 텍스트(`sessions.py:916`)는 주입하는 비대칭. (orchestrator 분기 자체는 텍스트 경로로 도달하므로 데드 아님) | medium | 음성 턴도 평가 대상이면 `eval_hook=evaluator.make_eval_hook(engine_client)` 추가. 의도적 제외면 사유 주석 명시 |
| 3 | 서버 `services/voice.py:223,132`·`routes/voice.py:378` / 프론트 `Session.tsx:913-1001,58-68` | 서버 립싱크 RMS 힌트(`tts_chunk.rms/seq`)가 프론트에서 완전 dead. `onmessage`가 해당 type 미처리, `VoiceEvent` 인터페이스에 필드 선언조차 없음. 실제 립싱크는 클라이언트 `AnalyserNode`가 자체 계산 → 두 RMS 산출 공존, 서버 쪽 폐기 | medium | 서버 RMS 미사용이면 `estimate_chunk_rms`·`TTSChunk.rms/seq`·`tts_chunk` 메타 제거(서버 CPU·프로토콜 cruft 제거). 또는 클라이언트 경로 제거 후 서버 힌트로 일원화 |
| 4 | `data/personas/P4~P7.json` (+`services/persona.py:383`) | 백엔드가 P4~P7 JSON을 전혀 로드 안 함. 런타임 시드는 `SEED_PERSONAS={P1,P2,P3}` 하드코딩이 전부, `data/personas` 읽는 로더 0건. 완성된 카드·아바타 자산이 있어도 학습자가 내담자로 인스턴스화 불가 | medium | 사용 의도면 로더/마이그레이션 추가 또는 `SEED_PERSONAS` 편입, 미사용이면 삭제. 프론트 `live2dModel.ts`의 P4~P7 정의와 정합 필요 |
| 5 | `public/live2d/personas/p4~p7/**` (+`dist` 미러) | Live2D 모델 자산이 런타임 fetch 안 됨. 실제 아바타는 SVG 컴포넌트로 렌더, `live2dModel3Path()`가 만든 URL은 `data-live2d-model-url` 속성 문자열로만 노출(fetch/cubism/pixi grep → 0). e2e도 속성 문자열만 어서션 | medium | Live2D 제거 기조(최근 커밋)와 맞춰 디스크 자산 트리 삭제 또는 빌드 제외. 남길 경우 실제 로더 부착 |
### 3.2 과다 파라미터 함수 → 파라미터 객체화
| 우선 | Location | 이슈 | 심각도 | 권고 |
|---|---|---|---|---|
| 1 | `services/state_machine.py:234 init_state` (+`:130`,`:164`) | 5개 키워드 중 앞 3~4개가 데이터 클럼프. 모든 호출부(5곳)가 `card.base_resistance()`/`unlock_rate()`/`decay_floor()`/`ideation_baseline()`를 1:1 분해. 트리오가 `compute_effective_openness`·`evolve`에도 중복 | high | frozen `OpennessParams` + `PersonaCard.openness_params()` 한 메서드. 5개 call-site의 4줄 분해가 1줄로 축약 |
| 2 | `services/orchestrator.py:97 prepare_turn` / `persona.py:147 build_turn_messages` | `prepare_turn` 10개 키워드 최다. `recall_summary/pinned_facts/recent_turns/kb_behavior_cues` 4개가 `build_turn_messages`에도 재등장. 호출부는 이미 `memory.RecallContext`에서 풀어헤침 | high | frozen `TurnMemory`(+`from_recall` 팩토리). prepare_turn 10→6, 두 곳 클럼프 단일 타입 통일 |
| 3 | `auth_sessions.py:408,371,516` | `upsert_managed_user`·`_memory_upsert_managed_user`가 7개 키워드를 글자 그대로 중복 선언, `update_managed_user`가 부분집합 5개 공유 → 변경 시 세 곳 동기화 | high | pydantic `ManagedUserInput`으로 묶어 DB/메모리 두 구현이 동일 입력 객체 수신 |
| 4 | `routes/voice.py:213,273` | `_handle_utterance`(9kw)·`_run_turn_and_speak`(8kw)가 프로소디 메타(`audio_ref/silence_ms/speech_rate/barge_in`)를 WS→...→`TurnRecord`까지 3단계 전달 | medium | frozen `ProsodyMeta`로 캡슐화, 타이밍 계산 결과 묶음 |
| 5 | `store.py:26 TurnRecord` (15필드) | LLM 사용량(`llm_provider/model/tokens_*/cost_usd`)과 음성 프로소디가 상호배타 두 묶음인데 평면 나열 → 생성부마다 한쪽만 채우고 나머지 None | medium | 코어 유지 + `usage: LlmUsage\|None`·`prosody: ProsodyMeta\|None` 중첩, voice `ProsodyMeta`와 타입 공유 |
| 6 | `session_persistence.py:224 save_session_evaluation` | 8개 키워드 중 6개가 진입 직후 `record` dict로 재조립(235~242행) → 평면화가 즉시 dict화로 무효 | medium | `SessionEvaluationRecord` 정의 후 `record:` 단일 인자로 축약 |
| 7 | `services/memory.py:117 make_carry_over` | 7개 키워드 중 5개가 함수 내부에서 그대로 `CompressionJob` 생성자에 전달 → 이미 존재하는 객체를 풀었다 재조립 | medium | `job: CompressionJob` 직접 수신, 7→3 파라미터 |
| 8 | `session_persistence.py:377 create_session` / `store.py:87 SessionStore.create` | DB판(8kw)·in-proc판(6kw)이 세션 부트스트랩 묶음 중복. `persona_id/persona_version`은 항상 함께 쓰이는 핀 쌍 | low | `PersonaPin` 값객체 + `SessionBootstrap`로 두 구현 공통 입력 정렬 |
| 9 | `services/evaluator.py:664,425` | `evaluate_session`(7kw)이 5개를 `build_deep_messages`(5kw)로 거의 그대로 위임 | low | frozen `DeepEvalRequest`로 통일, distribution은 req에서 파생 |
| — | `apps/web/src/**` | TS 전수 스캔 결과 위치 파라미터 5개 이상 0건. 최대 `smoothMouthFromRMS` 4개로 임계 미만, 컴포넌트는 이미 props 객체 사용 | low | 현행 유지. 회귀 방지로 typescript-eslint `max-params`·ruff PLR0913만 CI 추가 |
### 3.3 중복 · 유사 로직
| 우선 | Location | 이슈 | 심각도 | 권고 |
|---|---|---|---|---|
| 1 | `voice.py:_append_voice_turn(409-419)`/`_update_voice_state(422-435)``sessions.py:_append_session_turn(241-251)`/`_update_session_state(254-267)` | Type-2 완전 중복. 영속화 시도→성공 시 갱신+put→실패 시 폴백 4단계가 두 벌, 유일 차이는 로그 문자열뿐. 정책 변경 시 두 곳 동기화 필요 | high | `persist_turn(sess, turn, *, channel)`·`persist_state(...)`로 추출, `channel`만 파라미터화 |
| 2 | `sessions.py:submit_turn(850-906)`,`stream_turn done(941-969)``voice.py:_run_turn_and_speak(293-347)` | Type-3 핵심 중복. 동일 파이프라인 3복제 + **stage 표기 불일치**(`sessions`는 한글 `_stage_label`, `voice`는 enum `.value` 원시값) → 같은 turns 테이블에 채널별 다른 표기 | high | `record_completed_turn(sess, ctx, result, *, channel, audio_meta=None)` 추출, stage 규칙 단일화, 음성 필드는 optional `audio_meta` 흡수 |
| 3 | `voice.py:_load_voice_session(391-406)``sessions.py:_load_session_or_404(221-238)` | Type-3 중복. 로드→put→폴백→오너십→ended 검증 동일, 차이는 전달 방식(HTTPException raise vs 튜플 반환)과 ended 처리(`allow_ended` vs 항상 거부). IDOR/권한 변경 시 두 곳 동기화 | high | 공통 `load_owned_session(...) -> tuple[..., LoadError\|None]` 코어 + sessions는 예외 매핑 얇은 래퍼 |
| 4 | `sessions.py:_generate_and_save_session_evaluation(489-533)``eval.py:reevaluate_session(97-135)` | Type-3 + **seq base 불일치**: `sessions.py:494`는 1-기준 강제 덮어쓰기, `eval.py:101`은 0-기준 `setdefault` → 동일 데이터가 평가 프롬프트에 1-base/0-base로 다르게 입력 | medium | `enrich_masked_turns`(seq 규칙 1개 고정) + `run_and_persist_session_evaluation` 추출, 에러 전송 정책만 차이로 |
| 5 | `teacher.py:_iso(48-51)``sessions.py:_iso(286-289)` | Type-1 바이트 동일 함수. `_summary`/`_learner_summary`도 동형 구조인데 stage 표기 또 불일치(`.value` vs `_stage_label`) | medium | `_iso`(+시간 포맷 순수함수)를 `app/timefmt.py`로 단일 정의, `build_session_summary(sess, *, viewer_role)`로 통합 |
| 6 | `store.py:recent_turns(68-71)``masked_turns(76-78)` | Type-2. 두 본문 사실상 동일, `recent_turns``[-k:]` 슬라이스만 차이 | low | `recent_turns(k,...)=self.masked_turns(...)[-k:]` 위임, `{'speaker','text'}` 직렬화는 `_to_masked_dict` 헬퍼 |
| 7 | 내담자 `TurnRecord` 생성 3곳(`sessions.py:884-895,957-968`·`voice.py:334-345`) | Type-2 반복. 텔레메트리 매핑 3복제 + `stream_turn``ev.data` dict 재파싱(또 다른 변형) | low | `TurnRecord.from_engine_result`/`from_learner` 팩토리 또는 `record_completed_turn`에 흡수 |
| 8 | `teacher.py:60-61`·`sessions.py:544-545,310-311` | Type-2. 턴 카운팅·speaker 정규화(`counselor→learner`)가 여러 곳 산재 | low | `count_turns(turns)`·`display_speaker(speaker)` 순수 헬퍼로 일괄 치환 |
### 3.4 규약 · 인터페이스 SSOT화
| 우선 | Location | 이슈 | 심각도 | 권고 |
|---|---|---|---|---|
| 1 | `sessions.py:40-192``web/src/lib/api.ts:205-355` | 세션/리뷰 응답 ~14개 pydantic 모델이 TS에 수기 미러. **드리프트 실증**: 백엔드 `TurnResponse.crisis_kind`·`SessionEndResponse.end_state`가 TS에 누락 | high | openapi-typescript 도입(openapi.json→`api.gen.ts` 생성, `api.ts`는 re-export만), CI "재생성 후 diff 없음" 게이트. 즉시조치로 누락 필드부터 보충 |
| 2 | `state_machine.py:23``taxonomy.py:35` | `Stage(str,Enum)`가 두 모듈에 글자 동일 중복(`라포/탐색/개입/정리`). 주석이 중복 자인. `taxonomy.Stage`는 외부 import 0건이라 "단일 코드 원천" 주장과 모순 | high | Stage를 한 곳에만 정의, `taxonomy.py`는 re-export. 미사용 확인 후 삭제. 라벨·phase_key는 enum 메서드로 흡수 |
| 3 | `taxonomy.py:47 Speaker``store.py:30` ↔ 산재 문자열 | 화자 어휘 4종 분열: enum(import 0건), `TurnRecord.speaker:str`, `'learner' if speaker=='counselor'` 삼항 다수 산재, DB용 `human_learner/client_ai` 즉석 매핑 | high | `taxonomy.Speaker`를 실제 SoT로 승격, `to_api_speaker`/`to_db_actor` 단일 헬퍼로 변환 캡슐화, 삼항식 치환 |
| 4 | `deps.py:22 AIView` ↔ 6+곳(`engine_client/kb/rag/store`+SQL) | AI-view 삼중쌍 `client/counselor/evaluator`가 6+곳 독립 정의, `rag.py:48` docstring이 수기 동기화 자인 | medium | `domain/ai_view.py`에 단일 `AIView(StrEnum)`+`AI_VIEW_VALUES`, SQL 기본값도 상수 바인딩 |
| 5 | `deps.py:16 Role` ↔ 백엔드 4곳+프론트 3곳 | 역할 `learner/teacher/admin` 중복 + app↔db 매핑(`teacher↔instructor`) 3중 복제 | medium | `Role` enum SoT, app↔db 매핑 단일 dict, 프론트는 OpenAPI 생성 타입 |
| 6 | `personas.py:24-47``api.ts:176-203` | `PersonaSummary` 수기 미러. `difficulty`가 백엔드 str ↔ TS 좁힘, `PersonaReviewStatus` 이중 정의 | medium | OpenAPI 생성으로 자동 해소, `difficulty`는 백엔드에서 `Literal` 좁혀 SoT 고정 |
| 7 | `sessions.py:198-213 (_PHASE_KEY_BY_LABEL,_stage_label)` | Stage 영문→한글, 한글→phase_key 역매핑이 enum과 별개 dict 리터럴로 하드코딩 | medium | `_stage_label``stage.value` 직접 사용, `phase_key`는 Stage 프로퍼티로 단일 출처화 |
| 8 | `orchestrator.py:251``sessions.py:937-979``api.ts:359` | SSE 이벤트명(`token/done/safety/ping/error`)이 상수 없이 3계층 산재, 집합도 미세 불일치(`safety` vs `ping`) | low | 백엔드 `SSE_EVENTS` 단일 정의, 프론트는 공유 const 객체로 매직 문자열 제거 |
### 3.5 추상화 · 디자인 패턴 · 서비스 최적화
| 우선 | Location | 이슈 | 심각도 | 권고 |
|---|---|---|---|---|
| 1 | `engine_gateway/gateway.py:424-433,379-397` + `engine_client.py:71-203` | tier 라우팅 dead-contract. `GwGenerateReq.tier``_resolve_session`이 미사용, 항상 `DEFAULT_MODEL`+`claude_cli` 반환. tier별 비용/지연 최적화(평가=Opus, fast=Haiku) 미구현 | high | `EngineProvider(Protocol)` + 레지스트리, `TierModelPolicy`(Strategy)로 tier→(provider,model) 매핑. 우선 게이트웨이에 정책 테이블만 추가해도 해소 |
| 2 | `persona.py:147``orchestrator.py:97``evaluator.py:367-372` | `theory_mode`(humanistic/cbt/integrative)가 죽은 전략 축. DB까지 전파되나 `build_turn_messages`가 인자로 받지도 않아 프롬프트가 이론별 무변화. evaluator는 문자열로만 끼움. `_theory_mode``persona.theory_target` 참조로 소스 이원화 | high | `TheoryModeStrategy(Protocol)` 도입, 단일 소스(`session.theory_mode`)로 통일. 1차로 이론별 L6 발화지시+평가 기법 가중만 분기해도 효과 |
| 3 | 라우트 3곳 영속성 분기(`sessions.py:221-267`·`voice.py:391-419`·`eval.py:58-66`) | DB-or-inproc fallback이 3곳 거의 동일 복제. **표류 실재**: voice `_run_turn_and_speak`가 빈 `RecallContext()`를 매 턴 생성 → 회상/메모리 주입 누락 | high | `SessionRepository(Protocol){load,append_turn,update_state,end,list}`로 정책 1곳 캡슐화, 라우트는 도메인 호출만. RecallContext를 repo/세션 컨텍스트에 귀속 |
| 4 | `evaluator.py:738-750 make_eval_hook``sessions.py:862-863,936` | DI seam이 정의·export·테스트만 있고 호출 0건. fast-loop 평가가 운영 경로에서 미실행, 평가는 회기말 deep-loop에만 의존 | high | (A) `eval_hook`/`log_hook` 실제 배선해 fast-loop 켜기, 또는 (B) fast-loop 미사용이면 경로 제거. vulture로 사후 참조 0 재확인 |
| 5 | `session_persistence.py:695-720 list_sessions` | N+1. 세션 목록(최대 100) 순회마다 `session_state` fetchrow+`turns` fetch 개별 실행 → 1+2N 왕복, NAS Postgres 지연 곱 | medium | `session_state`는 LEFT JOIN, `turns``WHERE session_id = ANY($ids)` 단일 fetch 후 그룹핑 또는 `json_agg`. 목록이 본문 불필요하면 count 집계만 |
| 6 | `orchestrator.py:121-123,168-174``store.py:68-71` | 턴마다 불필요 재마스킹. 불변 `recall_summary/pinned_facts`를 매 턴 Presidio 재실행 + `recent_turns`는 이미 마스킹된 `text_masked`를 또 마스킹(이중) | medium | 불변값 1회 마스킹 후 캐시, `_mask_recent_turns` 제거(또는 마스킹 불변식을 타입으로 명확화). `test_orchestrator_masking`으로 가드 |
| 7 | `guardrail.py:75-100` (`prepare_turn` 호출) · `voice.py:223-246` | 이벤트 루프 동기 블로킹. async 핸들러 안에서 `mask_pii`(Presidio CPU 바운드) 동기 실행, `estimate_chunk_rms`도 async 제너레이터 안 동기 바이트 루프 | medium | `anyio.to_thread.run_sync`로 오프로딩, Presidio 엔진 startup 워밍업. RMS는 옵션화(`VOICE_SERVER_RMS=off`)로 핫패스 제거 가능 |
| 8 | `orchestrator.py:180-241,255-346` | 위기·가드레일·평가 파이프라인이 한 함수 절차적 if 블록. 재생성 루프·신규 안전단계 삽입 시 본문 수술 필요 | medium | Chain of Responsibility: `TurnStage(Protocol)` 핸들러 리스트 순회. 단락·재시도·삽입을 핸들러 추가/순서로 처리 |
| 9 | `sessions.py:536-539,1007` | 회기말 평가가 `create_task` 결과 미보관 fire-and-forget → GC로 조용히 취소/예외 유실 가능 | low | 모듈 set에 add + `add_done_callback(discard)`로 강참조, 예외 로깅 콜백. 견고화는 작업 큐로 이전 |
| 10 | `persona.py:99-111 _format_openness_directive` | 0.2/0.4/0.65/0.85 임계 if/elif 사다리 하드코딩 → 밴드 추가/튜닝/A/B 시 본문 수정(OCP 위반) | low | 테이블 주도((threshold, directive) 리스트)로 치환, TheoryModeStrategy가 이론별 오버라이드 확장 |
---
## 4. 적용 순서 · 리스크 · 회귀 방지
순서 논리: **삭제(되돌리기 쉬움) → 자동화(생성) → 통합 → 재구성(시그니처) → 재설계(구조) → 튜닝.** 단계마다 리스크와 diff 범위가 커지고, 각 단계는 다음 단계의 전제다.
```
0. 측정 도구 고정 + 베이스라인 [리스크 0, 모든 것의 전제]
1. 데드코드 제거 [저위험·고효과, 표면 축소]
2. 계약 SSOT화 (OpenAPI→TS) [구조적 최고가치, 타입만이라 저위험]
3. 중복 통합 (rule of three 준수) [중위험, drift 잠재버그 제거]
4. 파라미터 객체화 [중위험, 시그니처 변경]
5. 추상화/패턴 (복잡 핫스팟만) [고위험, 테스트 선행 필수]
6. 서비스 최적화 [최후, 측정 기반]
```
| 단계 | 무엇부터 | 리스크 | 회귀 방지 테스트 |
|---|---|---|---|
| 0 | `pyproject.toml`·`knip.json` 신설, `quality-baseline.ps1` | 없음 | — (수치 스냅샷만) |
| 1 | `ruff --fix`(즉시) → knip `files`+`dependencies` → vulture 핫스팟(`rag.py`/`evaluator.py`/`auth_sessions.py`). Live2D 잔재 1순위 | 저 | 삭제 PR은 모듈 단위, 문자열 동적참조 수동 grep, FastAPI/Pydantic은 whitelist |
| 2 | DTO 미러 자동생성(누락 `crisis_kind`/`end_state`부터) → Stage/Speaker/Role enum 통합 | 저(타입만) | `tsc -b` 컴파일 통과 = 드리프트 해소, CI openapi.json diff 게이트, `test_runtime_policy` |
| 3 | `record_completed_turn`/`load_owned_session`/`persist_turn` 추출(턴 파이프라인), `_iso` 통합 | 중 | `test_session_turn_persistence`, `test_voice_ws`를 기준선으로 추출 전후 동등성. stage/seq 불일치는 통합 중 단일 규칙 확정 |
| 4 | `OpennessParams`(저위험 클럼프) → `TurnMemory``ManagedUserInput`. 호출처 적은 것부터 | 중(시그니처) | `test_orchestrator_masking`, `test_state_machine_resistance`, `test_rbac_idor` 선갱신. 순수 리팩토링 단독 PR |
| 5 | `SessionRepository`(영속성 통합) → `TierModelPolicy`(dead-contract) → `TheoryModeStrategy` → 파이프라인 CoR. radon 핫스팟 순 | 고 | characterization test 선행 필수. `test_gateway_model`, `test_state_machine_resistance` |
| 6 | `list_sessions` N+1 → 불변값 마스킹 캐시 → Presidio/RMS 오프로딩 | 최후·측정 | before/after 벤치(py-spy/pyinstrument, EXPLAIN ANALYZE), e2e `voice-success.spec.ts` |
---
## 5. 즉시 안전 적용 vs 설계 변경 필요
### A. 즉시 안전 적용 (저위험 · 단독 PR · 되돌리기 쉬움)
- **`ruff --fix`(F401/F811/F841)** 미사용 import·재정의·지역변수 자동 정리. 리뷰 부담 거의 0.
- **명백한 데드코드 삭제**: `LogHook` 타입·`log_hook` 파라미터·두 분기·`__all__` export(caller 0건 입증됨).
- **고아 자산 제거**: Live2D 모델 트리(`public/live2d/personas/**` + dist 미러, fetch 주체 0건), 사용 계획 없는 `data/personas/P4~P7.json` — 단, 프론트 `live2dModel.ts` 정의와의 정합 확인 후.
- **Type-1 동일 함수 통합**: `_iso``app/timefmt.py`로 단일 정의.
- **음성 `eval_hook` 누락 보충**(`voice.py:307`): 텍스트 경로와 동일한 한 줄 주입(단, 음성 턴 평가가 제품 의도인지 먼저 확인 — 의도적 제외라면 주석만).
- **DTO 드리프트 즉시 보충**: `crisis_kind`/`end_state` 등 누락 필드를 TS에 수동 추가(자동생성 전 최소 조치).
- **회귀 방지 린트 게이트 추가**(동작 무변경): typescript-eslint `max-params`, ruff `PLR0913`/`ASYNC`/`PERF``--exit-zero`로.
### B. 설계 변경 필요 (테스트 선행 · 단계적 · 동작 변경 동반 가능)
- **턴 처리 파이프라인 통합**(3.3-1~3): `record_completed_turn`/`load_owned_session`/`SessionRepository` 추출. stage 표기·seq base·RecallContext 주입을 통합 과정에서 단일 규칙으로 확정 — **동작 변경(채널 간 일관성 회복)을 동반**하므로 골든 테스트 기준선 필수.
- **계약 자동생성 파이프라인**(3.4-1): openapi-typescript 도입 + CI diff 게이트. 인프라 변경이라 단독 단계.
- **enum SSOT 통합**(3.4-2~5): Stage/Speaker/AIView/Role를 단일 모듈로, 변환을 헬퍼로 캡슐화. import 0건인 `taxonomy.Stage` 등은 vulture 확인 후 삭제 — 광범위 치환이라 모듈별 PR.
- **파라미터 객체화 전반**(3.2): frozen dataclass/pydantic 도입은 시그니처 변경 = 광범위 diff. 테스트 선갱신 후 순수 리팩토링 단독 PR.
- **전략/패턴 도입**(3.5-1~2,8): `TierModelPolicy`·`TheoryModeStrategy`·파이프라인 CoR. dead-contract를 실제 기능으로 살리는 것이므로 **신규 동작 추가**이고, `theory_mode`는 프롬프트/평가 결과를 바꾸므로 회귀·품질 검증 필요.
- **DI seam 결론**(3.5-4): `make_eval_hook` fast-loop을 켤지(동작 추가) 제거할지(표면 축소) 제품 결정 선행. 어느 쪽이든 vulture로 사후 참조 0 재확인.
- **성능 최적화**(3.5-5~7): N+1 JOIN화, 마스킹 캐시, Presidio/RMS 오프로딩. 정확성 의미가 바뀔 수 있어(마스킹 불변식) before/after 벤치와 e2e 회귀망 위에서만.
> **단정 제한 주의:** 본 보고서의 "데드"·"미사용" 판정은 제공된 분석의 grep/AST 근거에 기반하나, 문자열 동적참조(`getattr`/라우트 문자열/리플렉션)와 멀티라인 호출은 정적 도구가 놓칠 수 있다. 삭제 전 해당 모듈에 vulture whitelist·knip entry point 설정과 수동 "Find usages"를 반드시 교차검증할 것. 또한 일부 항목(P4~P7 사용 여부, 음성 턴 평가 포함 여부, fast-loop 사용 여부)은 **제품 의도 확인이 선행**되어야 "삭제 vs 배선"이 결정된다.