# 함수 설계서: `ModelLifecycle` + `LlmService` (#215) > **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0) > **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (stub) · **테스트**: `app/test/data/ai/model_lifecycle_test.dart` + `app/test/state/model_download_controller_test.dart` (총 10 케이스) > · 동기화 노트: `GemmaLlmService` 는 모든 메서드가 `UnimplementedError` 를 던지는 stub 상태 — OQ-1 (실 모델 URL+SHA) 해결 시 활성. v1 런타임은 `MockLlmService` 가 `llmServiceProvider` 에 주입. > 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리. --- ## §A. `LlmService` (추상 인터페이스) ### 1. 시그니처 ```dart abstract class LlmService { Future load(); Future unload(); bool get isLoaded; Future> generateStructured( String prompt, Map schema, ); } ``` ### 2. 책임 (1줄) LLM 백엔드를 도메인에 노출하는 단일 인터페이스. 구현체는 `GemmaLlmService` (flutter_gemma) 와 `MockLlmService` (테스트). ### 3. 입력 / 출력 | 메서드 | 입력 | 출력 | |--------|------|------| | `load()` | (구현체 내부에서 path 결정) | void. 모델 메모리 상주 | | `unload()` | none | void. 메모리 회수 | | `isLoaded` | none | bool | | `generateStructured(prompt, schema)` | prompt string, function calling JSON schema | parsed JSON map | ### 4. 부수효과 - `load`: 모델 파일 read + 네이티브 메모리 할당. - `unload`: 네이티브 메모리 해제. - `generateStructured`: 네이티브 추론 호출. prompt 본문은 로그 X (privacy). ### 5. 인터페이스 계약 (구현체가 반드시 지켜야 할 것) - `load` 가 throw 하지 않고 정상 종료하면 `isLoaded == true`. - `generateStructured` 호출 시 `!isLoaded` → throw `StateError("not loaded")`. - `generateStructured` 응답은 schema 의 `parameters` 와 일치 — 위반 시 throw `FormatException`. - `unload` 는 idempotent. `!isLoaded` 상태에서 호출해도 noop. - timeout 은 호출자가 `.timeout(...)` 로 부여. 본 인터페이스는 별도 타임아웃 처리 X. ### 6. 추적성 - 인수조건: #215 AC-6 (응답), AC-9 (graceful — throw 종류 명세). - 관련 ADR: ADR-0003. --- ## §B. `GemmaLlmService` (flutter_gemma 구현체) ### 1. 시그니처 ```dart class GemmaLlmService implements LlmService { GemmaLlmService({ required this.modelPath, FlutterGemma? gemma, }); // ... } ``` ### 2. 책임 (1줄) `flutter_gemma` 를 호출해 Gemma 4 E2B Q4_0 모델을 디스크에서 로드하고 function calling 응답을 받는다. ### 3. 입력 | 파라미터 | 타입 | 제약/검증 | 설명 | |----------|------|-----------|------| | `modelPath` | String | 절대 경로, 파일 존재 | `ModelLifecycle.checkAvailability` 가 ready 일 때만 전달 | | `gemma` | `FlutterGemma?` | nullable (DI 용) | 테스트 시 mock 주입 가능 | ### 4. 출력 - `load`: void. 내부 `_loaded = true`. - `generateStructured`: parsed JSON (function calling 의 arguments). - `unload`: void. 내부 `_loaded = false`. ### 5. 동작 / 알고리즘 ``` load: 1. if _loaded: return (idempotent) 2. gemma = FlutterGemma() 3. await gemma.init(modelPath: modelPath, backend: 'litert_lm_android') - flutter_gemma 의 정확한 API 는 패키지 문서 확정 후 (OQ-1) 4. _loaded = true catch: - OOM → throw StateError("OOM during load") - File not found → throw FileSystemException - 기타 → rethrow generateStructured(prompt, schema): 1. if !_loaded: throw StateError("not loaded") 2. response = await gemma.generateWithFunctionCalling( prompt: prompt, functions: [ {"name": "emit_frame_candidates", "parameters": schema}, ], ) 3. if response.functionCall == null: throw FormatException("no function call") 4. return response.functionCall.arguments // already parsed Map catch: - FormatException → rethrow (호출자가 결정) unload: 1. if !_loaded: return 2. await gemma.close() 3. _loaded = false ``` ### 6. 에러 & 실패 모드 | 조건 | 처리 | 반환/예외 | |------|------|-----------| | 모델 파일 없음 | rethrow | FileSystemException | | OOM | catch + 변환 | StateError("OOM") | | init crash (native) | rethrow | Exception (구체 클래스는 flutter_gemma 정의) | | function call 없는 응답 | throw | FormatException | | schema 위반 응답 | flutter_gemma 가 거부 + retry → 최종 실패 시 FormatException | (패키지가 처리) | ### 7. 엣지케이스 - 동시에 두 번 `load()` 호출 → idempotent. 두 번째는 noop. - `load()` 진행 중 `unload()` 호출 → 첫 load 완료 후 unload (Lock 사용 권장). - 추론 도중 앱 background → flutter_gemma 가 native 측 isolate 로 계속. UI 가 result 받음. - 추론 도중 unload 호출 → race condition. v1 은 `_inferring = true` flag 로 unload 지연. ### 8. 복잡도 / 성능 - `load`: cold 1–3초 (디스크 → RAM). 1.5GB read. - `generateStructured`: cold (모델 첫 호출) 1–2초, warm 0.5–2초. - `unload`: < 100ms. - 메모리: 모델 상주 ≈ 1.5GB. ### 9. 의존성 - `flutter_gemma` 패키지 (latest, pubspec.lock 동결). - `dart:async`. ### 10. 테스트 케이스 - [ ] **정상 load → generate → unload**: dummy 1KB fixture 모델 + mock FlutterGemma 주입. - [ ] **!loaded 상태 generateStructured**: throw StateError. - [ ] **load idempotent**: 두 번 호출 → 두 번째 noop, `isLoaded == true`. - [ ] **unload idempotent**: 두 번 호출 → 두 번째 noop. - [ ] **OOM 시뮬레이션**: mock 이 OOM throw → StateError 변환. - [ ] **function call 없는 응답**: mock 응답 → FormatException. - [ ] **schema 일치 응답**: mock 응답 → arguments map 반환. ### 11. 추적성 - 인수조건: #215 AC-6, AC-7, AC-9. - 관련 ADR: ADR-0003 (flutter_gemma + LiteRT-LM Android). - Open Question: OQ-1 (모델 URL), OQ-4 (timeout). --- ## §C. `ModelLifecycle.checkAvailability` ### 1. 시그니처 ```dart Future checkAvailability(); enum ModelAvailability { ready, missing, corrupt, downloading } ``` ### 2. 책임 (1줄) 디스크의 모델 파일 존재 + SHA-256 무결성 + meta_kv 상태를 종합해 가용성을 결정한다. ### 3. 입력 / 출력 - 입력: none (내부에서 `MetaKvDao` + `path_provider` 사용). - 출력: `ModelAvailability` enum. ### 4. 동작 / 알고리즘 ``` 1. opt_in = await metaKv.get('ai_opt_in') - 'false' or null → return missing (사용자가 끔) 2. state = await metaKv.get('ai_download_state') - 'downloading' or 'paused' → return downloading 3. path = await metaKv.get('ai_model_path') - null → return missing 4. file = File(path) - !exists → return missing (외부 삭제 시나리오) 5. expectedSha = await metaKv.get('ai_model_sha256') - null → return corrupt 6. actualSha = sha256.convert(await file.readAsBytes()).toString() - != expectedSha → return corrupt 7. return ready ``` > 실제 1.5GB 파일 SHA-256 매번 계산은 비용 큼. v1 은 **앱 시작 시 1회만** 검증 + 결과 캐시. 매번 호출 시 캐시 hit. v1.1 에서 마지막 검증 시각 + invalidation 정책 검토. ### 5. 에러 & 실패 모드 | 조건 | 처리 | 반환 | |------|------|------| | meta_kv I/O 실패 | catch | missing | | file read 실패 (권한) | catch | corrupt | | SHA 계산 도중 OOM (희박) | catch | corrupt | ### 6. 복잡도 / 성능 - 첫 호출 (해시 계산): 1.5GB read + SHA-256 → 5–15초. **앱 시작 시 백그라운드 isolate 에서 수행**. - 이후 캐시: < 5ms. ### 7. 의존성 - `MetaKvDao` (#204), `path_provider`, `crypto` (SHA-256), `dart:io`. ### 8. 테스트 케이스 - [ ] **ready**: 올바른 path + 일치 SHA → ready. - [ ] **missing (opt_out)**: ai_opt_in = false → missing. - [ ] **missing (path null)**: ai_model_path = null → missing. - [ ] **missing (file 없음)**: path 는 있으나 file 부재 → missing. - [ ] **downloading**: state = paused → downloading. - [ ] **corrupt**: SHA mismatch → corrupt. ### 9. 추적성 - 인수조건: #215 AC-4 (무결성), AC-9 (graceful). --- ## §D. `ModelLifecycle.download` ### 1. 시그니처 ```dart Stream download({String? modelUrl}); class DownloadProgress { final int bytesReceived; final int totalBytes; // -1 if unknown final DownloadState state; // downloading | paused | completed | failed final String? errorMessage; } ``` ### 2. 책임 (1줄) Gemma 4 E2B Q4_0 모델 파일을 HTTP Range 로 백그라운드 다운로드 + 일시정지/재개 + SHA-256 검증. ### 3. 입력 | 파라미터 | 타입 | 제약/검증 | 설명 | |----------|------|-----------|------| | `modelUrl` | String? | 기본값 = config 상수 (OQ-1) | 테스트 / 추후 swap 시 override | ### 4. 출력 - `Stream` — 진행률 broadcast. - 부수효과: 디스크 파일 write, meta_kv 4개 키 update. ### 5. 동작 / 알고리즘 ``` 1. tempPath = await pathProvider.getApplicationSupportDirectory() + '/gemma4_e2b_q4.bin.tmp' 2. finalPath = without '.tmp' 3. existingBytes = File(tempPath).existsSync() ? File(tempPath).lengthSync() : 0 4. await metaKv.set('ai_download_state', 'downloading') 5. await metaKv.set('ai_download_bytes', existingBytes.toString()) 6. yield DownloadProgress(existingBytes, -1, downloading) 7. response = await http.get(modelUrl, headers: {'Range': 'bytes=$existingBytes-'}) - 200 (full) or 206 (partial) 8. totalBytes = existingBytes + response.contentLength 9. stream = response.stream 10. for each chunk in stream: - if pauseRequested: break (state='paused', save bytes) - if cancelRequested: delete tempPath, state='failed', break - file.writeAsBytes(chunk, mode: append) - existingBytes += chunk.length - await metaKv.set('ai_download_bytes', existingBytes.toString()) - yield DownloadProgress(existingBytes, totalBytes, downloading) 11. if completed: - rename tempPath → finalPath - actualSha = sha256(finalPath) - if actualSha != EXPECTED_SHA: - delete finalPath, state='failed', yield failed - return - await metaKv.set('ai_model_path', finalPath) - await metaKv.set('ai_model_sha256', actualSha) - await metaKv.set('ai_download_state', 'completed') - yield DownloadProgress(totalBytes, totalBytes, completed) 12. catch network error: - retry 3회 exponential backoff (1s, 2s, 4s) - 최종 실패 → state='paused' (자동 재개 가능), yield failed with message ``` > `EXPECTED_SHA` 는 모델 파일의 알려진 hash. OQ-1 에서 URL + SHA 함께 확정. ### 6. 에러 & 실패 모드 | 조건 | 처리 | 반환 (stream emit) | |------|------|---------------------| | 네트워크 끊김 | retry 3회 → paused | `failed` (자동 재개 가능 표시) | | 디스크 공간 부족 | catch IOException | `failed` ("저장 공간 부족") | | SHA mismatch | 파일 삭제 + state failed | `failed` ("다운로드 손상") | | HTTP 416 (Range 위반) | 0 부터 재시작 | `downloading` from 0 | | 사용자 취소 | tempPath 삭제 + meta_kv clear | `failed` ("취소됨") | | 사용자 일시정지 | tempPath 보존 + state=paused | `paused` | ### 7. 엣지케이스 - 다운로드 완료 직전 앱 종료 → tempPath 만 존재 (rename 미실행). 재시작 시 `checkAvailability` = downloading 상태로 인식 → 재개 시 nearly complete. - 두 번 동시 호출 → v1 은 lock 으로 단일 인스턴스 보장. - WiFi → cellular 전환 — 본 함수는 신경 X. Settings 의 "WiFi only" 토글이 별도 차단 (OQ-3). ### 8. 복잡도 / 성능 - 시간: 1.5GB @ 50Mbps = 약 4분. WiFi 200Mbps = 약 1분. - SHA 검증: 5–15초. - 메모리: 스트림 chunk 만 (≤ 64KB). ### 9. 의존성 - `http` 또는 `dio` (range request 지원). - `path_provider`, `crypto`, `dart:io`. - `MetaKvDao`. ### 10. 테스트 케이스 - [ ] **fresh download (small fixture)**: mock HTTP server 가 1KB 응답 → completed, file 존재, SHA 매치. - [ ] **resume**: 512B 다운로드 후 pause → 재개 시 Range=512- 요청 → completed. - [ ] **SHA mismatch**: mock 이 잘못된 byte 전송 → failed + file 삭제. - [ ] **network error → retry → success**: mock 이 첫 2번 throw, 3번째 OK → completed. - [ ] **cancel**: 진행 중 cancel → tempPath 삭제 + meta_kv clear. - [ ] **pause + 앱 재시작 시뮬레이션**: bytes 보존 → 다시 download() 호출 시 Range header. - [ ] **HTTP 416**: mock 이 416 → 0부터 재시작. ### 11. 추적성 - 인수조건: #215 AC-3 (일시정지/재개/취소), AC-4 (무결성). - Open Question: OQ-1 (URL), OQ-3 (cellular). --- ## §E. 통합 점검 - `LlmService` 인터페이스만 도메인에 노출. 도메인 (`suggest_frame.dart`) 은 `flutter_gemma` 를 import 하지 않는다 (I/O ↔ 순수 경계). - `ModelLifecycle.purge` (단순 함수, fn 분리 안 함): ``` 1. await unload() (혹시 로드 중) 2. path = meta_kv.get('ai_model_path') 3. if path != null: File(path).delete() // idempotent 4. meta_kv.delete(['ai_model_path', 'ai_model_sha256', 'ai_download_state', 'ai_download_bytes']) 5. return freedBytes ``` - 모든 메서드는 graceful — `suggestFrame` 호출 흐름에서 모델 로드 실패 / 다운로드 실패 시 throw 하지 않고 호출자가 `[]` 빈 결과를 반환받도록 한다. UI 가 사용자 메시지 결정 (AC-9 graceful degradation 보장).