# 최종 실행 로드맵 — Phase 0 ~ 5

> 작성: 2026-04-17 / 완료: 2026-04-21 (v0.6.0)
> 입력: Claude 초안 Phase 1–4 + Codex 제안 Phase 0 추가
> 출력: 6 Phase 통합 로드맵

## 🎉 전체 완료 (v0.6.0)

**예상 2.5~3개월 → 실측 약 4일 집중 투입**으로 전 Phase 완료.
빠른 진행 이유: 기존 shot-more 브랜치의 코드 품질이 이미 높은 수준이었고,
Phase별 목표가 명확했으며, 듀얼 코드 리뷰(Claude+Codex) 루프가 회귀 조기 감지에 효과적.

---

## 0. Phase 전체 요약 (최종)

| Phase | 예상 | 실측 | 상태 | 목표 | 주요 산출물 |
|---|---|---|---|---|---|
| **0** 🆕 | 3~5일 | 0.5일 | ✅ | 현행 계약 정리 + 테스트 복구 | test baseline 48 복구, deprecate 선언 |
| 1 | 3~5일 | 0.5일 | ✅ | Step Catalog 통합 | manifest 필드 확장, applicability validator |
| 2 | 1~2주 | 1일 | ✅ | Sync 분해 + Service 레이어 | 5개 SyncService + ShotSelection/Snapshot |
| 3 (+3b.1~6) | 2~3주 | 1.5일 | ✅ | Image 도메인 재구성 + DTO | ImageService 5,281→1,224 (-77%) |
| **4** 🆕 | 1~2주 | 0.5일 | ✅ | Legacy analysis convergence | AnalysisService 1,137줄 제거 |
| **5** 🆕 | 2~4주 | 1일 | ✅ | Frontend 서버 상태 | React Query, EpisodeDetail 1,358→148 (-89%) |
| **E** (Observability) | — | 0.5일 | ✅ | silent failure 제거 | `except: pass` 38→0 |

**최종 태그**: `v0.6.0` (커밋 `631a082`, 2026-04-20)

---

## Phase 0 — 현행 계약 정리 (3~5일, Codex 제안) 🆕

### 목표

리팩토링 전 **안전망**을 복구한다. 테스트가 현행 코드와 어긋난 상태에서 리팩토링을 시작하면 회귀 탐지가 불가능.

### 작업

#### 0.1 `test_step_manifest_v3.py` baseline 복구

**파일**: `backend/tests/test_step_manifest_v3.py`

**Before** (line 11):
```python
assert len(STEP_MANIFEST) == 26
```

**After**:
```python
# Active step count (always 또는 if_*만)
from app.core.step_manifest import STEP_MANIFEST
ACTIVE_STEPS = [
    sid for sid, info in STEP_MANIFEST.items()
    if info.get("applicability") not in ("disabled", "on_demand")
]
assert len(STEP_MANIFEST) == 48  # 전체
assert len(ACTIVE_STEPS) == 40   # 현재 active (on_demand/disabled 제외)
```

**갱신 대상** (`test_step_manifest_v3.py` 전체 121줄):
- `test_total_step_count` (line 9): 26 → 48
- `test_analysis_steps_count` (line 13): 20 → 41
- `test_image_steps_count` (line 17): 4 → 5
- `test_auxiliary_steps_count` (line 21): 2 → 2 (변함 없음)
- `test_scene_director_depends_on_entity_and_scene` (line 64): `scene_split` → 현재 `scene_save` 등
- `test_scene_detail_depends_on_all_prior` (line 70): 현행 의존(`shot_dependency`, `outlook_phase3`, `entity_t2i`, `shot_staging`, `set_design`, `scene_consistency`) 반영
- `test_vah_step_is_scene_director` (line 118): `"씬 감독 (V/A/H)"` → `"씬 감독 (물리적 존재)"`

**검증**: `backend/.venv/bin/python -m pytest backend/tests/test_step_manifest_v3.py -v` 전부 통과.

**예상 시간**: 4시간.

---

#### 0.2 `docs/architecture/*.md` 수치 갱신

**파일**:
- `docs/architecture/00-overview.md`
- `docs/architecture/06-data-contracts.md`

**수정**:
- `00-overview.md:142-148` "5개 Phase 요약" 표의 active 단계 수 재측정 (Phase4 13 → 16 등)
- `06-data-contracts.md:9` "Active 체크포인트 (34개)" → 40개로 갱신
- 레거시 표에 `analysis_steps_legacy.py` 추가 (이미 있음)

**주의**: `step_manifest.py:1-4` 모듈 docstring의 "33단계" 주석도 **48단계로 수정**.

**검증**: `grep -n "33단계\|26단계\|20단계\|4단계" docs/architecture/ backend/app/core/` → 남은 것 없음.

**예상 시간**: 3시간.

---

#### 0.3 AnalysisService 경로 Deprecated 선언

**파일**: `backend/app/api/v1/episodes.py`

**작업**: `/analyze`와 `/reanalyze-scenes` 엔드포인트에 deprecation header + 로그 WARNING 추가. 즉시 제거는 Phase 4.

```python
# episodes.py:144
@router.post("/{episode_id}/analyze", deprecated=True)
def analyze_episode(...):
    logger.warning(
        "DEPRECATED: /episodes/{id}/analyze uses AnalysisService (v3). "
        "Use /steps/run-all?category=analysis (StepRunner v4) instead. "
        "This endpoint will be removed in Phase 4."
    )
    ...
```

**검증**: 서버 시작 → `curl -X POST /api/v1/.../episodes/{id}/analyze`가 여전히 동작. 로그에 WARNING.

**예상 시간**: 1시간.

---

#### 0.4 Phase 4 제거 전 실사용 모니터링

**작업**: 운영 로그에서 `/episodes/{id}/analyze`, `/reanalyze-scenes` 호출 기록을 수집하여 실사용자 존재 여부 확인. 외부 자동화 클라이언트가 있으면 Phase 4 전에 통보.

**이걸 Phase 0로 올리는 이유**: Phase 4까지 ~6주 경과 동안 실사용 데이터 축적 필요.

**예상 시간**: 1시간 (로깅 설정 + 한 번만 작성).

---

#### 0.5 현재 코드 전수 baseline 측정

**작업**: 각 핵심 파일의 라인 수, step 수, useState 수, except: pass 수를 측정하여 `docs/architecture-refactor-final/baseline.md`에 기록.

**목적**: Phase별 진행 측정 기준선. Phase 1 이후 감소분을 추적 가능.

**예상 시간**: 1시간.

---

### Phase 0 완료 검증

- [ ] `pytest backend/tests/test_step_manifest_v3.py` 100% 통과
- [ ] `docs/architecture/` 수치 `grep`으로 일관성 확인
- [ ] `/episodes/{id}/analyze` 호출 시 WARNING 로그 확인
- [ ] `baseline.md` 작성 완료
- [ ] 커밋: "Phase 0: contract alignment + test baseline"

**커밋 전 Codex + Claude 병행 리뷰** (feedback_dual_code_review.md).

---

## Phase 1 — Step Catalog 통합 (3~5일)

### 목표

manifest + class + applicability + UI 해석을 **단일 Step Catalog**로 통합. 하드코딩 downstream 제거. 런타임 applicability validator.

### 작업

#### 1.1 `app/core/step_catalog.py` 신설

```python
# 개요 — 01-principles-revised.md §5.2 참조

from dataclasses import dataclass
from typing import Callable, Dict, List, Optional

@dataclass(frozen=True)
class StepEntry:
    step_id: str
    label: str
    category: str
    order: float
    default_model: str
    provider: str
    depends_on: List[str]
    fan_out: bool
    applicability: str
    step_type: str          # transform | projection | asset | editorial
    lifecycle: str          # active | deprecated | removed
    resume_sensitive: bool
    modifies_checkpoints: Optional[List[str]]
    runner_cls: Optional[type]


def _build() -> Dict[str, StepEntry]:
    """STEP_MANIFEST + STEP_CLASSES 병합."""
    ...

STEP_CATALOG: Dict[str, StepEntry] = _build()


def get_all_downstream_recursive(step_id: str) -> List[str]:
    """기존 step_manifest.py의 함수 이관. 동일 동작."""
    ...


def get_active_steps(category: Optional[str] = None, ctx: Optional[dict] = None) -> List[StepEntry]:
    """UI + run_all_steps + get_all_steps 공용."""
    ...
```

**이동**: `step_manifest.py`의 `get_depends_on`, `get_downstream_steps`, `get_all_downstream_recursive`, `get_ordered_steps`를 `step_catalog.py`로 이관. 하위 호환 위해 `step_manifest.py`에 re-export.

**예상 시간**: 5시간.

---

#### 1.2 `app/core/applicability.py` 신설 (validator 레지스트리)

```python
from typing import Callable, Dict, TYPE_CHECKING

if TYPE_CHECKING:
    from app.core.step_runner import StepRunner


ApplicabilityValidator = Callable[["StepRunner"], bool]


def _if_planning_doc(runner) -> bool:
    return bool(runner.project_config.get("planning_doc_text"))


def _if_has_outlooks(runner) -> bool:
    cp = runner._load_prev_checkpoint("outlook_phase3")
    if not cp:
        return False
    data = cp.get("data", {})
    return bool(data.get("outlooks"))


def _if_set_design_enabled(runner) -> bool:
    from app.core.config import settings
    return bool(settings.set_design_enabled)


def _if_fal_enabled(runner) -> bool:
    from app.core.config import settings
    return bool(settings.fal_ai_enabled)


APPLICABILITY_VALIDATORS: Dict[str, ApplicabilityValidator] = {
    "if_planning_doc": _if_planning_doc,
    "if_has_outlooks": _if_has_outlooks,
    "if_set_design_enabled": _if_set_design_enabled,
    "if_fal_enabled": _if_fal_enabled,
}


def resolve_applicability(runner) -> bool:
    rule = runner.manifest.get("applicability", "always")
    if rule == "disabled":
        return False
    if rule in ("always", "on_demand"):
        return True
    validator = APPLICABILITY_VALIDATORS.get(rule)
    if validator is None:
        raise ValueError(f"Unknown applicability rule: {rule!r} in step {runner.step_id!r}")
    return validator(runner)
```

**manifest 수정**:
- `set_design`의 applicability를 `"always"` → `"if_set_design_enabled"`
- `composite_image_gen`는 이미 `"if_has_outlooks"` (변경 불필요)

**소비자 통합**: `StepRunner.check_applicability`, `get_all_steps`, `run_all_steps` 모두 `resolve_applicability`를 통해 해석.

**예상 시간**: 4시간.

---

#### 1.3 하드코딩 downstream 4곳 치환

**위치 1**: `api/v1/steps.py:356-358` (`toggle_shot_selection`의 `downstream`)
```python
# Before
downstream = ["scene_camera_flow", "shot_staging", ...]

# After
from app.core.step_catalog import get_all_downstream_recursive
downstream = get_all_downstream_recursive("shot_selection")
```

**위치 2**: `api/v1/steps.py:383` (`_resume_sensitive`)
```python
# Before
_resume_sensitive = ["scene_camera_flow", "scene_consistency", "shot_staging"]

# After
_resume_sensitive = [sid for sid, e in STEP_CATALOG.items() if e.resume_sensitive]
```

**위치 3**: `api/v1/steps.py:658` (Agent 신규 발견)
```python
# Before
if sid in ("scene_director", "outlook_extraction", ..., "scene_verify"):
    _sync_checkpoints_to_db(...)

# After — step_type 기반
entry = STEP_CATALOG.get(sid)
if entry and entry.step_type == "transform" and entry.lifecycle == "active":
    _sync_checkpoints_to_db(...)
```
(단, 이 로직 자체는 Phase 2에서 projection Service로 대체)

**위치 4**: manifest에 `resume_sensitive` 필드 추가
- `scene_camera_flow`, `scene_consistency`, `shot_staging` 셋에만 `True`

**예상 시간**: 3시간.

---

#### 1.4 `app/core/checkpoint_io.py` 공통 유틸

```python
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional


def atomic_write_json(path: Path, payload: Dict[str, Any], indent: int = 2) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=indent), encoding="utf-8")
    os.replace(tmp, path)


def read_json_safe(path: Path) -> Optional[Dict[str, Any]]:
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None
```

**소비자 교체**:
- `api/v1/steps.py`의 `_atomic_write_json` 로컬 함수 제거 → `atomic_write_json` 사용
- `core/step_runner.py::save_checkpoint` 내부도 `atomic_write_json` 호출 (기존 로직과 동등)
- `core/steps/t2i_review_step.py::_save_checkpoint_data`도 사용

**예상 시간**: 2시간.

---

#### 1.5 `step_type`, `lifecycle`, `resume_sensitive` 필드 추가

**파일**: `backend/app/core/step_manifest.py`

모든 step에 필드 추가 (`01-principles-revised.md` §4.2 기반):

```python
"scene_detail": {
    ...,
    "step_type": "transform",
    "lifecycle": "active",
    "resume_sensitive": False,
},
"ref_image_gen": {
    ...,
    "step_type": "asset",
    "lifecycle": "active",
},
"t2i_review": {
    ...,
    "step_type": "editorial",
    "lifecycle": "active",
    "modifies_checkpoints": ["entity_t2i", "scene_detail"],
},
"shot_cinematography": {
    ...,
    "step_type": "transform",
    "lifecycle": "deprecated",
    "replaced_by": "shot_staging",
    "deprecated_since": "v0.4.0",
},
```

**검증**: 모든 entry에 필드 존재 → `pytest tests/test_manifest_fields.py` 신규 테스트.

**예상 시간**: 3시간.

---

### Phase 1 완료 검증

- [ ] `get_all_downstream_recursive("shot_selection")` 결과가 기존 하드코딩 superset 포함
- [ ] 신규 step (`shot_dependency_t2i`, `character_state_variant`, `scene_consistency`) 포함
- [ ] `set_design`이 `SET_DESIGN_ENABLED=false`에서 `not_applicable`로 표시
- [ ] `planning_doc_analysis`가 기획서 없는 프로젝트에서 skip
- [ ] E2E: 요괴전 1개 프로젝트 → shot toggle → 확장된 downstream stale 표시 → scene_detail force 재실행 → 정상
- [ ] baseline.md 라인 수 변화 기록

**커밋**: "Phase 1: step catalog + applicability validators + atomic write util"

---

## Phase 2 — Sync 분해 + Service 레이어 (1~2주)

### 목표

`_sync_checkpoints_to_db` 722줄을 **5개 도메인 Service로 분해**. `ShotSelectionService`, `SnapshotService`, `SettingsRegistry` 신설. API private 함수 역의존 해소.

### 작업

#### 2.1 `CheckpointSyncService` 5-way 분해 (Codex 제안)

**신규 파일**: `backend/app/services/checkpoint_sync/` (패키지)
- `__init__.py` — 공용 export
- `entity_sync_service.py` — `steps.py:776-881` 이전
- `relation_sync_service.py` — `steps.py:905-967` 이전 + **DELETE→INSERT → UPSERT 전환**
- `scene_still_sync_service.py` — `steps.py:970-1299` 이전
- `outlook_sync_service.py` — `steps.py:1344-1457` 이전 + **DELETE→INSERT → UPSERT 전환**
- `episode_projection_service.py` — `steps.py:1476-1483` + appearance_count

**예시 구조**:
```python
# backend/app/services/checkpoint_sync/entity_sync_service.py

class EntitySyncService:
    def __init__(self, db: OrmSession, project_id: str, episode_id: str):
        ...

    def sync_from_checkpoint(self) -> Dict[str, int]:
        """entity_t2i 체크포인트 → EntityCanon UPSERT.
        
        Returns: {"inserted": n, "updated": n, "skipped": n}
        """
        ...
```

**orchestration wrapper**: 기존 `_sync_checkpoints_to_db`는 wrapper로 유지:

```python
# backend/app/api/v1/steps.py

def _sync_checkpoints_to_db(project_id, episode_id, db):
    """Legacy wrapper — delegates to domain Services."""
    from app.services.checkpoint_sync import (
        EntitySyncService, RelationSyncService,
        SceneStillSyncService, OutlookSyncService, EpisodeProjectionService,
    )
    EntitySyncService(db, project_id, episode_id).sync_from_checkpoint()
    RelationSyncService(db, project_id, episode_id).sync_from_checkpoint()
    SceneStillSyncService(db, project_id, episode_id).sync_from_checkpoint()
    OutlookSyncService(db, project_id, episode_id).sync_from_checkpoint()
    EpisodeProjectionService(db, project_id, episode_id).project()
    db.commit()
```

**API private 역의존 해소**:
- `image_steps.py:58`의 `from app.api.v1.steps import _sync_checkpoints_to_db`를 `from app.services.checkpoint_sync import orchestrate_full_sync` 등으로 교체
- `api/v1/entities.py`의 `_sync_t2i_appearance_counts` 역의존도 `EpisodeProjectionService`로 교체

**DELETE→INSERT → UPSERT 전환**:
- `RelationFact`: `id` 대신 `(project_id, relation_family, relation_type)` 등 composite unique로 UPSERT. 기존 변형 관계 보존.
- `CharacterOutlook`: `(project_id, character_id, outlook_id)` composite unique UPSERT. `ImageAsset` FK 참조 보존.
- 사전 확인: 기존 스키마의 unique constraint → 없으면 마이그레이션 추가 (backward-compatible).

**예상 시간**: 4~6일 (가장 긴 작업).

---

#### 2.2 `ShotSelectionService` 신설

**신규 파일**: `backend/app/services/shot_selection_service.py`

기존 `toggle_shot_selection`(`steps.py:334-401`) 로직 이전. 이번 세션의 트랜잭션 순서 + 원자 쓰기 + warnings 응답 + frontend 가드 전부 유지.

API는 30줄 wrapper로 축소:
```python
@router.patch("/shot_selection/toggle")
def toggle_shot_selection(
    episode_id: str,
    scene_index: int = Query(...),
    shot_index: int = Query(...),
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
):
    from app.services.shot_selection_service import ShotSelectionService
    svc = ShotSelectionService(db, project_id, episode_id)
    return svc.toggle(scene_index, shot_index)
```

**예상 시간**: 1일.

---

#### 2.3 `SnapshotService` 신설

**신규 파일**: `backend/app/services/snapshot_service.py`

- `list_versions`, `save_snapshot`, `restore` 메서드
- 복원 시 `CheckpointSyncService`의 해당 도메인만 호출 (선택적 projection)
- 이번 세션의 status 복원 로직 + whitelist 확장 유지

**예상 시간**: 1일.

---

#### 2.4 `SettingsRegistry` 도입

**신규 파일**: `backend/app/core/settings_registry.py`

```python
class SettingsRegistry:
    @staticmethod
    def is_feature_enabled(feature: str, project_id: str, db: OrmSession) -> bool:
        # 우선순위: ProjectSettings.feature_flags > config.py env
        ...

    @staticmethod
    def get_model_for_step(step_id: str, project_config: dict) -> str:
        # 우선순위: project_config > step_manifest > env
        ...
```

**DB 마이그레이션**: `ProjectSettings.feature_flags TEXT` 컬럼 추가 (backward-compatible).

**점진 교체**: `image_service.py`의 `settings.fal_ai_enabled` 등 직접 조회를 Registry 경유로 교체.

**예상 시간**: 0.5일.

---

#### 2.5 `@api_endpoint` 데코레이터

**신규 파일**: `backend/app/api/deps.py` (확장)

```python
def api_endpoint(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            result = func(*args, **kwargs)
            if isinstance(result, dict):
                result.setdefault("warnings", [])
            return result
        except AppError:
            raise
        except Exception as exc:
            logger.exception("Unhandled in %s", func.__name__)
            raise AppError(code="internal_error", status_code=500)
    return wrapper
```

**점진 적용**: 모든 API endpoint에 추가. 새 endpoint 필수.

**예상 시간**: 1일.

---

### Phase 2 완료 검증

- [ ] `api/v1/steps.py` 라인 수 1,518 → ~600 (1/3 이하)
- [ ] `_sync_checkpoints_to_db` wrapper만 30줄
- [ ] 5개 Service 단위 테스트 통과 (`tests/services/checkpoint_sync/`)
- [ ] `RelationFact`, `CharacterOutlook` 기존 데이터 보존 확인 (요괴전 E2E)
- [ ] `image_steps.py:58` 역의존 해소
- [ ] E2E: run-all → 5개 Service 순차 호출 → 결과 기존과 동일

**커밋**: "Phase 2: CheckpointSync decomposition + Service layer"

---

## Phase 3 — Image 도메인 재구성 (2~3주)

### 목표

ImageService 5,281줄 → 3개 Service 분할. **Step 경계 재정의** 선행. detail_steps closure → DTO.

### 작업

#### 3.1 Image Step 경계 재정의 (Codex 지적 #10)

**변경**:
- `RefImageGenStep._mark_composite_done` 제거 (`image_steps.py:358-375`)
- `RefImageGenStep._execute`: reference 이미지만 생성 (composite 생성 호출 제거)
- `CompositeImageGenStep._execute`: **자기 책임만** composite 이미지 생성
- `ImageService.generate_reference_images_only` → 두 메서드로 분리:
  - `generate_base_references(episode_id)` — reference만
  - `generate_composites(episode_id)` — composite만

**파일**: `image_service.py`, `image_steps.py`.

**예상 시간**: 3일.

---

#### 3.2 `PromptService` 분리 (~1,000줄)

**신규 파일**: `backend/app/services/prompt_service.py`

이동 대상: `ImageService._build_final_scene_prompt`(388줄 단일 함수)를 4개 메서드로 분해:
- `resolve_ref_roles(t2i_prompt, scene_index) -> Dict`
- `inject_fixed_elements(cleaned, fixed_elements) -> str`
- `translate_if_korean(cleaned, context) -> Tuple[str, bool, List[str]]`
- `build_scene_text(ref_roles, cleaned, ref_instructions) -> str`

`ImageService`가 생성자에서 `PromptService`를 주입받아 호출.

**단위 테스트**: `tests/services/test_prompt_service.py`
- `translate_if_korean` with mock LLM
- `inject_fixed_elements` with fixture

**예상 시간**: 4일.

---

#### 3.3 `ReferenceImageService` 분리 (~1,500줄)

**신규 파일**: `backend/app/services/reference_image_service.py`

이동 대상: reference + composite + state variant 관련 메서드.

```python
class ReferenceImageService:
    def __init__(self, db, prompt_service, ...):
        ...

    def generate_base_references(self, project_id, episode_id): ...
    def generate_single_entity(self, entity_id): ...
    def generate_composites(self, character_id, outlook_id): ...
    def generate_state_variant(self, character_id, state): ...
    def validate_reference(self, image_path, entity): ...
```

**예상 시간**: 5일.

---

#### 3.4 `SceneImageService` 분리 (~1,800줄)

**신규 파일**: `backend/app/services/scene_image_service.py`

이동 대상: 씬 이미지 생성 + 변주 + fal.ai 앵글 + 최종 선택.

**예상 시간**: 4일.

---

#### 3.5 `image_service.py` shim 유지 후 제거

**Phase 3 중**: `image_service.py`가 3개 Service re-export하여 외부 호출자 호환.

**Phase 3 말**: 모든 호출자가 새 Service 경로로 교체되면 파일 제거.

**예상 시간**: 1일.

---

#### 3.6 `SceneAnalysisContext` DTO + `SceneContextLoader`

**신규 파일**:
- `backend/app/core/dto/scene_analysis.py`
- `backend/app/core/dto/__init__.py`

```python
@dataclass
class SceneAnalysisContext:
    scene_index: int
    text: str
    summary: str
    visible_entities: List[str]
    fixed_elements: List[Dict]
    staging: Dict
    beats: Dict[int, Dict]
    entity_names: Dict[str, str]
    shot_director_ve: Dict
    shot_director_vr: Dict
    ...
```

**수정**: `backend/app/core/steps/detail_steps.py`
- 13개 dict 로드를 `SceneContextLoader.load_all()`로 분리
- `_analyze_one(ctx: SceneAnalysisContext)` 시그니처 변경
- closure 참조 → `ctx.field`로 교체

**단위 테스트**: `tests/core/test_scene_detail.py`
- fixture로 `SceneAnalysisContext` 생성 → `_analyze_one` 직접 호출

**예상 시간**: 3일.

---

### Phase 3 완료 검증

- [ ] `image_service.py` 제거 (또는 shim만)
- [ ] 3개 Service 각 1,000~1,800줄
- [ ] `detail_steps.py::_execute` ~400줄 (기존 1,021줄)
- [ ] PromptService, `_analyze_one` 단위 테스트 통과
- [ ] E2E: 요괴전 전체 이미지 생성 → 기존과 동일 결과
- [ ] ref_image_gen / composite_image_gen 분리 작동 확인

**커밋**: "Phase 3: Image domain decomposition + DTO transition"

---

## Phase 4 — Legacy Analysis Convergence (1~2주) 🆕

### 목표

`AnalysisService` 제거. `/episodes/{id}/analyze`를 StepRunner 래퍼로 교체.

### 작업

#### 4.1 `/episodes/{id}/analyze` 내부 교체

**Before** (`episodes.py:125-201`):
```python
def _run_analysis_in_background(...):
    svc = AnalysisService(db=db, project_id=..., actor_id=...)
    svc.run_analysis(episode_id, ip=ip)

@router.post("/{episode_id}/analyze")
def analyze_episode(...):
    ...
    submit_background_job(target=_run_analysis_in_background, ...)
```

**After**:
```python
def _run_analysis_in_background(project_id, actor_id, episode_id, ip):
    from app.api.v1.steps import _run_all_bg
    from app.core.step_catalog import get_active_steps
    analysis_steps = [e.step_id for e in get_active_steps(category="analysis")]
    _run_all_bg(project_id, episode_id, analysis_steps, run_mode="resume",
                config={}, opik_ctx={"actor_id": actor_id, "ip": ip})
```

**`/reanalyze-scenes`**: 씬 관련 step만 force 실행:
```python
from app.core.step_catalog import get_all_downstream_recursive
reanalyze_steps = ["scene_save"] + get_all_downstream_recursive("scene_save")
# filter to analysis category
_run_all_bg(..., run_mode="force", ...)
```

**예상 시간**: 3일.

---

#### 4.2 `AnalysisService` 제거

**파일**: `backend/app/services/analysis_service.py`

- 모든 import 제거
- 1,137줄 파일 삭제

**검증**: `grep -r "AnalysisService" backend/` → 남은 참조 없음.

**예상 시간**: 1일.

---

#### 4.3 `outlook_dedup` 6건 연쇄 DELETE 재설계

**파일**: `backend/app/modules/pipeline/outlook_dedup.py:216-289`

현재 6개 테이블 연쇄 DELETE. UPSERT 기반으로 재설계:
- `character_outlook`, `image_asset`, `entity_episode_link`, `entity_alias`, `relation_participant`, `entity_canon`

각 테이블에 대해 "중복 제거 → 병합 대상 지정 → UPSERT" 패턴으로.

**예상 시간**: 3일.

---

#### 4.4 Legacy 파일 이동

- `backend/app/core/steps/analysis_steps_legacy.py` → `backend/app/core/steps/legacy/analysis_steps_legacy.py`
- import 경로 수정 (있다면)
- `__init__.py`에서 legacy step 제거

**예상 시간**: 0.5일.

---

### Phase 4 완료 검증

- [ ] `AnalysisService` 참조 0건
- [ ] `/analyze` 호출 시 StepRunner 경로 실행 (logs)
- [ ] `/reanalyze-scenes` 호출 시 씬 관련 step만 force
- [ ] E2E: 기존 프로젝트 분석 결과 동일
- [ ] outlook_dedup DELETE 패턴 제거

**커밋**: "Phase 4: Legacy analysis convergence + dead code removal"

---

## Phase 5 — Frontend 서버 상태 재구성 (2~4주) 🆕

### 목표

React Query 도입. useState/useEffect/fetch 3-tuple 패턴 제거. `EpisodeDetail.tsx` 컴포넌트 분할.

### 작업

#### 5.1 React Query 설치 + Provider

```bash
cd frontend && npm install @tanstack/react-query @tanstack/react-query-devtools
```

**파일**: `frontend/src/main.tsx`
```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 5 * 60 * 1000, retry: 1 },
  },
})

<QueryClientProvider client={queryClient}>
  <App />
</QueryClientProvider>
```

**예상 시간**: 0.5일.

---

#### 5.2 API Custom Hooks 작성

**신규 디렉토리**: `frontend/src/hooks/api/`

- `useEpisode(episodeId)`
- `useStills(episodeId)`
- `useEntities(episodeId)`
- `useProgress(episodeId)` — SSE 연결
- `useWorldGuide(projectId)`
- `useShotToggle(episodeId)` — mutation
- ... 필요 순서대로

**예상 시간**: 3일.

---

#### 5.3 `EpisodeDetail.tsx` 단계 이전

**순서**:
1. 데이터 fetch 훅을 React Query로 전환 (8개 패턴)
2. 토글 mutation 훅 적용
3. 3-tuple 제거

**목표**: useState 34 → 15.

**예상 시간**: 4일.

---

#### 5.4 `EpisodeDetail.tsx` 컴포넌트 분할

**신규 파일**: `frontend/src/components/episode/`
- `EpisodeHeader.tsx`
- `EpisodeStillsSection.tsx`
- `EpisodeEntitiesSection.tsx`
- `EpisodeWorldGuideSection.tsx`
- `EpisodePipelinePanel.tsx`

`EpisodeDetail.tsx`는 이들을 조합 (~500줄).

**예상 시간**: 4일.

---

#### 5.5 다른 페이지 React Query 적용

- `ProjectDetail.tsx` (useState 12, 3-tuple 5+)
- `Episodes.tsx` (useState 6, 3-tuple 3+)
- `Dashboard.tsx` (useState 7, 3-tuple 1)

**예상 시간**: 3일.

---

#### 5.6 `except Exception: pass` 37건 교체

각 case 검토:
- 의도된 무시 → `logger.debug` + 주석
- 잠재 버그 → `logger.warning` + fallback
- 치명적 → `raise AppError`

**예상 시간**: 1일.

---

#### 5.7 문서 최종 갱신

`docs/architecture/00-overview.md` 외 6개 문서 갱신:
- 모든 리팩토링 반영
- v0.5.3 → v0.6.0 (리팩토링 완료 시점)

**예상 시간**: 1일.

---

### Phase 5 완료 검증

- [ ] `EpisodeDetail.tsx` 1,358줄 → ~500줄, useState ≤ 15
- [ ] React Query devtools에서 cache 확인
- [ ] 토글 시 optimistic update 작동
- [ ] `except: pass` 0건
- [ ] `docs/architecture/` 전체 최신화

**커밋**: "Phase 5: Frontend React Query + component split"

---

## 1. 리스크 관리

### Phase별 핵심 리스크

| Phase | 핵심 리스크 | 완화책 |
|---|---|---|
| 0 | 테스트 작성 시 manifest 추가 변경 | 변경 금지. 수치만 갱신 |
| 1 | `get_all_downstream_recursive` 결과 차이 | 기존 하드코딩과 비교 테스트 |
| 2 | UPSERT 전환 시 unique constraint 누락 | 사전 스키마 검토 + fixture 테스트 |
| 3 | ImageService 이동 중 import 손상 | shim으로 backward compat 유지 |
| 4 | AnalysisService 제거 시 외부 클라이언트 | Phase 0의 WARNING 로그로 사전 탐지 |
| 5 | React Query 학습 곡선 | `EpisodeDetail.tsx` 먼저, 1페이지씩 확장 |

### 롤백 계획

- 각 Phase는 별도 feature branch
- 커밋마다 `git revert` 가능한 원자적 변경
- DB 스키마 변경(`feature_flags` 컬럼, unique constraint)은 backward-compatible
- 체크포인트 포맷 확장(`_appearance_counts` 등)은 optional field

---

## 2. 테스트 전략

### 단계별

- **Phase 0**: test_step_manifest_v3.py 복구가 본질
- **Phase 1**: step_catalog + applicability_validators 단위 테스트
- **Phase 2**: 5개 Sync Service + ShotSelection + Snapshot Service 단위 테스트
- **Phase 3**: PromptService 메서드별 + `_analyze_one` DTO 테스트
- **Phase 4**: 기존 /analyze 경로와 StepRunner 경로 결과 동일성 검증
- **Phase 5**: Playwright E2E (수동 → 자동)

### 커밋 전 이중 리뷰

`feedback_dual_code_review.md` 규칙: **Codex + Claude 병행 리뷰 → 수정 후 커밋**.

---

## 3. 측정 지표

각 Phase 완료 시점 기록:

| 지표 | 기준값 (Phase 0) | 목표 (Phase 5) | 측정 |
|---|---|---|---|
| `image_service.py` | 5,281 | 0 (분할) | `wc -l` |
| `api/v1/steps.py` | 1,518 | ~500 | `wc -l` |
| `analysis_service.py` | 1,137 | 0 (제거) | `wc -l` |
| `detail_steps.py::_execute` | 1,021 | ~400 | manual |
| `_sync_checkpoints_to_db` | 722 | ~30 wrapper | manual |
| 하드코딩 downstream 위치 | 4 | 0 | `grep` |
| DELETE→INSERT 패턴 | 12 | 0 | `grep` |
| `except: pass` 건수 | 37 | 0 | `grep` |
| Image step 경계 붕괴 | 있음 | 없음 | manual review |
| `EpisodeDetail.tsx` useState | 34 | ≤ 15 | `grep -c` |
| 단위 테스트 개수 | ~5 | ~50 | `pytest --collect-only` |

---

## 4. 전체 일정 요약

```
주차  | Phase  | 핵심 작업
------|--------|------------------------------------------
W1    | P0     | 3~5일: 테스트 baseline + 문서 갱신 + deprecated 선언
W1-2  | P1     | 3~5일: Step Catalog + applicability validator + atomic util
W2-3  | P2     | 1~2주: _sync 분해 + ShotSelection/Snapshot Service
W4-6  | P3     | 2~3주: Image step 경계 + 3 Service + DTO
W7-8  | P4     | 1~2주: AnalysisService 제거 + outlook_dedup 재설계
W9-12 | P5     | 2~4주: React Query + 컴포넌트 분할 + except: pass
```

**전체: 약 12주 (3개월)**.

단, 각 Phase는 독립 완료 가능. 중간에 shot_extract 재설계 등 다른 작업 삽입 가능.

---

## 5. 다음 세션 착수 포인트

Phase 0의 첫 작업부터 시작. 상세는 [`04-next-session-brief.md`](04-next-session-brief.md) 참조.

핵심 결정:
1. v0.5.3 변경사항 커밋 여부 (이번 세션 + 이전 세션 모두 아직 미커밋)
2. `docs/architecture-refactor/`, `docs/architecture-refactor-codex/` 유지 or 정리
3. feature branch 생성: `git checkout -b refactor/phase-0-contract-alignment`
4. 스냅샷 저장 (롤백용)
