시리즈 목차 1~12편 – Supabase 이론 완벽 가이드 13편 – 실전: 실시간 채팅 앱 14편 👉 실전: SaaS 대시보드 (현재 글) 15편 – 실전: AI 챗봇 서비스 16편 – 실전: Todo/협업툴
완성 기능 미리보기
✅ 이메일/소셜 인증 (Supabase Auth)
✅ 3-tier 플랜 (Free / Pro / Team)
✅ Stripe Checkout 결제
✅ Stripe Customer Portal (구독 관리/취소)
✅ Stripe Webhook → Supabase DB 동기화
✅ 플랜별 기능 제한 (사용량 추적)
✅ 팀 멤버 초대 / 역할(RBAC)
✅ 사용량 대시보드 (차트)
✅ 미들웨어 기반 접근 제어
my-saas/
├── src/
│ ├── app/
│ │ ├── (marketing)/
│ │ │ ├── page.tsx ← 랜딩 페이지
│ │ │ └── pricing/page.tsx ← 가격 페이지
│ │ ├── (dashboard)/
│ │ │ ├── layout.tsx ← 인증 보호 레이아웃
│ │ │ ├── dashboard/page.tsx
│ │ │ ├── billing/page.tsx
│ │ │ └── settings/
│ │ │ ├── page.tsx
│ │ │ └── team/page.tsx
│ │ ├── api/
│ │ │ ├── stripe/
│ │ │ │ ├── checkout/route.ts
│ │ │ │ ├── portal/route.ts
│ │ │ │ └── webhook/route.ts
│ │ │ └── team/invite/route.ts
│ │ └── auth/callback/route.ts
│ ├── lib/
│ │ ├── supabase/
│ │ ├── stripe.ts
│ │ ├── plans.ts ← 플랜 정의
│ │ └── database.types.ts
│ └── middleware.ts
└── supabase/migrations/
1단계: 플랜 정의
// src/lib/plans.ts
export type PlanId = 'free' | 'pro' | 'team'
export interface Plan {
id: PlanId
name: string
price: { monthly: number; yearly: number }
stripePriceId: { monthly: string; yearly: string }
limits: {
projects: number // -1 = 무제한
teamMembers: number
apiCalls: number // 월간
storage: number // MB
}
features: string[]
}
export const PLANS: Record<PlanId, Plan> = {
free: {
id: 'free',
name: 'Free',
price: { monthly: 0, yearly: 0 },
stripePriceId: { monthly: '', yearly: '' },
limits: {
projects: 3,
teamMembers: 1,
apiCalls: 1_000,
storage: 100,
},
features: [
'프로젝트 최대 3개',
'월 1,000 API 호출',
'100MB 스토리지',
'이메일 지원',
],
},
pro: {
id: 'pro',
name: 'Pro',
price: { monthly: 19, yearly: 190 },
stripePriceId: {
monthly: process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID!,
yearly: process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID!,
},
limits: {
projects: 20,
teamMembers: 5,
apiCalls: 50_000,
storage: 5_000,
},
features: [
'프로젝트 최대 20개',
'월 50,000 API 호출',
'5GB 스토리지',
'팀 멤버 최대 5명',
'우선 이메일 지원',
],
},
team: {
id: 'team',
name: 'Team',
price: { monthly: 49, yearly: 490 },
stripePriceId: {
monthly: process.env.NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID!,
yearly: process.env.NEXT_PUBLIC_STRIPE_TEAM_YEARLY_PRICE_ID!,
},
limits: {
projects: -1,
teamMembers: -1,
apiCalls: 500_000,
storage: 50_000,
},
features: [
'무제한 프로젝트',
'월 500,000 API 호출',
'50GB 스토리지',
'무제한 팀 멤버',
'전화 + 이메일 지원',
'SAML SSO',
],
},
}
export function getPlan(planId: PlanId | null | undefined): Plan {
return PLANS[planId ?? 'free']
}
export function isWithinLimit(current: number, limit: number): boolean {
return limit === -1 || current < limit
}
2단계: 데이터베이스 스키마
supabase migration new saas_schema
-- supabase/migrations/[timestamp]_saas_schema.sql
-- 프로필
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT,
avatar_url TEXT,
-- Stripe
stripe_customer_id TEXT UNIQUE,
-- 현재 플랜 (webhook이 업데이트)
plan TEXT DEFAULT 'free' CHECK (plan IN ('free','pro','team')),
plan_status TEXT DEFAULT 'active' CHECK (plan_status IN ('active','trialing','past_due','canceled')),
-- 구독 정보
subscription_id TEXT UNIQUE,
subscription_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 조직 (팀 기능)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
owner_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
plan TEXT DEFAULT 'free',
stripe_customer_id TEXT UNIQUE,
subscription_id TEXT UNIQUE,
subscription_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 조직 멤버
CREATE TABLE org_members (
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member' CHECK (role IN ('owner','admin','member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (org_id, user_id)
);
-- 팀 초대
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT DEFAULT 'member',
token TEXT NOT NULL UNIQUE DEFAULT encode(gen_random_bytes(32), 'hex'),
invited_by UUID REFERENCES profiles(id),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days',
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 프로젝트 (예시 리소스)
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 사용량 추적
CREATE TABLE usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- 'api_call' | 'storage_upload'
amount INTEGER DEFAULT 1,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 월간 사용량 집계 뷰
CREATE OR REPLACE VIEW monthly_usage AS
SELECT
org_id,
type,
SUM(amount) AS total,
DATE_TRUNC('month', created_at) AS month
FROM usage_logs
WHERE created_at >= DATE_TRUNC('month', NOW())
GROUP BY org_id, type, DATE_TRUNC('month', created_at);
-- 인덱스
CREATE INDEX idx_projects_org ON projects(org_id);
CREATE INDEX idx_usage_org_month ON usage_logs(org_id, created_at DESC);
CREATE INDEX idx_invitations_token ON invitations(token);
-- 신규 유저 프로필 자동 생성
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, name, avatar_url)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'name', split_part(NEW.email,'@',1)),
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
RLS 정책
-- supabase/migrations/[timestamp]_saas_rls.sql
-- profiles
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "본인 프로필 조회/수정"
ON profiles FOR ALL TO authenticated
USING ((SELECT auth.uid()) = id)
WITH CHECK ((SELECT auth.uid()) = id);
-- organizations: 멤버만 조회
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "멤버 조회"
ON organizations FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM org_members
WHERE org_id = id AND user_id = (SELECT auth.uid())
)
);
CREATE POLICY "소유자 수정"
ON organizations FOR UPDATE TO authenticated
USING (owner_id = (SELECT auth.uid()));
-- org_members
ALTER TABLE org_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "멤버 조회"
ON org_members FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM org_members om
WHERE om.org_id = org_id AND om.user_id = (SELECT auth.uid())
)
);
-- projects
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "멤버 CRUD"
ON projects FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM org_members
WHERE org_id = projects.org_id AND user_id = (SELECT auth.uid())
)
);
-- invitations: admin/owner만 관리
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "admin 이상 조회"
ON invitations FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM org_members
WHERE org_id = invitations.org_id
AND user_id = (SELECT auth.uid())
AND role IN ('owner','admin')
)
);
3단계: Stripe 설정
npm install stripe @stripe/stripe-js
// src/lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-01-27.acacia',
})
// Stripe Customer 생성 또는 조회
export async function getOrCreateStripeCustomer(
userId: string,
email: string,
name?: string | null
): Promise<string> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', userId)
.single()
if (profile?.stripe_customer_id) {
return profile.stripe_customer_id
}
const customer = await stripe.customers.create({
email,
name: name ?? undefined,
metadata: { supabase_user_id: userId },
})
await supabase
.from('profiles')
.update({ stripe_customer_id: customer.id })
.eq('id', userId)
return customer.id
}
4단계: Stripe Checkout API 라우트
// src/app/api/stripe/checkout/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe, getOrCreateStripeCustomer } from '@/lib/stripe'
import { PLANS, type PlanId } from '@/lib/plans'
export async function POST(req: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { planId, interval }: { planId: PlanId; interval: 'monthly' | 'yearly' } = await req.json()
const plan = PLANS[planId]
if (!plan || planId === 'free') {
return NextResponse.json({ error: 'Invalid plan' }, { status: 400 })
}
const customerId = await getOrCreateStripeCustomer(
user.id,
user.email!,
user.user_metadata.name
)
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{
price: plan.stripePriceId[interval],
quantity: 1,
}],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
subscription_data: {
metadata: {
supabase_user_id: user.id,
plan_id: planId,
},
},
allow_promotion_codes: true,
})
return NextResponse.json({ url: session.url })
}
5단계: Stripe Customer Portal
// src/app/api/stripe/portal/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe } from '@/lib/stripe'
export async function POST() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single()
if (!profile?.stripe_customer_id) {
return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: profile.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
})
return NextResponse.json({ url: portalSession.url })
}
6단계: Stripe Webhook (핵심)
Stripe의 모든 결제·구독 이벤트를 수신해 Supabase DB를 동기화합니다.
// src/app/api/stripe/webhook/route.ts
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import { createServiceClient } from '@/lib/supabase/service'
import Stripe from 'stripe'
// 서비스 롤 클라이언트 (RLS 우회)
// src/lib/supabase/service.ts
// import { createClient } from '@supabase/supabase-js'
// export const createServiceClient = () => createClient(url, serviceKey)
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
const supabase = createServiceClient()
switch (event.type) {
// 결제 성공 → 구독 활성화
case 'checkout.session.completed': {
const session = event.data.object as Stripe.CheckoutSession
if (session.mode !== 'subscription') break
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
const userId = subscription.metadata.supabase_user_id
const planId = subscription.metadata.plan_id
await supabase.from('profiles').update({
plan: planId,
plan_status: subscription.status,
subscription_id: subscription.id,
subscription_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
}).eq('id', userId)
break
}
// 구독 갱신/변경/취소
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const userId = subscription.metadata.supabase_user_id
const planId = subscription.metadata.plan_id
const isActive = ['active', 'trialing'].includes(subscription.status)
await supabase.from('profiles').update({
plan: isActive ? planId : 'free',
plan_status: subscription.status,
subscription_id: subscription.status === 'canceled' ? null : subscription.id,
subscription_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
}).eq('id', userId)
break
}
// 결제 실패
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
const customerId = invoice.customer as string
await supabase.from('profiles')
.update({ plan_status: 'past_due' })
.eq('stripe_customer_id', customerId)
break
}
}
return NextResponse.json({ received: true })
}
7단계: 플랜 제한 미들웨어 패턴
// src/lib/plan-guard.ts
import { createClient } from '@/lib/supabase/server'
import { getPlan, isWithinLimit } from '@/lib/plans'
export async function checkLimit(
orgId: string,
resource: 'projects' | 'teamMembers' | 'apiCalls'
): Promise<{ allowed: boolean; current: number; limit: number }> {
const supabase = await createClient()
// 조직 플랜 조회
const { data: org } = await supabase
.from('organizations')
.select('plan')
.eq('id', orgId)
.single()
const plan = getPlan(org?.plan as any)
const limit = plan.limits[resource]
// 현재 사용량 조회
let current = 0
if (resource === 'projects') {
const { count } = await supabase
.from('projects')
.select('*', { count: 'exact', head: true })
.eq('org_id', orgId)
current = count ?? 0
} else if (resource === 'teamMembers') {
const { count } = await supabase
.from('org_members')
.select('*', { count: 'exact', head: true })
.eq('org_id', orgId)
current = count ?? 0
} else if (resource === 'apiCalls') {
const { data } = await supabase
.from('monthly_usage')
.select('total')
.eq('org_id', orgId)
.eq('type', 'api_call')
.single()
current = data?.total ?? 0
}
return {
allowed: isWithinLimit(current, limit),
current,
limit,
}
}
// 프로젝트 생성 Server Action
// src/app/(dashboard)/dashboard/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { checkLimit } from '@/lib/plan-guard'
export async function createProject(orgId: string, name: string) {
const { allowed, limit } = await checkLimit(orgId, 'projects')
if (!allowed) {
return {
error: `현재 플랜에서는 프로젝트를 ${limit}개까지 만들 수 있습니다. 업그레이드가 필요합니다.`,
}
}
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { error } = await supabase.from('projects').insert({
org_id: orgId,
name,
created_by: user!.id,
})
if (error) return { error: error.message }
revalidatePath('/dashboard')
return { success: true }
}
8단계: 팀 멤버 초대
// src/app/api/team/invite/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { checkLimit } from '@/lib/plan-guard'
export async function POST(req: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { orgId, email, role } = await req.json()
// 초대 권한 확인 (admin 이상)
const { data: member } = await supabase
.from('org_members')
.select('role')
.eq('org_id', orgId)
.eq('user_id', user.id)
.single()
if (!member || !['owner', 'admin'].includes(member.role)) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
// 팀 멤버 수 제한 확인
const { allowed, limit } = await checkLimit(orgId, 'teamMembers')
if (!allowed) {
return NextResponse.json({
error: `현재 플랜은 팀 멤버 ${limit}명까지 지원합니다.`
}, { status: 402 })
}
// 초대 레코드 생성
const { data: invitation, error } = await supabase
.from('invitations')
.insert({ org_id: orgId, email, role, invited_by: user.id })
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
// 초대 이메일 발송 (Edge Function 또는 Resend 활용)
await supabase.functions.invoke('send-invite-email', {
body: {
to: email,
inviteUrl: `${process.env.NEXT_PUBLIC_APP_URL}/invite/${invitation.token}`,
orgName: 'My SaaS',
inviterName: user.email,
},
})
return NextResponse.json({ success: true })
}
9단계: 빌링 페이지 UI
// src/app/(dashboard)/dashboard/billing/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getPlan, PLANS } from '@/lib/plans'
import { BillingClient } from './BillingClient'
export default async function BillingPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase
.from('profiles')
.select('plan, plan_status, subscription_period_end, stripe_customer_id')
.eq('id', user.id)
.single()
const currentPlan = getPlan(profile?.plan as any)
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-2">빌링 & 구독</h1>
{/* 현재 플랜 카드 */}
<div className="bg-white rounded-xl border p-6 mb-8">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">현재 플랜</p>
<p className="text-2xl font-bold">{currentPlan.name}</p>
{profile?.subscription_period_end && (
<p className="text-sm text-gray-500 mt-1">
다음 갱신일: {new Date(profile.subscription_period_end).toLocaleDateString('ko-KR')}
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
profile?.plan_status === 'active'
? 'bg-green-100 text-green-700'
: profile?.plan_status === 'past_due'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-600'
}`}>
{profile?.plan_status === 'active' ? '활성' :
profile?.plan_status === 'past_due' ? '결제 실패' : '무료'}
</span>
</div>
</div>
</div>
{/* 플랜 비교 카드 */}
<BillingClient currentPlanId={currentPlan.id} hasSubscription={!!profile?.stripe_customer_id} />
</div>
)
}
// src/app/(dashboard)/dashboard/billing/BillingClient.tsx
'use client'
import { useState } from 'react'
import { PLANS, type PlanId } from '@/lib/plans'
import { useRouter } from 'next/navigation'
interface Props {
currentPlanId: PlanId
hasSubscription: boolean
}
export function BillingClient({ currentPlanId, hasSubscription }: Props) {
const router = useRouter()
const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly')
const [loading, setLoading] = useState<string | null>(null)
const handleCheckout = async (planId: PlanId) => {
if (planId === 'free') return
setLoading(planId)
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId, interval }),
})
const { url } = await res.json()
window.location.href = url
} finally {
setLoading(null)
}
}
const handlePortal = async () => {
setLoading('portal')
try {
const res = await fetch('/api/stripe/portal', { method: 'POST' })
const { url } = await res.json()
window.location.href = url
} finally {
setLoading(null)
}
}
return (
<div>
{/* 연간/월간 토글 */}
<div className="flex items-center justify-center gap-4 mb-8">
<span className={interval === 'monthly' ? 'font-semibold' : 'text-gray-400'}>월간</span>
<button
onClick={() => setInterval(v => v === 'monthly' ? 'yearly' : 'monthly')}
className={`relative w-12 h-6 rounded-full transition-colors ${
interval === 'yearly' ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
interval === 'yearly' ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
<span className={interval === 'yearly' ? 'font-semibold' : 'text-gray-400'}>
연간 <span className="text-green-500 text-sm">2개월 무료</span>
</span>
</div>
{/* 플랜 카드 */}
<div className="grid grid-cols-3 gap-4">
{Object.values(PLANS).map((plan) => {
const isCurrentPlan = plan.id === currentPlanId
const price = interval === 'yearly'
? Math.round(plan.price.yearly / 12)
: plan.price.monthly
return (
<div key={plan.id} className={`rounded-xl border-2 p-6 ${
isCurrentPlan ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 bg-white'
}`}>
<h3 className="font-bold text-lg">{plan.name}</h3>
<div className="mt-2 mb-4">
<span className="text-3xl font-bold">${price}</span>
<span className="text-gray-500">/월</span>
</div>
<ul className="space-y-2 mb-6">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm text-gray-600">
<span className="text-green-500">✓</span> {f}
</li>
))}
</ul>
{isCurrentPlan ? (
<div className="space-y-2">
<div className="w-full py-2 text-center text-indigo-600 font-medium text-sm bg-indigo-100 rounded-lg">
현재 플랜
</div>
{hasSubscription && (
<button
onClick={handlePortal}
disabled={loading === 'portal'}
className="w-full py-2 text-sm text-gray-500 hover:text-gray-700 underline"
>
구독 관리 / 취소
</button>
)}
</div>
) : (
<button
onClick={() => handleCheckout(plan.id)}
disabled={!!loading || plan.id === 'free'}
className={`w-full py-2 rounded-lg font-medium text-sm transition-colors ${
plan.id === 'free'
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-indigo-500 text-white hover:bg-indigo-600 disabled:opacity-50'
}`}
>
{loading === plan.id ? '처리 중...' : plan.id === 'free' ? '다운그레이드' : '업그레이드'}
</button>
)}
</div>
)
})}
</div>
</div>
)
}
10단계: 사용량 대시보드
// src/app/(dashboard)/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getPlan } from '@/lib/plans'
import { UsageBar } from '@/components/UsageBar'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase
.from('profiles')
.select('plan')
.eq('id', user.id)
.single()
const plan = getPlan(profile?.plan as any)
// 사용량 조회 (본인 프로필 기준, 조직 기능 있으면 org_id로)
const [{ count: projectCount }, { data: usageData }] = await Promise.all([
supabase
.from('projects')
.select('*', { count: 'exact', head: true }),
supabase
.from('monthly_usage')
.select('type, total')
.eq('type', 'api_call'),
])
const apiCallsUsed = usageData?.[0]?.total ?? 0
const usageItems = [
{
label: '프로젝트',
current: projectCount ?? 0,
limit: plan.limits.projects,
unit: '개',
},
{
label: 'API 호출 (이번 달)',
current: apiCallsUsed,
limit: plan.limits.apiCalls,
unit: '회',
},
]
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">대시보드</h1>
{/* 플랜 배너 */}
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl p-6 text-white mb-8">
<p className="text-sm opacity-80">현재 플랜</p>
<p className="text-3xl font-bold">{plan.name}</p>
</div>
{/* 사용량 */}
<div className="bg-white rounded-xl border p-6 space-y-6">
<h2 className="font-semibold">리소스 사용량</h2>
{usageItems.map((item) => (
<UsageBar key={item.label} {...item} />
))}
</div>
</div>
)
}
// src/components/UsageBar.tsx
interface Props {
label: string
current: number
limit: number
unit: string
}
export function UsageBar({ label, current, limit, unit }: Props) {
const isUnlimited = limit === -1
const percentage = isUnlimited ? 0 : Math.min((current / limit) * 100, 100)
const isNearLimit = percentage > 80
const isAtLimit = percentage >= 100
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{label}</span>
<span className={isAtLimit ? 'text-red-500 font-semibold' : 'text-gray-500'}>
{current.toLocaleString()}{unit} / {isUnlimited ? '무제한' : `${limit.toLocaleString()}${unit}`}
</span>
</div>
{!isUnlimited && (
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
isAtLimit ? 'bg-red-500' : isNearLimit ? 'bg-yellow-500' : 'bg-indigo-500'
}`}
style={{ width: `${percentage}%` }}
/>
</div>
)}
{isAtLimit && (
<p className="text-xs text-red-500 mt-1">
한도에 도달했습니다. 플랜을 업그레이드하세요.
</p>
)}
</div>
)
}
11단계: 환경 변수 & 배포
# .env.local
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID=price_...
NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID=price_...
NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID=price_...
NEXT_PUBLIC_STRIPE_TEAM_YEARLY_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
로컬 Stripe Webhook 테스트
# Stripe CLI 설치 후
stripe listen --forward-to localhost:3000/api/stripe/webhook
# 테스트 결제 트리거
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
마치며
이번 편에서는 실제 SaaS 제품의 핵심인 구독 결제 → DB 동기화 → 플랜 제한 적용의 전체 흐름을 구현했습니다. Stripe Webhook이 결제 이벤트를 수신하고 Supabase를 업데이트하는 패턴은 SaaS 개발의 핵심 패턴으로, 한 번 익혀두면 어떤 제품에도 적용할 수 있습니다.
다음 15편에서는 AI 챗봇 서비스를 만듭니다. Edge Functions로 OpenAI 스트리밍 프록시를 구성하고, pgvector로 문서 기반 RAG 검색을 구현합니다.