# TheRoad Scene Lab — Before/After 비교표

> 현재 아키텍처(v0.5.3)와 목표 아키텍처의 구체적 대비.
> 각 섹션은 (1) 현재 구조, (2) 목표 구조, (3) 무엇이 바뀌는가, (4) 왜 바뀌는가, (5) 마이그레이션 영향 순으로 기술.

---

## 1. 레이어 구조

### Before
```
FastAPI Router (api/v1/*.py)
  │  ─ 라우팅 + DB SQL 직접 조작 + 파일 I/O + 722줄 동기화 함수
  ↓
Service (services/*.py)
  │  ─ 주로 image_service (5,281줄, 11개 책임)
  │  ─ auth/project는 얇음
  ↓
Core/Steps (core/steps/*.py)
  │  ─ step 로직 + DB 직접 조작 일부
  ↓
Model (models/*.py)
  │  ─ SQLAlchemy 모델
  ↓
Postgres
```

**문제점**: API가 비즈니스 로직의 50% 이상 수행. Service가 스스로 역할을 못 하는 구조.

### After
```
FastAPI Router (api/v1/*.py)
  │  ─ 라우팅 + 권한 체크 + Service 호출 + 응답 포맷 (endpoint 당 ~50줄)
  ↓
Service (services/*.py)
  │  ─ 도메인 비즈니스 로직
  │  ─ CheckpointSyncService, ShotSelectionService, SnapshotService (신규)
  │  ─ ImageService 3분할 (reference/scene/prompt)
  ↓
Core (core/*.py)
  │  ─ StepRunner, SettingsRegistry, ApplicabilityValidators, CheckpointIO
  ↓
Steps (core/steps/*.py)
  │  ─ Pure transformation (DTO in/out)
  │  ─ CheckpointSyncService를 통한 DB 업데이트 위임
  ↓
Model (models/*.py)
  │  ─ SQLAlchemy 모델
  ↓
Postgres
```

**무엇이 바뀌나**:
- API → Service → Core → Steps → Model 단방향 의존성 강제.
- Service 레이어가 실질적 비즈니스 로직 수행.
- Step은 DB를 직접 조작하지 않고 CheckpointSyncService에 위임.

**왜**: 각 레이어의 책임이 명확해져야 테스트/유지보수/기능 추가 비용이 감소. 현재 API가 비대화된 구조에서는 간단한 변경에도 API 재배포 필요.

**마이그레이션 영향**:
- Phase 1~3에 걸쳐 점진 이전.
- 외부 API 인터페이스는 유지 (URL, method, 파라미터 동일).
- 내부 호출 구조만 변경 — 기존 단위 테스트는 대부분 통과.

---

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

### Before

| 데이터 | 진실원 | 계약 | 위험 |
|---|---|---|---|
| `scene_still.is_selected` | 혼재 (DB와 파일 둘 다) | 암묵적 | 파일 쓰기 실패 시 불일치 |
| `entity_canon.*` | 파일 (entity_t2i cp) | 없음 | DB UPSERT가 C/L/P 한정 (outlook 삭제 위험) |
| `t2i_appearance_count` | DB만 (휘발성) | 없음 | snapshot 복원 시 재계산 |
| `character_outlook` | 파일 → DB DELETE+INSERT | 없음 | UPSERT 규칙 위반 |
| `relation_fact` | 파일 → DB DELETE+INSERT | 없음 | 동일 |
| `visible_entities` | 3단 fallback | 없음 | silent degradation |

### After

| 데이터 | 진실원 | 계약 (문서화) | 쓰기 주체 |
|---|---|---|---|
| `scene_still.*` | **DB Primary**, 파일은 backup | `data-contracts.md` | `CheckpointSyncService.sync_scene_still()` |
| `scene_still.is_selected` | **DB Primary** | 동일 | `ShotSelectionService.toggle()` (DB 먼저 → 원자 파일 쓰기) |
| `entity_canon.*` | **DB Primary** | 동일 | `CheckpointSyncService.sync_entities()` (UPSERT) |
| `t2i_appearance_count` | **DB Primary + 체크포인트 backup** | 동일 | `CheckpointSyncService.sync_t2i_appearance_counts()` (파일에도 기록) |
| `character_outlook` | **DB Primary** | 동일 | `CheckpointSyncService.sync_outlooks()` (**UPSERT**) |
| `relation_fact` | **DB Primary** | 동일 | `CheckpointSyncService.sync_relations()` (**UPSERT**) |
| `visible_entities` | shot_director > T2I regex > scene_director (현재 유지) | 명시적 fallback chain 문서화 | 동일 |

**무엇이 바뀌나**:
- **진실원 계약이 문서로 존재** (`docs/architecture/data-contracts.md` 신설 예정).
- **DELETE→INSERT 패턴이 UPSERT로 전환** — `character_outlook`, `relation_fact`.
- **`t2i_appearance_count`가 체크포인트에도 기록** — snapshot 복원 시 값 보존.

**왜**: 
- snapshot 복원 신뢰성 확보.
- CLAUDE.md의 "scene_still UPSERT 규칙"을 전체 sync에 확대 적용.
- 휘발성 데이터 손실 방지.

**마이그레이션 영향**:
- entity_t2i 체크포인트 포맷 확장 (`_appearance_counts` 필드 추가, optional → 기존 체크포인트 호환).
- DB 마이그레이션 없음.
- UPSERT 전환은 Service 메서드 내부 로직만 변경.

---

## 3. 체크포인트 ↔ DB 동기화

### Before: `_sync_checkpoints_to_db` (722줄 한 함수)

```python
# backend/app/api/v1/steps.py:768-1487
def _sync_checkpoints_to_db(project_id, episode_id, db):
    """Run-all 완료 후 / scene_director 사전 / 개별 step 완료 후 호출."""
    # Block 1: entity_t2i → entity_canon + entity_episode_link (110줄)
    # Block 1.5: entity_relation → RelationFact + RelationParticipant (65줄, DELETE+INSERT)
    # Block 2: scene_detail → scene_still (330줄, 조건부 UPSERT)
    # Block 2c: scene_summary UPDATE (11줄)
    # Block 2d: shot_cinematography UPDATE (29줄)
    # Block 3: outlook_phase3 → entity_canon + character_outlook (114줄, DELETE+INSERT)
    # Block 3b: orphan outlook cleanup (16줄)
    # Block 3c: _sync_t2i_appearance_counts 호출 (1줄 + 72줄 함수)
```

**문제**: 
- 3개 호출 경로가 모두 전체 블록 실행 (일부만 필요해도).
- 테스트 불가능.
- 한 블록 수정이 다른 블록 회귀 유발.
- API 파일에 존재 → Service가 재사용 못 함.

### After: `CheckpointSyncService` (Service 클래스로 분리)

```python
# backend/app/services/checkpoint_sync_service.py (신규, ~800줄)

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

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

    def sync_entities(self) -> int: ...
    def sync_relations(self) -> int: ...  # UPSERT
    def sync_scene_still(self) -> int: ...
    def sync_scene_summary(self) -> int: ...
    def sync_shot_cinematography(self) -> int: ...
    def sync_outlooks(self) -> int: ...  # UPSERT
    def sync_t2i_appearance_counts(self) -> int: ...


# API는 얇은 wrapper
# backend/app/api/v1/steps.py
def _sync_checkpoints_to_db(project_id, episode_id, db):
    svc = CheckpointSyncService(db, project_id, episode_id)
    return svc.sync_all()
```

**무엇이 바뀌나**:
- **7개 독립 메서드로 분해** — 각각 테스트 가능.
- **선택적 호출 가능** — snapshot 복원 시 특정 블록만 실행.
- **Service 레이어 이동** — Step도 필요 시 호출 가능.
- **UPSERT 규칙 준수** — relations, outlooks의 DELETE+INSERT 제거.

**왜**:
- 테스트 작성 가능 (각 메서드 단위).
- 유지보수성 향상 (한 블록 수정이 다른 블록에 영향 적음).
- snapshot 복원 시 세밀한 제어 (복원된 파일에 대응하는 sync만 호출).

**마이그레이션 영향**:
- 기존 호출 경로(`_sync_checkpoints_to_db(...)`)는 유지 — 함수 내부가 `svc.sync_all()` 호출로 바뀔 뿐.
- Step 내부에서 일부 호출하던 패턴도 호환.
- UPSERT 전환은 Phase 2에서 개별 메서드 내부 로직 수정.

---

## 4. Step Invalidation

### Before: 하드코딩 downstream 리스트

```python
# backend/app/api/v1/steps.py:356-358
# toggle_shot_selection 내부
downstream = ["scene_camera_flow", "shot_staging", "shot_director", "shot_dependency",
              "scene_consistency", "scene_detail", "scene_verify",
              "scene_image_pipeline", "composite_image_gen", "world_guide"]
```

**문제**:
- `step_manifest.py:579-588`에 `get_all_downstream_recursive()` 인프라가 이미 존재하는데 사용하지 않음.
- 신규 step 추가 시 이 리스트 갱신 필요 → 누락 위험.
- 같은 패턴 3곳 (`downstream`, `_resume_sensitive`, `_invalidate_*`).

### After: manifest 기반 자동 계산

```python
# backend/app/services/shot_selection_service.py
from app.core.step_manifest import get_all_downstream_recursive

class ShotSelectionService:
    def toggle(self, scene_index, shot_index):
        ...
        downstream = get_all_downstream_recursive("shot_selection")
        self._mark_stale(downstream)
        ...
```

**같은 패턴을 _resume_sensitive에도 적용**:
```python
# resume_sensitive는 "shot_staging 이후 체크포인트 재사용하는 step"
# manifest에 새 필드로 선언하거나, heuristic으로 계산
_resume_sensitive = [
    sid for sid in downstream
    if STEP_MANIFEST[sid].get("supports_resume", False)
]
```

**무엇이 바뀌나**:
- 하드코딩된 리스트 3곳 모두 manifest 기반으로 전환.
- 신규 step 추가 시 invalidation 자동 반영.

**왜**:
- 이미 존재하는 인프라 활용.
- 신규 step 추가 시 누락 방지.
- manifest가 실제 의존성 그래프의 single source of truth.

**마이그레이션 영향**:
- `toggle_shot_selection` API 응답에 포함된 `invalidated_steps` 리스트 내용이 바뀔 수 있음 (manifest 기반이 더 넓을 가능성).
- Frontend는 `invalidated_steps`를 정보 표시용으로만 사용하므로 영향 미미.

---

## 5. Applicability 런타임 검증

### Before: 기본 `True` 반환

```python
# backend/app/core/step_runner.py:82-92
def check_applicability(self) -> bool:
    rule = self.manifest.get("applicability", "always")
    if rule == "disabled":
        return False
    if rule == "always":
        return True
    if rule == "on_demand":
        return True
    # 서브클래스에서 오버라이드 가능
    return True  # ← "if_has_outlooks" 등 모두 True
```

**문제**: `"if_planning_doc"`, `"if_has_outlooks"` 등 조건부 규칙이 기본 구현에서 전부 `True` → 서브클래스 오버라이드 안 하면 silent bug.

### After: 중앙 Validator 레지스트리

```python
# backend/app/core/applicability.py (신규)
from typing import Callable, Dict

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

APPLICABILITY_VALIDATORS: Dict[str, ApplicabilityValidator] = {
    "if_planning_doc": lambda r: bool(r.project_config.get("planning_doc_text")),
    "if_has_outlooks": lambda r: r._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)


# step_runner.py
from app.core.applicability import resolve_applicability

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

**set_design 특수 사례**:
- manifest: `"applicability": "if_set_design_enabled"` (변경)
- validator: `"if_set_design_enabled": lambda r: settings.set_design_enabled`
- 현재 런타임 env 스위치가 manifest와 일치.

**무엇이 바뀌나**:
- `if_*` 규칙의 런타임 검증.
- Unknown rule 시 `ValueError` (silent True 대신).
- 서브클래스 오버라이드 의존 → Validator 함수로 중앙 관리.

**왜**:
- 조건부 step이 조건 안 맞을 때 실제로 skip.
- 신규 `if_*` 규칙 추가 시 "검증 잊음" 방지.

**마이그레이션 영향**:
- 현재 `if_has_outlooks` 등이 서브클래스 오버라이드로 작동 중 — 기능 변화 없음.
- `set_design` applicability를 `"always"` → `"if_set_design_enabled"`로 변경 시 manifest와 runtime 일치.

---

## 6. ImageService 분할

### Before: 단일 파일 5,281줄

```python
# backend/app/services/image_service.py (5,281 lines)

class ImageService:
    def generate_reference_images_only(...): ...     # 참조 이미지
    def generate_single_entity_image(...): ...       # 단일 엔티티
    def generate_scene_with_variations(...): ...     # 씬 이미지 batch
    def generate_single_scene_image(...): ...        # 단일 씬
    def _build_final_scene_prompt(...): ...          # 388줄 함수
    def compose_prompts(...): ...                    # 프롬프트 작곡
    def _rewrite_t2i_with_image_refs(...): ...       # ref 치환
    def _resolve_refs_for_prompt(...): ...           # ref 해소
    def _apply_fal_angle(...): ...                   # fal.ai
    def _validate_reference(...): ...                # GPT Vision
    # ... 20+ 더
```

**문제**: 11개 책임이 한 클래스에. 테스트 불가. 변경 위험도 극도로 높음.

### After: 3개 Service로 분할

```python
# backend/app/services/prompt_service.py (~1,000줄)
class PromptService:
    def build_final_scene_prompt(self, scene_index, t2i_prompt, ...): ...
    def compose_t2i_prompts(self, scene_detail, ...): ...
    def rewrite_t2i_with_image_refs(self, t2i_prompt, sid_to_img): ...

    # Private helpers
    def _resolve_ref_roles(self, ...): ...
    def _inject_fixed_elements(self, ...): ...
    def _translate_if_korean(self, cleaned, ...): ...
    def _build_scene_text(self, ref_roles, t2i, ...): ...


# backend/app/services/reference_image_service.py (~1,500줄)
class ReferenceImageService:
    def __init__(self, db, prompt_service: PromptService, ...):
        self.prompt_service = prompt_service
        ...

    def generate_reference_images(self, project_id, episode_id): ...
    def generate_single_entity_image(self, entity_id): ...
    def generate_composite(self, character_id, outlook_id): ...
    def generate_state_variant(self, character_id, state): ...
    def validate_reference_image(self, image_path, entity): ...


# backend/app/services/scene_image_service.py (~1,800줄)
class SceneImageService:
    def __init__(self, db, prompt_service: PromptService, ...):
        self.prompt_service = prompt_service
        ...

    def generate_scene_with_variations(self, still_id): ...
    def generate_single_scene_image(self, still_id, variation): ...
    def edit_angle_fal(self, image_id, h, v, z): ...
    def edit_color_gemini(self, image_id, prompt): ...
    def regenerate_with_prompt(self, still_id, custom_prompt): ...
```

**의존성 주입**:
```python
# backend/app/api/deps.py
def get_scene_image_service(db: OrmSession = Depends(get_db)) -> SceneImageService:
    prompt_svc = PromptService(db)
    return SceneImageService(db, prompt_svc)
```

**무엇이 바뀌나**:
- 11개 책임 → 3개 Service로 분산.
- `_build_final_scene_prompt` 388줄 → 4개 메서드로 세분화.
- 의존성 명시 (PromptService가 다른 Service의 의존성).

**왜**:
- 단위 테스트 가능.
- 변경 위험 격리.
- 병렬 개발 용이.

**마이그레이션 영향**:
- 기존 `ImageService` 호출처는 (1) 초기엔 기존 파일에 wrapper 유지, (2) 점진적으로 새 Service 직접 호출로 교체.
- 기능 변화 없음 — 코드 재배치만.

---

## 7. detail_steps Closure → DTO

### Before: 13개 dict closure 공유

```python
# backend/app/core/steps/detail_steps.py

class SceneDetailStep(StepRunner):
    def _execute(self, mode="resume"):
        # 13개 dict를 로드
        fixed_elements_by_scene: Dict[int, List[Dict]] = {...}
        staging_map: Dict[str, Dict] = {...}
        beats_by_scene: Dict[int, Dict[int, dict]] = {...}
        shot_director_ve: Dict[tuple, list] = {...}
        # ... 9개 더

        def _analyze_one(seg, shot_info=None):
            si = seg.get("scene_index", 0)
            # 외부 closure 참조
            fixed = fixed_elements_by_scene.get(si, [])
            staging = staging_map.get(f"{si}_{shot_idx}", {})
            # ... 복잡한 lookup 체인
            ...

        with ThreadPoolExecutor(...) as ex:
            futures = [ex.submit(_analyze_one, seg) for seg in segments]
```

**문제**:
- `_analyze_one` 테스트 시 13개 dict 전부 mock 필요.
- 타입 안전성 없음.
- 메모리 상주 — 대규모 시나리오에서 부담.

### After: SceneAnalysisContext DTO

```python
# backend/app/core/dto/scene_analysis.py (신규)
from dataclasses import dataclass
from typing import Dict, List, Set

@dataclass(frozen=True)
class SceneAnalysisContext:
    scene_index: int
    scene_text: str
    scene_summary: str
    visible_entities: List[str]
    fixed_elements: List[Dict]
    staging: Dict[str, Dict]
    beats: Dict[int, Dict]
    entity_names: Dict[str, str]
    shot_director_ve: Dict[tuple, list]
    shot_director_vr: Dict[tuple, dict]
    selected_shots: Set[int]
    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]:
        """모든 체크포인트를 읽어 씬별 컨텍스트 생성."""
        fixed_elements_by_scene = self._load_fixed_elements()
        staging_map = self._load_staging()
        # ...
        
        contexts = {}
        for seg in segments:
            si = seg["scene_index"]
            contexts[si] = SceneAnalysisContext(
                scene_index=si,
                scene_text=seg["text"],
                visible_entities=scene_visible.get(si, []),
                fixed_elements=fixed_elements_by_scene.get(si, []),
                staging=staging_map,
                beats=beats_by_scene.get(si, {}),
                # ...
            )
        return contexts


# backend/app/core/steps/scene_detail_step.py
class SceneDetailStep(StepRunner):
    def _execute(self, mode="resume"):
        loader = SceneContextLoader(self)
        contexts = loader.load_all()

        with ThreadPoolExecutor(...) as ex:
            futures = [
                ex.submit(self._analyze_one, ctx)
                for ctx in contexts.values()
            ]
            ...

    def _analyze_one(self, ctx: SceneAnalysisContext) -> Dict:
        # 명시적 인자, 타입 안전, 테스트 가능
        si = ctx.scene_index
        fixed = ctx.fixed_elements
        staging = ctx.staging.get(f"{si}_{shot_idx}", {})
        ...
```

**무엇이 바뀌나**:
- Closure 공유 → 명시적 DTO 전달.
- `_analyze_one` 단위 테스트 가능 (mock 대신 DTO 구성).
- 타입 안전성 (IDE 자동완성, mypy).

**왜**:
- 테스트 가능성.
- 변경 영향 범위 축소 (DTO 필드만 보면 됨).
- 메모리 효율 (필요 시 lazy-load 가능).

**마이그레이션 영향**:
- 기능 변화 없음 — 내부 구조만.
- 로직 재배치 중 버그 유입 가능 → Phase 3에 배치, 기존 E2E 테스트 통과 확인.

---

## 8. Frontend 상태 관리

### Before: 51개 useState

```tsx
// frontend/src/pages/EpisodeDetail.tsx (1,358줄)
const [episode, setEpisode] = useState<Episode | null>(null)
const [loading, setLoading] = useState(true)
const [stills, setStills] = useState<SceneStill[]>([])
const [stillsLoading, setStillsLoading] = useState(false)
const [stillImages, setStillImages] = useState<Record<string, ImageAsset[]>>({})
const [entities, setEntities] = useState<Entity[]>([])
const [entitiesLoading, setEntitiesLoading] = useState(false)
// ... 44개 더

useEffect(() => {
  fetchEpisode()
  fetchStills()
  fetchEntities()
  // ... 많은 fetch
}, [id])

const fetchEpisode = async () => {
  setLoading(true)
  try { setEpisode(await api.get(...)) }
  catch { addToast('error', ...) }
  finally { setLoading(false) }
}
// ... 반복
```

### After: React Query + Custom Hooks

```tsx
// frontend/src/hooks/api/useEpisode.ts (신규)
export function useEpisode(episodeId: string) {
  return useQuery({
    queryKey: ['episode', episodeId],
    queryFn: () => api.getEpisode(episodeId),
    staleTime: 5 * 60 * 1000,
  })
}

// frontend/src/hooks/api/useStills.ts
export function useStills(episodeId: string) {
  return useQuery({
    queryKey: ['stills', episodeId],
    queryFn: () => api.getStills(episodeId),
  })
}

// frontend/src/hooks/api/useShotToggle.ts
export function useShotToggle(episodeId: string) {
  const queryClient = useQueryClient()
  return 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) => 
        optimisticToggle(old, sceneIdx, shotIdx)
      )
      return { previous }
    },
    onError: (err, vars, context) => {
      queryClient.setQueryData(['stills', episodeId], context.previous)
    },
    onSettled: () => queryClient.invalidateQueries(['stills', episodeId]),
  })
}

// frontend/src/pages/EpisodeDetail.tsx (500줄 목표)
export function EpisodeDetail() {
  const { id: episodeId } = useParams()
  const { data: episode, isLoading: epLoading } = useEpisode(episodeId)
  const { data: stills = [] } = useStills(episodeId)
  const { data: entities = [] } = useEntities(episodeId)
  const toggle = useShotToggle(episodeId)

  // Modal/Dialog 상태 정도만 useState
  const [addOpen, setAddOpen] = useState(false)
  const [editingPrompt, setEditingPrompt] = useState<string | null>(null)
  
  // 렌더링만
  ...
}
```

**무엇이 바뀌나**:
- `useState` 51 → ~15 (로컬 UI 상태만).
- Fetch/로딩/에러 상태 자동 관리.
- 낙관적 업데이트 내장.
- 캐싱으로 중복 요청 감소.

**왜**:
- 코드량 50% 감소.
- UX 향상 (빠른 응답, 일관된 로딩 UI).
- 버그 감소 (stale data, race condition).

**마이그레이션 영향**:
- `@tanstack/react-query` 설치.
- 페이지 단위로 점진 이전 (EpisodeDetail부터, 이후 Dashboard 등).
- 기존 `api/client.ts` 유지 — React Query가 이를 wrap.

---

## 9. 에러 처리 표준화

### Before

```python
# API layer
try:
    ...
except AppError:
    raise
except Exception as exc:
    logger.error("...")
    raise AppError(...)

# Service layer
try:
    ...
except Exception:
    pass  # silent swallow — 12건

# Step layer
try:
    ...
except Exception as exc:
    logger.error("...")
    # 어떤 경우는 재시도, 어떤 경우는 propagate, 어떤 경우는 swallow
```

### After

```python
# backend/app/api/deps.py
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
@router.post("/...")
@api_endpoint
def my_endpoint(...):
    svc = SomeService(db)
    return svc.do_something()  # 에러는 AppError or @api_endpoint로


# Service layer 규칙
# 1) 예상된 도메인 에러: raise AppError
# 2) 예상된 외부 실패 (LLM timeout): logger.warning + 재시도 or fallback
# 3) 예상 못 한 예외: logger.exception + raise (silent swallow 금지)
```

**무엇이 바뀌나**:
- 모든 API 응답에 `warnings: List[str]` 필드.
- `except: pass` 12건 모두 명시적 처리로 교체.
- 로깅은 `logger.exception` (stack trace 포함) 사용.

**왜**:
- 응답 스키마 일관성.
- 숨은 버그 제거 (silent swallow).
- 디버깅 편의.

**마이그레이션 영향**:
- 기존 응답에 `warnings` 필드가 추가됨 — 일반적으로 빈 배열. Frontend가 읽지 않아도 호환.
- `except: pass`는 개별 검토 후 교체.

---

## 10. 설정 관리

### Before: 4개 소스 분산

```python
# config.py
class Settings(BaseSettings):
    fal_ai_enabled: bool = False
    set_design_enabled: bool = False
    openai_model: str = "gpt-5.4"
    max_concurrent_image_gen: int = 15

# step_manifest.py
"scene_detail": {..., "default_model": "gpt", ...}

# DB
# ProjectSettings.llm_config_json = {"scene_detail": {"model": "gemini-pro"}}

# 각 호출처
from app.core.config import settings
if settings.fal_ai_enabled: ...  # 각자 조회
```

### After: SettingsRegistry 중앙화

```python
# backend/app/core/settings_registry.py (신규)

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 > settings.*_model"""
        # 1) Project override
        ps = db.query(ProjectSettings).filter(ProjectSettings.project_id == project_id).first()
        if ps and ps.llm_config_json:
            config = json.loads(ps.llm_config_json)
            step_cfg = config.get(step_id, {})
            if step_cfg.get("model"):
                return step_cfg["model"]
        
        # 2) step_manifest
        manifest = STEP_MANIFEST.get(step_id, {})
        if manifest.get("default_model"):
            return manifest["default_model"]
        
        # 3) env default
        return settings.openai_model
    
    @classmethod
    def is_feature_enabled(cls, feature: str, project_id: str, db: OrmSession) -> bool:
        """우선순위: ProjectSettings.feature_flags > config.{feature}_enabled"""
        # 1) Project override
        ps = db.query(ProjectSettings).filter(ProjectSettings.project_id == project_id).first()
        if ps and ps.feature_flags:
            flags = json.loads(ps.feature_flags)
            if feature in flags:
                return bool(flags[feature])
        
        # 2) env
        attr = f"{feature}_enabled"
        return getattr(settings, attr, False)
    
    @classmethod
    def get_concurrency_limit(cls, purpose: str) -> int:
        """max_concurrent_{purpose} 조회."""
        attr = f"max_concurrent_{purpose}"
        return getattr(settings, attr, 5)


# 사용처
from app.core.settings_registry import SettingsRegistry

model = SettingsRegistry.get_model_for_step("scene_detail", project_id, db)
if SettingsRegistry.is_feature_enabled("fal_ai", project_id, db):
    use_fal()
```

**무엇이 바뀌나**:
- 4개 소스 → 1개 API (`SettingsRegistry`).
- 우선순위 명시 (project > manifest > env).
- 런타임 feature flag 지원 (ProjectSettings DB 기반).

**왜**:
- 설정 조회 지점 일관.
- 프로젝트별 override 간편.
- 재배포 없이 feature 토글 가능.

**마이그레이션 영향**:
- `ProjectSettings` 모델에 `feature_flags TEXT` 컬럼 추가 (마이그레이션).
- 기존 `settings.fal_ai_enabled` 등 직접 조회 지점을 `SettingsRegistry.is_feature_enabled("fal_ai", ...)` 로 점진 교체.
- 교체하지 않아도 동작 (기존 fallback 유지).

---

## 11. 메트릭 비교 (목표)

| 지표 | Before (v0.5.3) | Phase 1 후 | Phase 2 후 | Phase 3 후 | Phase 4 후 (최종) |
|---|---|---|---|---|---|
| `image_service.py` 라인 | 5,281 | 5,281 | 5,281 | 1,200 | 0 (삭제) |
| `api/v1/steps.py` 라인 | 1,518 | 1,400 | 800 | 600 | 500 |
| `_sync_checkpoints_to_db` (단일 함수) | 722줄 | 722 | 60 (wrapper) | 60 | 60 |
| `CheckpointSyncService` 메서드 단위 평균 | - | - | ~100줄 | ~100 | ~80 |
| 하드코딩 downstream 리스트 | 3곳 | 0 | 0 | 0 | 0 |
| Applicability 규칙 검증 | ✗ (기본 True) | ✓ | ✓ | ✓ | ✓ |
| `EpisodeDetail.tsx` useState 수 | 51 | 51 | 51 | 20 | 15 |
| 단위 테스트 가능 Service 메서드 | ~5% | 20% | 60% | 80% | 90% |
| DELETE→INSERT sync 지점 | 5곳 | 5 | 0 | 0 | 0 |
| 체크포인트 원자 쓰기 유틸 사용처 | 2곳 (StepRunner, toggle) | 2+ | 모든 곳 | 모든 곳 | 모든 곳 |
| Frontend React Query 적용 페이지 | 0 | 0 | 0 | 1 (EpisodeDetail) | 전체 |
| Silent exception swallow (`except: pass`) | 12건 | 12 | 12 | 0 | 0 |
| ProjectSettings feature_flags 지원 | ✗ | ✗ | ✓ | ✓ | ✓ |
| Step `lifecycle` 필드 | ✗ | ✓ | ✓ | ✓ | ✓ |

---

## 12. API 변경 요약

| Endpoint | Before | After |
|---|---|---|
| `PATCH /shot_selection/toggle` | 응답에 `invalidated_steps: [...]` (하드코딩 목록) | 응답에 `invalidated_steps: [...]` (manifest 기반), `warnings: [...]` |
| `POST /snapshots/restore` | `{restored: [...]}` | `{restored: [...], synced_data: {entities: N, relations: N, ...}, warnings: [...]}` |
| 전체 | - | 모든 응답에 `warnings: List[str]` 필드 추가 |
| `GET /episodes/{eid}/retry-point` (신규) | - | `{step_id: "scene_detail" | null, reason: "..."}` — 재시도 가능 지점 |

---

## 13. DB 스키마 변경

### ProjectSettings 확장

```sql
ALTER TABLE project_settings ADD COLUMN IF NOT EXISTS feature_flags TEXT;
```

JSON 형식: `{"fal_ai": true, "set_design": false, ...}`.

### entity_t2i 체크포인트 포맷 (코드 변경 — DB 아님)

```json
{
  "status": "completed",
  "data": {
    "characters": [...],
    "locations": [...],
    "props": [...],
    "_appearance_counts": {  // 신규 — optional
      "C01": 5, "L02": 3
    }
  }
}
```

기존 체크포인트에 `_appearance_counts`가 없으면 `CheckpointSyncService.sync_t2i_appearance_counts()`가 동적 계산 fallback.

---

## 14. 테스트 전략 변화

### Before
- E2E 테스트 일부 (`test_project_export_import.py`)
- 대부분 수동 QA

### After
- **단위 테스트 증가**: 각 Service 메서드 단위 테스트 작성 가능 구조.
- **통합 테스트 유지**: E2E 테스트는 Phase별 회귀 확인용.
- **계약 테스트 신설**: `data-contracts.md`의 계약이 코드와 일치하는지 검증하는 테스트.

---

## 15. 문서 업데이트 (이 refactor 과정에서)

| 문서 | 현재 상태 | 업데이트 계획 |
|---|---|---|
| `docs/architecture/00-overview.md` | v0.5.2 반영 | v0.5.3 변경사항 + 레이어 구조 갱신 (Phase 2 완료 후) |
| `docs/architecture/02-entity-extraction.md` | entity_t2i 설명 일부 부정확 | entity_character_list 의존성 정정 |
| `docs/architecture/06-data-contracts.md` | 일부 필드 누락 | `_appearance_counts` 추가, UPSERT 규칙 명시 |
| `docs/architecture/data-contracts.md` | 없음 | **신설** — 7장 "데이터 진실원 계약" 내용 |
| `CLAUDE.md` | scene_still UPSERT만 언급 | "모든 DB sync는 UPSERT" 규칙 확장 |

---

다음 문서: `03-roadmap.md` — Phase별 실행 계획.
