# 인터스티셜 히스토리 + 세션 영속화 — 구현 계획 > 작성일: 2026-07-06 · 상태: **구현 완료** (2026-07-06, feature/persistence) > 검증: pytest 26건 통과(신규 8건 포함) + 서버 재시작 e2e (E01-11 진행 → kill/재기동 → > GET 복원 동일 카드 → advance 계속 진행 · 미존재 세션 404) > 후속 수정 (2026-07-06, 유저 실플레이 피드백 2회): 오버레이 방식 자체를 폐기 — > 연출 컷을 **자체 번호를 가진 독립 페이지**로 (예: 6 본문 → 7 컷 → 8 본문, total 66 = 61+5). > 번호는 서버(router `_page_index`)가 권위, 컷 페이지 번호 = progress.index - 1. > 프론트의 오버레이/pendingPage/클릭가드 로직 전부 제거 → 일반 페이지 흐름으로 단순화. > Node DOM 스텁 시뮬레이션으로 정/역방향·좌클릭 통과 검증 > 선행 결정 (유저 확정): > - 포켓(채팅) 중간 상태는 영속화하지 않는다 — 서버 재시작/복원 시 포켓은 버리고 읽기 화면으로 > - 복원 범위는 "현재 페이지부터 이어 읽기"만 — 뒤로가기 히스토리는 새로고침 시 포기 > 권장안 채택: SQLite + 스냅샷 + 매 변경 즉시 쓰기(write-through) + localStorage 세션 ID + 콘텐츠 버전 스탬프로 무효화 ## 문제 정의 1. **인터스티셜 뒤로가기 버그**: 연출 컷이 히스토리의 페이지가 아니라 일회성 오버레이라서, 뒤로 넘기면 이미지가 건너뛰어지고 이전 스크립트가 나온다. 의도는 정방향과 동일하게 "스크립트 → 이미지 페이지 → 스크립트"가 역방향에서도 유지되는 것. 2. **영속화 부재**: 플레이 세션이 3층 모두 휘발성 — `router._sessions`(파이썬 dict) · ADK `InMemorySessionService` · 브라우저 `SID`(JS 변수). 새로고침이면 세션 ID를 잃고, 서버 재시작이면 전 유저 진행이 사라진다. CLAUDE.md 권위 원칙("현재 유저의 서사 위치 권위: DB `play_session.current_state_id`")이 예정한 DB가 아직 없다. ## 설계 핵심 ### 복원의 함정 — `_enter()` 부수효과 `StoryEngine._enter()`는 진입 시 쇠약도 증가·축 적용·앎 확립을 수행한다. 복원을 `_enter(current_id)` 재호출로 구현하면 **상태가 이중 적용**된다. 따라서 복원은 부수효과 없는 전용 경로로: 필드를 직접 복원하고, 현재 카드의 StepResult는 렌더 전용 메서드로 재구성한다 (`_visible_narration` + `card.choices`는 순수 조회). ### 스냅샷 페이로드 (JSON 직렬화 — 전 필드 int/str/bool/list) ```json { "story": {"current_id": "E04-07", "visited": [...], "preset_branch": null, "ending_id": null}, "hidden": {"trust_score": 24, "doubt_score": 12, "frailty": 3, "awareness_established": true, "axis_log": [...]} } ``` - `ending_id`: 엔딩 도달 세션도 복원 대상 (새로고침 시 엔딩 화면 유지). 복원 시 `chapter.ending_rules`에서 id로 역조회 - 포켓 진행 중 저장분은 없다 — 포켓 안의 증분은 close 커밋 후에만 영속 상태에 반영 (기존 단일 경로 유지). 포켓 도중 서버가 죽으면 그 대화의 신뢰축 증분은 유실된다 (승인된 트레이드오프) - 기존 `HiddenState.to_session()`은 포켓 전용(부분·키 상이)이므로 재사용하지 않고 스냅샷 메서드를 별도 추가 ### 콘텐츠 버전 스탬프 - `ContentRepository`에 `content_version` 프로퍼티: `chapter.id` + 전역 순서의 카드 ID 목록을 sha256 해시 - 저장 시 스탬프, 복원 시 불일치면 **무효화**(404 → 프론트가 새 세션 시작). 카드 본문만 바뀐 경우는 잡지 못하지만(ID/순서 동일) 복원이 깨지지는 않으므로 데모 수용 ### 저장 시점 (write-through) 영속 상태가 바뀌는 모든 지점에서 즉시 저장 — `POST /session`(생성) · `advance` · `choose` · `pocket/turn`(수렴 커밋 시) · `pocket/close`. `pocket/open`은 영속 상태 무변경이라 저장 없음. ### 동시성·수명 - uvicorn 워커 1개 전제 유지 (인메모리 캐시 + ADK InMemory 세션 때문). SQLite는 WAL 모드 - 같은 세션 동시 접근은 마지막 쓰기 승리 (데모 수용) - 서버 기동 시 30일 미갱신 세션 purge ## 변경 파일 목록 ### A. 인터스티셜 히스토리 (버그 수정 — 프론트 단독) | 파일 | 변경 | |---|---| | `web/index.html` | 히스토리 항목을 2종으로 타입화: `{kind:'page', html, progress, ep}` · `{kind:'inter', image, caption, progress, ep}`. ① `showStep()`: `step.image` 있으면 inter 항목을 먼저 push하고 오버레이 표시, 오버레이 클릭 시 page 항목 push + 렌더 ② `go()`: 커서 이동을 공통 `renderEntry(cursor)`로 통일 — inter 항목이면 오버레이 표시, page 항목이면 HTML 복원 + 오버레이 닫기 ③ 브라우징 중 오버레이 클릭 = `go(+1)` (오버레이가 내비 존을 덮으므로; ← 키는 계속 동작) ④ `choose()`의 스냅샷 갱신(`history[cursor].html`)은 page 항목에만 닿으므로 그대로 | ### B. 영속화 | 파일 | 변경 | |---|---| | `engine/services/state.py` | `HiddenState.snapshot() -> dict` / `HiddenState.from_snapshot(d)` 추가 (전 필드. `to_session()`과 별개 — 포켓 경로 불변) | | `engine/services/story.py` | ① `snapshot() -> dict` (story 파트) ② `StoryEngine.restore(repo, snap) -> StoryEngine` 클래스메서드 — **부수효과 없이** 필드 직접 복원 ③ `current_step() -> StepResult` — 현재 카드/엔딩의 StepResult를 렌더 전용으로 재구성 | | `engine/repositories/content.py` | `content_version` 프로퍼티 (sha256 스탬프) | | `engine/repositories/play_session.py` | **신규.** stdlib `sqlite3`, WAL. 테이블 `play_session(sid TEXT PK, payload TEXT, content_version TEXT, updated_at TEXT)`. 메서드: `save(sid, payload, version)` / `load(sid, version) -> dict | None`(버전 불일치 시 None) / `purge(days=30)` | | `engine/router.py` | ① `PlaySessionRepository(ROOT/"data"/"sessions.db")` 생성 + 기동 시 `purge()` ② `_sessions` dict는 캐시로 유지, `_get()` 미스 시 저장소에서 로드 → `StoryEngine.restore` + `current_step()`으로 PlaySession 재구성 (`pocket_sid=None` — 포켓 폐기) ③ 저장 헬퍼 `_save(sid, ps)`를 생성/advance/choose/pocket 수렴/close에 write-through | | `web/index.html` | ① 기동 시 `localStorage.carmilla_sid` 있으면 `GET /api/session/{sid}` 시도 — 성공 시 표지 버튼이 "이어 읽기"로 동작(새 세션 생성 생략, 현재 페이지부터), 404면 sid 폐기 후 기존 흐름 ② 세션 생성 시 sid를 localStorage에 저장 ③ 엔딩 "다시, 처음의 밤으로" 클릭 시 sid 제거 후 reload | | `.gitignore` | `data/` 추가 | | `tests/test_persistence.py` | **신규.** ① 스냅샷→복원 라운드트립: 몇 카드 진행 후 복원했을 때 `current_id`/숨은 상태/진행도 동일 ② **복원이 쇠약도·축을 재적용하지 않음** (핵심 회귀) ③ 엔딩 세션 복원 ④ 저장소 save/load + 버전 불일치 시 None ⑤ 라우터 레벨: `_sessions` 캐시 비운 뒤(재시작 모사) GET으로 복원되고 포켓은 닫혀 있음 | | `docs/contract/demo_api.md` | `GET /api/session/{sid}`가 복원 진입점임을 명시 (404 = 미존재 또는 콘텐츠 버전 불일치 → 새 세션 생성할 것). 포켓 비영속 명시 | | `docs/plans/persistence.md` | 본 문서 | ### 변경하지 않는 것 - `engine/app.py` — ADK `InMemorySessionService` 유지 (포켓 비영속 결정에 따라 `SqliteSessionService` 불필요) - `engine/services/pocket.py` — 포켓 커밋 단일 경로 불변 - `content/` — 무변경 (콘텐츠 커밋 금지 규칙) ## 작업 순서 1. **P1 — 인터스티셜 히스토리** (`web/index.html` 단독, 서버 무관) → 브라우저에서 정/역방향 수동 확인 2. **P2 — 스냅샷/복원 엔진** (`state.py`, `story.py`) + 단위 테스트 (부수효과 미재적용 회귀 포함) 3. **P3 — SQLite 저장소 + 라우터 write-through/복원** (`play_session.py`, `content.py`, `router.py`) + 라우터 테스트 4. **P4 — 프론트 이어 읽기** (`web/index.html` localStorage) → 새로고침·서버 재시작 후 이어 읽기 수동 확인 5. **P5 — 문서 갱신** (`demo_api.md`, ARCHITECTURE.md 치트시트 1줄) 검증: `pytest tests/ -q` 전건 통과 + 수동 시나리오 3종 (① 이미지 페이지 뒤로가기/앞으로가기 ② 새로고침 후 이어 읽기 ③ uvicorn 재시작 후 이어 읽기 — 포켓 중이었다면 읽기 화면으로). ## 리스크 / 한계 (승인된 트레이드오프 포함) - 포켓 도중 서버 사망 시 그 대화의 신뢰축 증분 유실 (포켓 비영속 결정의 귀결) - 새로고침 시 뒤로가기 히스토리·선택 결과 산문은 소실, 현재 페이지부터 (결정 ⑤) - 카드 본문만 바뀐 콘텐츠 갱신은 버전 스탬프가 감지 못함 (ID/순서 동일 시) — 복원은 안 깨짐 - 멀티 워커/멀티 프로세스는 여전히 비지원 (ADK 세션·캐시가 인메모리)