Supabase 완전 정복 시리즈 14편 — 실전: SaaS 대시보드 (Stripe 결제 + 플랜 관리 + 팀 기능)




시리즈 목차 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 검색을 구현합니다.




댓글 남기기