Files
Obsidian/ZELLYY/zellyy note/02_기술_문서/Supabase_연동_가이드.md
2025-03-26 18:16:46 +09:00

21 KiB

Supabase 연동 가이드

이 문서는 Zellyy 프로젝트에서 Supabase를 연동하고 설정하는 방법을 안내합니다.

개요

Zellyy 프로젝트는 백엔드로 자체 호스팅된 Supabase를 사용합니다. 이 가이드는 기존에 설치된 Supabase 인스턴스에 Zellyy 프로젝트를 위한 스키마와 테이블을 설정하는 방법을 설명합니다.

사전 요구사항

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 대시보드에서:

  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 스토리지 정책 설정

각 버킷에 대한 접근 정책을 설정합니다:

-- 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 일반적인 문제 및 해결 방법

  1. RLS 정책 문제:

    • 정책이 너무 제한적인지 확인
    • auth.uid()가 올바르게 작동하는지 확인
  2. 성능 문제:

    • 인덱스가 올바르게 생성되었는지 확인
    • 쿼리 실행 계획 분석
  3. 인증 문제:

    • JWT 토큰이 올바른지 확인
    • 세션 만료 시간 확인

11.2 로그 확인

# Supabase 로그 확인
docker logs supabase-db
docker logs supabase-auth
docker logs supabase-rest

12. 보안 고려사항

  1. API 키 관리:

    • 익명 키와 서비스 키를 안전하게 보관
    • 서비스 키는 서버 측 코드에서만 사용
  2. 데이터 암호화:

    • 민감한 정보는 암호화하여 저장
    • 소셜 미디어 토큰과 같은 민감한 정보는 암호화
  3. 정기적인 보안 감사:

    • RLS 정책 검토
    • 권한 설정 검토

결론

이 가이드는 Zellyy 프로젝트를 위한 Supabase 설정 방법을 제공합니다. 기존 Supabase 인프라를 활용하면서도 데이터를 분리하여 관리함으로써 효율적인 개발과 운영이 가능합니다. 추가적인 기능이나 요구사항이 생기면 이 문서를 업데이트하여 최신 상태를 유지하세요.