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
28 KiB
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, 추상화/패턴)과 모노레포 실측 구조를 종합할 때, 투자 대비 효과가 가장 큰 작업은 다음 셋이다.
-
REST/WebSocket 턴 처리 파이프라인 중복 제거 (구조적 최대 부채).
routes/sessions.py의submit_turn/stream_turn과routes/voice.py의_run_turn_and_speak가 동일한 도메인 절차(세션 로드→오너십 검증→prepare_turn→run_turn_generate→학습자/내담자TurnRecordappend→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로 추출하면 중복·드리프트·기능 누락을 한 번에 해소한다. -
백엔드 Pydantic ↔ 프론트
lib/api.ts계약 SSOT화 (드리프트 실증됨).routes/sessions.py의 ~14개 응답 모델이api.ts에 수기 미러되어 있고(주석에 "계약 미러" 명시), 이미 드리프트가 발생했다 — 백엔드TurnResponse.crisis_kind,SessionEndResponse.end_state가 TS 타입에 없다. OpenAPI → openapi-typescript 단방향 자동 생성으로 전환하면 후속 모든 프론트 리팩토링의 토대가 되고, 타입만 다루므로 런타임 리스크가 낮다. -
죽은 확장점/계약 정리 (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, ruffPLR0913/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_hookfast-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 배선"이 결정된다.