Files
life-helper/docs/adr/0002-dose-variants-normalized.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

5.8 KiB

ADR-0002: Dose Variants 정규화 — 별도 habit_dose_variants 테이블

상태: Accepted 날짜: 2026-06-11 · 결정자: [AI] Architect (사용자 OQ-1 승격 확정) · 관련 이슈: #204 관련 ADR: ADR-0001 — dose-variants (왜)

맥락 (Context)

ADR-0001 에서 habit.dose_variants[]존재 자체 (왜 필요한가) 는 결정됐다. 그러나 어떻게 저장할 것인가 — 즉 Drift/SQLite 스키마 상의 물리 형태 — 는 결정되지 않았다. 두 갈래가 있었다:

  1. A. JSON 단일 컬럼: habit.dose_variants_json TEXT 한 컬럼에 배열 통째 직렬화. habit.schema.json 의 nested 구조와 1:1 매핑.
  2. B. 별도 정규화 테이블: habit_dose_variants(id, habit_id, label, dose_text, context_tags_json, condition_tags_json, is_minimum) 로 row 단위 분해.

본 결정은 Phase 1 (#204) 의 Drift 스키마 구현 직전 시점에서 필요하다. tracker_entry.variant_id어디에 FK 를 박는가recommendVariant, weeklyMinimumRatio 함수의 쿼리 형태가 본 결정에 종속된다.

설계서 docs/design/204-flutter-bootstrap/README.md §11 에서 옵션 B 를 채택한 바 있으며, 사용자 OQ-1 결정으로 이 결정을 ADR 한 장으로 승격 한다.

결정 (Decision)

dose_variants[]별도 habit_dose_variants 테이블 로 정규화한다.

스키마 (Drift):

habit_dose_variants(
  id              TEXT PRIMARY KEY,           -- ULID, dv_<26-char>
  habit_id        TEXT NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
  label           TEXT NOT NULL,
  dose_text       TEXT NOT NULL,
  context_tags_json   TEXT,                   -- List<String> JSON
  condition_tags_json TEXT,                   -- List<String> JSON
  is_minimum      BOOLEAN NOT NULL DEFAULT 0,
  sort_order      INTEGER NOT NULL DEFAULT 0  -- 사용자 정의 순서 보존
)

tracker_entry.variant_idhabit_dose_variants.id 를 참조하는 nullable FK. 삭제 시 ON DELETE SET NULL (이미 기록된 체크인의 컨텍스트 유실 방지).

habit.schema.json (SoT) 의 nested dose_variants[] 구조는 그대로 유지. Seed/Adapter 계층 (lib/data/seed/adapters/) 에서 nested ↔ 정규화 변환 수행.

근거 (Rationale)

  • 참조 무결성: tracker_entry.variant_id FK 를 SQL 레벨에서 강제 가능. JSON 옵션은 inner id 가 JSON 내부에 있어 FK 불가, app layer 가 무결성 책임.
  • SQL 단순성 (recommendVariant): variant 별 매칭 점수 계산이 표준 SELECT (WHERE habit_id = ? AND ...) + INDEX 로 가능. JSON 옵션은 json_extract() /json_each() 필요 → SQLite JSON1 extension 의존.
  • JSON1 extension 의존성 회피: iOS/Android sqlite3_flutter_libs 가 JSON1 을 번들하긴 하지만, 미래 sqlcipher 등으로 교체 시 보장 흔들림. 정규화는 ANSI SQL 만 사용.
  • 인덱싱 가능성: IDX_habit_dose_variants_habit_min(habit_id, is_minimum) 로 fallback variant 조회 O(log n). JSON 옵션은 partial index 작성 복잡.
  • 부분 업데이트: variant 1 개 수정 = UPDATE 1 row. JSON 옵션은 read-modify-write 트랜잭션 필요.
  • Drift type-safe codegen: Drift 의 Companion / TypeConverter 가 row 단위 로 깨끗하게 적용됨. JSON 컬럼은 freezed 어댑터 + 수동 직렬화.
  • R8 (≤ 60 초 체크인) 보장: recommendVariant 가 < 50ms 안에 결과 내려면 인덱스 사용 가능한 row 기반 조회가 필수.

결과 (Consequences)

  • 긍정:
    • tracker_entry.variant_id 무결성 SQL 강제.
    • recommendVariant / weeklyMinimumRatio 가 표준 SQL → 테스트 용이.
    • Drift schema dump 에 모든 컬럼이 가시화 → 회귀 방지 (tool/schema/v1.json).
    • Phase 2 에서 multi-device sync 도입 시 row 단위 conflict resolution 가능.
  • 부정 / 비용:
    • 테이블 1 개 추가 — Phase 1 v1 의 정규화 부속 테이블 1 개 증가 (Catalog 8 + User 11 + 부속 1 + meta_kv = 21 테이블).
    • habit.schema.json (nested) 과 Drift (flat) 사이 어댑터 코드 필요 (lib/data/seed/adapters/habit_adapter.dart).
    • HabitDao.insertWithVariants 는 트랜잭션 (habit + 다중 variant) 으로 처리.
  • 후속 작업:
    • Phase 1 (#204) 구현 단계에서 Developer 가 Drift 테이블 작성 + 어댑터 작성.
    • Phase 1 통과 시 tool/schema/v1.json dump 에 habit_dose_variants 포함 확인.
    • fn-recommend-variant.md / fn-weekly-minimum-ratio.md 가 본 정규화 가정 위에서 작성되어 있는지 cross-check.

검토한 대안 (Alternatives Considered)

  • A. JSON 단일 컬럼 (habit.dose_variants_json TEXT)

    • 기각 사유: (1) tracker_entry.variant_id 참조 무결성 SQL 강제 불가. (2) recommendVariant 가 JSON1 extension 의존 → 플랫폼/엔진 교체 위험. (3) variant 별 인덱스 작성 어렵고, 부분 업데이트 = read-modify-write. (4) Drift codegen 의 type-safe 이점 상실 (수동 JSON 직렬화).
  • C. Hybrid (habit 에 JSON + 별도 캐시 테이블)

    • 기각 사유: 두 표현의 동기화 책임 발생. 단일 SoT 원칙 위반. 복잡도만 증가.
  • D. EAV (entity-attribute-value)

    • 기각 사유: variant 의 attribute 가 고정 (label, dose_text, tags, is_minimum) 이라 EAV 의 유연성 불필요. 쿼리 복잡도만 증가.

추적성

  • 본 ADR 은 ADR-0001 — dose-variants (왜) 와 한 쌍.
    • ADR-0001 = "왜 variants 가 필요한가" (행동심리·R9·R10 도입).
    • ADR-0002 = "어떻게 저장하는가" (정규화 vs JSON).
  • 설계서: docs/design/204-flutter-bootstrap/README.md §11 (대안 표) + 03-drift-schema-user.md (실제 Drift 테이블 정의) + 04-migrations.md (v1 진입점 + 인덱스).