vignette/docs/ops/code-quality-research-2026-06-26.md
2026-06-27 19:04:46 +09:00

28 KiB

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 누락 항목은 과거 기록으로만 본다.


Vignette 코드품질 개선 연구 보고서

1. 총평 — 임팩트 큰 리팩터 Top 3

차원별 분석 5종(데드코드, 파라미터 객체화, 중복 로직, SSOT, 추상화/패턴)과 모노레포 실측 구조를 종합할 때, 투자 대비 효과가 가장 큰 작업은 다음 셋이다.

  1. REST/WebSocket 턴 처리 파이프라인 중복 제거 (구조적 최대 부채). routes/sessions.pysubmit_turn/stream_turnroutes/voice.py_run_turn_and_speak가 동일한 도메인 절차(세션 로드→오너십 검증→prepare_turnrun_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_turnev.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-192web/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:23taxonomy.py:35 Stage(str,Enum)가 두 모듈에 글자 동일 중복(라포/탐색/개입/정리). 주석이 중복 자인. taxonomy.Stage는 외부 import 0건이라 "단일 코드 원천" 주장과 모순 high Stage를 한 곳에만 정의, taxonomy.py는 re-export. 미사용 확인 후 삭제. 라벨·phase_key는 enum 메서드로 흡수
3 taxonomy.py:47 Speakerstore.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-47api.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_labelstage.value 직접 사용, phase_key는 Stage 프로퍼티로 단일 출처화
8 orchestrator.py:251sessions.py:937-979api.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:147orchestrator.py:97evaluator.py:367-372 theory_mode(humanistic/cbt/integrative)가 죽은 전략 축. DB까지 전파되나 build_turn_messages가 인자로 받지도 않아 프롬프트가 이론별 무변화. evaluator는 문자열로만 끼움. _theory_modepersona.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_hooksessions.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, turnsWHERE session_id = ANY($ids) 단일 fetch 후 그룹핑 또는 json_agg. 목록이 본문 불필요하면 count 집계만
6 orchestrator.py:121-123,168-174store.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(저위험 클럼프) → TurnMemoryManagedUserInput. 호출처 적은 것부터 중(시그니처) 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 동일 함수 통합: _isoapp/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 배선"이 결정된다.