d3ro-voice/docs/v2/phase-V2-2.md
yunchan8804 97eb886ec3 feat(V2-2): Supabase 인프라 — 스키마 + RLS + Edge Functions 스캐폴딩
server/supabase/ 신규 디렉토리:

config.toml — Supabase CLI 설정
  - 프로젝트 ID, DB 포트, auth providers (Google/GitHub/Apple),
    edge_runtime, functions.*.verify_jwt 설정

migrations/ (PostgreSQL DDL, 시간순):
  - 20260409000001_initial_schema.sql
    12개 테이블: profiles, teams, team_members, meetings,
    meeting_memos, meeting_documents, transcripts, history,
    dictionary, memo_tags, daily_usage, subscriptions
    + 인덱스 + FK CASCADE + transcripts Realtime publication
  - 20260409000002_rls_policies.sql
    개인 전용(history/dictionary): user_id = auth.uid()
    팀 공유(meetings 등): 본인 OR team_members 조회 subquery
    daily_usage: 읽기만, 쓰기는 service_role RPC
  - 20260409000003_auth_triggers.sql
    handle_new_user — auth.users INSERT → profiles+subscriptions 자동 생성
    moddatetime — 8개 테이블 updated_at 자동 갱신
    increment_daily_usage — service_role 전용 쿼터 RPC
  - 20260409000004_storage_buckets.sql
    audio/exports/avatars 3개 버킷 + 경로 기반 접근 정책
    (파일 경로가 {user_id}/...로 시작해야 쓰기 허용)

functions/ (Deno/TypeScript Edge Functions):
  - _shared/cors.ts — CORS 헤더 + preflight 핸들러
  - _shared/auth.ts — requireUser (JWT 검증 + User 반환)
  - _shared/quota.ts — 티어별 쿼터 체크 + consume + service role client
  - stt-proxy/index.ts — Google Cloud STT 래퍼 스캐폴딩
    placeholder 응답, 실제 API 호출 코드는 주석으로 포함
  - llm-proxy/index.ts — Anthropic Messages API 래퍼 스캐폴딩
    티어별 허용 모델 정책 (free=Haiku, pro=Sonnet, team=Opus)

설계 문서:
  - docs/v2/phase-V2-2.md — 상세 설계 (스키마/RLS/Edge Functions/Realtime)
  - docs/v2/phase-V2-2-setup.md — 사용자 액션 가이드 (Supabase 계정/
    OAuth 등록/CLI/배포/검증)

apps/desktop typecheck 통과 (server/는 Deno 런타임이라 별도).
실제 Supabase 프로젝트 배포는 사용자가 phase-V2-2-setup.md 따라 수행.
2026-04-08 15:21:08 +09:00

9.2 KiB

Phase V2-2: Supabase 인프라 — 상세 설계

멀티플랫폼(desktop/web/mobile)의 단일 백엔드를 Supabase 기반으로 구축. 로컬 완결 작업(SQL 마이그레이션, Edge Functions)은 이 페이즈에서 전부 작성. 사용자 액션(계정 생성, OAuth 등록, 배포)은 phase-V2-2-setup.md 참조.


1. 목표

  1. 인증: Supabase Auth — Google/GitHub/Apple OAuth + Email
  2. 데이터 SSOT: PostgreSQL — V1 SQLite 스키마를 멀티테넌시(user_id, team_id)로 포팅
  3. 보안: Row Level Security — 개인 데이터 격리 + 팀 공유 지원
  4. AI 프록시: Edge Functions — Google STT, Claude LLM 호출을 서버에서 래핑 (API 키 은닉 + 쿼터 집계)
  5. 파일 스토리지: Supabase Storage 버킷 3개 (audio, exports, avatars)
  6. 실시간: 회의 진행 중 전사 세그먼트 실시간 동기화 (Realtime subscription)

2. 디렉토리 구조

server/supabase/
├── config.toml                     # 프로젝트 설정 (로컬 개발용)
├── seed.sql                        # 개발 시드 데이터
├── migrations/
│   ├── 20260409000001_initial_schema.sql       # 테이블 + 인덱스
│   ├── 20260409000002_rls_policies.sql         # RLS policies
│   ├── 20260409000003_auth_triggers.sql        # handle_new_user, moddatetime
│   └── 20260409000004_storage_buckets.sql      # storage 버킷 + policies
└── functions/
    ├── _shared/
    │   ├── cors.ts                 # CORS 헤더 헬퍼
    │   ├── auth.ts                 # JWT 검증 + user_id 추출
    │   └── quota.ts                # 티어별 쿼터 체크
    ├── stt-proxy/
    │   └── index.ts                # Google Cloud STT 래퍼
    └── llm-proxy/
        └── index.ts                # Claude API 래퍼

3. 데이터 모델

3.1 테이블 요약

테이블 설명 멀티테넌시
public.profiles auth.users 확장 (name, avatar_url, locale, tier) id = auth.users.id
public.meetings 회의 세션 (V1 meeting_sessions) user_id, team_id?
public.meeting_memos 회의 중 타임스탬프 메모 meeting_id → meetings
public.meeting_documents LLM 생성 문서 (minutes/report/mindmap) meeting_id → meetings
public.transcripts 전사 세그먼트 (실시간 동기화 대상) meeting_id → meetings
public.history 음성 입력 히스토리 (dictation/translate 등) user_id
public.dictionary 사용자 사전 user_id
public.memo_tags 히스토리 태그 user_id, history_id → history
public.daily_usage 기능별 일일 사용량 (쿼터 집계) user_id
public.teams owner_id → auth.users
public.team_members 팀 멤버십 team_id, user_id, role
public.subscriptions Stripe 구독 상태 user_id

3.2 V1 SQLite ↔ V2 PostgreSQL 타입 매핑

V1 (drizzle sqlite-core) V2 (PostgreSQL) 비고
text('id').primaryKey() (nanoid) uuid primary key default gen_random_uuid() 신규는 UUID 사용
integer('created_at') (epoch ms) timestamptz not null default now() Date/Time 네이티브
integer('indexed', mode: 'boolean') boolean not null default false
real('duration') double precision
text('status', {enum: ...}) text check (status in (...)) 또는 enum enum 타입으로
text('embedding') (JSON float[]) vector(768) via pgvector RAG 전용

3.3 공통 컬럼

모든 유저 소유 테이블:

  • user_id uuid not null references auth.users(id) on delete cascade
  • created_at timestamptz not null default now()
  • updated_at timestamptz not null default now() (트리거로 자동 갱신)

회의 관련 테이블만 추가:

  • team_id uuid references public.teams(id) on delete set null (nullable — 개인 회의는 null)

4. RLS 정책 패턴

4.1 개인 전용 테이블 (history, dictionary, memo_tags, daily_usage)

CREATE POLICY "users_own_rows" ON public.history
  FOR ALL
  USING (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());

4.2 팀 공유 가능 테이블 (meetings, meeting_memos, meeting_documents, transcripts)

-- SELECT: 본인 소유 OR 소속 팀의 리소스
CREATE POLICY "meetings_read" ON public.meetings
  FOR SELECT
  USING (
    user_id = auth.uid()
    OR team_id IN (
      SELECT team_id FROM public.team_members WHERE user_id = auth.uid()
    )
  );

-- INSERT: 본인 user_id 강제
CREATE POLICY "meetings_insert" ON public.meetings
  FOR INSERT
  WITH CHECK (user_id = auth.uid());

-- UPDATE: 본인 소유 OR 팀 admin 이상
CREATE POLICY "meetings_update" ON public.meetings
  FOR UPDATE
  USING (
    user_id = auth.uid()
    OR team_id IN (
      SELECT team_id FROM public.team_members
      WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
    )
  );

-- DELETE: 본인 소유만
CREATE POLICY "meetings_delete" ON public.meetings
  FOR DELETE
  USING (user_id = auth.uid());

4.3 팀 자체 (teams, team_members)

-- 팀 조회: 본인이 소유 OR 멤버
CREATE POLICY "teams_read" ON public.teams
  FOR SELECT
  USING (
    owner_id = auth.uid()
    OR id IN (SELECT team_id FROM public.team_members WHERE user_id = auth.uid())
  );

-- 팀 생성: 누구나 (owner = 본인)
CREATE POLICY "teams_insert" ON public.teams
  FOR INSERT WITH CHECK (owner_id = auth.uid());

-- 팀 수정: owner만
CREATE POLICY "teams_update" ON public.teams
  FOR UPDATE USING (owner_id = auth.uid());

5. Auth 트리거

5.1 handle_new_user — auth.users → profiles 자동 생성

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
  INSERT INTO public.profiles (id, name, avatar_url, locale)
  VALUES (
    NEW.id,
    COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name', split_part(NEW.email, '@', 1)),
    NEW.raw_user_meta_data->>'avatar_url',
    COALESCE(NEW.raw_user_meta_data->>'locale', 'ko')
  );
  RETURN NEW;
END;
$$;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

5.2 moddatetime — updated_at 자동 갱신

CREATE OR REPLACE FUNCTION public.moddatetime()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

-- 각 테이블마다:
CREATE TRIGGER set_updated_at
  BEFORE UPDATE ON public.meetings
  FOR EACH ROW EXECUTE FUNCTION public.moddatetime();

6. Edge Functions

6.1 공통: _shared/auth.ts

import { createClient, User } from 'https://esm.sh/@supabase/supabase-js@2'

export async function requireUser(req: Request): Promise<User> {
  const authHeader = req.headers.get('Authorization')
  if (!authHeader) throw new Response('Unauthorized', { status: 401 })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } }
  )

  const { data: { user }, error } = await supabase.auth.getUser()
  if (error || !user) throw new Response('Unauthorized', { status: 401 })
  return user
}

6.2 stt-proxy/ — Google Cloud STT 래퍼

  • 입력: multipart/form-data (audio blob + sample_rate + language_code)
  • 인증: JWT → user 추출
  • 쿼터: daily_usage 확인 → 초과 시 429
  • 호출: Google Cloud Speech-to-Text API (v2 recognize)
  • 응답: { transcript, confidence, language_code, duration_seconds }
  • 사이드 이펙트: daily_usage.count 증가

6.3 llm-proxy/ — Claude API 래퍼

  • 입력: application/json (messages + system + max_tokens + model)
  • 인증: 동일
  • 쿼터: 동일 (llm_process feature)
  • 호출: Anthropic Messages API (스트리밍 지원)
  • 응답: SSE 스트림 또는 JSON
  • 모델 정책: Free → Haiku 강제, Pro+ → Sonnet 허용

7. Storage 버킷

INSERT INTO storage.buckets (id, name, public) VALUES
  ('audio', 'audio', false),       -- 녹음 원본 (비공개)
  ('exports', 'exports', false),   -- PDF/DOCX 내보내기
  ('avatars', 'avatars', true);    -- 프로필 사진 (공개)

정책: 파일 경로가 {user_id}/...로 시작해야 쓰기 허용.

8. 실시간 구독 전략

  • 회의 중 transcripts INSERT → Realtime 구독으로 팀원에게 전파
  • 회의록 편집 중 meeting_documents UPDATE → CRDT 병합 (V2-7 팀 기능에서 구현)

9. 검증 전략

V2-2는 로컬에서 전체 검증 불가 (Supabase 프로젝트 필요). 이 페이즈 완료 판정 기준:

  1. SQL 마이그레이션 파일 작성 완료
  2. Edge Functions 스캐폴딩 완료 (TypeScript 컴파일 가능)
  3. 설정 가이드 문서 완료
  4. 실제 deploy 검증은 사용자가 supabase db push 후 진행 (V2-2b)

10. 다음 페이즈와의 관계

  • V2-3 (Web App): Next.js에서 @supabase/supabase-js 클라이언트로 직접 연결
  • V2-4 (데스크톱 동기화): apps/desktop에 supabase 클라이언트 추가, 로컬 SQLite ↔ Postgres 양방향 sync 레이어
  • V2-7 (팀 기능): teams/team_members 테이블 + invite flow