- OQ-1: dose_variants 정규화 결정을 ADR-0002 로 승격 (ADR-0001 = 왜, ADR-0002 = 어떻게). - OQ-3: nutrition diet 패턴 5개를 별도 diet_pattern 카탈로그(19번째 SoT)로 분리. · 02-catalog §8 신규, 인덱스 IDX_diet_patterns_evidence / IDX_diet_patterns_kfit. · 05-seed: diet_patterns.json (5행) 추가, 로딩 순서 끝에 배치. · 04-migrations: v1 테이블 합계 = Catalog 8 + User 11 + 부속 1 + meta_kv = 21. - README §2/§3/§6/§11 갱신: 18→19 SoT, AC-2 에 diet_pattern=5 검증 추가. - README §12 OQ → Resolved Open Questions 표 (OQ-1~OQ-8 결정 결과). - habit_dose_variant → habit_dose_variants 표기 통일. - fn-weekly-minimum-ratio, 03-drift-schema-user 의 ADR-0002 cross-link. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
33 KiB
설계서: Phase 1 — Flutter + Drift Bootstrap + 19 Schema (#204)
상태: Draft (OQ Resolved 2026-06-11) 작성: [AI] Architect · 최종수정: 2026-06-11 추적성 — Redmine: #204 · 관련 ADR: ADR-0001 dose-variants (왜), ADR-0002 dose-variants 정규화 (어떻게) · 구현 파일:
app/(TBD by Developer) · 테스트:app/test/(TBD by Developer) 하위 문서:
- 01-project-structure.md — Flutter 레이아웃 + pubspec + codegen
- 02-drift-schema-catalog.md — Catalog 테이블 8개
- 03-drift-schema-user.md — User-data 테이블 11개 + R1~R10 강제 위치 표
- 04-migrations.md — Drift schemaVersion 전략
- 05-seed-data.md — 시드 로딩 전략
- 06-ux-contracts.md — 체크인 / 추천 / 주간 reflection UX 계약
- fn-recommend-variant.md — variant 매칭 점수 함수
- fn-compute-streak.md — 5-Tier milestone + Never miss twice
- fn-weekly-minimum-ratio.md — 주간 minimum_ratio 집계
- fn-validate-frame-level.md — R3 + R7 코끼리 회피 린터
- fn-active-habit-quota.md — R1/R2 한도 검사
- fn-seed-importer.md — 시드 JSON → Drift 첫 부팅 import
1. 목적 (Why)
Planner 목표: "Huberman/방법론/끊기/식이 SoT 를 앱에서 쓸 수 있는 로컬-우선 Flutter 앱의 데이터 계층 + 최소 vertical slice 를 만든다."
Phase 1 의 단일 과제는 "머릿속/마크다운에만 있는 19 개 SoT 엔티티 schema 를 실제 디바이스에서 굴러가는 Drift DB 로 내려놓는 것" 이다. 그 위에서 vertical slice (habit 1 개 생성 → 1 회 체크인 → 화면 확인) 가 동작하면, 이후의 모든 페르소나 작업 (UI 확장, 시드 보강, ADR 적용) 은 이 데이터 계층 위에서 점진적으로 진행된다. 동시에 R1~R10 운영 규칙이 schema 가 아닌 어디서 강제되는지 를 명확히 박아 두어, 향후 어떤 페르소나도 "이 규칙은 어디서 검사하지?" 라는 표류를 만들지 않게 한다.
2. 범위 (Scope)
포함
- Flutter 프로젝트 부트스트랩 (
app/서브디렉토리, iOS + Android 빌드 타깃). - Drift (sqlite) ORM 도입 + 코드 생성 파이프라인 (
build_runner). - 19 개 SoT 테이블 Dart 정의 (Catalog 8 + User 11) —
schema/*.json19 개 SoT 와 1:1 매핑 + 정규화 부속habit_dose_variants1 개. dose_variants[]저장 형태 결정 — 별도habit_dose_variants테이블로 정규화 (ADR-0002 참조, 상세는 03-drift-schema-user.md).tracker_entry.variant_id+context_snapshot저장.- R1~R10 운영 규칙별 강제 위치 (schema CHECK · index · trigger · app layer) 매트릭스.
- Drift
schemaVersion = 1마이그레이션 진입점 + 향후 변경 패턴. - 시드 로딩 전략: build 시
assets/seed/*.json번들 + 첫 부팅 시 import. - 단일 vertical slice:
habit1 개 생성 폼 + 일일 체크인 1 회 + tracker 그리드 1 일 셀 표시. - ULID 생성 + ID prefix 규약.
- 핵심 함수 6 개에 대한 단위 테스트 케이스 명세.
제외 (out of scope)
- 클라우드 동기화 / 인증 / 멀티 디바이스 — Phase 2+ (단,
user_id컬럼은 호환성 유지). - 알림 / cron / 백그라운드 스케줄러 — Phase 3+.
- Apple Health / Google Fit / Notion 통합 — Phase 4+.
- 고급 UI (대시보드 위젯, 통계 그래프, 캘린더 히트맵) — Phase 2.
habit_stack,dashboard_preference,notification_rule,social_link등 v2 엔티티 — data-model.md §8 참조.- 시드 SoT 본문 (Huberman 마크다운 텍스트) 의 앱 내 풀텍스트 검색 — Phase 3+.
- 회피 키워드 자동 변환기 (L0→L2 자동 생성) — 본 Phase 는 키워드 감지 + warning 까지만.
- 앱 스토어 출시 절차 — Phase 5 (Release 페르소나).
3. 인수조건 (Acceptance Criteria)
QA 가 다음 항목을 모두 통과 표시할 수 있어야 한다.
- AC-1:
flutter pub get && dart run build_runner build가 새 환경에서 에러 없이 통과한다 (Drift codegen 포함). - AC-2: 앱 실행 직후 첫 부팅 시 19 개 SoT 테이블 + 정규화 부속
habit_dose_variants+meta_kv가 모두 생성되고 (sqlite_master조회로 검증), 카탈로그 8 개 테이블에 시드 행이 0 개 이상 들어가 있다 (Huberman protocol ≥ 28, break_protocol = 8, common_frame = 5, methodology = 21, reward_menu_item ≥ 10, diet_pattern = 5). - AC-3:
user_id = 'u_local_default'user 1 행이 자동 생성된다. - AC-4:
habit생성 폼에서 build 타입 1 개를 만들고 (protocol_id 참조, frame.level = L2, anchor.when 입력),habit테이블 +habit_dose_variants테이블에 행이 들어간다. - AC-5: 만든 habit 에 대해 오늘 날짜로 체크인 (장소 1 탭 + 컨디션 1 탭) 시
tracker_entry1 행이 생성되고variant_id+context_snapshot.location+context_snapshot.condition이 모두 채워진다. - AC-6: 같은 habit 에 대해 같은 날짜로 두 번째
tracker_entry를 insert 하면 unique 위반으로 거부된다 (UNIQUE INDEX(habit_id, date)). - AC-7: build habit 을 3 개 active 상태로 가진 user 가 4 번째 build habit 을 active 로 생성하려 하면 app layer 가 거부하고 사용자 메시지를 반환한다 (R1).
- AC-8:
frame.level = L0으로 habit 을 생성하려는 시도는 app layer 가 거부한다 (R3). - AC-9:
tracker_entry.value에done/blank이외 값을 넣으려는 시도는 거부된다 (R5, CHECK 제약). - AC-10: phase 시작 후 8 일째에
reward_declarationinsert 를 시도하면 app layer 가 거부한다 (R4). - AC-11:
fn-recommend-variant가context_tags/condition_tags매칭 점수에 따라 variant 1 개를 반환하고, 매칭 0 점일 때is_minimum=truevariant 를 fallback 으로 반환한다. - AC-12:
fn-compute-streak가 3 연속 done → T1, 7/7 → T2, 24/30 → T3, 42 일 6 주 통과 → T4 를 각각 정확히 판정한다 + "Never miss twice" 규칙 (연속 blank ≥ 2 → 스트릭 0 으로 리셋, 단 1 회 blank 는 스트릭을 끊지 않음) 이 동작한다. - AC-13:
fn-weekly-minimum-ratio가 주어진 주에 대해is_minimum=truevariant 비율을 0.0~1.0 으로 반환하며, 해당 주 done = 0 일 때null을 반환한다. - AC-14: 모든
*.dart단위 테스트가flutter test로 통과한다 (각 함수 §7 표의 "테스트 케이스" 와 1:1). - AC-15: 첫 부팅 후 앱을 강제 종료 → 재실행 시 데이터가 유지된다 (Drift 파일 영속).
- AC-16: schema 파일 하나를 임의로 추가했을 때 schemaVersion 을 v1 그대로 두면 Developer 가 즉시 "마이그레이션 누락" 컴파일 경고를 받도록 04-migrations.md 의 가이드대로 lint 설정이 켜져 있다 (또는 README 에 절차 명시).
4. 컨텍스트 & 제약
의존성
- Flutter SDK ≥ 3.22 (Dart 3.4+).
- Drift (sqlite ORM) —
drift,drift_dev,sqlite3_flutter_libs,path_provider,path. - build_runner — Drift
.g.dart코드 생성. - ulid — Dart 패키지 (typed ULID 생성). 없으면
uuid+ base32 변환으로 자체 구현. - flutter_riverpod (또는
provider) — 상태 관리. 본 Phase 는 Riverpod 으로 결정 (테스트 가능성). - freezed +
json_serializable— 시드 JSON ↔ Dart 모델 변환. - NO 네트워크 의존 — 모든 SoT 는
assets/seed/*.json으로 번들.
제약
- R8 (≤ 60 초 체크인): UI 두 탭 이상 금지. 추천 매칭 알고리즘은 클라이언트 메모리에서 < 50ms 안에 결과를 내야 한다 (habit 당 variant ≤ 약 10 개 가정 — R9 가 제한 없음이지만 실용 상한).
- 데이터 손실 금지: schema 변경 시 destructive migration (drop & recreate) 는 production user 가 0 명인 본 Phase 에서만 허용. Phase 2 부터는 metadata 보존 migration.
- 로컬 only: 모든 비밀 (없음) 은
.env가 아니라 디바이스 secure storage. 본 Phase 는 비밀 없음. - 단일 사용자 우선: 모든 쿼리에서
user_id = 'u_local_default'가 default. 다만 user_id 컬럼은 모든 user-data 테이블에 살아있어야 한다 (멀티 호환). - i18n: ko-KR 만. 다국어는 v2.
- 시간대:
Asia/Seoul고정 default.user.timezone컬럼은 살리되 본 Phase 는 무시.
가정
- 사용자는 본인 한 명 (joungmin). 따라서 race condition / 락 우려 없음.
- 시드 SoT 의 한국어 텍스트 인코딩은 UTF-8.
- iOS/Android 모두 sqlite 3.39+ (Flutter sqlite3_flutter_libs 가 번들). JSON1 extension 미사용 (정규화 결정으로 회피 — §11 참조).
- 첫 릴리스 전이므로 schemaVersion = 1 부터 시작, downgrade 처리 없음.
5. 아키텍처 개요
디렉토리 구조 (요약, 상세는 01-project-structure.md)
life-helper/
├── app/ # Flutter 프로젝트 root
│ ├── pubspec.yaml
│ ├── lib/
│ │ ├── main.dart
│ │ ├── core/ # 공통 유틸 (id, datetime, result)
│ │ ├── data/
│ │ │ ├── db/ # Drift database + tables
│ │ │ │ ├── app_database.dart
│ │ │ │ ├── tables/
│ │ │ │ │ ├── catalog/ # 7 개 catalog 테이블
│ │ │ │ │ └── user/ # 11 개 user-data 테이블
│ │ │ │ ├── daos/
│ │ │ │ └── migrations/
│ │ │ └── seed/ # JSON import 코드
│ │ ├── domain/ # 순수 도메인 로직 (Drift 미참조)
│ │ │ ├── rules/ # R1~R10 검증기
│ │ │ ├── recommend/ # fn-recommend-variant
│ │ │ ├── streak/ # fn-compute-streak
│ │ │ └── frame/ # fn-validate-frame-level
│ │ └── features/ # feature-first UI
│ │ ├── habit_create/
│ │ ├── daily_checkin/
│ │ └── tracker_view/
│ ├── assets/
│ │ └── seed/ # 카탈로그 JSON (빌드 시 번들)
│ └── test/
└── (기존 docs/, schema/, ... 그대로)
데이터 흐름
[App start]
│
▼
[main.dart] ──► [AppDatabase 초기화]
│
├─ 첫 실행 감지 (meta_kv 테이블의 seeded_v1 flag)
│
├─ if 첫 실행:
│ ├─ 18 테이블 CREATE (Drift schemaVersion=1)
│ ├─ assets/seed/*.json 로드
│ ├─ 카탈로그 7 테이블에 batch insert
│ ├─ user('u_local_default') 1 행 insert
│ └─ meta_kv['seeded_v1'] = true
│
└─ else: noop
│
▼
[features/habit_create] ──► [domain/rules/active_quota] (R1/R2 검사)
│ │
│ ▼
│ [domain/frame/validate_frame_level] (R3 + R7)
│ │
│ ▼
│ [data/db/daos/HabitDao.insertWithVariants]
│ │
│ ▼ (insert 트랜잭션)
│ habit + habit_dose_variants 동시 commit
▼
[features/daily_checkin] ──► location + condition 1 탭씩
│
▼
[domain/recommend/recommend_variant] (장소·컨디션 → variant + score)
│
▼
사용자 override 여부 (1 탭)
│
▼
[TrackerDao.insert] (habit_id + date + value=done + variant_id + context_snapshot)
│
▼
[domain/streak/compute_streak] 재계산
│
▼
UI 갱신 (오늘 ○ + 현재 tier 표시)
I/O ↔ 순수 로직 경계
- I/O 계층 (Drift, FileSystem, asset bundle):
lib/data/. 외부 부수효과를 가진 모든 코드. - 순수 도메인 로직:
lib/domain/. Drift 타입 import 금지. 입력은 plain Dart 모델 (freezed), 출력도 plain Dart. → 테스트가 sqlite 없이 가능. - DAO 는 I/O 와 도메인 사이의 변환기. DAO 안에서 Drift
Companion↔ 도메인 모델 매핑. - UI (
features/) 는 Riverpod provider 를 통해서만 데이터를 받고, 비즈니스 결정은 모두 domain 함수 호출로 위임.
6. 데이터 모델
19 개 SoT 테이블 + 정규화 부속 1 한눈에
| # | 테이블 | 분류 | SoT JSON | 행 수 (시드) | Drift 정의 위치 |
|---|---|---|---|---|---|
| 1 | protocol |
catalog | protocol.schema.json | ~34 (Huberman 28 + diet 6) | 02-catalog |
| 2 | break_protocol |
catalog | break_protocol.schema.json | 8 | 02-catalog |
| 3 | common_frame |
catalog | common_frame.schema.json | 5 | 02-catalog |
| 4 | methodology |
catalog | methodology.schema.json | 21 | 02-catalog |
| 5 | frame_pattern |
catalog | frame_pattern.schema.json | ~30 (32 변환표) | 02-catalog |
| 6 | reward_menu_item |
catalog | reward_menu_item.schema.json | ~30 (메뉴 30선) | 02-catalog |
| 7 | reference |
catalog | reference.schema.json | ~50 (출처 통합 합계) | 02-catalog |
| 8 | diet_pattern |
catalog | diet_pattern.schema.json | 5 (지중해/케토/TRE/식물성/한식) | 02-catalog |
| 9 | user |
user | user.schema.json | 1 (자동) | 03-user |
| 10 | phase |
user | phase.schema.json | 0 | 03-user |
| 11 | habit |
user | habit.schema.json | 0 | 03-user |
| 12 | if_then_rule |
user | if_then_rule.schema.json | 0 | 03-user |
| 13 | tracker_entry |
user | tracker_entry.schema.json | 0 | 03-user |
| 14 | lapse_log |
user | lapse_log.schema.json | 0 | 03-user |
| 15 | urge_log |
user | urge_log.schema.json | 0 | 03-user |
| 16 | reward_declaration |
user | reward_declaration.schema.json | 0 | 03-user |
| 17 | reward_claim |
user | reward_claim.schema.json | 0 | 03-user |
| 18 | reflection |
user | reflection.schema.json | 0 | 03-user |
| — | habit_dose_variants |
부속 (user, 정규화) | habit.schema.json dose_variants[] 분해 |
0 | 03-user (ADR-0002) |
총 19 SoT 테이블 = Catalog 8 + User 11. 추가로
dose_variants[]가habit_dose_variants로 정규화 (ADR-0002) 되며, 이 테이블은 user-data 의 부속 (habit row 의 normalized child) 으로 SoT 카운트와 별도 집계한다. 메타meta_kv(seed 완료 플래그) 는 Drift 내부 관리용 보조 테이블로 SoT 카운트에 포함하지 않는다. v1 실제 생성 테이블 합계 = Catalog 8 + User 11 + 부속 1 (habit_dose_variants) + meta_kv = 21 테이블 (04-migrations 참조).
경계 검증 규칙 (요약)
- 입력 검증은 모두 domain 계층에서 (Drift insert 진입 전).
- ULID 형식 검사는
core/id.dart::isValidUlid()—enums.schema.json#/$defs/Ulid의 정규식 적용. - 한국어 텍스트 normalize: NFC.
- timezone-aware DateTime 만 받음. Drift 저장은 ISO 8601 string + (ms epoch) 두 컬럼 병행 ❌ — string 단일.
enum ↔ Drift 매핑
enums.schema.json 의 모든 enum 은 Dart enum 으로 정의 + Drift TypeConverter 로 string ↔ enum 변환. CHECK 제약은 Drift 의 customConstraint('CHECK (value IN (...))') 로 박는다 (R3/R5 강제 — 03-user §"R 강제 매트릭스" 참조).
7. 함수 명세 (Function Specs)
단순 = 본 표만으로 충분 / 복잡 =
fn-*.md별도 작성.
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|---|---|---|---|---|---|---|
AppDatabase() |
Drift DB 진입점 (lazy open + migration) | class AppDatabase extends _$AppDatabase |
path | DB instance | path 권한 실패 → fatal | 단순 |
AppDatabase.migration |
schemaVersion 1 초기 생성 + 향후 onUpgrade dispatch | MigrationStrategy get migration |
from,to | void | migration 실패 → 예외 | 단순 |
generateUlid(prefix) |
typed ULID 1 개 생성 | String generateUlid(String prefix) |
prefix | <prefix>_<26-char> |
prefix 빈 문자열 → ArgumentError | 단순 |
isValidUlid(s) |
ULID 형식 검사 | bool isValidUlid(String s) |
string | bool | 없음 | 단순 |
SeedImporter.importIfNeeded |
첫 부팅 시 카탈로그 시드 import | Future<void> importIfNeeded() |
DB | void (idempotent) | JSON parse 실패 → 예외 + rollback | 복잡 → fn-seed-importer.md |
validateFrameLevel(input) |
R3 (L0/L1 차단) + R7 (회피 키워드 warning) | FrameValidationResult validateFrameLevel(FrameInput) |
FrameInput | result(ok/warn/err) | L0 입력 → reject | 복잡 → fn-validate-frame-level.md |
checkActiveHabitQuota(uid, type) |
R1/R2 — active build ≤ 3, break ≤ 1 | Future<QuotaResult> checkActiveHabitQuota(...) |
userId, HabitType | QuotaResult | 초과 → reject 메시지 | 복잡 → fn-active-habit-quota.md |
HabitDao.insertWithVariants |
habit + dose_variant 트랜잭션 insert | Future<String> insertWithVariants(HabitDraft) |
HabitDraft | habitId | 트랜잭션 실패 → rollback | 단순 |
recommendVariant(habit, ctx) |
location/condition 매칭 점수 → variant 1 개 | VariantPick recommendVariant(Habit, CheckInContext) |
habit, ctx | VariantPick | variants 없음 → null | 복잡 → fn-recommend-variant.md |
TrackerDao.recordCheckIn |
tracker_entry insert + UNIQUE 위반 처리 | Future<TrackerEntry> recordCheckIn(...) |
habitId, date, value, variantId, ctx | entry | (habit_id,date) 중복 → 사용자 메시지 | 단순 |
computeStreak(habitId, asOf) |
T0~T4 milestone 진입 판정 + Never miss twice | Future<StreakState> computeStreak(...) |
habitId, asOfDate | StreakState | 데이터 0 → empty | 복잡 → fn-compute-streak.md |
weeklyMinimumRatio(userId, weekStart) |
주간 done 중 is_minimum=true 비율 | Future<double?> weeklyMinimumRatio(...) |
userId, weekStart | 0..1 or null | done=0 → null | 복잡 → fn-weekly-minimum-ratio.md |
validateRewardDeclarationWindow(phase, now) |
R4 — phase 시작 +7 일 이내만 허용 | bool validateRewardDeclarationWindow(Phase, DateTime) |
phase, now | bool | 초과 시 false + 메시지 | 단순 |
validateTrackerValue(s) |
R5 — done/blank 외 거부 | bool validateTrackerValue(String) |
string | bool | 없음 | 단순 |
detectAvoidanceKeywords(text) |
R7 — 회피 키워드 list 반환 | List<String> detectAvoidanceKeywords(String) |
string | list | 없음 | 단순 |
phaseAnchorChangeWarning(phase, now) |
R6 — 6 주 사이클 중간 anchor 변경 시 warning | bool phaseAnchorChangeWarning(Phase, DateTime) |
phase, now | bool | 없음 | 단순 |
CheckInTimer.elapsed |
R8 — 체크인 화면 시작~완료 시간 측정 | Duration get elapsed |
timestamp | Duration | 없음 | 단순 |
assertXorProtocol(habit) |
habit type=build → protocol_id, type=break → break_protocol_id (XOR) | void assertXorProtocol(Habit) |
habit | void | XOR 깨짐 → 예외 | 단순 |
toggleFrameLevelL2L3(current) |
UI 헬퍼: L2 ↔ L3 변환 제안 텍스트 반환 | String toggleFrameLevelL2L3(FrameLevel) |
level | text | 없음 | 단순 |
복잡 함수 6 개:
SeedImporter.importIfNeeded,validateFrameLevel,checkActiveHabitQuota,recommendVariant,computeStreak,weeklyMinimumRatio. 각각fn-*.md있음.
8. 흐름 / 알고리즘
시나리오 A: 첫 부팅 + 시드 import
main.dart→AppDatabase인스턴스화 (lazy open).- Drift
onCreate콜백에서 18 테이블 +meta_kv생성. SeedImporter.importIfNeeded호출 —meta_kv['seeded_v1']확인.- flag 가 false 면
assets/seed/*.json7 파일을 순서대로 읽어 batch insert.- 순서:
reference→protocol→break_protocol→common_frame→methodology→frame_pattern→reward_menu_item(참조 무결성 순).
- 순서:
user('u_local_default')1 행 insert.meta_kv['seeded_v1'] = true기록.- 트랜잭션 commit. 실패 시 전체 rollback → 다음 부팅에 재시도.
시나리오 B: build habit 생성 (vertical slice)
- UI: 사용자가 protocol 1 개 선택 → 제목, anchor.when, frame.framed_text 입력.
- domain:
validateFrameLevel({level:L2, original_text, framed_text})호출.- L0/L1 → reject + 변환 제안 표시.
- L2/L3 + 회피 키워드 감지 시 warning + 계속 진행 가능.
- domain:
checkActiveHabitQuota('u_local_default', 'build')호출. active build ≥ 3 면 reject. - UI: dose_variants 입력 (최소 1 개, 강제 X. 본 vertical slice 는 기본 variant 1 개 자동 생성: label='기본', dose_text=protocol.min_dose_for_start, is_minimum=true).
HabitDao.insertWithVariants트랜잭션:habitinsert.habit_dose_variants다중 insert.if_then_rule다중 insert (있다면).
- commit → UI 가 habit list 갱신.
시나리오 C: 일일 체크인 (R8 ≤ 60 초 보장)
- UI: 오늘 habit 카드 탭 → 체크인 화면 진입.
CheckInTimer.start(). - UI: 장소 chip 1 탭 (예: '집') —
context.location채워짐. - UI: 컨디션 chip 1 탭 (예: '보통') —
context.condition채워짐. - domain:
recommendVariant(habit, ctx)호출 → variant 1 개 + score. - UI: 추천 variant 카드 표시 + (선택) "다른 옵션" 펼치기 → override.
- 사용자가 "○ 완료" 탭 →
TrackerDao.recordCheckIn호출. - domain:
computeStreak(habitId, today)호출 → 현재 tier 갱신. - UI: 오늘 셀이 ○ 로 채워짐. T1~T4 진입했다면 축하 모달 (5-tier Reward Ladder).
CheckInTimer.elapsed가 60 초 초과 시 dev log 경고 (production 에선 silent).
시나리오 D: 주간 reflection
- 일요일 22:00 (수동 진입) UI 에서 "이번 주 회고" 진입.
- domain:
weeklyMinimumRatio('u_local_default', weekStart)호출. - UI: kept / missed / adjust 3 필드 입력. minimum_ratio 가 결과로 hint 표시 (강제 X — R10).
ReflectionDao.insert.
9. 엣지케이스 & 에러 처리
| 상황 | 처리 | 비고 |
|---|---|---|
| 시드 JSON parse 실패 | rollback + 다음 부팅 재시도 + 사용자에게 "시드 로딩 실패, 앱 재시작 권장" 메시지 | seeded_v1 flag 미설정 유지 |
| Drift open 권한 실패 (Android scoped storage) | fatal — 앱 시작 차단 + 사용자 안내 | rare |
| 같은 날짜 중복 체크인 | UNIQUE INDEX 위반 → "오늘은 이미 체크함" 토스트, 기존 row 표시 | tracker_entry.UNIQUE(habit_id,date) |
| habit 삭제 시 자식 row | hard delete 금지 (R6 시기 변경 warning 정신). 본 Phase 는 status='abandoned' 로 soft delete | cascade X |
| ULID 충돌 | 26 자리 randomness 로 사실상 불가능. 단 unit test 에서 mock ULID 충돌 케이스 1 개 검증 | |
| 시간대 변경 (해외 출장) | 본 Phase 는 Asia/Seoul 고정. 날짜 경계는 사용자 local timezone 기준 — DateTime.now().toLocal() |
v2 에서 timezone 컬럼 활용 |
| variant 0 개 habit 에 체크인 | 추천 결과 null → UI 가 "도즈 입력 후 체크" 안내. tracker_entry.variant_id = null 허용 | recommendVariant 가 null 반환 |
| phase 없이 habit 만 운영 | habit.phase_id = null 허용. computeStreak 는 phase 무관 동작 | data-model.md §6 단일 사용자 예시와 동일 |
Drift onUpgrade 호출 (Phase 2 진입) |
Phase 1 종료 후 04-migrations.md 의 v1→v2 패턴 적용. 본 Phase 는 onUpgrade 진입 시 assert false (도달 불가 보장) | |
| 회피 키워드 false positive (예: '안전한 식단') | warning 만 띄우고 사용자가 '계속' 선택 가능 (R7 = soft) |
안전한 기본값
- 시드 import 실패 시 → 카탈로그 빈 상태로 진행 (사용자가 catalog 선택 못 함). hard fail 아님.
- recommendVariant 결과 score 동률 → variant 의 정의 순서 (변경 안정성) 로 첫번째 선택.
- Streak 데이터 누락 (DB 손상) → tier='T0', streak=0 으로 표시. 자동 복구 X.
10. 테스트 계획
단위 테스트 (각 AC 1:1)
| AC | 테스트 | 위치 |
|---|---|---|
| AC-1 | flutter pub get 통과 + dart run build_runner build 통과 (CI smoke) |
scripts/ci |
| AC-2 | seed_importer_test.dart — 첫 부팅 후 카탈로그 행 수 ≥ 시드 카운트 |
test/data/seed |
| AC-3 | user_default_test.dart — u_local_default 존재 |
test/data/db |
| AC-4 | habit_create_test.dart — habit + variant insert 성공 |
test/features/habit_create |
| AC-5 | daily_checkin_test.dart — tracker_entry 행 + variant_id + ctx 채워짐 |
test/features/daily_checkin |
| AC-6 | tracker_unique_test.dart — 중복 insert 거부 |
test/data/db |
| AC-7 | active_habit_quota_test.dart — 4 번째 build 거부 |
test/domain/rules |
| AC-8 | validate_frame_level_test.dart — L0 reject + 변환 제안 |
test/domain/frame |
| AC-9 | tracker_value_check_test.dart — invalid value 거부 |
test/data/db |
| AC-10 | reward_window_test.dart — D+8 reject |
test/domain/rules |
| AC-11 | recommend_variant_test.dart — context 매칭 + fallback |
test/domain/recommend |
| AC-12 | compute_streak_test.dart — T1~T4 + Never miss twice |
test/domain/streak |
| AC-13 | weekly_min_ratio_test.dart — null + 0..1 케이스 |
test/domain/streak |
| AC-14 | flutter test 전체 green |
CI |
| AC-15 | 통합: 앱 재시작 후 db 영속 (golden test 또는 manual QA) | test/integration |
| AC-16 | schema 변경 lint — 04-migrations.md 가이드 준수 (manual) | docs check |
모킹 / 드라이런
- Drift in-memory mode (
NativeDatabase.memory()) — 모든 domain 테스트는 in-memory DB 로 동작. - Asset bundle mocking —
assets/seed/대신 test fixture (test/fixtures/seed/). - DateTime 주입 — domain 함수는
DateTime now를 파라미터로 받아 테스트 가능.
11. 리스크 & 대안 검토
핵심 결정: dose_variants[] 저장 형태
| 옵션 | 장점 | 단점 | 채택 |
|---|---|---|---|
A. JSON 단일 컬럼 (habit.dose_variants_json TEXT) |
코드 적음, habit.schema.json 과 1:1 매핑. ULID 같은 inner id 그대로. |
(1) tracker_entry.variant_id 참조 무결성 SQL 강제 불가. (2) variant 별 집계 (recommendVariant, weeklyMinimumRatio) 가 JSON1 extension 필요. iOS/Android 빌드 sqlite 의 JSON1 보장 흔들림. (3) 부분 업데이트 (variant 1 개 수정) 가 read-modify-write. |
✗ |
B. 별도 habit_dose_variants 테이블 (정규화, ADR-0002) |
(1) FK + INDEX 로 참조 무결성 + 빠른 조회. (2) recommendVariant 가 standard SQL 로 가능. (3) variant CRUD 가 row 단위. (4) Drift type-safe codegen 그대로 활용. | (1) schema/*.json 의 nested 구조와 1:1 안 됨 → 변환 어댑터 필요. (2) 부속 테이블 1 개 추가. | ✓ 채택 (ADR-0002) |
결정 근거: B 의 장점이 R8 (≤ 60 초) 와 직결되는 recommendVariant 의 쿼리 단순성 + AC-5/AC-11 의 테스트 가능성을 모두 달성한다. JSON 옵션의 "schema 1:1" 장점은 core/seed/adapters/ 의 가벼운 어댑터로 회수 가능. data-model.md 와 habit.schema.json 은 nested 표현을 유지하되, Drift 계층에서 정규화 한다. ADR-0001 의 결정 (R9 무제한 / R10 hint) 은 그대로 보존.
테이블 수 count: 19 SoT 테이블 (Catalog 8 + User 11) 을 외부 표기 기준으로 한다.
habit_dose_variants는habit의 정규화 부속으로 별도 집계 (총 v1 = 21 테이블 — 04-migrations 참조). AC-2 검증 시habit_dose_variants도 함께 생성됨을 확인한다.
그 외 검토 대안
- Drift vs sqflite + SQL 직접 작성 — Drift 채택. 이유: type-safe + migration 도구 + DAO 패턴. sqflite 는 boilerplate 큼.
- Riverpod vs Provider vs Bloc — Riverpod 채택. 이유: 테스트 가능성 (provider override), 컴파일 타임 의존성 검증.
- 시드 import: build 시 generated Dart 코드 vs 첫 부팅 시 JSON read — 첫 부팅 채택. 이유: SoT JSON 이 곧 검증 가능한 단일 진실. generated dart 는 변경마다 codegen 강제 → 마찰. 자세히는 05-seed-data.md.
- feature-first vs layer-first — feature-first 채택 (
features/), 단 공용 도메인은 layer-first (domain/,data/). 자세히는 01-project-structure.md. - enum 강제: CHECK 제약 vs app layer 만 — 둘 다. CHECK 는 마지막 방어선, app layer 가 1 차. R3/R5 는 CHECK 까지 박는다.
되돌리기 어려운 결정 → ADR 후보
habit_dose_variants정규화: ADR-0002 로 승격 완료 (2026-06-11). 본 설계서는 ADR-0002 결정을 참조.- Riverpod 채택: 코드 분량 큼. ADR-0003 후보 (Phase 2 진입 전 검토).
12. Resolved Open Questions (2026-06-11)
Developer 단계 진입 직전 사용자 (joungmin) 결정으로 모두 해결됨. 본 섹션은 결정 결과의 기록.
| OQ | 질문 | 결정 | 반영 위치 |
|---|---|---|---|
| OQ-1 | habit_dose_variants 정규화를 ADR 로 분리? |
분리 — ADR-0002 로 승격. | docs/adr/0002-dose-variants-normalized.md 신규, ADR-0001 cross-link 추가 |
| OQ-2 | 시드 JSON 자동 추출 vs 손 작성? | 손 작성 (Phase 1). Phase 2 에서 scripts/extract_seed.py 재검토. |
05-seed-data.md §7 유지 |
| OQ-3 | diet 패턴 5 개를 protocol(category='diet') 합치기 vs 별도 카탈로그? |
별도 diet_pattern 카탈로그 (19 번째 SoT). |
schema/diet_pattern.schema.json 신규, schema/_index.json 등재, 02-drift-schema-catalog.md §8 추가, 05-seed-data.md 8 번째 시드 파일 추가, 04-migrations.md v1 테이블 목록 갱신 |
| OQ-4 | frame_pattern 한국어 only? |
한국어 only OK — i18n 은 v2. | 02-drift-schema-catalog.md §5 유지 |
| OQ-5 | "Never miss twice" 정의? | 연속 2 일 blank → tier 강등 + streak 0. 1 일 blank → streak 0 유지하되 tier 유지. | fn-compute-streak.md 본문 + AC-12 (R10 외 streak 동작) |
| OQ-6 | tracker_entry.variant_id nullable? |
nullable — variant 없는 habit 도 허용. | 03-drift-schema-user.md tracker_entries 정의 유지 |
| OQ-7 | vertical slice UI 화면 갯수? | 4 개 — habit 생성 / 목록 / 체크인 / 스트릭. | 06-ux-contracts.md |
| OQ-8 | 앱 ID / 패키지명? | kr.cloud_handson.life_helper (iOS bundle id 동일). |
01-project-structure.md |
모든 OQ 가 해결됐으므로 본 설계서는 Developer 단계 진입 준비 완료 상태. 추가 OQ 발생 시 본 표에 row 추가 + 해결 결과 명시.
부록: 자가 점검 (Architect 가 작업 종료 시 검증)
_TEMPLATE.md12 개 섹션 모두 비어있지 않음- 19 SoT 테이블 (catalog 8 + user 11) + 부속 1 (habit_dose_variants) 전부 §6 표 + 02/03-drift-schema-*.md 의 Drift 컬럼/타입/제약 정의 완료
- R1~R10 강제 위치 매트릭스 = 03-drift-schema-user.md §"R 강제 매트릭스"
- dose_variants 결정 = ADR-0001 (왜) + ADR-0002 (어떻게: 정규화)
- diet_pattern = 19 번째 catalog SoT (OQ-3 결정) — schema/diet_pattern.schema.json + 02-catalog §8 + 05-seed §3 + 04-migrations v1 반영
- 복잡 함수 6 개 각각 fn-*.md 존재 (recommend-variant, compute-streak, weekly-minimum-ratio, validate-frame-level, active-habit-quota, seed-importer)
- §7 함수 명세 표에 모든 함수 등재 (단순 13 + 복잡 6). diet_pattern 은 read-only 카탈로그라 신규 함수 추가 없음 (
fn-seed-importer가 처리) - AC §3 — QA 판정 가능한 16 항목 (AC-1 ~ AC-16). AC-2 에 diet_pattern = 5 시드 검증 추가
- §12 모든 OQ (OQ-1 ~ OQ-8) Resolved 표로 정리