# TheRoad Scene Lab — 목표 아키텍처 설계

> 이 문서는 `00-analysis.md`에서 식별된 10개 결함을 해결하기 위한 목표 아키텍처를 정의한다.
> 실제 실행 순서와 작업 항목은 `03-roadmap.md` 참조.

---

## 0. 설계 원칙

### 원칙 1 — **DB는 진실원, 체크포인트는 백업/아카이브**
- 런타임 상태를 표현하는 모든 데이터는 **Postgres가 primary source of truth**.
- 체크포인트 JSON 파일은 (a) LLM 결과 원본 보존, (b) snapshot/복원, (c) 재현성 확보 목적만 담당.
- 데이터 흐름은 **파일 → DB 단방향 sync**. 역방향(DB → 파일) sync는 허용하지 않음 (예외: snapshot 저장 시 archive).

### 원칙 2 — **명시적 계약 (Contract-first)**
- 각 데이터 항목의 진실원, 생성 시점, 변경 주체, 소비자를 문서화된 계약으로 선언 (`docs/architecture/data-contracts.md`).
- 신규 데이터 추가 시 계약 업데이트 필수.
- 계약과 코드가 어긋나면 코드를 수정하거나 계약을 갱신하되, "문서는 틀려도 된다"고 방치하지 않음.

### 원칙 3 — **단방향 의존성 (Unidirectional Dependency)**
```
UI (React)
    ↓ (HTTP)
API layer (api/v1/*.py)
    ↓ (function call)
Service layer (services/*.py)
    ↓ (function call)
Core layer (core/*, models/*)
    ↓ (ORM)
DB
```
- 상위 레이어는 하위를 호출할 수 있지만 역방향 금지.
- 같은 레이어 간 호출은 허용.
- Step은 Service를 호출, Service는 Core/Model을 호출.

### 원칙 4 — **Step은 Pure Transformation**
- Step은 "입력 체크포인트 + 컨텍스트 → 새 체크포인트"를 생성하는 **순수 변환**에 집중.
- DB 쓰기는 Step 내부가 아니라 StepRunner 또는 CheckpointSyncService가 담당.
- Step이 DB를 직접 UPDATE하는 현재 패턴(`scene_still.t2i_prompt_cinematic = ...` 같은)은 점진 제거.

### 원칙 5 — **중앙 레지스트리 (Single Registry)**
- **Manifest**는 step 정의의 유일한 진실원. Step 실행/의존성/invalidation 모두 manifest 기반.
- **SettingsRegistry**는 설정(env, ProjectSettings, 기본값)의 유일한 조회 창구.
- **ApplicabilityValidators**는 조건부 applicability의 유일한 판단 위치.

### 원칙 6 — **선언적 > 명령적 (Declarative)**
- "어떤 step이 어느 단계에 의존하는가"는 manifest에 선언 → 코드가 매번 하드코딩하지 않음.
- "어떤 데이터가 어느 저장소에 있는가"는 data-contracts에 선언 → 각 호출처가 임시 판단하지 않음.

### 원칙 7 — **테스트 가능성 (Testability First)**
- 모든 public 함수/메서드는 단위 테스트 작성 가능해야 함 (필수는 아니지만 작성 가능성).
- Service는 생성자 주입 또는 factory로 의존성 명시.
- Step은 DTO를 인자로 받아 DTO를 반환하는 형태 지향.

---

## 1. 레이어 정의

### 1.1 UI Layer (`frontend/src/`)

**책임**:
- 사용자 입력 수집
- 데이터 표시
- HTTP 호출
- 로컬 상태 관리 + 서버 상태 캐싱

**비책임**:
- 비즈니스 로직 (검증, 계산, 재시도 정책)
- DB 구조 지식

**도구**:
- React 19 + React Router 7
- **React Query (도입 예정)** — 서버 상태 캐싱, 낙관적 업데이트
- Custom hooks로 반복 패턴 추출 (`useEntities`, `useEpisode`, `useStills` 등)

### 1.2 API Layer (`backend/app/api/v1/*.py`)

**책임**:
- 요청 파싱 (Pydantic)
- 권한 체크 (`verify_project_access`, `get_current_user`)
- Service 호출
- 응답 포맷

**비책임**:
- DB 직접 조작 (SQL 쿼리, ORM 세션 변경)
- 파일 I/O
- 복잡한 비즈니스 로직

**규칙**:
- 각 API endpoint는 **50줄 이하**를 목표 (권한 체크 + service 호출 + 응답 구성).
- 에러 처리는 `@api_endpoint` 데코레이터로 통일.

### 1.3 Service Layer (`backend/app/services/*.py`)

**책임**:
- 도메인 비즈니스 로직
- 여러 Model을 조합한 고수준 작업
- 트랜잭션 경계 관리
- 외부 의존성 (LLM API, 파일 I/O) 조정

**구성** (목표):

| Service | 책임 범위 |
|---|---|
| `checkpoint_sync_service.py` | 체크포인트 → DB 동기화 (현재 `_sync_checkpoints_to_db`의 7개 블록을 메서드로 분리) |
| `shot_selection_service.py` | shot 선택/해제, 하위 invalidation |
| `snapshot_service.py` | 체크포인트 snapshot 저장/목록/복원 |
| `reference_image_service.py` | 참조/합성 이미지 생성, 검증 |
| `scene_image_service.py` | 씬 이미지 생성, 변형 추천, i2i 편집 |
| `prompt_service.py` | 프롬프트 빌드, 번역, ref 해소, 안전필터 fallback |
| `entity_service.py` | 엔티티 CRUD, outlook/composite 관리 |
| `auth_service.py` | 인증, 권한 (기존 유지) |
| `project_service.py` | 프로젝트 CRUD, 멤버 관리 (기존 유지) |
| `activity_logger.py` | 활동 로그 (기존 유지) |

**규칙**:
- Service 한 파일은 **2,000줄 이하**를 목표.
- Service는 `__init__`에서 필요한 의존성(db, 다른 service)을 주입받음.
- 내부 상태를 가지지 않는 것이 원칙 (stateless).

### 1.4 Core Layer (`backend/app/core/*.py`)

**책임**:
- 도메인 중립적 인프라
- Step 실행 엔진 (`StepRunner`)
- Step 정의 (`step_manifest`)
- 설정 (`config`, `SettingsRegistry`)
- 에러 (`errors`), 로깅, 보안

**비책임**:
- 도메인 비즈니스 로직
- DB 모델 정의 (그건 `models/`)

### 1.5 Model Layer (`backend/app/models/*.py`)

**책임**:
- SQLAlchemy ORM 모델 정의
- DB 관계 선언
- 컬럼 타입 및 제약

**비책임**:
- 비즈니스 로직 (모델 메서드에 복잡한 계산 금지)

### 1.6 Steps Layer (`backend/app/core/steps/*.py`)

**책임**:
- 파이프라인 step 실행 로직
- 체크포인트 읽기/쓰기 (StepRunner 통해)
- LLM 호출, 결과 변환

**비책임**:
- DB 직접 조작 (CheckpointSyncService를 통해 위임)
- API 라우팅
- 권한 체크

---

## 2. 데이터 진실원 계약

### 2.1 진실원 계층화

```
┌─────────────────────────────────────────────┐
│ Tier 1 — Primary (Postgres DB)              │
│ ─────────────────────────────────────────── │
│ • 런타임 상태 (is_selected, image_generated) │
│ • UI 조회 대상                               │
│ • 트랜잭션 필요 데이터                       │
│ • 엔티티 CRUD 결과 (canon, outlook, link)    │
│ • step_run 상태                              │
│ • image_asset 메타                           │
└─────────────────────────────────────────────┘
          ↑ sync (파일→DB 단방향)
          │
┌─────────────────────────────────────────────┐
│ Tier 2 — Backup/Archive (체크포인트 JSON)   │
│ ─────────────────────────────────────────── │
│ • LLM 결과 원본 보존                         │
│ • step별 중간 결과                           │
│ • snapshot/복원 대상                         │
│ • 디버깅/재현                                │
└─────────────────────────────────────────────┘
```

### 2.2 데이터별 계약 (목표)

| 데이터 | Tier 1 (DB) | Tier 2 (파일) | 쓰기 주체 | 소비자 |
|---|---|---|---|---|
| `scene_still.*` (메타) | ✓ Primary | scene_detail cp (원본 보존) | SceneDetailStep 완료 후 `CheckpointSyncService.sync_scene_still()` | UI, scene_image_pipeline |
| `scene_still.is_selected` | ✓ Primary | shot_selection cp (사용자 선택 원본) | `ShotSelectionService.toggle()` — DB 먼저, 파일 나중 | UI, detail_steps, scene_consistency |
| `scene_still.image_generated` | ✓ Primary | image_asset count로 파생 | `SceneImageService` | UI |
| `entity_canon` | ✓ Primary | entity_t2i cp | `CheckpointSyncService.sync_entities()` | UI, image_service |
| `entity_episode_link` | ✓ Primary | entity_t2i cp (참조) | 동일 | 동일 |
| `entity_episode_link.t2i_appearance_count` | ✓ Primary | **entity_t2i cp에도 저장 (신규)** | `_sync_t2i_appearance_counts` + cp에 기록 | pipeline_gate, UI |
| `character_outlook` | ✓ Primary | outlook_phase3 cp | `CheckpointSyncService.sync_outlooks()` — **UPSERT** (현재 DELETE+INSERT) | UI |
| `relation_fact`, `relation_participant` | ✓ Primary | entity_relation cp | `CheckpointSyncService.sync_relations()` — **UPSERT** | UI, image_service |
| `image_asset` | ✓ Primary | 파일 시스템 (실제 이미지) | 이미지 생성 service | UI |
| 원본 시나리오 텍스트 | DB `episode.source_text` | 파일 (PDF 원본) | upload API | 모든 텍스트 분석 step |
| beat/shot 추출 결과 | ✗ (DB 없음) | ✓ beat_extract, shot_extract cp | Step | 하위 step |
| scene_detail 원본 | ✗ | ✓ scene_detail cp | Step | image_service |
| scene_consistency fixed_elements | ✗ | ✓ scene_consistency cp | Step | detail_steps |

**변화 사항**:
- **휘발성 데이터(`t2i_appearance_count`)를 체크포인트에도 기록** — snapshot 복원 시 재계산 대신 파일 값 우선 사용.
- **DELETE→INSERT 패턴을 UPSERT로 전환** — `character_outlook`, `relation_fact` 등.
- **각 데이터의 쓰기 주체 명시** — Service 클래스 이름으로.

### 2.3 쓰기 규칙

1. **DB 쓰기 먼저, 파일 쓰기 나중** (`toggle_shot_selection`이 이미 이 패턴).
2. **파일 쓰기는 원자적** (tmp + rename). `app/core/checkpoint_io.py::atomic_write_json` 유틸 사용.
3. **같은 트랜잭션 안에서 여러 엔티티 변경** — Service가 트랜잭션 경계 관리.
4. **UPSERT만 사용** — DELETE→INSERT는 "이 데이터는 이 sync에서 완전 교체"가 명시될 때만 허용하며, 해당 지점에 주석 필수.

---

## 3. 컴포넌트 책임 분해

### 3.1 Step 실행 엔진 (StepRunner)

**현재**: 대부분 이미 올바름 (manifest 기반 의존성, 원자적 체크포인트 저장, invalidate_downstream).

**개선**:
- `check_applicability`에 ApplicabilityValidator 레지스트리 추가.
- Unknown rule 시 `raise ValueError` (silent True 방지).

### 3.2 CheckpointSyncService (신규)

**책임**:
- 체크포인트 파일 → DB UPSERT.
- 7개 메서드로 분리 (현재 `_sync_checkpoints_to_db`의 블록들).

**인터페이스**:
```python
class CheckpointSyncService:
    def __init__(self, db: OrmSession, project_id: str, episode_id: str):
        ...

    def sync_all(self) -> Dict[str, int]:
        """전체 동기화 (기존 _sync_checkpoints_to_db 호환)."""
        results = {}
        results["entities"] = self.sync_entities()
        results["relations"] = self.sync_relations()
        results["scene_still"] = self.sync_scene_still()
        results["outlooks"] = self.sync_outlooks()
        results["t2i_counts"] = self.sync_t2i_appearance_counts()
        return results

    def sync_entities(self) -> int:
        """entity_t2i 체크포인트 → entity_canon + entity_episode_link (UPSERT)."""
        ...

    def sync_relations(self) -> int:
        """entity_relation 체크포인트 → relation_fact + relation_participant (UPSERT)."""
        ...

    def sync_scene_still(self) -> int:
        """scene_detail 체크포인트 → scene_still (UPSERT)."""
        ...

    def sync_outlooks(self) -> int:
        """outlook_phase3 체크포인트 → character_outlook (UPSERT)."""
        ...

    def sync_t2i_appearance_counts(self) -> int:
        """T2I 프롬프트 등장 횟수 계산 → entity_episode_link.t2i_appearance_count."""
        ...
```

**호출 지점**:
- `run-all` 완료 후 (`api/v1/steps.py`)
- 개별 step 완료 후 (`StepRunner.run()` 내부에서 자동 호출 고려)
- snapshot 복원 후 (전체 또는 선택적)

### 3.3 SettingsRegistry (신규)

**책임**:
- env (config.py) + ProjectSettings (DB) + 기본값 조합.
- 런타임 feature flag 조회.

**인터페이스**:
```python
class SettingsRegistry:
    @classmethod
    def get_model_for_step(cls, step_id: str, project_id: str, db: OrmSession) -> str:
        # 우선순위: ProjectSettings.llm_config_json > step_manifest.default_model > config.openai_model
        ...

    @classmethod
    def is_feature_enabled(cls, feature: str, project_id: str, db: OrmSession) -> bool:
        # 우선순위: ProjectSettings.feature_flags > config.{feature}_enabled
        ...

    @classmethod
    def get_concurrency_limit(cls, purpose: str) -> int:
        # config.max_concurrent_{purpose}
        ...
```

### 3.4 ApplicabilityValidators (신규)

**책임**:
- `if_*` 규칙을 런타임에 검증.

**구현** (`backend/app/core/applicability.py`):
```python
ApplicabilityValidator = Callable[["StepRunner"], bool]

APPLICABILITY_VALIDATORS: Dict[str, ApplicabilityValidator] = {
    "if_planning_doc": lambda runner: bool(runner.project_config.get("planning_doc_text")),
    "if_has_outlooks": lambda runner: runner._load_prev_checkpoint("outlook_phase3") is not None,
    # 추가 규칙...
}


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}")
    return validator(runner)
```

`StepRunner.check_applicability`는 이 함수를 호출.

### 3.5 ImageService 분할

**현재**: 5,281줄 단일 파일.

**목표** — 3개 service:

#### 3.5.1 `prompt_service.py` (~1,000줄)

**책임**: 프롬프트 빌드/변환 전반.
- `build_final_scene_prompt(scene_index, t2i_prompt, ...)` — 현재 `_build_final_scene_prompt` 388줄을 세분화:
  - `_resolve_ref_roles()` — ref 해소
  - `_inject_fixed_elements()` — scene_consistency 주입
  - `_translate_if_korean()` — 번역
  - `_build_scene_text()` — 최종 조립
- `compose_t2i_prompts(...)` — scene_detail 결과에서 T2I 프롬프트 2개 작곡.
- `rewrite_t2i_with_image_refs(...)` — ID → "Image N" 치환.

#### 3.5.2 `reference_image_service.py` (~1,500줄)

**책임**: 참조/합성/variant 이미지 생성.
- `generate_reference_images(project_id, episode_id)` — 기존 `generate_reference_images_only` 이동.
- `generate_single_entity_image(entity_id)` — 단일 생성.
- `generate_composite(character_id, outlook_id)` — 합성.
- `generate_state_variant(character_id, state)` — character_state_variant.
- `validate_reference_image(image_path, entity)` — GPT Vision 검증.

#### 3.5.3 `scene_image_service.py` (~1,800줄)

**책임**: 씬 이미지 생성 + 편집.
- `generate_scene_with_variations(still_id)` — 기존 로직 이동.
- `generate_single_scene_image(still_id, variation)`.
- `edit_angle_fal(image_id, h, v, z)` — fal.ai 호출.
- `edit_color_gemini(image_id, prompt)` — Gemini i2i.
- `regenerate_with_prompt(still_id, custom_prompt)`.

### 3.6 SceneAnalysisContext (DTO)

**책임**: detail_steps의 closure 공유 제거.

**정의** (`backend/app/core/dto/scene_analysis.py`):
```python
from dataclasses import dataclass
from typing import Dict, List

@dataclass(frozen=True)
class SceneAnalysisContext:
    scene_index: int
    scene_text: str
    scene_summary: str
    visible_entities: List[str]
    fixed_elements: List[Dict]
    staging: Dict  # per-shot staging
    beats: Dict[int, Dict]
    entity_names: Dict[str, str]  # short_id → name
    shot_director_ve: Dict[tuple, list]
    shot_director_vr: Dict[tuple, dict]
    selected_shots: set
    summaries: Dict[int, str]
    shot_cinematography_map: Dict[str, Dict]
    shot_dependency_map: Dict[str, Dict]
    project_config: Dict


class SceneContextLoader:
    def __init__(self, step_runner: "StepRunner"):
        self._runner = step_runner

    def load_all(self) -> Dict[int, SceneAnalysisContext]:
        """모든 체크포인트 로드 후 씬별 컨텍스트 생성."""
        ...
```

`SceneDetailStep._analyze_one(ctx: SceneAnalysisContext)` — 명시적 인자. 테스트 가능.

### 3.7 Step lifecycle 필드 (manifest 확장)

**현재**: `applicability: "disabled"` 만으로 deprecated 표현.

**개선**:
```python
"shot_cinematography": {
    ...,
    "lifecycle": "deprecated",  # active | deprecated | removed
    "replaced_by": "shot_staging",  # optional
    "deprecated_since": "v0.5.0",
},
```

- `get_ordered_steps()`는 `lifecycle="removed"`를 제외.
- UI에서 deprecated step을 회색 처리.
- legacy 파일은 `backend/app/core/steps/legacy/` 디렉토리로 이동.

---

## 4. 의존성 그래프 & Invalidation

### 4.1 원칙

- **manifest.depends_on**이 유일한 의존성 정의.
- Invalidation은 `get_all_downstream_recursive(step_id)` 로 계산.
- 하드코딩된 downstream 리스트는 **제거**.

### 4.2 user 선택 변경 (shot_selection toggle)

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

    def toggle(self, scene_index: int, shot_index: int) -> Dict:
        with db_advisory_lock(f"shot_selection:{self.episode_id}"):  # 선택적
            # 1) 검증
            self._validate_shot_exists(scene_index, shot_index)

            # 2) 현재 상태 읽기
            cp = self._read_selection_cp()

            # 3) 토글 계산
            is_deselect, new_cp = self._compute_toggle(cp, scene_index, shot_index)

            # 4) DB 업데이트
            self._update_scene_still_is_selected(scene_index, shot_index, not is_deselect)

            # 5) Downstream invalidation (manifest 기반)
            from app.core.step_manifest import get_all_downstream_recursive
            downstream = get_all_downstream_recursive("shot_selection")
            self._mark_stale(downstream)
            self._clear_resume_checkpoints(downstream)  # 또는 해당 step들의 체크포인트 삭제

            # 6) DB commit
            self.db.commit()

            # 7) 파일 쓰기 (원자적)
            from app.core.checkpoint_io import atomic_write_json
            atomic_write_json(self._cp_path(), new_cp)

            return {"ok": True, ...}
```

API는 이 Service를 호출만 함 — 20줄 이하.

### 4.3 Snapshot/Restore

**현재**: 파일 복원 + DB step_run 업데이트만.

**개선** (`SnapshotService`):
```python
class SnapshotService:
    def restore(self, version: str, step_id: Optional[str] = None) -> Dict:
        # 1) 파일 복원
        restored_files = self._restore_files(version, step_id)

        # 2) step_run 상태 복원 (아카이브의 실제 status)
        self._restore_step_run(restored_files)

        # 3) 복원된 파일들에 대해 선택적 sync
        #    scene_detail 복원 → sync_scene_still
        #    entity_t2i 복원 → sync_entities
        sync_service = CheckpointSyncService(self.db, self.project_id, self.episode_id)
        for sid in restored_files:
            sync_method = SYNC_METHOD_MAP.get(sid)  # 매핑 레지스트리
            if sync_method:
                sync_method(sync_service)

        self.db.commit()
        return {"restored": restored_files, ...}
```

`SYNC_METHOD_MAP`:
```python
SYNC_METHOD_MAP = {
    "entity_t2i": lambda svc: svc.sync_entities(),
    "entity_relation": lambda svc: svc.sync_relations(),
    "scene_detail": lambda svc: svc.sync_scene_still(),
    "outlook_phase3": lambda svc: svc.sync_outlooks(),
    # ...
}
```

### 4.4 run-all 실행 & 재시도

**현재**: sequential, 실패 시 break, 최종 sync는 전체 성공 때만.

**개선**:
```python
class PipelineRunner:
    def run_all(self, steps: List[str], mode: str) -> Dict:
        executed = []
        for sid in steps:
            try:
                runner = get_step_runner(sid, ...)
                result = runner.run(mode)
                executed.append({"step": sid, "status": result["status"]})

                # step 단위 sync (신규 — 기존은 전체 성공 시만)
                if result["status"] in ("completed", "partial"):
                    self._sync_for_step(sid)
            except Exception as exc:
                executed.append({"step": sid, "status": "failed", "error": str(exc)})
                break  # 기존과 동일 — 이후 step skip

        return {"executed": executed, "retry_from": self._compute_retry_point(executed)}

    def _compute_retry_point(self, executed: List[Dict]) -> Optional[str]:
        """다음 재실행 시 어느 step부터 시작할지."""
        for e in executed:
            if e["status"] in ("failed", "partial"):
                return e["step"]
        return None
```

UI는 `retry_from`을 받아 "여기서부터 재개" 버튼 표시.

---

## 5. 에러 처리 표준

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

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

모든 endpoint는 이 데코레이터 적용. 응답에 항상 `warnings` 필드.

### 5.2 Service/Step 에러 처리 규칙

| 상황 | 규칙 |
|---|---|
| 예상된 도메인 에러 (검증 실패 등) | `raise AppError(code=..., message=..., status_code=...)` |
| 예상된 외부 실패 (LLM timeout) | `logger.warning + 재시도 또는 fallback`. 최종 실패 시 `raise AppError` 또는 부분 결과 반환. |
| 예상 못 한 예외 | **절대 silent swallow 금지**. `logger.exception` + raise. 상위가 `@api_endpoint`에서 500 처리. |

현재 `image_service.py`의 `except: pass` 12건은 모두 검토 후 명시적 처리로 교체.

---

## 6. Frontend 설계

### 6.1 React Query 도입

**모든 서버 데이터 fetching은 React Query로**:
```tsx
const { data: entities, isLoading, error } = useQuery({
  queryKey: ['entities', episodeId],
  queryFn: () => api.getEntities(episodeId),
  staleTime: 5 * 60 * 1000,
})

const toggleMutation = useMutation({
  mutationFn: ({ sceneIdx, shotIdx }) => api.toggleShot(episodeId, sceneIdx, shotIdx),
  onMutate: async ({ sceneIdx, shotIdx }) => {
    // 낙관적 업데이트
    await queryClient.cancelQueries(['stills', episodeId])
    const previous = queryClient.getQueryData(['stills', episodeId])
    queryClient.setQueryData(['stills', episodeId], old => {...})
    return { previous }
  },
  onError: (err, vars, context) => {
    queryClient.setQueryData(['stills', episodeId], context.previous)
  },
  onSettled: () => queryClient.invalidateQueries(['stills', episodeId]),
})
```

### 6.2 Custom Hook 추출

```tsx
// frontend/src/hooks/api/
useEpisode(episodeId)       // 에피소드 메타
useStills(episodeId)        // scene_still 리스트
useEntities(episodeId)      // 엔티티
useEpisodeProgress(eid)     // 파이프라인 진행률
useShotToggle(eid)          // 토글 mutation
```

페이지 컴포넌트는 이 hook들만 사용 → `useState` 5~10개로 감소.

### 6.3 Component 분할

현재 `EpisodeDetail.tsx` 1,358줄 → 여러 섹션으로 분할:
- `EpisodeHeader.tsx`
- `EpisodeStillsSection.tsx`
- `EpisodeEntitiesSection.tsx`
- `EpisodeWorldGuideSection.tsx`
- `EpisodePipelinePanel.tsx`

각 300~500줄 이하.

---

## 7. 디렉토리 구조 (목표)

```
backend/app/
├── api/v1/
│   ├── steps.py              # 500줄 (router + 얇은 호출만)
│   ├── episodes.py
│   ├── projects.py
│   ├── entities.py
│   ├── images.py
│   ├── auth.py
│   └── ...
├── services/
│   ├── checkpoint_sync_service.py  # 신규
│   ├── shot_selection_service.py   # 신규
│   ├── snapshot_service.py         # 신규
│   ├── reference_image_service.py  # image_service 분할
│   ├── scene_image_service.py      # image_service 분할
│   ├── prompt_service.py           # image_service 분할
│   ├── entity_service.py           # 강화
│   ├── auth_service.py             # 유지
│   ├── project_service.py          # 유지
│   └── activity_logger.py          # 유지
├── core/
│   ├── applicability.py            # 신규 — ApplicabilityValidators
│   ├── checkpoint_io.py            # 신규 — atomic_write_json
│   ├── config.py                   # 기존
│   ├── settings_registry.py        # 신규
│   ├── step_manifest.py            # 기존 + lifecycle 필드
│   ├── step_runner.py              # 개선 (applicability 사용)
│   ├── pipeline_gate.py            # 기존
│   ├── pipeline_runner.py          # 신규 — run_all 로직 분리
│   ├── errors.py                   # 기존
│   ├── logging_config.py           # 기존
│   ├── database.py                 # 기존
│   ├── security.py                 # 기존
│   ├── dto/
│   │   └── scene_analysis.py       # 신규 — SceneAnalysisContext
│   └── steps/
│       ├── <active>/
│       │   ├── scene_detail_step.py  # closure → DTO
│       │   ├── scene_consistency_step.py
│       │   └── ... (각 active step)
│       └── legacy/
│           └── analysis_steps_legacy.py  # 이동
└── modules/
    └── ... (LLM, pipeline utils 등 기존 유지)

frontend/src/
├── pages/
│   ├── EpisodeDetail.tsx       # 500줄 (slim)
│   ├── Dashboard.tsx
│   └── ...
├── components/
│   ├── episode/                # 신규 — EpisodeDetail 분할
│   │   ├── EpisodeHeader.tsx
│   │   ├── EpisodeStillsSection.tsx
│   │   └── ...
│   └── shared/
├── hooks/
│   ├── api/                    # 신규 — React Query hooks
│   │   ├── useEpisode.ts
│   │   ├── useStills.ts
│   │   └── ...
│   └── useToast.ts
└── api/
    └── client.ts               # fetch wrapper (기존)
```

---

## 8. 데이터 모델 변경

### 8.1 entity_t2i 체크포인트 스키마 확장

**현재**:
```json
{
  "status": "completed",
  "data": {
    "characters": [{...}],
    "locations": [{...}],
    "props": [{...}]
  }
}
```

**목표** (휘발성 데이터 보존):
```json
{
  "status": "completed",
  "data": {
    "characters": [{...}],
    "locations": [{...}],
    "props": [{...}],
    "_appearance_counts": {
      "C01": 5, "L02": 3, "P01": 1
    }
  }
}
```

`CheckpointSyncService.sync_t2i_appearance_counts()`는 체크포인트의 `_appearance_counts`를 우선 사용하고, 없으면 동적 계산.

### 8.2 step_manifest 확장

```python
"shot_cinematography": {
    ...,
    "lifecycle": "deprecated",
    "replaced_by": "shot_staging",
    "deprecated_since": "v0.5.0",
    "applicability": "disabled",
},
```

### 8.3 (선택) outlook 분리 (Phase 4)

**옵션 A**: `entity_canon`에서 outlook을 별도 테이블로 분리.
**옵션 B**: `entity_canon.scope` 컬럼 추가 (`project` | `episode`).

현재는 **현재 구조 유지** (Phase 4에서 재검토). 이번 리팩토링의 핵심은 아님.

---

## 9. API 변경 사항

### 9.1 응답 스키마 표준화

모든 API 응답에 `warnings: List[str]` 필드 추가 (빈 배열 가능).

### 9.2 신규 endpoint

- `GET /api/v1/projects/{pid}/episodes/{eid}/retry-point` — 재시도 가능 지점 조회.

### 9.3 변경 endpoint

- `PATCH /shot_selection/toggle` — 응답에 `warnings` 유지 (이미 v0.5.3에 추가됨).
- `POST /snapshots/restore` — 응답에 `synced_data: {entities: N, relations: N, ...}` 추가.

---

## 10. 마이그레이션 전략

### 10.1 원칙

- **기존 API 호환성 유지** — 외부 인터페이스 변경 최소화.
- **단계적 전환** — 새 Service와 기존 코드가 공존하는 기간 허용.
- **각 Phase 말 테스트** — 기존 E2E 테스트(요괴전 projects) 통과 확인.

### 10.2 Phase별 순서는 `03-roadmap.md` 참조.

---

## 11. 리스크 & 완화

| 리스크 | 영향 | 완화 |
|---|---|---|
| 대규모 리팩토링 중 회귀 | 높음 | Phase별 E2E 테스트, feature branch, snapshot 복원으로 롤백 |
| 기존 체크포인트 호환성 (신규 `_appearance_counts` 필드) | 낮음 | 파일에 필드 없으면 기존 동적 계산 fallback |
| React Query 학습 비용 | 중간 | 1개 페이지 먼저 적용 후 확산 |
| Service 이동 중 import 경로 문제 | 중간 | 이동 시 기존 경로에 `from ... import *` alias 임시 유지 |
| `_sync_checkpoints_to_db` 분해 중 트랜잭션 경계 실수 | 높음 | 기존 테스트 통과 후 이동 + 각 메서드 트랜잭션 단위 분리 |

---

## 12. 성공 지표 (목표 값)

| 지표 | 현재 | Phase 1 후 | Phase 3 후 | 최종 |
|---|---|---|---|---|
| `image_service.py` 라인 | 5,281 | 5,281 | 1,200 (prompt_service만) | 0 (삭제) |
| `api/v1/steps.py` 라인 | 1,518 | 1,450 | 800 | 500 |
| `_sync_checkpoints_to_db` 라인 (한 함수) | 722 | 722 | 60 (wrapper) | 60 |
| 하드코딩 downstream 리스트 수 | 3곳 | 0 | 0 | 0 |
| Applicability unknown rule → silent True | Yes | No | No | No |
| `EpisodeDetail.tsx` useState 수 | 51 | 51 | 51 | 15 |
| 단위 테스트 가능한 Service 메서드 비율 | <10% | 30% | 70% | 90% |
| CLAUDE.md UPSERT 규칙 준수율 (sync 지점) | 60% | 60% | 100% | 100% |

---

이 설계를 기반으로 한 실행 계획은 `03-roadmap.md`를 참조.
