# ADR-0002: Dose Variants 정규화 — 별도 `habit_dose_variants` 테이블 > **상태**: Accepted > **날짜**: 2026-06-11 · **결정자**: [AI] Architect (사용자 OQ-1 승격 확정) · **관련 이슈**: #204 > **관련 ADR**: [ADR-0001 — dose-variants (왜)](./0001-dose-variants.md) ## 맥락 (Context) [ADR-0001](./0001-dose-variants.md) 에서 `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 JSON condition_tags_json TEXT, -- List JSON is_minimum BOOLEAN NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0 -- 사용자 정의 순서 보존 ) ``` `tracker_entry.variant_id` 는 `habit_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 (왜)](./0001-dose-variants.md) 와 한 쌍. - 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 진입점 + 인덱스).