vignette/docs/ops/code-quality-research-2026-06-26.md
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
2026-07-02 02:50:36 +09:00

163 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Vignette 코드품질 연구 보고서 (2026-06-26)
> SSOT: `docs/dev_dashboard.html`. 이 문서는 코드품질 리팩토링 연구의 상세 근거다.
> 방법: 5개 차원 병렬 분석(데드코드·과다파라미터·중복·SSOT·추상화) + 방법론 연구 → 종합(워크플로우 code-quality-research).
> 주의: 분석의 'location/참조 0건'은 착수 전 코드 재확인 권고. 자기수정 포함(예: make_eval_hook은 sessions 경로 주입 완료, voice 경로만 누락).
> 2026-06-27 현행화: voice 경로도 `eval_hook=evaluator.make_eval_hook(engine_client)` 주입 완료. fast-loop 평가는 `TurnRecord.evaluation`에 더해 `app.feedback_scores`/`turn_technique`/`turn_client_state` 등 DB 정규화 적재·복원 경로가 추가됐다. 아래 표의 voice eval_hook 누락 항목은 과거 기록으로만 본다.
> 2026-07-02 현행화: 3.4-2 **Stage enum 중복 해소** — `taxonomy.Stage`를 라벨 단일 정의(SoT)로 두고 `services.state_machine`이 re-export(중복 `class Stage(str,Enum)` 제거, 순환 import 없음). 검증: 단일 enum 정체성(`state_machine.Stage is taxonomy.Stage`), 수집 379 import 무결, 백엔드 352 passed. 3.3-1~3(턴 파이프라인 dead-dup)·3.5-1(tier)·3.5-4(eval_hook 주입)는 이전 라운드에 이미 해소됨. 이번 라운드 추가 해소: **3.3-6** store `recent_turns`/`masked_turns` Type-2 중복 → `masked_turns()[-k:]` 위임, **3.3-8/3.4-3(부분)** 화자 한글라벨 4× 삼항 → `taxonomy.speaker_ko_label` 단일 헬퍼. **3.5-5** `_list_sessions` N+1 제거 — (1차) 세션별 `session_state`/`turns` fetch 루프(1+2N) → `= ANY($ids)` 배치 3쿼리 + Python 그룹핑, (2차) 상세뷰 턴평가 하이드레이션도 세션별 evaluator 연결·로드 → 전 세션 turn_refs 배치(연결 1회). 검증: 실 DB 동등성 50세션/109턴 불일치 0, 왕복 101→3, 백엔드 352 passed(회귀 mock 시퀀스 갱신). **3.4-3/3.4-5** Speaker/Role 계약 레벨은 실측 결과 조치 불요(DB actor 매핑 1곳·`DB_ROLE_BY_APP`/`APP_ROLE_BY_DB` 이미 단일 dict·`AIRole` 단일 Literal). **→ 그룹 E의 클린·행위보존·테스트가능 항목 소진.** 잔여는 소유자 결정(eval_hook 정책)·미래(Node 게이트웨이 구현 시)·인프라(migration runner)로 코드 리팩터 성격이 아니다.
---
# 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` (+`persona_repository.py`) | 해결됨. `load_file_personas()`/`built_in_personas()`가 저장소 P4~P7 JSON을 `PersonaCard`로 읽고, `materialize_seed_personas()`와 seed fallback catalog에 포함한다. | done | 후속은 항목형 저작 UI, 임상팀 최종 검수 evidence, 프론트 아바타 정의와의 표현 정합. |
| 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`은 seed/catalog 로더에 연결됐으므로 삭제 대상이 아니다.
- **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 배선"이 결정된다.