---
title: "🗄️ 260622 — 웨어러블 데이터 정규화 흡수층 스키마 설계 (v0.2, Codex 1차 반영)"
category: "design"
parent: "Claude Code 작업보고"
document_type: "설계"
source_status: "generated"
knowledge_group: "02_specs"
priority: "High"
purpose: "외부 웨어러블(헬스커넥트·가민 등) 데이터를 기존 기록 테이블에 흡수하기 위한 Supabase 스키마 설계. 출처·중복방지·충돌안전 메타 + 클라우드 연결/토큰 보안. Codex 1차 적대검수 반영."
read_when: ["웨어러블 어댑터","Supabase 스키마·마이그레이션","헬스커넥트·가민 연동","토큰 보안 RLS"]
updated: "2026-06-22"
work_timestamp: "20260622_164251"
context: "달록본레포CC (D:\\dallog\\dallog_git) — 달록 앱화·웨어러블 설계 묶음."
source_of_truth: "https://dallog-tools.hansbridge.co.kr/knowledge/"
---
> 2026-06-22 설계. 앱화 전체 그림은 짝 문서 `260622_설계_앱화-커리큘럼-아키텍처` 참조. 본 문서는 데이터 흡수 스키마 상세. **상태: 설계 확정후보(미적용)** — 실제 마이그레이션 SQL 작성 시 2차 재검수 후 apply.

## 설계 원칙

1. **기존 테이블이 곧 canonical** — running_logs·body_records·strength_logs가 표준. 어댑터가 이 형태로 변환해 넣음. 미러 테이블 없음(단순성).
2. **본체 무수정** — 출처/중복방지/충돌안전 메타 컬럼만 추가. nullable·default라 기존 코드 무영향.
3. **보안 우선** — OAuth 토큰은 클라이언트 절대 비노출(전용 테이블+FORCE RLS+REVOKE, Vault 권장).
4. **멱등성** — DDL은 IF NOT EXISTS. 재유입 기록은 부분 유니크로 차단.

## 라이브 DB 현황 (2026-06-22 확인)

- running_logs/body_records/strength_logs **모두 출처·외부ID·동기화 컬럼 없음**(순수 수동). user_id uuid NOT NULL DEFAULT auth.uid(), RLS 본인만.
- recorded_at = **date 타입**(정밀 timestamp 아님). running은 run_time_hour/minute/period로 시각 별도 보관.
- body_records = **(user_id, recorded_at) 1일1회 유니크**.
- exercise_configs엔 이미 source 컬럼 존재.

## A. data_sources lookup (CHECK 대신 FK)

```sql
CREATE TABLE IF NOT EXISTS public.data_sources (
  code text PRIMARY KEY, display_name text NOT NULL, enabled boolean NOT NULL DEFAULT true
);
INSERT INTO public.data_sources (code, display_name) VALUES
  ('manual','수동입력'),('healthconnect','Health Connect'),('garmin','Garmin'),
  ('strava','Strava'),('samsung_health','삼성헬스'),('apple_health','Apple Health')
ON CONFLICT (code) DO NOTHING;
```

## B. 기록 테이블 공통 메타 (running/body/strength)

```sql
ALTER TABLE public.running_logs
  ADD COLUMN IF NOT EXISTS data_source   text NOT NULL DEFAULT 'manual',
  ADD COLUMN IF NOT EXISTS external_id   text,
  ADD COLUMN IF NOT EXISTS origin_app    text,
  ADD COLUMN IF NOT EXISTS started_at    timestamptz,
  ADD COLUMN IF NOT EXISTS synced_at     timestamptz,
  ADD COLUMN IF NOT EXISTS user_modified boolean NOT NULL DEFAULT false,
  ADD COLUMN IF NOT EXISTS raw_payload   jsonb;
ALTER TABLE public.running_logs
  ADD CONSTRAINT running_logs_data_source_fk FOREIGN KEY (data_source) REFERENCES public.data_sources(code),
  ADD CONSTRAINT running_logs_external_id_len_chk CHECK (external_id IS NULL OR length(external_id) <= 512);
-- body_records: started_at→measured_at, 동일 메타+FK. strength_logs: 동일 메타+FK(저충실도, 우선순위 낮음).
```

### 중복방지 부분 유니크 (btrim만, lower 제외)
```sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_running_external
  ON public.running_logs (user_id, data_source, btrim(external_id)) WHERE external_id IS NOT NULL;
```
> lower() 미적용 이유: 가민 summaryId·스트라바 activityId 등 대소문자 구분 토큰이 lower()로 충돌할 위험. 공백 정규화만.

## C. 흡수 upsert 규칙 (부분인덱스 ON CONFLICT는 predicate 필수 → Edge/RPC)

supabase-js `.upsert({onConflict})`는 부분인덱스 predicate 표현 불가 → **웨어러블 흡수는 Edge Function/RPC에서 SQL 직접**.

```sql
WITH upserted AS (
  INSERT INTO public.running_logs (...) VALUES (...)
  ON CONFLICT (user_id, data_source, btrim(external_id)) WHERE external_id IS NOT NULL
  DO UPDATE SET synced_at = now(), raw_payload = EXCLUDED.raw_payload, ...
    WHERE NOT public.running_logs.user_modified
  RETURNING id
)
SELECT count(*) AS affected FROM upserted;
```
- affected=0 = user_modified로 skip된 경우 → "성공" 오인 금지, skip 로그+provider cursor 전진정책 별도 확정.
- body 충돌: `ON CONFLICT (user_id, recorded_at) DO UPDATE ... WHERE NOT body_records.user_modified`.

## D. 클라우드 어댑터 연결 상태

```sql
CREATE TABLE IF NOT EXISTS public.wearable_connections (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL DEFAULT auth.uid() REFERENCES auth.users(id) ON DELETE CASCADE,
  provider text NOT NULL REFERENCES public.data_sources(code),
  status text NOT NULL DEFAULT 'connected',
  external_user_id text, scopes text[],
  connected_at timestamptz DEFAULT now(), last_sync_at timestamptz, last_error text,
  UNIQUE (user_id, provider),
  CONSTRAINT wearable_connections_status_chk CHECK (status IN ('connected','revoked','error','expired'))
);
ALTER TABLE public.wearable_connections ENABLE ROW LEVEL SECURITY;
CREATE POLICY wc_select_own ON public.wearable_connections FOR SELECT TO authenticated USING (user_id = auth.uid());
CREATE POLICY wc_insert_own ON public.wearable_connections FOR INSERT TO authenticated WITH CHECK (user_id = auth.uid());
CREATE POLICY wc_update_own ON public.wearable_connections FOR UPDATE TO authenticated USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
CREATE POLICY wc_delete_own ON public.wearable_connections FOR DELETE TO authenticated USING (user_id = auth.uid());
```

## E. ★토큰 보안 — service_role 전용 이중차단

```sql
CREATE TABLE IF NOT EXISTS public.wearable_connection_secrets (
  connection_id uuid PRIMARY KEY REFERENCES public.wearable_connections(id) ON DELETE CASCADE,
  access_token text, refresh_token text, token_expires_at timestamptz, updated_at timestamptz DEFAULT now()
);
ALTER TABLE public.wearable_connection_secrets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.wearable_connection_secrets FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.wearable_connection_secrets FROM anon, authenticated, PUBLIC;
-- 정책 미생성 → authenticated/anon 전면차단, service_role(엣지함수)만 접근.
```
> 권장: Supabase Vault로 토큰 원문 대신 secret id만 보관(정식출시 전 검토).

## F. 타임존 — 어댑터 변환규칙 (하드 CHECK 미채택)

- 어댑터가 `recorded_at = ((started_at AT TIME ZONE 'Asia/Seoul')::date)` 계산. 한국 DST 없어 안정적.
- provider가 local date만 주면 그대로.
- 하드 CHECK 제약 미채택 이유: user_modified로 날짜 직접수정 시 started_at 불일치→제약위반→업데이트 실패. 규칙은 어댑터 코드+문서로 강제.

## G. Codex 1차 검수 반영 결과

- **[즉시반영 4]**: ①wearable_connections RLS 4정책+WITH CHECK ②secrets FORCE RLS+REVOKE+Vault권장 ③부분인덱스 ON CONFLICT predicate 필수→Edge/RPC ④body_records ALTER DDL 명시
- **[검토후반영 5]**: ⑥data_source CHECK→lookup+FK ⑤upsert RETURNING count(skip 오인방지) ⑨status CHECK+provider FK ⑧external_id 길이512+btrim(lower 미채택) ⑦타임존 문서화(하드CHECK 미채택)
- **[보류 1]**: ⑩raw_payload PII → 알려진 위험 기록, 정식출시 시 redacted/private schema 재검토
- **[반려 1]**: ⑪"뷰 깨짐" 우려 → nullable 추가는 뷰 무해. 적용 후 스모크쿼리만

## H. 적용 순서

1. A→B→D·E 마이그레이션. 실 SQL 작성 시 **2차 재검수 후 apply**(deploy 기준 복수검수 후 자율).
2. 프론트 타입(src/lib/supabase.ts) 신규 nullable 필드 반영(무영향).
3. 가민 클라우드 어댑터(Edge: OAuth+웹훅+upsert RPC) — 앱·던스 무관, 웹서 착수.
4. 헬스커넥트 어댑터는 Capacitor 단계.

---

<!-- ───────────────────────────────────────────── -->
