# Supabase 연동 가이드 이 문서는 Zellyy 프로젝트에서 Supabase를 연동하고 설정하는 방법을 안내합니다. ## 개요 Zellyy 프로젝트는 백엔드로 자체 호스팅된 Supabase를 사용합니다. 이 가이드는 기존에 설치된 Supabase 인스턴스에 Zellyy 프로젝트를 위한 스키마와 테이블을 설정하는 방법을 설명합니다. ## 사전 요구사항 - 설치된 Supabase 인스턴스 (참조: [[Nginx Supabase 설치 가이드]]) - PostgreSQL 기본 지식 - Supabase API 키 및 URL ## 1. Supabase 프로젝트 설정 ### 1.1 스키마 생성 Zellyy 프로젝트를 위한 별도의 스키마를 생성합니다. 이는 기존 프로젝트와의 데이터 분리를 위한 것입니다. ```sql -- Supabase SQL 편집기에서 실행 CREATE SCHEMA IF NOT EXISTS zellyy; ``` ### 1.2 확장 활성화 필요한 PostgreSQL 확장을 활성화합니다. ```sql -- UUID 생성을 위한 확장 CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- 전문 검색을 위한 확장 CREATE EXTENSION IF NOT EXISTS pg_trgm; ``` ## 2. 데이터베이스 테이블 생성 ### 2.1 사용자 테이블 ```sql CREATE TABLE zellyy.users ( id UUID REFERENCES auth.users(id) PRIMARY KEY, email TEXT UNIQUE NOT NULL, username TEXT UNIQUE, display_name TEXT, avatar_url TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), last_login TIMESTAMP WITH TIME ZONE, is_premium BOOLEAN DEFAULT FALSE, premium_until TIMESTAMP WITH TIME ZONE ); -- 트리거 설정 CREATE OR REPLACE FUNCTION zellyy.update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_users_timestamp BEFORE UPDATE ON zellyy.users FOR EACH ROW EXECUTE FUNCTION zellyy.update_timestamp(); ``` ### 2.2 카드 테이블 ```sql CREATE TABLE zellyy.cards ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES zellyy.users(id) NOT NULL, content TEXT NOT NULL, background_color TEXT DEFAULT '#FFFFFF', text_color TEXT DEFAULT '#000000', font_family TEXT DEFAULT 'system', font_size INTEGER DEFAULT 16, text_align TEXT DEFAULT 'center', is_public BOOLEAN DEFAULT FALSE, is_synced BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), deleted_at TIMESTAMP WITH TIME ZONE ); CREATE TRIGGER update_cards_timestamp BEFORE UPDATE ON zellyy.cards FOR EACH ROW EXECUTE FUNCTION zellyy.update_timestamp(); ``` ### 2.3 카드 태그 테이블 ```sql CREATE TABLE zellyy.card_tags ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), card_id UUID REFERENCES zellyy.cards(id) NOT NULL, tag_name TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(card_id, tag_name) ); ``` ### 2.4 소셜 공유 테이블 ```sql CREATE TABLE zellyy.social_shares ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), card_id UUID REFERENCES zellyy.cards(id) NOT NULL, user_id UUID REFERENCES zellyy.users(id) NOT NULL, platform TEXT NOT NULL, share_url TEXT, shared_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), status TEXT DEFAULT 'pending', response_data JSONB ); ``` ### 2.5 소셜 계정 테이블 ```sql CREATE TABLE zellyy.social_accounts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES zellyy.users(id) NOT NULL, platform TEXT NOT NULL, platform_user_id TEXT, access_token TEXT, refresh_token TEXT, token_expires_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(user_id, platform) ); CREATE TRIGGER update_social_accounts_timestamp BEFORE UPDATE ON zellyy.social_accounts FOR EACH ROW EXECUTE FUNCTION zellyy.update_timestamp(); ``` ### 2.6 구독 테이블 ```sql CREATE TABLE zellyy.subscriptions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES zellyy.users(id) NOT NULL, plan_type TEXT NOT NULL, status TEXT NOT NULL, start_date TIMESTAMP WITH TIME ZONE NOT NULL, end_date TIMESTAMP WITH TIME ZONE NOT NULL, payment_provider TEXT, payment_id TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE TRIGGER update_subscriptions_timestamp BEFORE UPDATE ON zellyy.subscriptions FOR EACH ROW EXECUTE FUNCTION zellyy.update_timestamp(); -- 구독 상태에 따라 사용자의 프리미엄 상태 업데이트 CREATE OR REPLACE FUNCTION zellyy.update_premium_status() RETURNS TRIGGER AS $$ BEGIN IF NEW.status = 'active' THEN UPDATE zellyy.users SET is_premium = TRUE, premium_until = NEW.end_date WHERE id = NEW.user_id; ELSIF NEW.status IN ('canceled', 'expired') AND NEW.end_date <= NOW() THEN UPDATE zellyy.users SET is_premium = FALSE, premium_until = NULL WHERE id = NEW.user_id; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_premium_status_trigger AFTER INSERT OR UPDATE ON zellyy.subscriptions FOR EACH ROW EXECUTE FUNCTION zellyy.update_premium_status(); ``` ## 3. 인덱스 생성 성능 최적화를 위한 인덱스를 생성합니다. ```sql -- 카드 검색 최적화 CREATE INDEX idx_cards_user_id ON zellyy.cards(user_id); CREATE INDEX idx_cards_created_at ON zellyy.cards(created_at); CREATE INDEX idx_cards_is_public ON zellyy.cards(is_public); -- 태그 검색 최적화 CREATE INDEX idx_card_tags_card_id ON zellyy.card_tags(card_id); CREATE INDEX idx_card_tags_tag_name ON zellyy.card_tags(tag_name); -- 소셜 공유 검색 최적화 CREATE INDEX idx_social_shares_user_id ON zellyy.social_shares(user_id); CREATE INDEX idx_social_shares_card_id ON zellyy.social_shares(card_id); ``` ## 4. Row Level Security (RLS) 설정 Supabase의 Row Level Security를 사용하여 데이터 접근을 제한합니다. ### 4.1 사용자 테이블 정책 ```sql -- RLS 활성화 ALTER TABLE zellyy.users ENABLE ROW LEVEL SECURITY; -- 사용자는 자신의 정보만 읽을 수 있음 CREATE POLICY "사용자는 자신의 정보만 읽을 수 있음" ON zellyy.users FOR SELECT USING (auth.uid() = id); -- 사용자는 자신의 정보만 업데이트할 수 있음 CREATE POLICY "사용자는 자신의 정보만 업데이트할 수 있음" ON zellyy.users FOR UPDATE USING (auth.uid() = id); ``` ### 4.2 카드 테이블 정책 ```sql -- RLS 활성화 ALTER TABLE zellyy.cards ENABLE ROW LEVEL SECURITY; -- 사용자는 자신의 카드만 읽을 수 있음 (또는 공개 카드) CREATE POLICY "사용자는 자신의 카드만 읽을 수 있음" ON zellyy.cards FOR SELECT USING (auth.uid() = user_id OR is_public = TRUE); -- 사용자는 자신의 카드만 생성할 수 있음 CREATE POLICY "사용자는 자신의 카드만 생성할 수 있음" ON zellyy.cards FOR INSERT WITH CHECK (auth.uid() = user_id); -- 사용자는 자신의 카드만 업데이트할 수 있음 CREATE POLICY "사용자는 자신의 카드만 업데이트할 수 있음" ON zellyy.cards FOR UPDATE USING (auth.uid() = user_id); -- 사용자는 자신의 카드만 삭제할 수 있음 CREATE POLICY "사용자는 자신의 카드만 삭제할 수 있음" ON zellyy.cards FOR DELETE USING (auth.uid() = user_id); ``` ### 4.3 기타 테이블 정책 다른 테이블에도 유사한 방식으로 RLS 정책을 적용합니다. ```sql -- 카드 태그 테이블 ALTER TABLE zellyy.card_tags ENABLE ROW LEVEL SECURITY; CREATE POLICY "카드 태그 읽기 정책" ON zellyy.card_tags FOR SELECT USING ( EXISTS ( SELECT 1 FROM zellyy.cards WHERE cards.id = card_tags.card_id AND (cards.user_id = auth.uid() OR cards.is_public = TRUE) ) ); -- 소셜 공유 테이블 ALTER TABLE zellyy.social_shares ENABLE ROW LEVEL SECURITY; CREATE POLICY "소셜 공유 읽기 정책" ON zellyy.social_shares FOR SELECT USING (user_id = auth.uid()); -- 소셜 계정 테이블 ALTER TABLE zellyy.social_accounts ENABLE ROW LEVEL SECURITY; CREATE POLICY "소셜 계정 읽기 정책" ON zellyy.social_accounts FOR SELECT USING (user_id = auth.uid()); -- 구독 테이블 ALTER TABLE zellyy.subscriptions ENABLE ROW LEVEL SECURITY; CREATE POLICY "구독 읽기 정책" ON zellyy.subscriptions FOR SELECT USING (user_id = auth.uid()); ``` ## 5. 저장 프로시저 및 함수 자주 사용되는 작업을 위한 저장 프로시저와 함수를 생성합니다. ### 5.1 카드 생성 함수 ```sql CREATE OR REPLACE FUNCTION zellyy.create_card( content TEXT, background_color TEXT DEFAULT '#FFFFFF', text_color TEXT DEFAULT '#000000', font_family TEXT DEFAULT 'system', font_size INTEGER DEFAULT 16, text_align TEXT DEFAULT 'center', is_public BOOLEAN DEFAULT FALSE, tags TEXT[] DEFAULT '{}' ) RETURNS UUID AS $$ DECLARE card_id UUID; tag TEXT; BEGIN -- 카드 생성 INSERT INTO zellyy.cards ( user_id, content, background_color, text_color, font_family, font_size, text_align, is_public ) VALUES ( auth.uid(), content, background_color, text_color, font_family, font_size, text_align, is_public ) RETURNING id INTO card_id; -- 태그 추가 FOREACH tag IN ARRAY tags LOOP INSERT INTO zellyy.card_tags (card_id, tag_name) VALUES (card_id, tag); END LOOP; RETURN card_id; END; $$ LANGUAGE plpgsql SECURITY DEFINER; ``` ### 5.2 카드 검색 함수 ```sql CREATE OR REPLACE FUNCTION zellyy.search_cards( search_query TEXT DEFAULT NULL, tag_filter TEXT DEFAULT NULL, page_number INTEGER DEFAULT 1, items_per_page INTEGER DEFAULT 20 ) RETURNS TABLE ( id UUID, content TEXT, background_color TEXT, text_color TEXT, font_family TEXT, font_size INTEGER, text_align TEXT, is_public BOOLEAN, created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, tags TEXT[] ) AS $$ BEGIN RETURN QUERY WITH filtered_cards AS ( SELECT c.id, c.content, c.background_color, c.text_color, c.font_family, c.font_size, c.text_align, c.is_public, c.created_at, c.updated_at FROM zellyy.cards c WHERE (c.user_id = auth.uid() OR c.is_public = TRUE) AND (search_query IS NULL OR c.content ILIKE '%' || search_query || '%') AND c.deleted_at IS NULL AND ( tag_filter IS NULL OR EXISTS ( SELECT 1 FROM zellyy.card_tags ct WHERE ct.card_id = c.id AND ct.tag_name = tag_filter ) ) ), cards_with_tags AS ( SELECT fc.*, array_agg(ct.tag_name) as tags FROM filtered_cards fc LEFT JOIN zellyy.card_tags ct ON fc.id = ct.card_id GROUP BY fc.id, fc.content, fc.background_color, fc.text_color, fc.font_family, fc.font_size, fc.text_align, fc.is_public, fc.created_at, fc.updated_at ) SELECT * FROM cards_with_tags ORDER BY created_at DESC LIMIT items_per_page OFFSET (page_number - 1) * items_per_page; END; $$ LANGUAGE plpgsql SECURITY DEFINER; ``` ## 6. Supabase 클라이언트 설정 ### 6.1 React Native 앱에서 Supabase 클라이언트 설정 `services/supabase.js` 파일을 생성하고 다음 코드를 추가합니다: ```javascript import { createClient } from '@supabase/supabase-js'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@env'; const supabaseUrl = SUPABASE_URL; const supabaseAnonKey = SUPABASE_ANON_KEY; // AsyncStorage 어댑터 생성 const AsyncStorageAdapter = { getItem: (key) => AsyncStorage.getItem(key), setItem: (key, value) => AsyncStorage.setItem(key, value), removeItem: (key) => AsyncStorage.removeItem(key), }; // Supabase 클라이언트 생성 const supabase = createClient(supabaseUrl, supabaseAnonKey, { auth: { storage: AsyncStorageAdapter, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, }, }); export default supabase; ``` ### 6.2 환경 변수 설정 `.env` 파일에 Supabase URL과 익명 키를 추가합니다: ``` SUPABASE_URL=https://a11.ism.kr SUPABASE_ANON_KEY=your_anon_key ``` ## 7. 인증 설정 ### 7.1 이메일/비밀번호 인증 활성화 Supabase 대시보드에서: 1. Authentication > Settings로 이동 2. Email Auth를 활성화 3. 필요한 경우 이메일 템플릿 커스터마이징 ### 7.2 소셜 로그인 설정 (선택 사항) 소셜 로그인을 활성화하려면: 1. Authentication > Settings > OAuth Providers로 이동 2. 원하는 소셜 로그인 제공자 활성화 (예: Google, Facebook) 3. 각 제공자의 클라이언트 ID와 시크릿 설정 ## 8. 스토리지 설정 ### 8.1 버킷 생성 Supabase 대시보드에서: 1. Storage로 이동 2. "New Bucket" 클릭 3. 다음 버킷 생성: - `avatars`: 사용자 프로필 이미지용 - `card-images`: 카드 이미지용 ### 8.2 스토리지 정책 설정 각 버킷에 대한 접근 정책을 설정합니다: ```sql -- avatars 버킷 정책 CREATE POLICY "사용자는 자신의 아바타만 업로드할 수 있음" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'avatars' AND auth.uid() = (storage.foldername(name)::uuid) ); CREATE POLICY "사용자는 자신의 아바타만 업데이트할 수 있음" ON storage.objects FOR UPDATE USING ( bucket_id = 'avatars' AND auth.uid() = (storage.foldername(name)::uuid) ); CREATE POLICY "아바타는 공개적으로 접근 가능" ON storage.objects FOR SELECT USING (bucket_id = 'avatars'); -- card-images 버킷 정책 CREATE POLICY "사용자는 자신의 카드 이미지만 업로드할 수 있음" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'card-images' AND auth.uid() = (storage.foldername(name)::uuid) ); CREATE POLICY "사용자는 자신의 카드 이미지만 업데이트할 수 있음" ON storage.objects FOR UPDATE USING ( bucket_id = 'card-images' AND auth.uid() = (storage.foldername(name)::uuid) ); CREATE POLICY "카드 이미지는 공개적으로 접근 가능" ON storage.objects FOR SELECT USING (bucket_id = 'card-images'); ``` ## 9. Edge Functions 설정 (선택 사항) 소셜 미디어 공유와 같은 복잡한 기능을 위해 Supabase Edge Functions를 사용할 수 있습니다. ### 9.1 Edge Function 생성 ```bash # Supabase CLI 설치 npm install -g supabase # 로그인 supabase login # 프로젝트 연결 supabase link --project-ref your-project-ref # 소셜 공유 함수 생성 supabase functions new social-share # 함수 배포 supabase functions deploy social-share ``` ### 9.2 소셜 공유 함수 예시 `supabase/functions/social-share/index.ts` 파일: ```typescript import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.0.0'; const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; serve(async (req) => { // CORS 헤더 if (req.method === 'OPTIONS') { return new Response('ok', { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }, }); } try { // 요청 본문 파싱 const { cardId, platform, message } = await req.json(); // Supabase 클라이언트 생성 const supabase = createClient(supabaseUrl, supabaseServiceKey); // 인증 확인 const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const token = authHeader.replace('Bearer ', ''); const { data: { user }, error: authError } = await supabase.auth.getUser(token); if (authError || !user) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // 카드 정보 가져오기 const { data: card, error: cardError } = await supabase .from('zellyy.cards') .select('*') .eq('id', cardId) .single(); if (cardError || !card) { return new Response(JSON.stringify({ error: 'Card not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // 소셜 계정 정보 가져오기 const { data: socialAccount, error: socialError } = await supabase .from('zellyy.social_accounts') .select('*') .eq('user_id', user.id) .eq('platform', platform) .single(); if (socialError || !socialAccount) { return new Response(JSON.stringify({ error: 'Social account not connected' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // 소셜 미디어 공유 로직 (플랫폼별로 다름) let shareResult; let shareUrl; if (platform === 'facebook') { // Facebook API 호출 로직 // ... shareUrl = 'https://facebook.com/post/123456'; } else if (platform === 'instagram') { // Instagram API 호출 로직 // ... shareUrl = 'https://instagram.com/p/123456'; } else if (platform === 'twitter') { // Twitter API 호출 로직 // ... shareUrl = 'https://twitter.com/user/status/123456'; } else { return new Response(JSON.stringify({ error: 'Unsupported platform' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // 공유 기록 저장 const { data: share, error: shareError } = await supabase .from('zellyy.social_shares') .insert({ card_id: cardId, user_id: user.id, platform, share_url: shareUrl, status: 'success', response_data: shareResult }) .select() .single(); if (shareError) { return new Response(JSON.stringify({ error: 'Failed to record share' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } return new Response(JSON.stringify({ success: true, share }), { status: 200, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } catch (error) { return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } }); ``` ## 10. 백업 및 유지 관리 ### 10.1 정기 백업 설정 기존 백업 스크립트를 활용하여 Zellyy 스키마도 백업합니다: ```bash #!/bin/bash BACKUP_DIR="/home/ism-admin/backups" TIMESTAMP=$(date +"%Y%m%d_%H%M%S") BACKUP_FILE="$BACKUP_DIR/postgres_backup_$TIMESTAMP.sql.gz" # 백업 디렉토리 생성 mkdir -p $BACKUP_DIR # 데이터베이스 백업 docker exec supabase-db pg_dumpall -U postgres | gzip > $BACKUP_FILE # 30일 이상 된 백업 삭제 find $BACKUP_DIR -name "postgres_backup_*.sql.gz" -type f -mtime +30 -delete ``` ### 10.2 성능 모니터링 PostgreSQL 성능을 모니터링하기 위한 쿼리: ```sql -- 느린 쿼리 확인 SELECT query, calls, total_time, mean_time, rows FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10; -- 테이블 크기 확인 SELECT schemaname, relname, pg_size_pretty(pg_total_relation_size(relid)) as total_size FROM pg_catalog.pg_statio_user_tables WHERE schemaname = 'zellyy' ORDER BY pg_total_relation_size(relid) DESC; ``` ## 11. 문제 해결 ### 11.1 일반적인 문제 및 해결 방법 1. **RLS 정책 문제**: - 정책이 너무 제한적인지 확인 - `auth.uid()`가 올바르게 작동하는지 확인 2. **성능 문제**: - 인덱스가 올바르게 생성되었는지 확인 - 쿼리 실행 계획 분석 3. **인증 문제**: - JWT 토큰이 올바른지 확인 - 세션 만료 시간 확인 ### 11.2 로그 확인 ```bash # Supabase 로그 확인 docker logs supabase-db docker logs supabase-auth docker logs supabase-rest ``` ## 12. 보안 고려사항 1. **API 키 관리**: - 익명 키와 서비스 키를 안전하게 보관 - 서비스 키는 서버 측 코드에서만 사용 2. **데이터 암호화**: - 민감한 정보는 암호화하여 저장 - 소셜 미디어 토큰과 같은 민감한 정보는 암호화 3. **정기적인 보안 감사**: - RLS 정책 검토 - 권한 설정 검토 ## 결론 이 가이드는 Zellyy 프로젝트를 위한 Supabase 설정 방법을 제공합니다. 기존 Supabase 인프라를 활용하면서도 데이터를 분리하여 관리함으로써 효율적인 개발과 운영이 가능합니다. 추가적인 기능이나 요구사항이 생기면 이 문서를 업데이트하여 최신 상태를 유지하세요.