# TheRoad Scene Lab — 아키텍처 결함 심층 분석

> 작성일: 2026-04-17
> 분석 대상 브랜치: `shot-more` (v0.5.3)
> 분석 범위: backend 전체 + frontend 주요 페이지
> 분석 방법: 3개 에이전트 병렬 조사 + 직접 코드 검증

---

## 0. Executive Summary

TheRoad Scene Lab은 10개월간 기능 단위로 확장되면서 **아키텍처 부채**가 누적되었다. 초기 프로토타입에서 "체크포인트 파일 중심" 설계로 출발했으나, 이후 DB (Postgres)가 추가되면서 **진실원(source of truth)이 이중화**되었고, 이 모호성이 모든 구조적 문제의 뿌리가 되었다.

본 보고서는 10개 구조적 결함을 아래 3개 축으로 분류해 상세히 분석한다:

- **데이터 축** — 진실원, 동기화, 원자성
- **제어 축** — step 실행, 의존성, 무효화, 런타임 제어
- **책임 축** — 레이어 분리, 단일 책임, 결합도

각 결함마다 (1) 현상, (2) 증거 코드, (3) 근본 원인, (4) 연쇄 영향, (5) 수정 복잡도를 기록한다.

---

## 1. 배경 — 이 코드베이스가 걸어온 길

### 1.1 진화 타임라인 (추정)

| 시기 | 단계 | 아키텍처 특성 |
|---|---|---|
| 초기 프로토타입 | Phase 1~3 | 체크포인트 JSON 파일 중심. 각 step이 결과를 파일로 저장하고 다음 step이 파일을 읽음. DB는 user/project 메타만. |
| Pipeline v3 | 26단계 리팩토링 | Postgres 본격 도입. `entity_canon`, `scene_still`, `image_asset` 등 도메인 테이블 신설. `_sync_checkpoints_to_db` 함수 탄생 — 파일 → DB 단방향 동기화. |
| Pipeline v4 | beat→shot 기반 | shot 단위로 전환. `scene_still.shot_index`, `is_selected`, `image_generated` 컬럼 추가. 일부 API가 DB 직접 조작 시작. |
| shot-more (v0.5) | 현재 | scene_camera_flow, scene_consistency, character_state_variant 등 신규 step. 체크포인트 + DB 양쪽 모두 변경하는 토글 API 등장. |

### 1.2 누적된 선택들

- **체크포인트 파일 포기 못 함**: 스냅샷/복원, 디버깅 편의, LLM 결과 원본 보존 등 여러 목적으로 유지.
- **DB가 실질적 진실원이 됨**: UI가 DB를 읽음, step_run 상태가 DB, `scene_still.is_selected` 같은 런타임 플래그가 DB.
- **두 저장소가 동등하게 취급됨**: 어떤 데이터가 어느 쪽이 진실원인지 계약(contract) 없음. 각 step/API가 임기응변으로 결정.
- **`_sync_checkpoints_to_db`가 거대화**: 722줄 한 함수에 7개 동기화 책임이 누적. 트랜잭션 경계 불명.

### 1.3 현재 상태 지표

| 지표 | 값 | 비고 |
|---|---|---|
| Backend 총 파일 수 | ~120 | `backend/app/**/*.py` |
| `image_service.py` | 5,281줄 | 11개 책임 |
| `api/v1/steps.py` | 1,518줄 | 6개 책임 (라우팅 + DB 동기화 + snapshot) |
| `_sync_checkpoints_to_db` | 722줄 (한 함수) | 7개 서브 블록 |
| `detail_steps.py::_execute` | 1,021줄 (중 함수) | 13개 dict closure |
| Active step 수 | 33 | manifest 기준 |
| Step applicability 종류 | 6+ | `always`, `on_demand`, `disabled`, `if_planning_doc`, `if_has_outlooks`, 기타 |
| `if_*` rule runtime validator | 0 | 모두 기본 `True` 반환 |
| DELETE→INSERT 패턴 사용처 | 최소 5곳 | CLAUDE.md는 scene_still만 UPSERT 강제 |
| 하드코딩 downstream 리스트 | 3곳 | `toggle_shot_selection`, `_resume_sensitive`, 기타 |
| `EpisodeDetail.tsx` useState 수 | 51 | 1,358줄 |

---

## 2. 데이터 축 결함

### 2.1 [Critical] 진실원 이중화 (Dual Source of Truth)

#### 2.1.1 현상

체크포인트 JSON 파일(`projects/{pid}/checkpoints/episodes/{eid}/{step_id}/manifest.json`)과 Postgres DB가 **동등한 위치의 진실원**으로 혼재되어 있다. 어느 쪽이 진실원인지가 데이터별로 다르며, 그 규칙이 코드에 분산되어 있다.

#### 2.1.2 진실원 분류 (실측)

| 데이터 항목 | 파일 저장소 | DB 저장소 | 실제 진실원 | 쓰기 순서 | 리스크 |
|---|---|---|---|---|---|
| `scene_still.is_selected` | `shot_selection/manifest.json` | `scene_still.is_selected` | **DB** (v0.5.3 수정 후) | DB → 파일 (원자) | 파일 쓰기 실패 시 파일 stale 영구화 가능 |
| `scene_still.image_generated` | `scene_image_pipeline/manifest.json` | `scene_still.image_generated` | **DB** | DB → 파일 | 동일 |
| `selected_shot_indices` | `shot_selection/manifest.json` | `scene_still.is_selected` (파생) | **파일** (원본) | 파일 쓰기 → DB 파생 | 둘 재계산 불일치 가능 |
| `entity_canon.short_id` | `entity_t2i/manifest.json` | `entity_canon.short_id` | **파일** → DB UPSERT | 파일 → DB | UPSERT 실패 시 DB stale |
| `t2i_appearance_count` | 없음 | `entity_episode_link.t2i_appearance_count` | **DB만** (휘발성) | 계산 후 DB | snapshot 복원 시 재계산 → 조정값 손실 |
| `visible_entities` | `scene_director`, `shot_director` cp | `scene_still.visible_entities_json` | **파일 우선 → T2I regex fallback** | 3단 fallback chain | soft failure 시 정확도 저하 (아래 상세) |
| `outlook_phase3 결과` | `outlook_phase3/manifest.json` | `entity_canon(type=outlook)` + `character_outlook` | **파일** | 파일 → DB DELETE+INSERT | UPSERT 규칙 위반, 기존 outlook 삭제 위험 |
| `relations` | `entity_relation/manifest.json` | `RelationFact` + `RelationParticipant` | **파일** | 파일 → DB DELETE+INSERT | 동일 |
| `scene_detail t2i_prompt` | `scene_detail/manifest.json` | `scene_still.t2i_prompt_cinematic`, `t2i_variations_json` | **파일** → DB UPSERT | 파일 → DB | 정상 |

#### 2.1.3 증거 코드

**DB 우선 사례 — `toggle_shot_selection`** (`backend/app/api/v1/steps.py:334-401`):
```python
# 1) DB 업데이트 선행
_upd = db.execute(_sql_text("UPDATE scene_still SET is_selected = ..."))
db.commit()
# 2) DB 커밋 성공 이후에만 파일 쓰기 (원자 쓰기)
try:
    _atomic_write_json(cp_path, cp)
    ...
except Exception as _write_exc:
    # 파일 쓰기 실패 시: DB는 이미 반영됨. 다음 scene_detail/_sync 재실행 시 복원됨.
    logger.error(...)
```

**파일 우선 사례 — `_sync_checkpoints_to_db` entity_t2i 블록** (`steps.py:798-881`):
```python
existing_canons = {e.name: e for e in db.query(EntityCanon).filter(...)}
for etype in ["characters", "locations", "props"]:
    for ent in data.get(etype, []):  # 파일의 데이터가 source
        ...
```

**3단 fallback chain — VE (Visible Entities)** (`steps.py:1152-1166`):
```python
if _sd_key in shot_director_ve_map:
    shot_ve = shot_director_ve_map[_sd_key]  # 1순위: shot_director 체크포인트
elif shot_vars:
    # 2순위: T2I 프롬프트에서 정규식 추출 (CLP 매칭, O 제외)
    used_ids = set(re.findall(r'[CLP]\d{2,3}', all_t2i_text))
    shot_ve = [sid for sid in director_ve if sid in used_ids]
else:
    shot_ve = director_ve  # 3순위: scene_director 전체
```

#### 2.1.4 근본 원인

- **초기 설계 의도 부재**: 체크포인트 파일 중심 → DB 도입 시 "DB가 일부 데이터를 받아간다"로 설계했을 뿐, **"어느 데이터가 어느 쪽의 진실원인가"를 계약으로 선언하지 않음**.
- **점진적 DB 전환**: scene_still처럼 DB 직접 조작이 필요한 기능(`is_selected` 토글)이 추가되면서 DB가 진실원이 된 경우도 생겼고, entity처럼 여전히 파일이 진실원인 경우도 공존.
- **문서화 부재**: CLAUDE.md에 "scene_still sync는 UPSERT" 한 줄만 있고, 나머지 데이터의 규칙은 없음.

#### 2.1.5 연쇄 영향

1. **스냅샷/복원 신뢰성 저하**: `restore_snapshot`이 파일만 복원 → DB와 파일 불일치. 전체 복원은 `_sync_checkpoints_to_db`를 호출하지만, 단계별 복원(`step_id=shot_selection`)은 호출 안 함 (`steps.py:577-589`).
2. **트랜잭션 원자성 없음**: 파일 쓰기와 DB 쓰기가 서로 다른 트랜잭션 → 중간 실패 시 불일치 영구화.
3. **디버깅 난이도**: "왜 UI엔 있는데 LLM 프롬프트에 없지?" → 누군가 DB를 읽고 누군가 파일을 읽음. 각 호출 경로를 추적해야 원인 파악.
4. **t2i_appearance_count 손실**: 계산 결과를 DB에만 저장 → snapshot 복원 시 재계산됨. 수동 조정 값이 있으면 소실.
5. **VE 3단 fallback의 silent degradation**: 체크포인트 누락 시 T2I regex 추출로 fallback하지만 정확도 낮고 O## 누락.

#### 2.1.6 수정 복잡도

**높음**. 아키텍처 전환 수준. 단계적 접근 필요:
- (단기) 각 데이터의 진실원 계약을 문서화 (`docs/architecture/data-contracts.md`)
- (중기) 휘발성 데이터(`t2i_appearance_count`)를 체크포인트에도 저장
- (장기) DB를 Primary, 체크포인트를 Backup/Archive로 계층화

---

### 2.2 [Critical] `_sync_checkpoints_to_db` 722줄 거대 함수

#### 2.2.1 현상

`backend/app/api/v1/steps.py:768-1487`에 **7개의 서로 다른 동기화 책임**이 한 함수에 압축되어 있다. 이 함수는 `run_all_steps` 완료 후(`steps.py:250`), scene_director 사전(`line 220`), 개별 step 완료 후(`line 667`) 세 경로에서 호출된다.

#### 2.2.2 구조 분해

| 블록 | 라인 | 대상 테이블 | 패턴 | CLAUDE.md 규칙 준수 |
|---|---|---|---|---|
| 1) entity_t2i sync | 776-881 | `entity_canon`, `entity_episode_link` | UPSERT ✓ | ✓ (이번 세션 수정) |
| 1.5) entity_relation sync | 905-967 | `relation_fact`, `relation_participant` | DELETE + INSERT | ✗ (위반) |
| 2) scene_detail sync | 970-1299 | `scene_still` | 조건부 UPSERT | ✓ |
| 2c) scene_summary sync | 1302-1312 | `scene_still.scene_summary` | UPDATE | ✓ |
| 2d) shot_cinematography sync | 1314-1342 | `scene_still.shot_type_*` | UPDATE | ✓ |
| 3) outlook_phase3 sync | 1344-1457 | `entity_canon(outlook)`, `entity_episode_link`, `character_outlook` | DELETE + INSERT | ✗ (위반) |
| 3b) orphan outlook cleanup | 1459-1474 | `entity_canon(outlook)` | DELETE sweep | - |
| 3c) `_sync_t2i_appearance_counts` | 1476-1477 | `entity_episode_link.t2i_appearance_count` | 런타임 계산 | - |

#### 2.2.3 증거 코드

**DELETE→INSERT 패턴 — RelationFact** (`steps.py:905-914`):
```python
# 기존 visual_variant 관계 삭제 (재생성)
old_rels = db.query(RelationFact).filter(
    RelationFact.project_id == project_id,
    RelationFact.relation_type == "visual_variant",
).all()
if old_rels:
    for rel in old_rels:
        db.delete(rel)  # ← DELETE
# ... 이후 INSERT
```

**DELETE→INSERT 패턴 — CharacterOutlook** (`steps.py:1360-1367`):
```python
db.execute(sql_text(
    "DELETE FROM character_outlook WHERE project_id = :pid "
    "AND character_id IN ("
    "  SELECT ec.id FROM entity_canon ec "
    "  JOIN entity_episode_link eel ON ec.id = eel.canon_id "
    "  WHERE eel.episode_id = :eid AND ec.entity_type = 'character'"
    ")"
), {"pid": project_id, "eid": episode_id})
```

#### 2.2.4 근본 원인

- **함수 당 책임 분리 원칙 미적용**: 한 함수가 "파이프라인 완료 후 할 일 전부"를 떠안음.
- **트랜잭션 경계 설계 부재**: 한 트랜잭션 안에서 7개 블록이 순차 실행 → 일부 실패 시 롤백되지만, 체크포인트 파일은 롤백 안 됨.
- **서비스 레이어 부재**: API가 직접 ORM을 조작함. 재사용 가능한 `CheckpointSyncService`가 없어 step 내부에서도 유사한 로직이 중복될 여지.

#### 2.2.5 연쇄 영향

1. **테스트 불가능**: 7개 블록이 얽혀 있어 단위 테스트 작성 비용이 과다. 현재 이 함수에 대한 테스트는 `test_project_export_import.py` 간접 호출만 존재.
2. **변경 위험도 높음**: 한 블록 수정하면 다른 블록 회귀 가능 (이번 세션 "outlook link 삭제 버그"가 그 예 — entity_t2i 블록 수정 시 outlook link sweep 경로에 영향).
3. **API 레이어 비대화**: 이 함수 덕분에 `steps.py`가 1,518줄. 라우팅 책임 외에 도메인 로직 절반 이상이 여기 있음.
4. **호출 경로 3개 각각의 semantics 불명**: scene_director 사전 호출은 short_id 확보 목적이지만 7개 블록 전부 실행 → 불필요한 작업.

#### 2.2.6 수정 복잡도

**중간**. 리팩토링 수준:
- `CheckpointSyncService` 클래스 신설 → 7개 블록을 메서드로 분리
- API는 얇은 wrapper만 유지
- 각 블록에 `dry_run` 옵션 추가 → snapshot 복원 시 특정 블록만 실행 가능

---

### 2.3 [Medium] 체크포인트 원자성 부분 적용

#### 2.3.1 현상

`StepRunner.save_checkpoint`는 **tmp+rename 원자 쓰기**를 이미 사용한다 (`step_runner.py:127-148`). 하지만 StepRunner를 거치지 않는 체크포인트 수정은 이 보호를 받지 못한다.

#### 2.3.2 증거 코드

**StepRunner 내부 — 이미 원자적** (`step_runner.py:141-145`):
```python
self._archive_manifest(manifest_path)
tmp_path.write_text(json.dumps(data, ...), encoding="utf-8")
os.replace(str(tmp_path), str(manifest_path))  # atomic
```

**toggle_shot_selection — v0.5.3에서 추가** (`steps.py:373-374`):
```python
def _atomic_write_json(path: _Path, payload: dict) -> None:
    import os as _os
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(_json.dumps(payload, ...), encoding="utf-8")
    _os.replace(tmp, path)  # atomic
```
하지만 이건 로컬 함수. 다른 곳에서 재사용 불가.

**StepRunner 외부 체크포인트 수정 — 원자성 없음 추정**:
- `toggle_shot_selection` (이번 세션 수정됨)
- `save_user_edit_*` 유사 API (있다면)
- `shot_dependency_t2i` step 내부에서 `scene_detail` 체크포인트 읽기/수정 가능성 (코드 확인 필요)

#### 2.3.3 근본 원인

- **공통 유틸 부재**: 원자 쓰기 로직이 `StepRunner._archive_manifest` + `save_checkpoint` 내부에 있어 외부에서 재사용 불가.
- **API 레이어가 파일 I/O 직접 수행**: 체크포인트 파일을 API가 직접 읽고 수정하는 패턴이 퍼져 있음.

#### 2.3.4 연쇄 영향

- 드물지만 OS crash / 프로세스 SIGKILL 타이밍에 따라 JSON 부분 쓰기 → 파싱 실패 → 체크포인트 손실.
- 매우 드문 경우지만 발견 시 복구 불가능 (archive 파일에서 복원 필요).

#### 2.3.5 수정 복잡도

**낮음**. `app/core/checkpoint_io.py` 유틸로 추출 + 모든 호출처 교체.

---

## 3. 제어 축 결함

### 3.1 [Critical] 하드코딩된 downstream invalidation

#### 3.1.1 현상

`toggle_shot_selection` API (`steps.py:356-358`)에서 invalidate할 step 목록이 **수동으로 나열**되어 있다. 그러나 `step_manifest.py:579-588`에 이미 `get_all_downstream_recursive()` 함수가 구현되어 `StepRunner.invalidate_downstream`에서 사용되고 있음 — **자체 인프라를 무시**하는 패턴.

#### 3.1.2 증거 코드

**하드코딩 — `steps.py:356-358`**:
```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"]
for sid in downstream:
    db.execute(...)  # stale 마킹
```

**인프라 존재 — `step_manifest.py:579-588`**:
```python
def get_all_downstream_recursive(step_id: str) -> List[str]:
    """주어진 step_id의 모든 하위 의존 step_id를 재귀적으로 반환 (BFS)."""
    visited: set = set()
    queue = get_downstream_steps(step_id)
    while queue:
        current = queue.pop(0)
        if current not in visited:
            visited.add(current)
            queue.extend(get_downstream_steps(current))
    return sorted(visited)
```

**StepRunner는 이미 사용 — `step_runner.py:161`**:
```python
def invalidate_downstream(self) -> None:
    downstream = get_all_downstream_recursive(self.step_id)
    ...
```

#### 3.1.3 같은 패턴 추가 발견

- `_resume_sensitive` (`steps.py:383`): `["scene_camera_flow", "scene_consistency", "shot_staging"]` — 역시 하드코딩.
- 기타 `_invalidate_scene_*` 유사 패턴 존재 가능성 (코드 전수 조사 필요).

#### 3.1.4 근본 원인

- `toggle_shot_selection`이 `StepRunner` 인스턴스를 거치지 않고 직접 DB/파일을 조작하는 구조이기 때문.
- shot_selection은 "step 실행 결과"가 아니라 "사용자 선택"이므로 StepRunner 패턴에 맞지 않는 API. 그래서 기존 인프라 우회.

#### 3.1.5 연쇄 영향

1. **신규 step 추가 시 invalidation 누락**: 예를 들어 `shot_dependency_t2i` 같은 신규 step은 이 리스트에 없음. shot_selection 변경 시 이 step의 체크포인트가 stale 처리 안 됨 → 재실행 시 구버전 데이터 사용.
2. **일관성 깨짐**: `StepRunner.invalidate_downstream`은 올바르게 작동하지만 `toggle_shot_selection`은 부분 invalidation → "어떤 경로로 왔느냐"에 따라 결과 다름.
3. **manifest 신뢰도 저하**: manifest의 depends_on이 실제 영향 범위를 대표하지 못함.

#### 3.1.6 수정 복잡도

**낮음**. 5줄 교체:
```python
from app.core.step_manifest import get_all_downstream_recursive
downstream = get_all_downstream_recursive("shot_selection")
```

---

### 3.2 [High] Applicability 런타임 검증 부재

#### 3.2.1 현상

`step_manifest.py`에 `"if_has_outlooks"`, `"if_planning_doc"` 같은 조건부 applicability가 선언되어 있지만, `StepRunner.check_applicability()` 기본 구현은 이 규칙들을 전부 `True`로 반환한다 (`step_runner.py:82-92`). 실제 조건 체크는 개별 step이 오버라이드하는 패턴.

#### 3.2.2 증거 코드

**기본 구현 — `step_runner.py:82-92`**:
```python
def check_applicability(self) -> bool:
    """이 단계가 적용 가능한지. False면 not_applicable."""
    rule = self.manifest.get("applicability", "always")
    if rule == "disabled":
        return False
    if rule == "always":
        return True
    if rule == "on_demand":
        return True  # on_demand는 호출 시 항상 실행
    # 서브클래스에서 오버라이드 가능
    return True  # ← "if_*" 룰 모두 True 반환
```

**manifest 선언 — `step_manifest.py:26, 498`**:
```python
"planning_doc_analysis": {..., "applicability": "if_planning_doc", ...}
"outlook_phase3": {..., "applicability": "if_has_outlooks", ...}
```

**오버라이드 — `analysis_steps_legacy.py`, `scene_steps.py`**에서 일부 step만 실제 조건 체크.

#### 3.2.3 근본 원인

- **규칙 레지스트리 없음**: `if_*` 규칙이 manifest에는 선언되지만 이를 해석할 중앙 validator가 없음.
- **서브클래스 오버라이드에 의존**: 새 step 추가 시 개발자가 오버라이드 잊으면 자동으로 `always`로 작동 → 조용한 버그.

#### 3.2.4 `set_design` 특수 사례

- manifest: `"applicability": "always"` (`step_manifest.py:454`)
- 실제: `if not settings.set_design_enabled: return no-op` (`set_design_step.py:42`)

manifest는 "항상 실행", runtime은 env 스위치로 no-op. 모니터링/UI에는 "완료"로 보이지만 실제로는 빈 작업 — **false positive completion**.

#### 3.2.5 연쇄 영향

1. **조건부 step이 조용히 항상 실행**: `planning_doc_analysis`는 기획서가 없어도 실행될 여지. 비용(LLM 호출) + 빈 결과 체크포인트 생성.
2. **신규 step에 `if_*` 규칙 붙이면 자동으로 always**: 개발자가 "조건부"로 선언했는데 실제는 unconditional.
3. **UI 표시와 실제 동작 불일치**: `set_design`이 "완료"로 표시되지만 실제 작업 안 함 → 사용자 혼란.

#### 3.2.6 수정 복잡도

**낮음**. `ApplicabilityValidator` 레지스트리:
```python
APPLICABILITY_VALIDATORS = {
    "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,
}

# step_runner.py
def check_applicability(self):
    rule = self.manifest.get("applicability", "always")
    if rule in ("always", "on_demand"):
        return True
    if rule == "disabled":
        return False
    validator = APPLICABILITY_VALIDATORS.get(rule)
    if validator is None:
        raise ValueError(f"Unknown applicability rule: {rule}")
    return validator(self)
```

---

### 3.3 [High] run-all 실패 복구 계약 부재

#### 3.3.1 현상

`_run_all_bg` (`steps.py:209-266`)는 sequential 실행하며 한 step 실패 시 break. 이후 partial 완료 상태에서 어느 step부터 재실행 가능한지 명시적 정의가 없다.

#### 3.3.2 증거 코드

```python
# steps.py:209-266 (요약)
for sid in steps_to_run:
    try:
        runner = _get_step_runner(sid, ...)
        runner.run(mode)
    except Exception as exc:
        logger.error("Step %s crashed: %s", sid, exc)
        all_ok = False
        break  # ← 중단, 이후 step은 실행 안 됨

if all_ok:
    try:
        _sync_checkpoints_to_db(pid, eid, db2)  # ← 최종 sync는 전부 성공일 때만
    except Exception as sync_exc:
        logger.error("Final sync failed: %s", sync_exc)
        db2.rollback()
```

#### 3.3.3 근본 원인

- **step_run 상태와 체크포인트 상태 간 계약 불명**: 한 step이 `partial` 또는 `failed`면 다음 step은 `can_run=False`? 아니면 그래도 시도?
- **재시도 경로 문서 없음**: "failed step부터 resume"인지 "처음부터 재실행"인지 UI가 선택지 제공하지 않음.

#### 3.3.4 연쇄 영향

1. **수동 개입 빈번**: 사용자가 실패한 단계를 UI에서 개별 `force` 실행해야 함.
2. **scene_detail 같은 fan_out step의 partial 처리 불명**: 일부 씬만 성공하면 `status=partial`. 재실행 시 성공한 씬도 재생성? resume 기준이 분명치 않음.
3. **디버깅 비용**: "왜 여기서 멈췄지?" → 로그 + step_run 테이블 + 체크포인트 세 곳 확인 필요.

#### 3.3.5 수정 복잡도

**중간**. 재시도 정책을 명시적으로 설계:
- `RetryPolicy.from_step(step_id)` — step별 재시도 가능 여부 + 기준
- UI에 "실패 지점부터 재개" 버튼 추가
- `partial` 상태의 재실행 시 "완료된 fan-out 항목은 skip" 규칙 명시

---

### 3.4 [Medium] Disabled/Legacy step 관리 체계 없음

#### 3.4.1 현상

문서(`docs/architecture/*.md`)에 "Active/Legacy/Disabled" 구분이 표로 있지만, 코드에는 `deprecated`, `disabled`, `legacy` 같은 명시적 플래그가 없다. `applicability: "disabled"`는 있지만 "legacy" 개념은 없음.

#### 3.4.2 현재 상태

- `analysis_steps_legacy.py` 파일이 존재 — import 경로에 있으나 실제 사용되는 클래스 확인 필요
- manifest에 `"applicability": "disabled"`인 step: `shot_cinematography`, `scene_dependency`, `scene_verify` (3개 추정)
- 문서는 이들을 "Legacy (롤백 가능)"로 표기

#### 3.4.3 근본 원인

- **명시적 deprecation 경로 없음**: 새 기능이 나와도 구 기능을 완전히 제거하지 않고 `disabled`로만 해 두어 점진적 dead code 누적.
- **legacy step의 import가 여전히 살아있음**: `analysis_steps_legacy.py`가 `__init__.py`에서 import될 가능성 → 로딩 비용.

#### 3.4.4 연쇄 영향

- Dead code 축적 → 코드베이스 탐색 비용 증가.
- 새 개발자가 "이게 실제 쓰이는 건가?" 판단하기 어려움.
- 테스트 커버리지 측정 왜곡.

#### 3.4.5 수정 복잡도

**낮음**. manifest에 `"lifecycle"` 필드 추가:
```python
"lifecycle": "active" | "deprecated" | "removed"
```
+ legacy 파일은 실제 제거 또는 별도 `legacy/` 디렉토리로 이동.

---

## 4. 책임 축 결함

### 4.1 [Critical] ImageService 5,281줄 거대 클래스

#### 4.1.1 현상

`backend/app/services/image_service.py` 단일 파일 5,281줄, 11개 책임을 한 클래스가 처리한다.

#### 4.1.2 책임 목록

| # | 책임 | 대표 메서드 | 대략 라인 범위 |
|---|---|---|---|
| 1 | 참조 이미지 생성 | `generate_reference_images_only` | 558-2850 |
| 2 | 단일 엔티티 이미지 생성 | `generate_single_entity_image` | 일부 |
| 3 | 씬 이미지 생성 (batch) | `generate_scene_with_variations` | 3000+ |
| 4 | 단일 씬 이미지 생성 | `generate_single_scene_image` | 3500+ |
| 5 | 최종 프롬프트 빌드 | `_build_final_scene_prompt` | 62-272 (388줄 함수) |
| 6 | 프롬프트 작곡/번역 | `compose_prompts`, 번역 로직 | 200-270 |
| 7 | Ref 해소 (ID → Image N) | `_rewrite_t2i_with_image_refs`, `_resolve_refs_for_prompt` | 150-200 |
| 8 | 안전필터 대응 | `_apply_safety_fallback` 유사 | 분산 |
| 9 | 이미지 검증 | `_validate_reference` (GPT Vision) | 분산 |
| 10 | fal.ai 앵글 적용 | `_apply_fal_angle` | 분산 |
| 11 | 체크포인트 읽기 | `_load_*` private 메서드 다수 | 분산 |

#### 4.1.3 핵심 증거 — `_build_final_scene_prompt`

**388줄 단일 함수**에 다음이 모두 들어있음:
- Ref 해소 (labeled_refs 구축)
- 프롬프트 구성 (style_context + ref_roles + t2i_prompt)
- 한국어 감지 → LLM 번역
- 번역 실패 시 error 로그 (v0.5.3 수정)
- 안전필터 fallback (Gemini empty → 순화 → GPT)
- scene_consistency fixed_elements 주입 (v0.5.2 추가)
- tracer 호출

#### 4.1.4 근본 원인

- **기능이 누적되면서 함수 splitting 없이 한 클래스/파일에 추가**: 새 기능(scene_consistency 주입, 안전필터 fallback 등) 추가 시마다 `_build_final_scene_prompt`에 if-블록 추가.
- **Service/Module 구분 부재**: `services/` 아래에 모든 이미지 관련 로직이 한 파일.

#### 4.1.5 연쇄 영향

1. **테스트 불가능**: `_build_final_scene_prompt` 단위 테스트 작성하려면 10개 dependency mock 필요.
2. **병렬 개발 충돌**: 두 개발자가 다른 기능 추가 시 같은 파일의 merge conflict 빈발.
3. **변경 위험도 높음**: 이번 세션 "keep the face → appearance reference" 수정 시 regex 매칭 로직 같이 건드려야 했음 — 한 함수 안에 모든 로직.
4. **코드 탐색 비용**: 특정 버그를 찾을 때 5,281줄 전체를 대상으로 grep 필요.

#### 4.1.6 수정 복잡도

**높음**. 3개 서비스로 분할:
- `reference_image_service.py` — 참조/합성 이미지 생성 (책임 1, 2, 9)
- `scene_image_service.py` — 씬 이미지 생성 (책임 3, 4, 10)
- `prompt_service.py` — 프롬프트 빌드/번역/ref 해소 (책임 5, 6, 7, 8)

각각 1,000~1,800줄 예상.

---

### 4.2 [High] detail_steps.py의 Closure 공유 패턴

#### 4.2.1 현상

`core/steps/detail_steps.py::_execute` (SceneDetailStep, 1,021줄 중 ~200줄이 로딩만)가 **13개의 dict를 로드하여 내부 `_analyze_one` 함수가 closure로 참조**한다.

#### 4.2.2 공유되는 dict 목록

```python
def _execute(self, mode="resume"):
    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] = {...}
    shot_director_vr: Dict[tuple, dict] = {...}
    selected_map: Dict[int, set] = {...}
    shot_scenes_map: Dict[int, list] = {...}
    summaries: Dict[int, str] = {...}
    shot_cinematography_map: Dict[str, Dict] = {...}
    shot_dependency_map: Dict[str, Dict] = {...}
    entity_names: Dict[str, str] = {...}
    # ... 추가 dict들

    def _analyze_one(seg, shot_info=None):
        si = seg.get("scene_index", 0)
        # 외부 dict 접근 (closure)
        fixed = fixed_elements_by_scene.get(si, [])
        staging = staging_map.get(f"{si}_{shot_idx}", {})
        beats = beats_by_scene.get(si, {})
        # ... 많은 closure 참조
```

#### 4.2.3 근본 원인

- **DTO 설계 부재**: 씬/샷 분석에 필요한 context를 명시적으로 정의한 타입이 없음.
- **함수 분리 비용 회피**: closure가 "편리"해서 계속 이 패턴으로 추가.

#### 4.2.4 연쇄 영향

1. **`_analyze_one` 단위 테스트 불가능**: 13개 dict를 mock하려면 `_execute` 전체를 호출해야 함.
2. **병렬화 제한**: ThreadPoolExecutor에서 실행되지만 모든 dict가 메모리 상주.
3. **타입 안전성 없음**: `Dict[str, Any]` 난무 → IDE 자동완성 없음, 필드 rename 시 놓치기 쉬움.
4. **기능 추가 비용**: 새 체크포인트를 읽으려면 `_execute` 상단에 로딩 코드 추가 → 100줄 더 길어짐.

#### 4.2.5 수정 복잡도

**중간**. `SceneAnalysisContext` dataclass 도입:
```python
@dataclass
class SceneAnalysisContext:
    scene_index: int
    text: str
    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
    # ...

class SceneContextLoader:
    def load_all(self, episode_id, project_id) -> Dict[int, SceneAnalysisContext]:
        """모든 체크포인트를 읽어 씬별 컨텍스트 구성."""
        ...

def _analyze_one(ctx: SceneAnalysisContext) -> SceneDetail:
    # 명시적 인자, 테스트 가능
```

---

### 4.3 [High] API 레이어 책임 오염

#### 4.3.1 현상

`backend/app/api/v1/steps.py` (1,518줄)에 다음이 공존:
- FastAPI 라우팅 (6개 endpoint)
- DB 직접 쿼리 (수십 회)
- 체크포인트 파일 I/O
- `_sync_checkpoints_to_db` (722줄)
- Snapshot 관리 (`restore_snapshot`, `list_snapshots`)
- `toggle_shot_selection` 비즈니스 로직

#### 4.3.2 구조적 문제

API 레이어는 보통 다음만 담당해야 한다:
1. 요청 파싱/검증
2. 권한 체크
3. Service 호출
4. 응답 포맷

하지만 이 파일은 Service 레이어가 수행해야 할 대부분을 직접 수행. Service가 제 역할을 못 함.

#### 4.3.3 증거

- `_sync_checkpoints_to_db`는 API 파일 안에 있음 — Service가 아님
- `toggle_shot_selection`이 DB SQL + JSON 파일 I/O + stale 마킹 + 원자 쓰기 전부 직접 처리
- `snapshots` 관련 로직도 전부 API 파일 안

#### 4.3.4 근본 원인

- **Service 레이어 정의 부재**: `app/services/` 아래 파일들이 "특정 기능"(image_service, auth_service)으로 분류됨 — "도메인 서비스"가 아니라 "기능 그룹".
- **Service가 만들어지지 않은 채로 API에 직접 구현**: 시간 압박 속에서 API에 로직 넣고 나중에 Service로 옮기기로 했지만 옮기지 않음.

#### 4.3.5 연쇄 영향

- API 파일 비대화 (1,518줄)
- 재사용 불가 (다른 API endpoint나 step이 같은 로직 필요해도 호출 못 함)
- 테스트 시 FastAPI TestClient로만 접근 가능 → 단위 테스트 어려움

#### 4.3.6 수정 복잡도

**중간**. Service 분리:
- `CheckpointSyncService` — `_sync_checkpoints_to_db` 이동
- `ShotSelectionService` — `toggle_shot_selection` 로직 이동
- `SnapshotService` — snapshot/restore 이동
- API는 30줄 wrapper만 유지

---

### 4.4 [Medium] 설정 분산 (Settings Sprawl)

#### 4.4.1 현상

| 설정 | 위치 1 | 위치 2 | 위치 3 | 위치 4 |
|---|---|---|---|---|
| `fal_ai_enabled` | `config.py:26` (env) | `image_service.py:2203` 체크 | `image_service.py:4088` 체크 | - |
| `set_design_enabled` | `config.py:27` | `image_service.py:1619` | `set_design_step.py:42` | manifest applicability는 "always" |
| `default_model` | `step_manifest.py:22, 33, 44, ...` | `llm_router.py` model_list | `api/v1/steps.py:621-624` 오버라이드 | - |
| `project_config` | `ProjectSettings.llm_config_json` (DB) | API에서 파싱 | image_service에서 파싱 | step들이 파싱 |
| `scene_variation_count` | `config.py:41` | `shot_variation_count` (별도) | - | - |
| `max_concurrent_*` | `config.py:37-39` | 각 step이 직접 참조 | - | - |

#### 4.4.2 근본 원인

- **중앙 레지스트리 부재**: 설정이 env + step_manifest + ProjectSettings + 하드코딩 (하드코딩 소수) 4개 소스에 흩어짐.
- **우선순위 계약 없음**: "project override > step_manifest > env default"가 어디서도 명시되지 않음. 각 호출처가 임시로 조합.

#### 4.4.3 연쇄 영향

1. **런타임 feature flag 변경 불가**: env 기반이라 재배포 필요.
2. **프로젝트별 override가 ad-hoc**: ProjectSettings.llm_config_json JSON 파싱이 여러 곳에서 중복.
3. **설정 변경 시 영향 범위 파악 어려움**: "fal_ai_enabled를 false로 하면 어디가 영향받지?" → grep 전수 조사 필요.

#### 4.4.4 수정 복잡도

**낮음**. `SettingsRegistry` 클래스:
```python
class SettingsRegistry:
    @staticmethod
    def get_model_for_step(step_id: str, project_id: str) -> str:
        # project override > step_manifest > env
    
    @staticmethod
    def is_feature_enabled(feature: str, project_id: str) -> bool:
        # ProjectSettings.feature_flags > config.py env
```

---

### 4.5 [Medium] Frontend 상태 관리 부재

#### 4.5.1 현상

`EpisodeDetail.tsx` 1,358줄, **51개 useState/useEffect/useCallback**.

#### 4.5.2 반복되는 패턴

```tsx
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

useEffect(() => {
  setLoading(true)
  api.get(...)
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false))
}, [deps])
```

이 3-tuple 패턴이 10회 이상 반복. React Query/SWR 같은 캐싱 라이브러리 미사용.

#### 4.5.3 근본 원인

- **초기 구현 시 필요 기능만 작성**: 캐싱 필요성이 느껴지기 전에 규모가 커짐.
- **Hook 추출 문화 부재**: custom hook으로 뽑을 수 있는 패턴이 매번 inline으로 작성.

#### 4.5.4 연쇄 영향

1. **스테일 데이터**: 탭 전환 시 재fetch 필요한데 어디서 해야 할지 불명.
2. **중복 fetch**: 여러 컴포넌트가 같은 데이터를 각자 fetch.
3. **재렌더 폭증**: 51개 상태 중 하나만 바뀌어도 전체 리렌더.
4. **낙관적 업데이트 어려움**: toggle 후 optimistic state 업데이트가 manual.

#### 4.5.5 수정 복잡도

**중간**. React Query 도입 + custom hook 추출.

---

### 4.6 [Medium] 에러 처리 일관성 부재

#### 4.6.1 현상

| 레이어 | 패턴 | 빈도 |
|---|---|---|
| API (`api/v1/*`) | `AppError` + `raise` | 일관성 ✓ |
| Service (`image_service.py`) | `AppError` 49회 / raw `Exception` 23회 / `except: pass` 12회 | ✗ |
| Step (`core/steps/*`) | 대부분 상위로 propagate, 간혹 `except Exception: log + continue` | ✗ |

#### 4.6.2 증거

**Silent swallow — `image_service.py` 다수**:
```python
try:
    ...
except Exception:
    pass  # ← 무시됨, 추적 불가
```

**v0.5.3 추가 — `warnings` 필드**:
```python
_response["warnings"] = _file_write_errors  # toggle_shot_selection만
```
이 패턴이 다른 API에는 없음 → 응답 스키마 불일치.

#### 4.6.3 근본 원인

- **에러 정책 없음**: 어떤 에러를 swallow하고 어떤 걸 propagate할지 규칙 부재.
- **데코레이터 패턴 미사용**: `@api_endpoint` 같은 공통 처리 없어 매번 수동.

#### 4.6.4 수정 복잡도

**낮음**. 정책 문서화 + 데코레이터 도입.

---

## 5. 결함 간 상호작용 / 연쇄 지도

```
           ┌────────────────────────────────┐
           │  진실원 이중화 (#1)           │
           │  (Dual Source of Truth)        │
           └────────────────────────────────┘
                ↓ 유발                ↓ 유발              ↓ 유발
         ┌──────────────┐    ┌──────────────┐   ┌──────────────┐
         │ _sync 722줄   │    │ t2i_count    │   │ VE 3단       │
         │ 거대 함수(#2)│    │ 휘발성       │   │ fallback     │
         └──────────────┘    └──────────────┘   └──────────────┘
                ↓                    ↓                    ↓
         ┌──────────────┐    ┌──────────────┐   ┌──────────────┐
         │ API 책임     │    │ snapshot     │   │ silent       │
         │ 오염(#4.3)  │    │ 복원 불안    │   │ degradation  │
         └──────────────┘    └──────────────┘   └──────────────┘

           ┌────────────────────────────────┐
           │  하드코딩 downstream (#3.1)     │
           └────────────────────────────────┘
                ↓
         ┌──────────────┐
         │ 신규 step    │
         │ invalidation │
         │ 누락 위험    │
         └──────────────┘

           ┌────────────────────────────────┐
           │  Applicability 런타임 미검증    │
           │  (#3.2)                         │
           └────────────────────────────────┘
                ↓              ↓
         ┌────────────┐  ┌──────────────┐
         │ 조건부 step│  │ set_design   │
         │ 항상 실행  │  │ false        │
         └────────────┘  │ positive     │
                        │ completion   │
                        └──────────────┘
```

핵심 관찰:
- **#1(진실원)**이 세 개의 구조적 문제(#2, t2i_count, VE fallback)를 동시 유발
- **#3.1(하드코딩 downstream)**과 **#3.2(applicability)**는 제어 흐름의 일관성 깨짐 — 비슷한 원인
- **#4.1~#4.3(책임 오염)**는 서로 연결 — 하나 수정하면 다른 것도 영향

---

## 6. 수정 난이도 vs 효과 매트릭스

| # | 결함 | 심각도 | 수정 복잡도 | 블래스트 반경 | 선행 조건 |
|---|---|---|---|---|---|
| 2.1 | 진실원 이중화 | 🔴 Critical | 높음 | 전체 | 계약 문서화 선행 |
| 2.2 | _sync 722줄 | 🔴 Critical | 중간 | API + DB | Service 레이어 도입 |
| 2.3 | 체크포인트 원자성 | 🟡 Medium | 낮음 | 로컬 | 없음 |
| 3.1 | 하드코딩 downstream | 🔴 Critical | **낮음** | 전체 | 없음 |
| 3.2 | Applicability 미검증 | 🟠 High | 낮음 | 전체 | 없음 |
| 3.3 | run-all 재시도 | 🟠 High | 중간 | UI + Step | 정책 설계 |
| 3.4 | Legacy 관리 | 🟡 Medium | 낮음 | 코드 정리 | 없음 |
| 4.1 | ImageService 비대 | 🔴 Critical | 높음 | 이미지 생성 | 분할 설계 |
| 4.2 | detail_steps closure | 🟠 High | 중간 | scene_detail | DTO 설계 |
| 4.3 | API 책임 오염 | 🟠 High | 중간 | API + Service | 2.2와 연동 |
| 4.4 | 설정 분산 | 🟡 Medium | 낮음 | 설정 조회처 | Registry 설계 |
| 4.5 | Frontend 상태 | 🟡 Medium | 중간 | UI 전체 | React Query 학습 |
| 4.6 | 에러 처리 | 🟡 Medium | 낮음 | API 전체 | 정책 설계 |

---

## 7. 결론

이 코드베이스의 근본 문제는 **"체크포인트 파일 중심 설계로 시작해 DB를 중간에 섞은 후 계약을 정의하지 않은 것"**이다. 이 한 가지 결정(혹은 결정 부재)이 10개 결함 중 최소 5개의 직접/간접 원인이다.

단기적으로는 Phase 1 수준의 빠른 수정(하드코딩 downstream 제거, applicability validator, 체크포인트 원자 쓰기 유틸화)으로도 **안정성 향상의 체감 효과**를 볼 수 있다. 중기적으로는 Service 레이어 분리 + `_sync` 함수 해체가 유지보수성의 전환점이 될 것이다. 장기적으로는 **진실원 계층화(DB Primary, 파일 Backup)**가 필수.

본 분석 이후의 설계는 `01-target-design.md`, 비교는 `02-before-after.md`, 실행 계획은 `03-roadmap.md`를 참조할 것.
