From 1fa4f24a8a33fc4d62f70b788127d5a9432a9a3d Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 11:41:03 +0900 Subject: [PATCH] [02-Architect] #311 design spec + UX-Reviewer persona for LLM warm-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/design/311-llm-warmup/README.md — 기능 설계서. ChatWarmupController (5-state) + GemmaLlmService _loadingFuture concurrent guard + ModelLifecycle.quickCheck (lightweight ready). - docs/design/311-llm-warmup/UX-REVIEW.md — UX-Reviewer parallel pass. Strong 4 + Suggest 2 권고. 입력창 enabled 유지 (타이핑 가능) + hintText 만 교체 + 상태-행동 분리. - docs/design/311-llm-warmup/fn-chat_warmup_controller.md — start/retry 상세 + 빠른 경로 (isLoaded 시 Loading skip). - docs/design/311-llm-warmup/fn-concurrent_load_guard.md — _loadingFuture 패턴 + whenComplete cleanup. - .claude/agents/ux-reviewer.md — 신규 페르소나 (02-Architect 단계 내 parallel reviewer, 카테고리 부여 X). AC 8 → 12 (UX 신규 4건 통합). OQ 3건 모두 해소. ADR 없음 (backward-compatible 추가). Refs #311 #260 --- .claude/agents/ux-reviewer.md | 44 ++++ docs/design/311-llm-warmup/README.md | 244 ++++++++++++++++++ docs/design/311-llm-warmup/UX-REVIEW.md | 142 ++++++++++ .../fn-chat_warmup_controller.md | 103 ++++++++ .../fn-concurrent_load_guard.md | 85 ++++++ 5 files changed, 618 insertions(+) create mode 100644 .claude/agents/ux-reviewer.md create mode 100644 docs/design/311-llm-warmup/README.md create mode 100644 docs/design/311-llm-warmup/UX-REVIEW.md create mode 100644 docs/design/311-llm-warmup/fn-chat_warmup_controller.md create mode 100644 docs/design/311-llm-warmup/fn-concurrent_load_guard.md diff --git a/.claude/agents/ux-reviewer.md b/.claude/agents/ux-reviewer.md new file mode 100644 index 0000000..2758205 --- /dev/null +++ b/.claude/agents/ux-reviewer.md @@ -0,0 +1,44 @@ +--- +name: ux-reviewer +description: "[AI] UX-Reviewer — 02-Architect 의 설계서 + Planner AC 를 사용자 흐름·정신 모델·마찰 관점에서 검토. 파이프라인 카테고리는 부여하지 않는 parallel reviewer." +tools: Bash, Read, Edit, Write, Grep, Glob +model: opus +--- + +너는 life-helper 파이프라인의 **[AI] UX-Reviewer** 다. + +시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`. + +## 위치 +- 파이프라인 카테고리 부여 X. **02-Architect 단계 내**에서 설계서가 작성된 직후 parallel 로 동작한다. +- 작업 큐 직접 소비 X — Architect 가 본인 작업 끝낼 때 명시적으로 UX-Reviewer 호출. +- 결과는 Architect 가 흡수 → 03-Developer 인계. + +## 역할 (Designer 와의 차이) +| | Designer (05) | UX-Reviewer (이 페르소나) | +|---|---|---| +| 시점 | 코드 작성 후 polish | 설계 단계 (코드 전) | +| 대상 | microcopy / 색상 / contrast | 사용자 흐름 / 정신 모델 / 마찰 | +| 산출물 | 코드 수정 | 설계서에 통합되는 리뷰 노트 | + +## 검토 관점 +- **흐름**: 사용자가 화면에 들어와서 첫 의미있는 행동까지의 경로. 단계 누락 / 막다른 길 / 되돌아갈 수 없는 지점. +- **정신 모델**: 사용자가 "지금 무슨 일이 일어나는가" 를 추론 가능한가. 상태가 숨어있지 않은가. +- **마찰**: 사용자가 멈춰야 하는 모든 지점 — disabled 상태, 모달, 확인, 에러. 정당한가, 줄일 수 있는가. +- **지연 / 비동기**: 로딩 / 진행 / 실패 상태가 명시적인가. 사용자가 기다리는 동안 무엇을 보는가. +- **에러 회복**: 실패 후 사용자가 다시 시도할 수 있는가. 다음 행동이 명확한가. +- **취소 / 탈출**: 사용자가 흐름을 떠날 수 있는가. 진행 중 state 가 어떻게 되는가. +- **접근성 / 다국어**: 라벨이 한국어 자연 톤인가. 시각 대비. 터치 타겟 크기. + +## 산출물 +- 설계서 디렉토리에 `UX-REVIEW.md` 추가 또는 README 의 `## UX 리뷰` 섹션. +- 형식: **권고 (Strong / Suggest / Question)** × (관점, 근거, 제안). + - **Strong**: 사용자가 막힐 위험. Architect 가 설계 변경 또는 명시 거절 (OQ로) 해야 함. + - **Suggest**: 사용자 경험이 더 좋아짐. 채택은 Architect 재량. + - **Question**: 결정 전 더 정보 필요. Architect 또는 후속 사용자 인터뷰. +- 각 항목에 Planner AC 번호와 mapping (어느 AC 의 해석을 좁히거나 넓히는지). + +## 핸드오프 +- 코드/설계 직접 수정 X — 모든 변경은 Architect 가 흡수. +- 산출물 git 커밋 가능 (`[UX-Reviewer] # ...`). +- Redmine 저널에 1-line 요약. diff --git a/docs/design/311-llm-warmup/README.md b/docs/design/311-llm-warmup/README.md new file mode 100644 index 0000000..0bf3bfb --- /dev/null +++ b/docs/design/311-llm-warmup/README.md @@ -0,0 +1,244 @@ +# 설계서: ChatScreen LLM warm-up (#311) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가) +> · 구현 파일: `app/lib/state/chat_warmup_provider.dart` (신규) · `app/lib/data/ai/llm_service.dart` (수정) · `app/lib/data/ai/model_lifecycle.dart` (`quickCheck` 추가) · `app/lib/data/ai/gemma_llm_service.dart` (concurrent load guard) · `app/lib/ui/screens/chat_screen.dart` (warmup binding) +> · 테스트: `app/test/state/chat_warmup_test.dart` (신규) · `app/test/data/ai/model_lifecycle_test.dart` (quickCheck 케이스 추가) · `app/test/ui/chat_screen_test.dart` (warmup 라벨/disabled 케이스 추가) + +## 1. 목적 (Why) +> Planner 목표 인용: ChatScreen 진입 시 LLM 모델을 백그라운드로 warm-up 하여, 첫 send 의 perceived latency 에서 cold load (수 초) 를 제거한다. + +현재 `chat_providers.dart:131` 의 `llm.load()` 가 첫 `userTurn` 시점에 lazy 실행된다. Gemma 4 E2B 의 native runtime 초기화 + `installModel.fromFile().install()` + `getActiveModel(maxTokens: 2048)` 가 합쳐 수 초가 걸려, 사용자는 첫 메시지 send 직후 빈 화면을 본다. 본 이슈는 그 비용을 사용자 입력 전(ChatScreen mount 시점) 으로 이동시킨다. + +## 2. 범위 (Scope) +- **포함**: + - ChatScreen mount 시 백그라운드 `llm.load()` 트리거. + - 로드 상태(idle/loading/ready/failed/unavailable) 노출 + 입력창 binding. + - `GemmaLlmService.load()` / `MockLlmService.load()` 의 concurrent-call 가드. + - `ModelLifecycle.quickCheck()` — SHA-256 해싱 없이 ready 여부 추정 (warmup gate 전용). + - Widget 테스트 (loading 라벨 노출 → 완료 → 사라짐). +- **제외 (out of scope)**: + - `HabitCreateScreen` 의 AI 제안 (frame suggestion) warm-up — 동일 패턴 필요 시 별도 후속 이슈. + - 다운로드 자체 진행률 UI — 이미 SettingsScreen 에 존재 (#218). + - `#219` idle auto-unload 구현 — 본 이슈는 entry point 만 정의. + - 추론 자체 (KV-cache warm 등) 의 추가 최적화 — `load()` 호출까지만. + +## 3. 인수조건 (Acceptance Criteria) +> Planner AC 8개 + UX-Reviewer 신규 AC 4개 (UX-REVIEW.md 흡수). QA 가 이걸로 판정. + +- [ ] **AC1** ChatScreen mount → background `llm.load()` 시작. 이미 `isLoaded` 면 no-op. +- [ ] **AC2** `ModelLifecycle.quickCheck() != ready` 일 때 warmup 시도하지 않음. 다운로드 미완 / opt-out / corrupt 상태에서 spurious load 방지. +- [ ] **AC3** 로드 진행 중 입력창은 `enabled: true` 유지 (사용자가 메시지를 미리 작성할 수 있음). send 버튼만 disabled + `CircularProgressIndicator(strokeWidth:2)`. 입력창 `hintText` 가 `"AI 준비 중… 첫 시작은 몇 초 걸려요"` 로 교체. ← UX R1+R3 흡수. +- [ ] **AC4** 로드 완료 시 send 버튼 활성, `hintText` 가 평상시 `"습관 추가, 기록, 카탈로그 질문…"` 로 복귀. 첫 send 가 cold load 비포함 수준 latency 로 응답. +- [ ] **AC5** 로드 실패 시 기존 chat_screen error container 재사용. **메시지는 상태만 기술**, 행동은 별도 `OutlinedButton('다시 시도')` 가 담당 (error container 내부, 우측 정렬). ← UX R5+R6 흡수. +- [ ] **AC6** Warmup 진행 중 사용자가 ChatScreen 을 떠나도 race / leak 없음. autoDispose StateNotifier + `_disposed` 가드. +- [ ] **AC7** `ChatSessionController.userTurn` 의 lazy load 와 백그라운드 warmup 이 동시 호출되어도 안전. `GemmaLlmService._loadingFuture` 가드로 중복 native init 차단. +- [ ] **AC8** Widget 테스트: + - delay mock → spinner + hintText 교체 노출 → 완료 후 send 활성 + hintText 복귀. + - error mock → error container + [다시 시도] 버튼 노출 + 클릭 시 retry. + - `quickCheck() = missing` → warmup 시도 안 함 + UI 변화 없음 (첫 send 시 기존 lazy 경로). +- [ ] **AC9** (UX R1+R2) Warmup 중 입력창은 타이핑 가능. send 만 disabled. +- [ ] **AC10** (UX R2) Warmup ready 전이 시점에 입력창에 비어있지 않은 텍스트가 있으면 send 자동 활성화. 자동 send 는 X. +- [ ] **AC11** (UX R4) `isLoaded=true` 인 재진입 시 Loading state 가 1 frame 이라도 노출되지 않음 (위젯 테스트로 verify). +- [ ] **AC12** (UX R5) 실패 메시지 본문에 "다시 시도해주세요" 같은 명령형 문구 금지. 행동은 버튼이 담당. + +## 4. 컨텍스트 & 제약 +- **의존성**: + - `LlmService` (`load`/`isLoaded`/`startChat`) — 기존 인터페이스 유지. + - `ModelLifecycle` (`checkAvailability` 기존, `quickCheck` 신규) — meta_kv DAO 의존. + - Riverpod (`StateNotifier`, `FutureProvider`, `autoDispose`). +- **제약**: + - `flutter_gemma 0.16.5` 의 `FlutterGemma.initialize` 는 isolate 당 1회 (`_initialized` 가드 있음). `installModel` + `getActiveModel` 은 idempotent 가 아니다 — 두 번째 호출 시 동작 미정의. **concurrent load 가드 필수**. + - `ModelLifecycle.checkAvailability()` 가 SHA-256 ~2.4GB 해싱을 포함. ChatScreen mount 마다 호출하면 비용 과대. quickCheck 분리. + - autoDispose StateNotifier 라이프사이클: ChatScreen pop 시 dispose 호출, 진행 중 future 가 unmounted state 변경 시도하면 안 됨. +- **가정**: + - `GemmaLlmService.load()` 비용 ≈ native init + mmap + `getActiveModel`. KV-cache warm 은 첫 inference 시 발생 (별도 트랙). → R3 해소. + - 사용자가 SettingsScreen 에서 모델을 ready 상태로 만든 적이 있다 (meta_kv 의 `ai_model_sha256` 가 채워져 있다). 그렇지 않으면 quickCheck=missing → warmup skip → 기존 lazy 경로 fallback. + +## 5. 아키텍처 개요 +- **모듈/파일**: + - 신규: `app/lib/state/chat_warmup_provider.dart` — `ChatWarmupController` (StateNotifier) + `chatWarmupProvider`. + - 수정: `app/lib/data/ai/llm_service.dart` — `MockLlmService._loadingFuture` 가드 추가. + - 수정: `app/lib/data/ai/gemma_llm_service.dart` — `_loadingFuture` 가드 추가. + - 수정: `app/lib/data/ai/model_lifecycle.dart` — `quickCheck()` 메서드 추가. + - 수정: `app/lib/ui/screens/chat_screen.dart` — `initState` 에서 warmup 트리거, body 에 상태 binding. + +- **데이터 흐름**: +``` +ChatScreen.initState + └─> ref.read(chatWarmupProvider.notifier).start() + ├─> meta_kv 의 ai_opt_in / ai_model_path / ai_model_sha256 조회 (ModelLifecycle.quickCheck) + │ ├─> ready 아님 → state = unavailable, 종료 (기존 lazy 경로 fallback) + │ └─> ready + ├─> state = loading + ├─> llm.load() ── concurrent 가드 (_loadingFuture 공유) ──┐ + │ ↓ + │ ChatSessionController.userTurn 의 llm.load() 호출 시 동일 future 반환 + ├─> 성공 → state = ready + └─> 실패 → state = failed(message) + +ChatScreen.build (Consumer) + ├─> warmup.state == loading + │ ├─ 입력창: enabled:true, hintText="AI 준비 중… 첫 시작은 몇 초 걸려요" + │ └─ send: disabled + spinner + ├─> warmup.state == failed + │ ├─ error container: 상태 메시지만 + │ └─ [다시 시도] OutlinedButton (container 내부, 우측 정렬) → controller.retry() + ├─> warmup.state == ready + │ ├─ 입력창: hintText="습관 추가, 기록, 카탈로그 질문…" (평상) + │ └─ send: 텍스트 비어있지 않으면 즉시 활성 (AC10) + ├─> warmup.state == unavailable → 정상 입력창 (warmup 라벨 X, 첫 send 시 lazy 경로) + └─> warmup.state == idle (lifecycle race) → 정상 입력창 (안전 기본값) +``` + +- **I/O ↔ 순수 로직 경계**: + - I/O: `ModelLifecycle.quickCheck()` (meta_kv read + file existsSync), `LlmService.load()` (native init). + - 순수: `ChatWarmupController` 의 state 전이는 의존성 주입된 함수만 호출 — 단위 테스트로 모든 분기 검증. + +## 6. 데이터 모델 + +### `ChatWarmupState` (sealed, `chat_warmup_provider.dart`) +```dart +sealed class ChatWarmupState { const ChatWarmupState(); } +final class ChatWarmupIdle extends ChatWarmupState { const ChatWarmupIdle(); } +final class ChatWarmupLoading extends ChatWarmupState { const ChatWarmupLoading(); } +final class ChatWarmupReady extends ChatWarmupState { const ChatWarmupReady(); } +final class ChatWarmupFailed extends ChatWarmupState { + final String message; + const ChatWarmupFailed(this.message); +} +/// quickCheck != ready — warmup 자체를 시도하지 않은 상태. +/// UI 는 정상 입력창 표시 (첫 send 시 기존 lazy 경로). +final class ChatWarmupUnavailable extends ChatWarmupState { const ChatWarmupUnavailable(); } +``` + +### `ModelLifecycle.quickCheck()` 반환 타입 +- 재사용: 기존 `ModelAvailability` enum (`ready` / `missing` / `corrupt` / `downloading`). +- 차이: SHA-256 재해싱을 건너뛴다. 파일 존재 + meta_kv 의 `ai_model_path` / `ai_model_sha256` 가 모두 채워져 있으면 `ready` 로 간주. 손상 감지는 `checkAvailability()` (cold 경로) 에 위임. + +### 입력창 binding (UX 흡수) +- `chat_screen.dart` 의 TextField `enabled` = `state.isStreaming == false`. + - **warmupState 와 무관** — 사용자가 warmup 중에도 메시지를 미리 작성 가능 (UX R1). +- TextField `hintText`: + - `warmupState is ChatWarmupLoading` → `"AI 준비 중… 첫 시작은 몇 초 걸려요"` + - 그 외 → `"습관 추가, 기록, 카탈로그 질문…"` +- send 버튼: `state.isStreaming || warmupState is ChatWarmupLoading || textIsEmpty` 면 disabled. Loading 일 때는 spinner 표시. +- send 자동 활성 (AC10): warmup 가 ready 로 전이될 때 입력창 텍스트가 비어있지 않으면 send 가 자동으로 enabled 로 바뀜 (텍스트 controller listener 가 이미 처리하므로 별도 코드 거의 없음). + +### 마이크로카피 사전 (UX-Reviewer 채택본) + +| 상태 | 한국어 라벨 | 위치 | +|---|---|---| +| warmup loading | hintText: `AI 준비 중… 첫 시작은 몇 초 걸려요` | 입력창 | +| warmup ready | hintText: `습관 추가, 기록, 카탈로그 질문…` | 입력창 (기존 유지) | +| warmup unavailable | (라벨 변경 없음) | — | +| warmup failed (file missing) | error container 본문: `AI 모델 파일을 찾을 수 없어요.` + [설정으로 가기] | error container | +| warmup failed (other) | error container 본문: `AI 를 시작하지 못했어요.` + [다시 시도] | error container | + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `ChatWarmupController.start` | 모델 ready 체크 → load 호출 → state 전이 | `Future start()` | (deps via ctor) | `void` | failed → `ChatWarmupFailed(msg)` | **복잡** (fn-chat_warmup_controller.md) | +| `ChatWarmupController.retry` | failed/idle 에서 start 재호출 | `Future retry()` | — | `void` | (start 동일) | 단순 | +| `ModelLifecycle.quickCheck` | SHA 해싱 없이 meta_kv + file existence 만으로 ready 추정 | `Future quickCheck()` | (this.meta) | `ModelAvailability` | DB 예외 → `corrupt` (보수적) | 단순 | +| `GemmaLlmService.load` (수정) | concurrent 호출 시 같은 Future 반환 | `Future load()` | — | `void` | (기존 동일) | **복잡** (fn-concurrent_load_guard.md) | +| `MockLlmService.load` (수정) | 동일한 concurrent 가드 적용 (테스트 일관성) | `Future load()` | — | `void` | (기존 동일) | 단순 | +| `_ChatScreenState.initState` (수정) | mount 시 `chatWarmupProvider.notifier.start()` 호출 | `void initState()` | — | `void` | (controller 가 흡수) | 단순 | +| `_ChatScreenState._buildInputRow` (신규 추출) | warmup 상태 ↔ TextField/send 버튼 binding | `Widget _buildInputRow(ChatWarmupState, ChatSessionState)` | states | `Widget` | — | 단순 | + +> 복잡 함수 2 개 → 개별 `fn-*.md`. 단순 함수는 본 표로 충분. + +## 8. 흐름 / 알고리즘 + +### Happy path (사용자가 SettingsScreen 에서 옵트인 + 다운로드 완료한 상태) +1. 사용자가 `HabitListScreen` AppBar 의 🤖 탭 → ChatScreen push. +2. `initState` → `chatWarmupProvider.notifier.start()`. +3. `quickCheck()` 반환 `ready` → state = `ChatWarmupLoading`. +4. UI rebuild → 입력창 자리에 "AI 준비 중…" + spinner. send 버튼 영역에는 작은 spinner. +5. `llm.load()` 백그라운드 진행 (수 초). 그동안 사용자는 메시지 입력 불가능. +6. `load()` 성공 → state = `ChatWarmupReady` → UI rebuild → 정상 입력창. +7. 사용자 send → `ChatSessionController.userTurn` 내부 `llm.load()` 가 `isLoaded` 체크로 즉시 통과 → 곧바로 inference. + +### quickCheck 가 ready 가 아닌 경우 (다운로드 미완 / opt-out / first-run) +1. `quickCheck()` 반환 `missing`/`downloading`/`corrupt` → state = `ChatWarmupUnavailable`. +2. UI 는 정상 입력창 (warmup 라벨 X). 사용자 send 시 기존 lazy `userTurn` 경로 → `llm.load()` 가 호출되면 어차피 `FileSystemException('model file missing')` 등으로 실패 → 기존 chat_screen error container 에 표시. +3. 즉, **warmup 은 "사용자가 이미 옵트인+다운로드 완료한 케이스" 만 최적화**. 다른 케이스는 기존 동작 유지 (변화 없음). + +### Concurrent load +1. ChatScreen mount → warmup → `llm.load()` (Future A 진행 중). +2. (race) 사용자가 매우 빠르게 send → `userTurn` 내부 `llm.load()` 호출. +3. `GemmaLlmService.load()` 내부 `_loadingFuture != null` 이면 **그 future 를 반환**. native init 중복 X. +4. Future A 완료 시 두 caller 모두 정상 진행. + +### Failure + retry +1. `load()` 가 throw (예: native init 실패, 파일 권한 변경, OOM) → catch. +2. state = `ChatWarmupFailed("AI 모델 준비에 실패했어요. 다시 시도해주세요.")` + 내부 error code 로깅 (사용자 노출 X). +3. UI: error container + `OutlinedButton('다시 시도')`. 탭 → `controller.retry()`. +4. retry = 단순히 state = `ChatWarmupIdle` 로 reset 후 `start()` 재호출. + +### Unmount race +1. `start()` 진행 중 사용자가 back 버튼 → ChatScreen.dispose() → autoDispose → controller.dispose(). +2. dispose() 에서 `_disposed = true` 플래그. +3. start() 의 `await llm.load()` 완료 후 `if (_disposed) return;` 가드 → state 변경 시도 skip. + +## 9. 엣지케이스 & 에러 처리 + +| 케이스 | 처리 | +|---|---| +| `meta_kv` DB 가 lock / 손상 | `quickCheck` catch → `corrupt` 반환 → state = `ChatWarmupUnavailable` (warmup skip). 첫 send 시 정상 에러 경로. | +| `LlmService.load()` 가 throw (FileSystemException) | state = `ChatWarmupFailed`. 메시지: "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." | +| `load()` 가 throw (Native init 실패 — OOM / 런타임 호환성) | state = `ChatWarmupFailed`. 메시지: "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." | +| concurrent load — 두 caller 동시 호출 | `_loadingFuture` 가드로 단일 future 공유. 두 caller 모두 완료 시점에 unblocked. | +| 사용자가 warmup 중 ChatScreen 떠남 | `_disposed` 가드 → state 변경 skip. 메모리는 native runtime 이 보유 (다음 진입 시 `isLoaded=true` → no-op). | +| AI opt-in 이 false 인 상태에서 ChatScreen 직접 진입 (불가능한 케이스 — 🤖 아이콘 자체가 hidden) | 안전 기본값으로 quickCheck=missing → unavailable. | +| ChatScreen 재진입 (앞서 load 됨) | `isLoaded=true` → `llm.load()` 즉시 return → state = ready 빠르게 전이 (사용자 인지 어려운 수 ms). 라벨 깜빡임 방지 위해 — **race 처리**: state 초기값을 `ChatWarmupIdle` 로 두고, `start()` 가 quickCheck 직후 isLoaded 체크해서 이미 loaded 면 곧바로 `ChatWarmupReady` (Loading 단계 skip). | + +## 10. 테스트 계획 + +| 테스트 | 케이스 | AC mapping | +|---|---|---| +| `chat_warmup_test.dart` — `start() happy` | quickCheck=ready + load delay 100ms → state 시퀀스 [Idle → Loading → Ready] | AC1, AC3, AC4 | +| `chat_warmup_test.dart` — `start() skip when already loaded` | isLoaded=true → state 시퀀스 [Idle → Ready] (Loading 없음) | AC1 | +| `chat_warmup_test.dart` — `start() unavailable` | quickCheck=missing → state = Unavailable, load 호출 안 됨 | AC2 | +| `chat_warmup_test.dart` — `start() failure` | load throws → state = Failed(msg) | AC5 | +| `chat_warmup_test.dart` — `retry after failure` | Failed → retry() → Loading → Ready | AC5 | +| `chat_warmup_test.dart` — `unmount race` | start() 진행 중 dispose() → state 변경 시도 skip | AC6 | +| `chat_warmup_test.dart` — `concurrent load shares future` | start() + userTurn 시뮬 동시 → load 1회만 호출 | AC7 | +| `model_lifecycle_test.dart` — `quickCheck ready` (신규) | meta_kv 채워짐 + 파일 존재 → ready (SHA 안 함) | AC2 | +| `model_lifecycle_test.dart` — `quickCheck missing` (신규) | 파일 없음 → missing | AC2 | +| `chat_screen_test.dart` — `warmup loading label` (신규) | delay mock → "AI 준비 중…" 라벨 + spinner 노출 | AC3 | +| `chat_screen_test.dart` — `warmup ready hides label` (신규) | 완료 후 라벨 사라지고 send 활성 | AC4 | +| `chat_screen_test.dart` — `warmup failed shows retry` (신규) | error mock → error container + 재시도 버튼 | AC5 | + +> 모킹 전략: `MockLlmService` 에 `loadDelay` / `loadThrows` 필드 추가 (테스트 helper). `ModelLifecycle` 은 in-memory `MetaDao` + `MemoryFileSystem` 패턴 (기존 `model_lifecycle_test.dart` 의 fake storage 재사용). + +## 11. 리스크 & 대안 검토 + +| 리스크 | 대안 | 선택 | 근거 | +|---|---|---|---| +| **R1: concurrent load race** | (a) controller-level coordination (b) service-level `_loadingFuture` guard | **(b)** | frame suggestion 등 다른 caller 도 보호. service 가 진실의 원천. | +| **R3: load() 비용 정의** | (a) load = mmap only (b) load = mmap + dummy inference (KV-cache warm) | **(a)** | 코드 확인 결과 현재 `load()` = native init + mmap + getActiveModel. KV-cache warm 은 첫 inference 시 발생. (b) 는 별도 트랙 (#312 이슈와 묶일 수 있음). | +| **R4: SHA-256 재해싱 비용** | (a) quickCheck 메서드 신설 (file existence + meta_kv 만) (b) checkAvailability 결과를 Riverpod 캐시 | **(a)** | (b) 는 무효화 시점 (다운로드 완료/재시작) 관리 부담. (a) 는 명시적 의도 표현 + SHA 검증은 SettingsScreen 의 cold path 에 남김. | +| **R-extra: warmup 비용이 너무 커서 사용자가 chat 안 쓸 때도 GPU/RAM 점유** | (a) ChatScreen 진입 시 warmup (이 설계) (b) HabitListScreen 🤖 hover/long-press 시 (c) opt-in tier (사용자 선택) | **(a)** | (b) 모바일 hover 없음. (c) 옵션 폭증. ChatScreen 진입 = "사용자가 곧 쓸 의도 명시" 의 가장 강한 신호. | + +> ADR 분리 안 함: 모든 결정이 backward-compatible 추가. `LlmService` 인터페이스 변경 없음, `ModelLifecycle.quickCheck` 도 추가 메서드. 되돌리기 어렵지 않음. + +## 12. 미해결 질문 (Open Questions) +> UX-Reviewer 패스로 모두 해소. 본 섹션은 의도적으로 비어 있음. + +- ~~OQ-1 microcopy 결정~~ → UX R3 채택, 마이크로카피 사전 §6 으로 이관. +- ~~OQ-2 재시도 버튼 위치~~ → UX R6 채택, error container 내부 우측 정렬. +- ~~OQ-3 재진입 깜빡임~~ → UX R4 endorse, fn-spec 의 빠른 경로로 Loading skip. min display time 같은 인위 지연은 금지 (안티패턴). + +## 13. UX 리뷰 흡수 노트 +- 본 설계서는 UX-REVIEW.md 의 Strong 4건 (R1, R2, R4, R5) 모두 채택, Suggest 2건 (R3, R6) 채택. +- 신규 AC4건 (AC9-AC12) 통합. +- 마이크로카피 사전 §6 으로 이관. +- 다음 페르소나 (03-Developer) 는 README 만 보면 충분. UX-REVIEW.md 는 결정 과정의 기록 으로 보존. + +## 14. 참조 +- Planner 산출물: Redmine #311 `## [AI] Planner` 섹션. +- 관련 follow-up: #219 (idle auto-unload), #220 (purge try/catch — 본 이슈의 concurrent load guard 와 동일 정신). +- 기존 설계: `docs/design/218-gemma-real-integration/`, `docs/design/260-gemma-tool-calling/`. diff --git a/docs/design/311-llm-warmup/UX-REVIEW.md b/docs/design/311-llm-warmup/UX-REVIEW.md new file mode 100644 index 0000000..a5ef7ea --- /dev/null +++ b/docs/design/311-llm-warmup/UX-REVIEW.md @@ -0,0 +1,142 @@ +# UX 리뷰: ChatScreen LLM warm-up (#311) + +> **검토**: [AI] UX-Reviewer · **대상**: `./README.md` v1 + Planner AC 8개 · **날짜**: 2026-06-15 +> **위치**: 02-Architect 단계 내 parallel review. Architect 가 흡수 후 03-Developer 인계. + +## 요약 + +설계서가 백엔드 흐름 (state 머신, concurrent guard, lifecycle) 은 견고하다. 그러나 사용자가 실제로 보는 표면 — 라벨 톤, spinner 위치, 빠른 전이 시 깜빡임, 실패 시 다음 행동 — 에는 **결정 안 된 것이 너무 많고 (OQ 3건), 결정된 부분도 마찰을 만들 위험**이 있다. 6가지 권고를 아래에 정리. + +--- + +## [Strong] R1 — "AI 준비 중…" 을 입력창 자리에 박으면 안 됨 + +**관점**: 마찰 / 흐름 / 정신 모델 +**근거**: +- README §5 / §6 은 "입력창 자리에 라벨 + spinner" 를 기본안으로 둠. +- 그러면 **사용자가 메시지를 미리 타이핑해두는 행동 자체가 차단**된다. 사용자는 모델 로드를 기다리는 동안에도 "어떤 질문을 할지" 머릿속에서 정리하면서 손가락은 이미 키보드 위에 있다. +- 입력창이 사라지면 사용자는 "왜 안 보이지?" 하고 한 번 더 추론해야 한다 (마찰 +1). +- 더 큰 문제: ChatScreen 의 ListView 영역이 비어 있는 첫 진입 시점에 입력창까지 사라지면 **화면 전체가 spinner 하나뿐**이 된다 — "이 앱이 멈췄나?" 시그널. + +**제안 (강력 권고)**: +- 입력창은 항상 보이게 유지. `enabled: false` 로만 잠그고 `hintText` 만 교체: + - 평상: `"습관 추가, 기록, 카탈로그 질문…"` + - warmup: `"AI 준비 중… 잠시만요"` +- send 버튼 자리에 `CircularProgressIndicator(strokeWidth: 2)` 표시 (현재 isStreaming 처리와 동일 패턴 — 일관성 ↑). +- 별도 상단 라벨/배지 추가 X. 사용자는 send 버튼이 spinner 인 것 + hint 한 줄로 충분히 추론 가능. + +**README 영향**: §3 AC3, §5 다이어그램, §6 binding 절 모두 수정. + +--- + +## [Strong] R2 — 사용자가 텍스트 입력하고 send 누르면 어떻게 되나? (현재 설계는 침묵) + +**관점**: 정신 모델 / 마찰 +**근거**: +- R1 권고를 받아들이면 입력창은 보이지만 `enabled: false`. 사용자가 키보드를 띄우고 타이핑하려 하면 → **반응 없음**. 또 다른 마찰. +- 만약 `enabled: true` 로 두고 send 만 disable 하면, 사용자가 메시지를 친 뒤 send 를 누르려는 순간 "왜 안 가지?" 로 또 다른 마찰. +- 어느 쪽이든 **사용자의 의도 (메시지를 보내고 싶음) 와 시스템의 상태 (아직 못 받음) 사이의 간극** 이 풀리지 않음. + +**제안 (강력 권고)**: +- 입력창은 `enabled: true` 로 두어 **타이핑은 허용**한다. 사용자가 미리 메시지를 작성하도록. +- send 버튼은 disabled + spinner. 누를 수는 없음. +- **warmup 완료 시점**에 사용자가 이미 타이핑해둔 메시지가 있으면 → send 버튼 자동 활성화. (자동 send 까지는 X — 사용자 의도 확인 필요) +- AC4 에 한 줄 추가: "warmup ready 시점에 입력창의 텍스트가 비어있지 않으면 send 활성화." + +**README 영향**: §3 AC3/AC4 보강. + +--- + +## [Suggest] R3 — 첫 warmup 은 "예상 시간" 한 마디 더 + +**관점**: 정신 모델 / 인지된 지연 +**근거**: +- 사용자에게 "AI 준비 중" 만 보여주면 — 0.5초 후에도, 5초 후에도, 10초 후에도 같은 라벨. 정신 모델은 점점 "이게 멈췄나?" 로 기운다. +- Gemma 4 E2B native init + mmap 은 디바이스에 따라 **2-8초** 범위로 추정 (cold launch). 첫 진입 시 한 번뿐이고 두 번째 진입부터는 거의 즉시 (`isLoaded=true`) — 즉 사용자가 이 라벨을 길게 보는 건 **첫 진입 단 한 번**. +- 그 한 번을 부드럽게 만들 가치가 있다. + +**제안**: +- hint 를 `"AI 준비 중… 첫 시작은 몇 초 걸려요"` 로 한 번만 명시. +- 1회성 SnackBar 도 검토할 수 있으나 — 사용자가 곧바로 입력창 영역으로 시선이 가므로 hint 한 줄로 통합하는 게 단순. + +**README 영향**: §3 AC3 의 라벨 문안, §12 OQ-1 해소. + +--- + +## [Strong] R4 — 빠른 경로 (이미 loaded) 의 라벨 깜빡임을 명시적으로 차단 + +**관점**: 정신 모델 +**근거**: +- README §9 의 "ChatScreen 재진입" 케이스 + fn-spec 의 "빠른 경로" 분기로 Loading state skip 처리가 들어가 있음 — 좋다. +- 하지만 §12 OQ-3 에 "라벨 깜빡임 가능 — 미해결" 이 남아있어 모순. fn-spec 의 빠른 경로가 Loading 을 스킵하므로 깜빡임은 일어나지 않음. +- 명확히 못 박을 것. + +**제안**: +- OQ-3 를 OQ 에서 제거하고 §9 의 "빠른 경로" 분기 + fn-spec 의 step 2 를 명시적으로 인용한 **결정 노트**로 전환. +- min display time (300ms 등) 같은 인위 지연은 도입 **금지** — 사용자에게 거짓 작업을 보여주는 안티패턴. + +**README 영향**: §9 endorse 표현, §12 OQ-3 삭제. + +--- + +## [Strong] R5 — 실패 메시지의 다음 행동이 약함 + +**관점**: 에러 회복 / 마찰 +**근거**: +- README §9 의 실패 메시지: + - "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." — 좋음 (다음 행동 명시). + - "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." — 약함. "잠시 후" 가 얼마인지, "다시 시도" 가 어떻게 인지 불명. +- 사용자는 두 가지 의문: ① 이게 일시적 문제인가, 영구적 문제인가 ② 내가 뭘 해야 하나. + +**제안**: +- "다시 시도" 버튼이 있으니, 메시지에서 "잠시 후 다시 시도해주세요" 는 빼고 **상태 + 행동 분리**: + - 상태: `"AI 를 시작하지 못했어요."` + - 행동: 별도 [다시 시도] 버튼 (이미 설계됨). +- AC5 에 "한국어 메시지는 상태만 기술, 행동은 버튼이 담당" 명시. +- 3회 연속 실패 시점에는 보조 안내 ("문제가 계속되면 앱을 재시작해보세요") — 후속 polish 로 deferrable. + +**README 영향**: §9 메시지 사전 갱신, §3 AC5 보강. + +--- + +## [Suggest] R6 — 재시도 버튼 위치는 error container 안 + +**관점**: 흐름 / 접근성 +**근거**: +- README §12 OQ-2 가 "error container 내부 vs 입력창 옆 icon" 으로 열어둠. +- 입력창 옆 icon 은 평상시에는 없는 자리에 갑자기 나타나 사용자가 학습해야 함. 게다가 send 자리 근처에 또 다른 액션 = 오탭 위험. +- error container 는 이미 실패 메시지 영역이라 컨텍스트 일관 + 사용자가 "여기서 다음 행동" 학습. + +**제안**: +- OQ-2 → 결정: **error container 내부 OutlinedButton('다시 시도')**. +- container 좌우 패딩, 메시지와 버튼은 column 으로 분리, 버튼은 우측 정렬. + +**README 영향**: §12 OQ-2 해소. + +--- + +## AC 보강 권고 (UX-Reviewer 가 작성한 추가 AC) + +UX 관점에서 검증 가능한 새 AC 를 제안 (Architect 가 흡수 시 README §3 에 추가): + +- [ ] **AC9 (신규)** Warmup 중 입력창은 `enabled: true` 로 타이핑 가능. send 만 disabled + spinner. → R1+R2. +- [ ] **AC10 (신규)** Warmup ready 시점에 입력창에 비어있지 않은 텍스트가 있으면 send 자동 활성화 (자동 send 는 X). → R2. +- [ ] **AC11 (신규)** `isLoaded=true` 인 재진입 시 Loading state 가 1 frame 이라도 노출되지 않는다 (위젯 테스트로 verify). → R4. +- [ ] **AC12 (신규)** 실패 메시지는 상태 기술만, 행동은 [다시 시도] 버튼이 담당. 메시지 본문에 "다시 시도해주세요" 같은 명령형 X. → R5. + +## 마이크로카피 사전 (Architect 가 채택 시 README §6 또는 별도 부록) + +| 상태 | 한국어 라벨 | 위치 | +|---|---|---| +| warmup loading | 입력창 `hintText`: `AI 준비 중… 첫 시작은 몇 초 걸려요` | 입력창 | +| warmup ready | `hintText`: `습관 추가, 기록, 카탈로그 질문…` | 입력창 (기존 유지) | +| warmup unavailable | (라벨 없음 — 평상시와 동일) | — | +| warmup failed (file missing) | error container: `AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요.` + [설정으로 가기] | error container | +| warmup failed (other) | error container: `AI 를 시작하지 못했어요.` + [다시 시도] | error container | + +> [설정으로 가기] 는 R5 의 file-missing 케이스에서 "설정에서 다시 다운로드" 문구의 다음 행동을 한 탭으로 짧게 만드는 보조 권고. 채택은 Architect 재량. + +## Architect 가 결정해야 할 것 (요약) +- Strong R1, R2, R4, R5 — 채택 또는 명시 거절 (OQ 로 남기지 말 것). +- Suggest R3, R6 + 마이크로카피 사전 + [설정으로 가기] — 재량. +- 새 AC 4건 — 채택 시 README §3 에 통합. diff --git a/docs/design/311-llm-warmup/fn-chat_warmup_controller.md b/docs/design/311-llm-warmup/fn-chat_warmup_controller.md new file mode 100644 index 0000000..1431426 --- /dev/null +++ b/docs/design/311-llm-warmup/fn-chat_warmup_controller.md @@ -0,0 +1,103 @@ +# 함수 설계서: `ChatWarmupController.start` (#311) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_warmup_provider.dart:start` · **테스트**: `app/test/state/chat_warmup_test.dart` + +## 1. 시그니처 +```dart +class ChatWarmupController extends StateNotifier { + ChatWarmupController({ + required this.llm, + required this.lifecycle, + }) : super(const ChatWarmupIdle()); + + final LlmService llm; + final ModelLifecycle lifecycle; + bool _disposed = false; + + Future start(); + Future retry(); + + @override + void dispose() { _disposed = true; super.dispose(); } +} +``` + +## 2. 책임 (단일 책임) +모델 ready 추정 → background `load()` → state 전이까지를 한 번의 트랜잭션으로 묶고, 모든 실패/취소 분기에서 안전하게 state 만 갱신한다. + +## 3. 입력 +| 파라미터 | 타입 | 제약/검증 | 설명 | +|----------|------|-----------|------| +| (ctor) `llm` | `LlmService` | non-null | `load`/`isLoaded` 만 사용. | +| (ctor) `lifecycle` | `ModelLifecycle` | non-null | `quickCheck` 만 사용. | + +## 4. 출력 +- **반환**: `Future` — 완료 시점에 state 가 ready/failed/unavailable 중 하나로 확정. +- **부수효과**: `state =` 설정. 다른 I/O 없음. + +## 5. 동작 / 알고리즘 +``` +1. 현재 state 가 Loading 이면 즉시 return (재진입 가드, retry 외에는 발생 X). +2. state = ChatWarmupLoading() 임시 설정 (단, 아래 빠른 경로 확인 전이라 안전). + → ChatScreen 재진입 시 깜빡임 방지 위해 isLoaded 빠른 경로를 먼저 확인: + + if (llm.isLoaded) { + _safeSet(const ChatWarmupReady()); + return; + } + +3. quickCheck = await lifecycle.quickCheck(); +4. quickCheck != ready: + _safeSet(const ChatWarmupUnavailable()); + return; +5. _safeSet(const ChatWarmupLoading()); // 본격 로드 시작 +6. try { await llm.load(); } + catch (e) { + _safeSet(ChatWarmupFailed(_messageFor(e))); + return; + } +7. _safeSet(const ChatWarmupReady()); + +_safeSet(s) = if (_disposed) return; state = s; +``` + +`retry()` = `state = ChatWarmupIdle();` 후 `await start();`. + +## 6. 에러 & 실패 모드 +| 조건 | 처리 | 반환/예외 | +|------|------|-----------| +| `quickCheck` 가 DB lock 등으로 throw | `lifecycle.quickCheck` 내부 catch → `corrupt` 반환 | state = `Unavailable` (보수적) | +| `llm.load()` 가 `FileSystemException('model file missing')` | _messageFor 가 매핑 → "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." | state = `Failed` | +| `llm.load()` 가 기타 throw (native init 실패, OOM) | _messageFor → "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." | state = `Failed` | +| start() 진행 중 dispose() | `_disposed = true` → `_safeSet` 가 no-op | state 변경 안 함 (마지막 set 유지) | +| concurrent start() 호출 | step 1 의 Loading 가드 — 외부에서는 retry() 만 사용하므로 정상 흐름에서 미발생 | early return | + +## 7. 엣지케이스 +- **ChatScreen 재진입 (이미 loaded)**: step 2 의 빠른 경로로 Loading 단계 skip → 라벨 깜빡임 없음. +- **start() 진행 중 ChatScreen pop → push (빠른 재진입)**: 첫 인스턴스 dispose, 두 번째 인스턴스의 start() 가 새로 호출. `_disposed` 가 인스턴스별이라 race 없음. `llm._loadingFuture` 가 native init 중복 차단. +- **opt-in 토글 race**: 사용자가 ChatScreen 진입과 동시에 SettingsScreen 에서 opt-out → ChatScreen 의 🤖 entry 가 hidden 되며 즉시 pop. dispose 가드로 안전. + +## 8. 복잡도 / 성능 +- 시간: O(1) + `lifecycle.quickCheck` O(1) (meta_kv 4 쿼리 + 1 stat) + `llm.load()` (수 초). +- 공간: state object 1개. +- 호출 빈도: ChatScreen mount 당 1회 (+retry 횟수). + +## 9. 의존성 +- `LlmService` (`load`, `isLoaded`) — 인터페이스 안정. +- `ModelLifecycle.quickCheck` (신규). +- `flutter_riverpod` `StateNotifier`. + +## 10. 테스트 케이스 +- [ ] 정상 happy: quickCheck=ready, load delay 100ms → 시퀀스 [Idle → Loading → Ready]. +- [ ] 빠른 경로: isLoaded=true → 시퀀스 [Idle → Ready] (Loading 없음). +- [ ] unavailable: quickCheck=missing → 시퀀스 [Idle → Unavailable], load 호출 안 됨. +- [ ] failure: load throws FileSystemException → state = Failed + 매핑된 한국어 메시지. +- [ ] failure: load throws StateError → state = Failed + generic 메시지. +- [ ] retry: Failed → retry() → Loading → Ready. +- [ ] unmount race: start() 진행 중 dispose() → state 변경 시도 무시 (마지막 state = Loading 유지). +- [ ] DB 예외: quickCheck 가 throw → Unavailable. + +## 11. 추적성 +- 인수조건: AC1, AC2, AC3, AC4, AC5, AC6. +- 관련 ADR: 없음. diff --git a/docs/design/311-llm-warmup/fn-concurrent_load_guard.md b/docs/design/311-llm-warmup/fn-concurrent_load_guard.md new file mode 100644 index 0000000..32fb720 --- /dev/null +++ b/docs/design/311-llm-warmup/fn-concurrent_load_guard.md @@ -0,0 +1,85 @@ +# 함수 설계서: `GemmaLlmService.load` concurrent guard (#311) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/gemma_llm_service.dart:load` (수정) · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart` (concurrent 케이스 추가) / `chat_warmup_test.dart` (시뮬) + +## 1. 시그니처 +```dart +class GemmaLlmService implements LlmService { + Future? _loadingFuture; // 신규 필드 + + @override + Future load() { + if (_loaded) return Future.value(); + final existing = _loadingFuture; + if (existing != null) return existing; + final future = _doLoad(); + _loadingFuture = future; + return future.whenComplete(() { + _loadingFuture = null; + }); + } + + Future _doLoad() async { + // 기존 load() 본문 (initialize → installModel → getActiveModel). + } +} +``` + +> `MockLlmService.load()` 도 같은 패턴 적용 (`_loadingFuture` 필드 추가). 테스트의 동시성 검증 일관성. + +## 2. 책임 (단일 책임) +`load()` 가 진행 중일 때 다른 caller 가 호출하면 새 작업을 시작하지 않고 같은 Future 를 반환한다. native runtime 의 `FlutterGemma.installModel` + `getActiveModel` 가 두 번 불리지 않도록 보호. + +## 3. 입력 +- 없음 (메서드). + +## 4. 출력 +- **반환**: `Future` — 단일 native init 작업의 완료 future. 모든 caller 가 같은 인스턴스 공유. +- **부수효과**: `_loadingFuture`, `_loaded`, `_model` 필드 변경. + +## 5. 동작 / 알고리즘 +``` +1. _loaded == true → 즉시 완료 Future 반환. +2. _loadingFuture != null → 그 future 그대로 반환. (새 작업 시작 X) +3. 그 외: + a. future = _doLoad(); + b. _loadingFuture = future; + c. future.whenComplete(() => _loadingFuture = null); + d. return future; +``` + +`_doLoad()` 내부 = 기존 `load()` 본문 그대로 (initialize → installModel → getActiveModel → _loaded=true). + +## 6. 에러 & 실패 모드 +| 조건 | 처리 | 반환/예외 | +|------|------|-----------| +| `_doLoad()` 가 throw | `whenComplete` 가 `_loadingFuture = null` 처리 후 throw 전파 | 모든 caller 가 같은 exception 받음 | +| caller A 가 await 중에 caller B 도 호출 | 같은 future 반환 (step 2) | 둘 다 동일하게 완료 또는 fail | +| 첫 호출 실패 후 재시도 | `_loadingFuture=null` 로 cleared → 다음 호출 시 새 `_doLoad()` 시작 | 정상 재시도 가능 | + +## 7. 엣지케이스 +- **load() 와 unload() race**: caller A 가 load → 진행 중 caller B 가 unload() 호출. `_doLoad()` 가 _model 설정 직후 unload 가 _model.close() 호출. 본 이슈 범위 외 — 현재 시점에 unload() 호출 경로 없음 (#219 가 다룰 영역). 본 설계는 load 의 concurrent 만 다룬다. +- **whenComplete 실행 시점**: future 가 동기 완료 (이미 _loaded=true 인 첫 분기) 시에도 `_loadingFuture=null` 보장. 단, step 1 에서 early return 이라 _loadingFuture 는 손대지 않음. + +## 8. 복잡도 / 성능 +- 시간: 첫 호출 = 기존 _doLoad 비용. 후속 caller = O(1) future 공유. +- 공간: future 1개 + null 가능 필드. +- 호출 빈도: ChatScreen mount + userTurn 첫 호출 + frame suggestion (#215) — 모두 일생에 몇 회. + +## 9. 의존성 +- `flutter_gemma` `FlutterGemma.initialize` / `installModel` / `getActiveModel` (기존). +- `_loaded` / `_model` 필드 (기존). + +## 10. 테스트 케이스 +- [ ] 정상: load() 1회 호출 → _doLoad() 1회 실행. +- [ ] concurrent: load() 두 번 await 동시 호출 → _doLoad() 1회만 실행, 두 future 같은 Future 인스턴스. +- [ ] 실패 후 재시도: 첫 _doLoad throws → caller A 에게 propagate → _loadingFuture cleared → 두 번째 load() 새 _doLoad 시작. +- [ ] isLoaded 이미 true: load() → 즉시 완료, _doLoad 미실행. + +> Gemma native 는 통합 테스트에서만 검증 가능. 단위 테스트는 MockLlmService 의 동일 가드로 시뮬. + +## 11. 추적성 +- 인수조건: AC7. +- 관련 follow-up: #220 (purge try/catch — 동일 정신). +- 관련 ADR: 없음.