21 KiB
Supabase 연동 가이드
이 문서는 Zellyy 프로젝트에서 Supabase를 연동하고 설정하는 방법을 안내합니다.
개요
Zellyy 프로젝트는 백엔드로 자체 호스팅된 Supabase를 사용합니다. 이 가이드는 기존에 설치된 Supabase 인스턴스에 Zellyy 프로젝트를 위한 스키마와 테이블을 설정하는 방법을 설명합니다.
사전 요구사항
- 설치된 Supabase 인스턴스 (참조: Nginx Supabase 설치 가이드)
- PostgreSQL 기본 지식
- Supabase API 키 및 URL
1. Supabase 프로젝트 설정
1.1 스키마 생성
Zellyy 프로젝트를 위한 별도의 스키마를 생성합니다. 이는 기존 프로젝트와의 데이터 분리를 위한 것입니다.
-- Supabase SQL 편집기에서 실행
CREATE SCHEMA IF NOT EXISTS zellyy;
1.2 확장 활성화
필요한 PostgreSQL 확장을 활성화합니다.
-- UUID 생성을 위한 확장
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 전문 검색을 위한 확장
CREATE EXTENSION IF NOT EXISTS pg_trgm;
2. 데이터베이스 테이블 생성
2.1 사용자 테이블
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 카드 테이블
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 카드 태그 테이블
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 소셜 공유 테이블
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 소셜 계정 테이블
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 구독 테이블
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. 인덱스 생성
성능 최적화를 위한 인덱스를 생성합니다.
-- 카드 검색 최적화
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 사용자 테이블 정책
-- 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 카드 테이블 정책
-- 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 정책을 적용합니다.
-- 카드 태그 테이블
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 카드 생성 함수
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 카드 검색 함수
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 파일을 생성하고 다음 코드를 추가합니다:
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 대시보드에서:
- Authentication > Settings로 이동
- Email Auth를 활성화
- 필요한 경우 이메일 템플릿 커스터마이징
7.2 소셜 로그인 설정 (선택 사항)
소셜 로그인을 활성화하려면:
- Authentication > Settings > OAuth Providers로 이동
- 원하는 소셜 로그인 제공자 활성화 (예: Google, Facebook)
- 각 제공자의 클라이언트 ID와 시크릿 설정
8. 스토리지 설정
8.1 버킷 생성
Supabase 대시보드에서:
- Storage로 이동
- "New Bucket" 클릭
- 다음 버킷 생성:
avatars: 사용자 프로필 이미지용card-images: 카드 이미지용
8.2 스토리지 정책 설정
각 버킷에 대한 접근 정책을 설정합니다:
-- 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 생성
# 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 파일:
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 스키마도 백업합니다:
#!/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 성능을 모니터링하기 위한 쿼리:
-- 느린 쿼리 확인
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 일반적인 문제 및 해결 방법
-
RLS 정책 문제:
- 정책이 너무 제한적인지 확인
auth.uid()가 올바르게 작동하는지 확인
-
성능 문제:
- 인덱스가 올바르게 생성되었는지 확인
- 쿼리 실행 계획 분석
-
인증 문제:
- JWT 토큰이 올바른지 확인
- 세션 만료 시간 확인
11.2 로그 확인
# Supabase 로그 확인
docker logs supabase-db
docker logs supabase-auth
docker logs supabase-rest
12. 보안 고려사항
-
API 키 관리:
- 익명 키와 서비스 키를 안전하게 보관
- 서비스 키는 서버 측 코드에서만 사용
-
데이터 암호화:
- 민감한 정보는 암호화하여 저장
- 소셜 미디어 토큰과 같은 민감한 정보는 암호화
-
정기적인 보안 감사:
- RLS 정책 검토
- 권한 설정 검토
결론
이 가이드는 Zellyy 프로젝트를 위한 Supabase 설정 방법을 제공합니다. 기존 Supabase 인프라를 활용하면서도 데이터를 분리하여 관리함으로써 효율적인 개발과 운영이 가능합니다. 추가적인 기능이나 요구사항이 생기면 이 문서를 업데이트하여 최신 상태를 유지하세요.