# 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) ```sql 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) ```sql -- 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) ```sql -- 팀 조회: 본인이 소유 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 자동 생성 ```sql 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 자동 갱신 ```sql 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` ```typescript import { createClient, User } from 'https://esm.sh/@supabase/supabase-js@2' export async function requireUser(req: Request): Promise { 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 버킷 ```sql 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