Files
life-helper/docs/design/204-flutter-bootstrap/README.md
joungmin 29befe4d97 [Architect] Refs #204 — apply OQ decisions: diet_pattern (19th), ADR-0002 normalize dose_variants
- 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>
2026-06-11 17:13:04 +09:00

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) 하위 문서:


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/*.json 19 개 SoT 와 1:1 매핑 + 정규화 부속 habit_dose_variants 1 개.
  • 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: habit 1 개 생성 폼 + 일일 체크인 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_entry 1 행이 생성되고 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.valuedone/blank 이외 값을 넣으려는 시도는 거부된다 (R5, CHECK 제약).
  • AC-10: phase 시작 후 8 일째에 reward_declaration insert 를 시도하면 app layer 가 거부한다 (R4).
  • AC-11: fn-recommend-variantcontext_tags/condition_tags 매칭 점수에 따라 variant 1 개를 반환하고, 매칭 0 점일 때 is_minimum=true variant 를 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=true variant 비율을 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

  1. main.dartAppDatabase 인스턴스화 (lazy open).
  2. Drift onCreate 콜백에서 18 테이블 + meta_kv 생성.
  3. SeedImporter.importIfNeeded 호출 — meta_kv['seeded_v1'] 확인.
  4. flag 가 false 면 assets/seed/*.json 7 파일을 순서대로 읽어 batch insert.
    • 순서: referenceprotocolbreak_protocolcommon_framemethodologyframe_patternreward_menu_item (참조 무결성 순).
  5. user('u_local_default') 1 행 insert.
  6. meta_kv['seeded_v1'] = true 기록.
  7. 트랜잭션 commit. 실패 시 전체 rollback → 다음 부팅에 재시도.

시나리오 B: build habit 생성 (vertical slice)

  1. UI: 사용자가 protocol 1 개 선택 → 제목, anchor.when, frame.framed_text 입력.
  2. domain: validateFrameLevel({level:L2, original_text, framed_text}) 호출.
    • L0/L1 → reject + 변환 제안 표시.
    • L2/L3 + 회피 키워드 감지 시 warning + 계속 진행 가능.
  3. domain: checkActiveHabitQuota('u_local_default', 'build') 호출. active build ≥ 3 면 reject.
  4. UI: dose_variants 입력 (최소 1 개, 강제 X. 본 vertical slice 는 기본 variant 1 개 자동 생성: label='기본', dose_text=protocol.min_dose_for_start, is_minimum=true).
  5. HabitDao.insertWithVariants 트랜잭션:
    • habit insert.
    • habit_dose_variants 다중 insert.
    • if_then_rule 다중 insert (있다면).
  6. commit → UI 가 habit list 갱신.

시나리오 C: 일일 체크인 (R8 ≤ 60 초 보장)

  1. UI: 오늘 habit 카드 탭 → 체크인 화면 진입. CheckInTimer.start().
  2. UI: 장소 chip 1 탭 (예: '집') — context.location 채워짐.
  3. UI: 컨디션 chip 1 탭 (예: '보통') — context.condition 채워짐.
  4. domain: recommendVariant(habit, ctx) 호출 → variant 1 개 + score.
  5. UI: 추천 variant 카드 표시 + (선택) "다른 옵션" 펼치기 → override.
  6. 사용자가 "○ 완료" 탭 → TrackerDao.recordCheckIn 호출.
  7. domain: computeStreak(habitId, today) 호출 → 현재 tier 갱신.
  8. UI: 오늘 셀이 ○ 로 채워짐. T1~T4 진입했다면 축하 모달 (5-tier Reward Ladder).
  9. CheckInTimer.elapsed 가 60 초 초과 시 dev log 경고 (production 에선 silent).

시나리오 D: 주간 reflection

  1. 일요일 22:00 (수동 진입) UI 에서 "이번 주 회고" 진입.
  2. domain: weeklyMinimumRatio('u_local_default', weekStart) 호출.
  3. UI: kept / missed / adjust 3 필드 입력. minimum_ratio 가 결과로 hint 표시 (강제 X — R10).
  4. 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.dartu_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_variantshabit 의 정규화 부속으로 별도 집계 (총 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.md 12 개 섹션 모두 비어있지 않음
  • 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 표로 정리