실제 서비스로 만들기 위한 단계별 구현 가이드입니다.
-
프로젝트 구조 설정
- Next.js 14 프로젝트 구조
- TypeScript 설정
- Tailwind CSS 구성
-
백엔드 API
/api/generate- 콘텐츠 생성/api/history- 히스토리 조회/api/history/[id]- 개별 조회/삭제
-
AI 통합
- Claude API 래퍼
- 플랫폼별 프롬프트 시스템
- 4개 플랫폼 지원 (티스토리, Medium, YouTube, Instagram)
-
데이터베이스 스키마
- Users, Content Generations, Subscriptions 테이블
- Row Level Security (RLS) 설정
- 자동 트리거 및 함수
-
핵심 UI 컴포넌트
- PlatformSelector: 플랫폼 선택기
- PromptInput: 프롬프트 입력
- ContentPreview: 마크다운 프리뷰
# https://console.anthropic.com/ 가입
# API Keys 생성
# .env.local에 추가
ANTHROPIC_API_KEY=sk-ant-xxx# 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-- Supabase > SQL Editor에서 실행
-- lib/db/schema.sql 파일 내용 복사 & 실행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>
)
}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
}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>
)
}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>
)
}# https://stripe.com/ 가입
# Developers > API keys에서 키 복사
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxxlib/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 })
}
}# GitHub에 코드 푸시
git add .
git commit -m "Initial commit"
git push origin main
# Vercel에서 프로젝트 import
# https://vercel.com/ > New Project
# GitHub 레포지토리 선택
# 환경 변수 입력
# Deploy!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 결제
- 이메일 알림
- 사용량 통계
- 템플릿 시스템
- 다크 모드
- Step 1-3 완료 → 기본 기능 동작
- Step 4 완료 → 히스토리 관리
- Step 5 완료 → 유료 결제 가능
- Step 6 완료 → 실제 서비스 런칭!
- 개발 중:
npm run dev로 로컬 테스트 - 타입 에러:
npx tsc --noEmit로 확인 - API 테스트: Postman 또는 Thunder Client 사용
- DB 확인: Supabase Dashboard > Table Editor
- API 키 보안:
.env.local은 절대 커밋하지 않기 - Rate Limiting: Claude API 사용량 모니터링
- 에러 처리: 모든 API 호출에 try-catch
- 사용자 경험: 로딩 상태 명확히 표시
이제 실제 서비스를 만들 준비가 완료되었습니다! 🚀