# TheRoad Scene Lab — 아키텍처 리팩토링 로드맵

> 이 문서는 `00-analysis.md`에서 식별되고 `01-target-design.md`에서 설계된 개선사항을 **4개 Phase**로 나누어 실행하는 구체적 계획이다.
> 각 Phase는 독립적으로 commit/배포 가능하며 E2E 테스트로 검증한다.

---

## 0. Phase 전체 요약

| Phase | 기간 | 위험도 | 주요 작업 | 주요 산출물 |
|---|---|---|---|---|
| 1 | 1~2일 | **낮음** | 빠른 승리 — 하드코딩/유틸화/런타임 검증 | 안정성 즉시 향상 |
| 2 | 1주 | **중간** | Service 레이어 도입 — `_sync` 분해 | 유지보수성 향상 |
| 3 | 2주 | **높음** | ImageService 분할 + detail_steps DTO | 테스트 가능성 확보 |
| 4 | 2~4주 | **높음** | Frontend React Query + 진실원 계층화 | 최종 아키텍처 |

---

## Phase 1 — 빠른 승리 (1~2일)

### 목표
코드베이스의 **신뢰성**을 즉시 향상시키되 **위험 없는 변경**만 수행. 기존 동작을 유지하면서 명백한 결함을 수정.

### 작업 항목

#### 1.1 하드코딩 downstream 제거 (#3.1)

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

**Before**:
```python
downstream = ["scene_camera_flow", "shot_staging", "shot_director", "shot_dependency",
              "scene_consistency", "scene_detail", "scene_verify",
              "scene_image_pipeline", "composite_image_gen", "world_guide"]
```

**After**:
```python
from app.core.step_manifest import get_all_downstream_recursive
downstream = get_all_downstream_recursive("shot_selection")
```

**유사 패턴 3곳 모두 교체**:
- `toggle_shot_selection`의 `downstream` 리스트
- `toggle_shot_selection`의 `_resume_sensitive` 리스트 (manifest에 `supports_resume` 플래그 추가 후 필터링)
- 기타 `_invalidate_*` 패턴 (grep으로 발견 시)

**검증**:
1. 실제 `get_all_downstream_recursive("shot_selection")` 반환값이 기존 하드코딩 리스트와 일치하는지 확인
2. 신규 step (`shot_dependency_t2i`, `character_state_variant`)이 포함되는지 확인
3. E2E: 요괴전 프로젝트로 shot 토글 후 scene_detail 재실행 → stale 감지되는지

**리스크**: 신규 리스트가 기존보다 **넓을** 수 있음 (더 많은 step invalidate). 사용자가 체감하는 것: 토글 후 더 많은 step이 stale로 표시됨 — 정상 동작.

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

---

#### 1.2 Applicability Validator 레지스트리 (#3.2)

**신규 파일**: `backend/app/core/applicability.py`
```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: "StepRunner") -> bool:
    return bool(runner.project_config.get("planning_doc_text"))


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


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


def _if_fal_enabled(runner: "StepRunner") -> 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: "StepRunner") -> 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}. "
            f"Register validator in APPLICABILITY_VALIDATORS."
        )
    return validator(runner)
```

**수정 파일**: `backend/app/core/step_runner.py`
```python
from app.core.applicability import resolve_applicability

class StepRunner:
    def check_applicability(self) -> bool:
        return resolve_applicability(self)
```

**manifest 정리**:
- `set_design`: applicability를 `"always"` → `"if_set_design_enabled"` 로 변경 (manifest와 runtime 일치).
- 기타 env 기반 무효화가 있다면 동일하게 `if_*` 규칙으로 명시화.

**검증**:
1. 기존 서브클래스에서 `check_applicability` 오버라이드한 step들이 Validator로 이동 가능한지 확인.
2. Unknown rule 시 `ValueError` 발생하는지 테스트.
3. E2E: 기획서 없는 프로젝트에서 `planning_doc_analysis`가 실제로 skip되는지.

**리스크**: 기존 override를 Validator로 옮기면서 로직 누락 가능 → 각 override 메서드와 Validator 1:1 대응 확인.

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

---

#### 1.3 체크포인트 원자 쓰기 유틸화 (#2.3)

**신규 파일**: `backend/app/core/checkpoint_io.py`
```python
import json
import os
from pathlib import Path
from typing import Any, Dict


def atomic_write_json(path: Path, payload: Dict[str, Any], indent: int = 2) -> None:
    """원자적으로 JSON 파일 쓰기 (tmp + rename).
    
    OS crash 중에도 기존 파일이 손상되지 않음. `os.replace`는 대부분의 FS에서 atomic.
    Windows 호환: `os.replace`는 Python 3.3+에서 cross-platform atomic rename.
    """
    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) -> Dict[str, Any] | None:
    """JSON 파일 읽기. 존재하지 않거나 손상되면 None."""
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None
```

**수정 파일**:
- `backend/app/api/v1/steps.py` — `toggle_shot_selection`의 로컬 `_atomic_write_json` 제거, 공통 유틸 사용.
- `backend/app/core/step_runner.py` — `save_checkpoint` 내부 로직도 `atomic_write_json` 유틸 사용 (코드 간결화).

**검증**:
1. 기존 `save_checkpoint` 동작과 동일한지.
2. `toggle_shot_selection` 정상 작동.

**리스크**: 매우 낮음. 유틸 추출만.

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

---

#### 1.4 Step lifecycle 필드 추가 (#3.4)

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

**Schema 확장** (기존 step 모두):
```python
"shot_cinematography": {
    "label": "촬영 기법 (레거시)",
    "category": "analysis",
    "order": 16,
    "default_model": "gemini-pro",
    "provider": "gemini",
    "depends_on": [...],
    "applicability": "disabled",
    "lifecycle": "deprecated",          # 신규
    "replaced_by": "shot_staging",       # 신규 (optional)
    "deprecated_since": "v0.5.0",        # 신규 (optional)
},
```

**신규 유틸 함수** (`step_manifest.py`):
```python
def is_active(step_id: str) -> bool:
    """Step이 현재 활성 상태인지."""
    info = STEP_MANIFEST.get(step_id, {})
    lifecycle = info.get("lifecycle", "active")
    return lifecycle == "active" and info.get("applicability") != "disabled"


def get_active_steps() -> List[Dict]:
    """lifecycle=active & applicability≠disabled 인 step만 반환."""
    return [info for sid, info in STEP_MANIFEST.items() if is_active(sid)]
```

**legacy 파일 이동**:
- `backend/app/core/steps/analysis_steps_legacy.py` → `backend/app/core/steps/legacy/analysis_steps_legacy.py`
- import 경로 업데이트.
- `__init__.py`에서 legacy step import 제거 (실사용 여부 확인 후).

**검증**:
1. `is_active("shot_cinematography")` → False.
2. UI에서 deprecated step이 회색 처리되는지 (Frontend 변경은 Phase 2에서).

**리스크**: legacy 파일 이동 중 import 경로 실수 → 이동 후 `python -c "from app.main import app"` smoke test.

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

---

#### 1.5 번역 실패 시 구체적 사용자 피드백 (#2.1 관련)

현재 `image_service.py`가 번역 실패 시 error 로그만 남기고 한국어 그대로 Gemini에 전달. Gemini가 안전 필터에 걸려 이미지 생성 실패 시 사용자는 "이미지 생성 실패"만 봄 — 근본 원인 숨김.

**수정**: 번역 실패 시 이미지 생성 요청 자체를 skip하거나, API 응답에 `translation_failed: true` 표시.

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

```python
if not translated and re.search(r'[가-힣]', cleaned):
    logger.error("Prompt translation failed and Korean text remains")
    # 신규: 메타데이터로 반환하여 상위에서 판단 가능하게
    final_with_warning = final
    return {
        "prompt": final_with_warning,
        "warnings": ["translation_failed_korean_remains"],
    }
```

**상위 호출자 수정**: `warnings`를 전파하여 API 응답에 포함.

**검증**:
1. 번역 실패 시뮬레이션 (LLM mock) → 응답에 warning 포함.

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

---

### Phase 1 완료 시 검증

**자동**:
- `backend/.venv/bin/python -c "from app.main import app; print('OK')"` 통과.
- Frontend `tsc -b` 통과.
- 기존 pytest 통과 (있는 경우).

**E2E** (요괴전 프로젝트 4개 중 1개 선택):
1. 스냅샷 저장
2. 임의 shot toggle → downstream invalidation 확인
3. scene_detail force 재실행 → 정상 완료
4. 이미지 생성 → 정상
5. 스냅샷 복원 → 상태 일치

**커밋 전 codex + claude 리뷰** (feedback_dual_code_review.md 준수).

---

## Phase 2 — Service 레이어 도입 (1주)

### 목표
비즈니스 로직을 API에서 Service로 이전. `_sync_checkpoints_to_db`를 CheckpointSyncService로 분해. UPSERT 규칙 일관 적용.

### 작업 항목

#### 2.1 `CheckpointSyncService` 신설

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

**구현 순서**:
1. 기본 클래스 + 생성자 + `sync_all` wrapper.
2. `sync_entities()` — 현재 `_sync_checkpoints_to_db` Block 1 (entity_t2i) 이동 (이미 UPSERT).
3. `sync_relations()` — Block 1.5 이동하면서 **DELETE+INSERT → UPSERT**로 전환.
4. `sync_scene_still()` — Block 2 이동.
5. `sync_scene_summary()` — Block 2c 이동.
6. `sync_shot_cinematography()` — Block 2d 이동.
7. `sync_outlooks()` — Block 3 이동하면서 **UPSERT 전환**.
8. `sync_orphan_outlook_cleanup()` — Block 3b 이동.
9. `sync_t2i_appearance_counts()` — Block 3c 이동 + 체크포인트에 기록 기능 추가.

**`entity_t2i` 체크포인트 포맷 확장**:
```python
# sync_t2i_appearance_counts 실행 후, entity_t2i 체크포인트에 _appearance_counts 기록
def sync_t2i_appearance_counts(self) -> int:
    counts = self._compute_counts()  # 기존 로직
    # DB 업데이트
    for entity_id, count in counts.items():
        self.db.execute(text("UPDATE entity_episode_link SET t2i_appearance_count = :c WHERE ..."), ...)
    
    # 체크포인트에 기록 (신규)
    cp_path = self._entity_t2i_cp_path()
    cp_data = read_json_safe(cp_path)
    if cp_data:
        cp_data.setdefault("data", {})["_appearance_counts"] = {
            sid: count for sid, count in counts.items()
        }
        atomic_write_json(cp_path, cp_data)
    
    return len(counts)
```

**API 수정**: `backend/app/api/v1/steps.py`의 `_sync_checkpoints_to_db`를 wrapper로 변경:
```python
def _sync_checkpoints_to_db(project_id, episode_id, db):
    from app.services.checkpoint_sync_service import CheckpointSyncService
    svc = CheckpointSyncService(db, project_id, episode_id)
    return svc.sync_all()
```

기존 호출 경로(`run-all`, `scene_director` 사전, 개별 step 후) 전부 유지.

**검증**:
1. 단위 테스트: 각 `sync_*` 메서드 호출 전후 DB 상태 검증 (in-memory SQLite or test fixture).
2. E2E: 요괴전 프로젝트 run-all → 기존과 동일 결과.
3. UPSERT 전환 시 기존 데이터 보존 확인 (특히 `t2i_appearance_count`, outlook image_asset FK 연쇄).

**리스크**: 
- UPSERT 전환 중 유니크 제약 누락 → 먼저 마이그레이션 확인. `character_outlook`, `relation_fact`에 필요한 unique constraint 있는지.
- 기존 외래키(`image_asset` 등)가 character_outlook 삭제에 의존하는 경우 → 확인 후 UPSERT 전략 결정.

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

---

#### 2.2 `ShotSelectionService` 신설

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

```python
class ShotSelectionService:
    def __init__(self, db: OrmSession, project_id: str, episode_id: str):
        ...

    def toggle(self, scene_index: int, shot_index: int) -> Dict:
        # 1) 현재 선택 상태 읽기 (file)
        cp = self._read_selection_cp()
        # 2) 샷 유효성 검증 (추가 시)
        # 3) DB 업데이트 (is_selected)
        # 4) Downstream invalidation (manifest 기반)
        # 5) DB commit
        # 6) 파일 원자 쓰기
        # 7) 응답 구성 (warnings 포함)
        ...
```

**API 수정**: `backend/app/api/v1/steps.py`의 `toggle_shot_selection`을 30줄로 축소:
```python
@router.patch("/shot_selection/toggle")
@api_endpoint
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),
    current_user: UserAccount = Depends(get_current_user),
):
    from app.services.shot_selection_service import ShotSelectionService
    svc = ShotSelectionService(db, project_id, episode_id)
    return svc.toggle(scene_index, shot_index)
```

**리스크**: 이번 세션에 수정한 트랜잭션 순서, 원자 쓰기, warnings 응답을 Service로 옮기면서 누락 가능 → 기존 로직 1:1 매핑 + 테스트.

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

---

#### 2.3 `SnapshotService` 신설

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

```python
SYNC_METHOD_MAP: Dict[str, str] = {
    "entity_t2i": "sync_entities",
    "entity_relation": "sync_relations",
    "scene_detail": "sync_scene_still",
    "outlook_phase3": "sync_outlooks",
}


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

    def list_versions(self, step_id: Optional[str] = None) -> List[Dict]: ...
    
    def save_snapshot(self, step_id: Optional[str] = None) -> str: ...
    
    def restore(self, version: str, step_id: Optional[str] = None) -> Dict:
        restored = self._restore_files(version, step_id)
        self._restore_step_run(restored)
        
        # 복원된 파일에 대응하는 sync 호출
        sync_svc = CheckpointSyncService(self.db, self.project_id, self.episode_id)
        synced_data = {}
        for sid in restored:
            method = SYNC_METHOD_MAP.get(sid)
            if method and hasattr(sync_svc, method):
                synced_data[sid] = getattr(sync_svc, method)()
        
        self.db.commit()
        return {"restored": restored, "synced_data": synced_data, "warnings": []}
```

**API 수정**: 스냅샷 관련 3개 endpoint가 각각 10줄 wrapper로 축소.

**리스크**: `restore_snapshot`의 기존 status 복원 로직(이번 세션 추가)이 이전되는 것 확인.

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

---

#### 2.4 `SettingsRegistry` 도입

**신규 파일**: `backend/app/core/settings_registry.py` (`01-target-design.md` 3.3 참조).

**DB 마이그레이션**: `ProjectSettings.feature_flags TEXT` 컬럼 추가.
```python
# backend/app/core/database.py
"ALTER TABLE project_settings ADD COLUMN IF NOT EXISTS feature_flags TEXT",
```

**점진 교체**:
- `image_service.py`의 `settings.fal_ai_enabled` 직접 조회 지점 → `SettingsRegistry.is_feature_enabled("fal_ai", project_id, db)`.
- 기존 코드는 유지하되 마킹 (`# TODO: migrate to SettingsRegistry`).

**검증**:
1. 기존 env 기반 동작 유지.
2. ProjectSettings.feature_flags JSON 작성 시 override 동작.

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

---

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

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

```python
from functools import wraps
from app.core.errors import AppError
import logging

logger = logging.getLogger(__name__)


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 exception in %s", func.__name__)
            raise AppError(
                code="internal_error",
                message=f"서버 오류: {type(exc).__name__}",
                status_code=500,
            )
    return wrapper
```

**점진 적용**: 모든 `@router.get/post/patch/delete` 데코레이터와 함께 `@api_endpoint` 추가. 새 endpoint는 필수.

**예상 시간**: 1일 (전체 endpoint 점진 교체).

---

### Phase 2 완료 시 검증
- `image_service.py` 외 Service 파일 신규 3개 (`checkpoint_sync_service`, `shot_selection_service`, `snapshot_service`).
- `api/v1/steps.py` 라인 수 ~800줄 (1,518줄 대비).
- DELETE→INSERT 패턴 제거 (relations, outlooks).
- E2E 전체 통과.

---

## Phase 3 — ImageService 분할 + detail_steps DTO (2주)

### 목표
가장 큰 단일 책임 위반인 `ImageService` (5,281줄)와 `detail_steps::_execute`의 closure 공유 문제 해결. 테스트 가능성 확보.

### 작업 항목

#### 3.1 `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]`
  - `build_scene_text(ref_roles, cleaned, ref_instructions) -> str`
- `ImageService.compose_prompts(...)` 이동.
- `ImageService._rewrite_t2i_with_image_refs(...)` 이동.
- `ImageService._resolve_refs_for_prompt(...)` 이동.

**API**:
```python
class PromptService:
    def __init__(self, db: OrmSession):
        ...

    def build_final_scene_prompt(
        self, 
        scene_index: int,
        t2i_prompt: str,
        labeled_refs: List[Tuple[Path, str]],
        ref_instructions_text: str,
        style_context: str,
        fixed_elements: Optional[List[Dict]] = None,
        tracer: Optional[Any] = None,
        project_config: Optional[Dict] = None,
    ) -> Dict:  # {prompt: str, warnings: List[str]}
        ...
```

**기존 `ImageService` 수정**: 내부에서 `PromptService` 주입받아 호출.

**단위 테스트 작성**: `tests/services/test_prompt_service.py`
- `resolve_ref_roles` — 주어진 labeled_refs로 ref_roles 생성
- `translate_if_korean` — 한국어 감지 및 번역 로직 (LLM mock)
- `inject_fixed_elements` — 고정 요소 텍스트 주입

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

---

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

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

**이동 대상**: `ImageService`의 reference 관련 메서드 20+개.

**API 재정리**:
```python
class ReferenceImageService:
    def __init__(
        self, 
        db: OrmSession, 
        prompt_service: PromptService,
        ...
    ):
        ...

    def generate_reference_images(self, project_id: str, episode_id: str) -> Dict: ...
    def generate_single_entity_image(self, entity_id: str) -> Dict: ...
    def generate_composite(self, character_id: str, outlook_id: str) -> Dict: ...
    def generate_state_variant(self, character_id: str, state: str) -> Dict: ...
    def validate_reference_image(self, image_path: Path, entity: Entity) -> Dict: ...
```

**예상 시간**: 4일 (가장 큰 이동).

---

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

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

**이동 대상**: `ImageService`의 scene 관련 메서드.

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

---

#### 3.4 기존 `image_service.py` 폐기

모든 호출처가 새 Service를 사용하게 되면 기존 파일 삭제 (또는 `from app.services.reference_image_service import *` 같은 re-export만 유지 → 외부 호출자 호환).

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

---

#### 3.5 `SceneAnalysisContext` DTO + `SceneContextLoader`

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

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

**단위 테스트**: `_analyze_one` 테스트가 가능해짐 — fixture로 `SceneAnalysisContext` 생성 후 호출.

**예상 시간**: 3일 (기존 로직이 복잡해서 이동 중 버그 유입 주의).

---

### Phase 3 완료 시 검증
- `image_service.py` → 3개 Service로 분할, 기존 파일 폐기.
- `detail_steps.py::_execute` 라인 수 감소 (~600줄).
- 단위 테스트 커버리지 증가 (PromptService 메서드, `_analyze_one`).
- E2E 전체 통과.

---

## Phase 4 — Frontend 상태 + 진실원 계층화 (2~4주)

### 목표
Frontend의 상태 관리 현대화 + 장기 아키텍처 목표(진실원 계층화) 완성.

### 작업 항목

#### 4.1 React Query 도입 (Frontend)

**설치**:
```bash
cd frontend && npm install @tanstack/react-query
```

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

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

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

**Custom Hooks 작성**: `frontend/src/hooks/api/*.ts`
- `useEpisode(episodeId)`
- `useStills(episodeId)`
- `useEntities(episodeId)`
- `useEpisodeProgress(episodeId)`
- `useWorldGuide(projectId)`
- `useShotToggle(episodeId)` — mutation
- ... (필요 순서대로 추가)

**점진 적용**:
1. `EpisodeDetail.tsx` 먼저 이전 (가장 큰 파일).
2. `Dashboard.tsx`, `ProjectDetail.tsx` 등 순차 적용.

**목표**: `EpisodeDetail.tsx` useState 수 51 → 15.

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

---

#### 4.2 EpisodeDetail 컴포넌트 분할

**신규 파일들**: `frontend/src/components/episode/`
- `EpisodeHeader.tsx` — 제목, 상태, 액션 버튼
- `EpisodeStillsSection.tsx` — 씬/샷 리스트 (SceneVariationCard 포함)
- `EpisodeEntitiesSection.tsx` — 엔티티 패널
- `EpisodeWorldGuideSection.tsx` — 세계관 규칙
- `EpisodePipelinePanel.tsx` — 파이프라인 상태/실행

`EpisodeDetail.tsx`는 이들을 조합 + 공통 state 전달.

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

---

#### 4.3 데이터 진실원 계약 문서화

**신규 파일**: `docs/architecture/data-contracts.md`

내용:
- 각 데이터 항목의 Tier, 저장소, 쓰기 주체, 소비자 명시 (`01-target-design.md` 2.2 기반).
- 변경 시 업데이트 책임 명시.
- 계약 테스트 가이드 (있다면).

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

---

#### 4.4 `except: pass` 12건 교체 (#4.6)

**파일**: `backend/app/services/image_service.py` (또는 분할된 새 Service들)

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

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

---

#### 4.5 문서 업데이트

`docs/architecture/00-overview.md` 외 6개 문서 업데이트:
- v0.5.3 + 모든 리팩토링 변경 반영.
- 모델명 표기 통일 (논리 alias vs 물리 ID 매핑표).
- 프롬프트 버전 형식 명확화 (`N.YYYYMMDDHHmm`).

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

---

### Phase 4 완료 시 검증
- Frontend `EpisodeDetail.tsx` 500~600줄.
- React Query 도입 — 모든 서버 상태 관리.
- Silent exception swallow 0건.
- docs/architecture/ 전체 최신화.
- E2E + 회귀 테스트 통과.

---

## Phase 외 — 선택적 작업

### (Optional) outlook 테이블 분리

**위험도**: 매우 높음. DB 마이그레이션 필요.

현재 `entity_canon`에 character/location/prop/outlook 혼재. outlook을 별도 테이블로 분리하면:
- 이점: 스코프 명확, FK 관계 단순.
- 단점: 광범위 코드 수정, 기존 프로젝트 데이터 마이그레이션.

Phase 4 완료 후 별도 프로젝트로 평가.

**예상 시간**: 3~4주 (데이터 마이그레이션 포함).

---

### (Optional) PipelineRunner 신설

현재 `_run_all_bg`는 `steps.py`에 inline. `PipelineRunner` 클래스로 분리하면 재시도 정책, partial 복구, 옵션 조정 가능.

Phase 4 완료 후 별도 평가.

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

---

## 전체 일정 요약

```
주차  | Phase  | 작업
------|--------|-------------------------------------
W1    | P1     | 1~2일: 빠른 승리 (하드코딩/유틸/검증)
W1-2  | P2     | 1주: Service 레이어 도입
W3-4  | P3     | 2주: ImageService 분할 + DTO
W5-8  | P4     | 2~4주: Frontend + 진실원 + 문서
```

**전체**: 약 2개월.

단, 각 Phase는 독립 완료 가능하므로 중간에 멈추고 다른 작업(shot_extract 재설계 등) 가능.

---

## 리스크 관리

### 위험도별 처리

| 위험 | Phase | 완화책 |
|---|---|---|
| UPSERT 전환 시 유니크 제약 누락 | 2 | 사전 스키마 검토, 테스트 fixture |
| ImageService 이동 중 import 경로 손상 | 3 | `from app.services.image_service import *` 호환 shim |
| detail_steps DTO 전환 중 로직 누락 | 3 | 기존 closure 참조를 `ctx.field`로 1:1 매핑, 코드 리뷰 |
| React Query 학습 곡선 | 4 | EpisodeDetail부터 시작, 1 페이지씩 확장 |
| 기존 snapshot 호환성 | 전체 | 체크포인트 포맷 확장 시 모두 optional, fallback 유지 |

### 롤백 계획

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

---

## 테스트 전략

### 단계별 테스트

**Phase 1**: 기존 E2E + 신규 unit test (applicability validator)
**Phase 2**: 기존 E2E + CheckpointSyncService 메서드별 단위 테스트
**Phase 3**: PromptService 단위 테스트, `_analyze_one` DTO 기반 테스트
**Phase 4**: Frontend E2E (Playwright or Cypress — 현재 없으면 수동)

### 커밋 전 이중 리뷰

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

---

## 측정 지표

Phase 완료 시점마다 기록:

| 지표 | 측정 방법 |
|---|---|
| 코드 라인 수 | `wc -l` |
| 테스트 커버리지 | `pytest --cov` (설정 필요) |
| E2E 성공률 | 요괴전 프로젝트 4개 run-all 통과 수 |
| UI 응답성 | `EpisodeDetail` 초기 로딩 시간 측정 |

---

## 다음 세션 착수 포인트

**Phase 1의 첫 작업부터 시작** — `04-next-session-brief.md` 참조.
