Skip to content

Latest commit

 

History

History
488 lines (405 loc) · 12.7 KB

File metadata and controls

488 lines (405 loc) · 12.7 KB

구현 가이드 - Content Creator SaaS

실제 서비스로 만들기 위한 단계별 구현 가이드입니다.

현재 구조 요약

✅ 완료된 항목

  1. 프로젝트 구조 설정

    • Next.js 14 프로젝트 구조
    • TypeScript 설정
    • Tailwind CSS 구성
  2. 백엔드 API

    • /api/generate - 콘텐츠 생성
    • /api/history - 히스토리 조회
    • /api/history/[id] - 개별 조회/삭제
  3. AI 통합

    • Claude API 래퍼
    • 플랫폼별 프롬프트 시스템
    • 4개 플랫폼 지원 (티스토리, Medium, YouTube, Instagram)
  4. 데이터베이스 스키마

    • Users, Content Generations, Subscriptions 테이블
    • Row Level Security (RLS) 설정
    • 자동 트리거 및 함수
  5. 핵심 UI 컴포넌트

    • PlatformSelector: 플랫폼 선택기
    • PromptInput: 프롬프트 입력
    • ContentPreview: 마크다운 프리뷰

🚀 다음 단계: 실제 서비스 완성하기

Step 1: 필수 설정

1.1 Anthropic API 키 발급

# https://console.anthropic.com/ 가입
# API Keys 생성
# .env.local에 추가
ANTHROPIC_API_KEY=sk-ant-xxx

1.2 Supabase 프로젝트 생성

# https://supabase.com/ 가입
# New Project 생성
# Settings > API에서 키 복사

NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxx
SUPABASE_SERVICE_ROLE_KEY=eyJxxx

1.3 데이터베이스 스키마 적용

-- Supabase > SQL Editor에서 실행
-- lib/db/schema.sql 파일 내용 복사 & 실행

Step 2: 인증 시스템 구현

2.1 Supabase Auth 설정

components/auth/LoginForm.tsx 생성:

'use client'

import { useState } from 'react'
import { supabase } from '@/lib/db/supabase'

export function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    })

    if (error) alert(error.message)
    setLoading(false)
  }

  return (
    <form onSubmit={handleLogin} className="space-y-4">
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="이메일"
        className="w-full px-4 py-2 border rounded-lg"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="비밀번호"
        className="w-full px-4 py-2 border rounded-lg"
      />
      <button
        type="submit"
        disabled={loading}
        className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg"
      >
        {loading ? '로그인 중...' : '로그인'}
      </button>
    </form>
  )
}

2.2 Protected Routes

lib/auth/session.ts 생성:

import { supabase } from '@/lib/db/supabase'

export async function getSession() {
  const { data: { session } } = await supabase.auth.getSession()
  return session
}

export async function requireAuth() {
  const session = await getSession()
  if (!session) {
    throw new Error('Unauthorized')
  }
  return session
}

Step 3: 메인 생성 페이지 구현

app/(dashboard)/generate/page.tsx 생성:

'use client'

import { useState } from 'react'
import { PlatformSelector } from '@/components/generate/PlatformSelector'
import { PromptInput } from '@/components/generate/PromptInput'
import { ContentPreview } from '@/components/generate/ContentPreview'
import { Platform, PlatformContents } from '@/types/content'
import { supabase } from '@/lib/db/supabase'
import axios from 'axios'

export default function GeneratePage() {
  const [prompt, setPrompt] = useState('')
  const [targetAudience, setTargetAudience] = useState('')
  const [selectedPlatforms, setSelectedPlatforms] = useState<Platform[]>([])
  const [loading, setLoading] = useState(false)
  const [results, setResults] = useState<PlatformContents | null>(null)
  const [error, setError] = useState<string | null>(null)

  // Get user's subscription tier
  const [maxPlatforms, setMaxPlatforms] = useState(1) // Default: Free tier

  const handleGenerate = async () => {
    if (!prompt || selectedPlatforms.length === 0) {
      setError('프롬프트와 플랫폼을 선택해주세요')
      return
    }

    setLoading(true)
    setError(null)

    try {
      // Get session token
      const { data: { session } } = await supabase.auth.getSession()
      if (!session) {
        setError('로그인이 필요합니다')
        return
      }

      // Call API
      const response = await axios.post(
        '/api/generate',
        {
          prompt,
          platforms: selectedPlatforms,
          targetAudience: targetAudience || undefined,
        },
        {
          headers: {
            Authorization: `Bearer ${session.access_token}`,
          },
        }
      )

      setResults(response.data.results)
    } catch (err: any) {
      if (err.response?.data?.error) {
        setError(err.response.data.error)
      } else {
        setError('콘텐츠 생성에 실패했습니다')
      }
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="max-w-6xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">콘텐츠 생성</h1>

      <div className="space-y-8">
        {/* Prompt Input */}
        <PromptInput
          value={prompt}
          onChange={setPrompt}
          targetAudience={targetAudience}
          onTargetAudienceChange={setTargetAudience}
          disabled={loading}
        />

        {/* Platform Selector */}
        <PlatformSelector
          selected={selectedPlatforms}
          onChange={setSelectedPlatforms}
          maxSelections={maxPlatforms}
          disabled={loading}
        />

        {/* Error Message */}
        {error && (
          <div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
            {error}
          </div>
        )}

        {/* Generate Button */}
        <button
          onClick={handleGenerate}
          disabled={loading || !prompt || selectedPlatforms.length === 0}
          className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
        >
          {loading ? '생성 중...' : '콘텐츠 생성하기'}
        </button>

        {/* Results */}
        {results && (
          <ContentPreview
            platforms={selectedPlatforms}
            results={results}
          />
        )}
      </div>
    </div>
  )
}

Step 4: 히스토리 페이지 구현

app/(dashboard)/history/page.tsx 생성:

'use client'

import { useEffect, useState } from 'react'
import { supabase } from '@/lib/db/supabase'
import { ContentGeneration } from '@/types/content'
import axios from 'axios'
import { formatDistanceToNow } from 'date-fns'
import { ko } from 'date-fns/locale'

export default function HistoryPage() {
  const [history, setHistory] = useState<ContentGeneration[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    loadHistory()
  }, [])

  const loadHistory = async () => {
    try {
      const { data: { session } } = await supabase.auth.getSession()
      if (!session) return

      const response = await axios.get('/api/history', {
        headers: {
          Authorization: `Bearer ${session.access_token}`,
        },
      })

      setHistory(response.data.items)
    } catch (error) {
      console.error('Failed to load history:', error)
    } finally {
      setLoading(false)
    }
  }

  if (loading) {
    return <div className="p-8 text-center">로딩 ...</div>
  }

  return (
    <div className="max-w-6xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">생성 히스토리</h1>

      <div className="space-y-4">
        {history.map((item) => (
          <div
            key={item.id}
            className="p-6 bg-white border border-gray-200 rounded-lg hover:shadow-md transition-shadow"
          >
            <div className="flex justify-between items-start mb-2">
              <h3 className="text-lg font-semibold">{item.prompt}</h3>
              <span className="text-sm text-gray-500">
                {formatDistanceToNow(new Date(item.createdAt), {
                  addSuffix: true,
                  locale: ko,
                })}
              </span>
            </div>

            <div className="flex gap-2 mb-4">
              {item.platforms.map((platform) => (
                <span
                  key={platform}
                  className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded"
                >
                  {platform}
                </span>
              ))}
            </div>

            <button
              onClick={() => {
                // Navigate to detail view or copy content
              }}
              className="text-blue-600 hover:text-blue-700 text-sm font-medium"
            >
              자세히 보기 
            </button>
          </div>
        ))}
      </div>
    </div>
  )
}

Step 5: Stripe 결제 통합 (선택사항)

5.1 Stripe 설정

# https://stripe.com/ 가입
# Developers > API keys에서 키 복사

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx

5.2 Stripe Checkout 생성

lib/stripe/client.ts 생성:

import Stripe from 'stripe'

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
})

app/api/subscription/create-checkout/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe/client'
import { supabaseAdmin } from '@/lib/db/supabase'

export async function POST(request: NextRequest) {
  try {
    const authHeader = request.headers.get('authorization')
    if (!authHeader) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }

    const token = authHeader.replace('Bearer ', '')
    const { data: { user } } = await supabaseAdmin.auth.getUser(token)

    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }

    // Create Stripe checkout session
    const session = await stripe.checkout.sessions.create({
      customer_email: user.email,
      mode: 'subscription',
      payment_method_types: ['card'],
      line_items: [
        {
          price: process.env.STRIPE_PRICE_ID_PRO!,
          quantity: 1,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?canceled=true`,
      metadata: {
        userId: user.id,
      },
    })

    return NextResponse.json({ url: session.url })
  } catch (error) {
    console.error('Checkout error:', error)
    return NextResponse.json({ error: 'Failed to create checkout' }, { status: 500 })
  }
}

Step 6: 배포

6.1 Vercel 배포

# GitHub에 코드 푸시
git add .
git commit -m "Initial commit"
git push origin main

# Vercel에서 프로젝트 import
# https://vercel.com/ > New Project
# GitHub 레포지토리 선택
# 환경 변수 입력
# Deploy!

6.2 환경 변수 설정 (Vercel Dashboard)

ANTHROPIC_API_KEY=sk-ant-xxx
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxx
SUPABASE_SERVICE_ROLE_KEY=eyJxxx
NEXT_PUBLIC_APP_URL=https://yourdomain.vercel.app

📋 체크리스트

필수 구현

  • 프로젝트 구조 설정
  • AI 프롬프트 시스템
  • 백엔드 API
  • 데이터베이스 스키마
  • 핵심 UI 컴포넌트
  • 인증 시스템
  • 메인 생성 페이지
  • 히스토리 페이지
  • 에러 처리
  • 로딩 상태

선택 구현

  • Stripe 결제
  • 이메일 알림
  • 사용량 통계
  • 템플릿 시스템
  • 다크 모드

🎯 다음 단계

  1. Step 1-3 완료 → 기본 기능 동작
  2. Step 4 완료 → 히스토리 관리
  3. Step 5 완료 → 유료 결제 가능
  4. Step 6 완료 → 실제 서비스 런칭!

💡 팁

  • 개발 중: npm run dev로 로컬 테스트
  • 타입 에러: npx tsc --noEmit로 확인
  • API 테스트: Postman 또는 Thunder Client 사용
  • DB 확인: Supabase Dashboard > Table Editor

🚨 주의사항

  1. API 키 보안: .env.local은 절대 커밋하지 않기
  2. Rate Limiting: Claude API 사용량 모니터링
  3. 에러 처리: 모든 API 호출에 try-catch
  4. 사용자 경험: 로딩 상태 명확히 표시

이제 실제 서비스를 만들 준비가 완료되었습니다! 🚀