⏱️ 예상 읽기 시간: 22분

서론

SaaS(Software as a Service) 애플리케이션 개발에서 가장 중요한 요소 중 하나는 안정적이고 확장 가능한 구독 결제 시스템입니다. Next.js SaaS Stripe Starter는 이러한 복잡한 요구사항을 만족하는 완전한 솔루션을 제공합니다.

이 가이드에서는 Next.js, Supabase, Stripe를 결합하여 프로덕션 준비가 완료된 SaaS 애플리케이션을 구축하는 모든 과정을 다루겠습니다. 사용자 인증부터 구독 관리, 결제 처리, 웹훅 처리까지 실무에 필요한 모든 기능을 구현해보겠습니다.

아키텍처 개요

🏗️ 기술 스택

technology_stack:
  frontend:
    framework: "Next.js 14"
    language: "TypeScript"
    styling: "Tailwind CSS"
    ui_components: "Headless UI"
    
  backend:
    runtime: "Next.js API Routes"
    database: "Supabase (PostgreSQL)"
    auth: "Supabase Auth"
    file_storage: "Supabase Storage"
    
  payments:
    gateway: "Stripe"
    features: ["구독", "일회성 결제", "고객 포털"]
    
  deployment:
    platform: "Vercel"
    cdn: "Vercel Edge Network"
    monitoring: "Vercel Analytics"

🔄 시스템 아키텍처

graph LR
    A[사용자] --> B[Next.js App]
    B --> C[Supabase Auth]
    B --> D[Supabase DB]
    B --> E[Stripe API]
    E --> F[Stripe Webhook]
    F --> B
    B --> G[Vercel Deployment]

📊 데이터베이스 스키마

-- 주요 테이블 구조
table_structure = {
  "users": {
    "id": "uuid PRIMARY KEY",
    "email": "text",
    "full_name": "text",
    "avatar_url": "text",
    "billing_address": "jsonb",
    "payment_method": "jsonb"
  },
  
  "products": {
    "id": "text PRIMARY KEY",
    "active": "boolean",
    "name": "text",
    "description": "text",
    "image": "text",
    "metadata": "jsonb"
  },
  
  "prices": {
    "id": "text PRIMARY KEY", 
    "product_id": "text REFERENCES products",
    "active": "boolean",
    "currency": "text",
    "type": "pricing_type",
    "unit_amount": "bigint",
    "interval": "pricing_plan_interval",
    "interval_count": "integer"
  },
  
  "subscriptions": {
    "id": "text PRIMARY KEY",
    "user_id": "uuid REFERENCES users",
    "status": "subscription_status",
    "metadata": "jsonb",
    "price_id": "text REFERENCES prices",
    "quantity": "integer",
    "cancel_at_period_end": "boolean",
    "created": "timestamp",
    "current_period_start": "timestamp",
    "current_period_end": "timestamp",
    "ended_at": "timestamp",
    "cancel_at": "timestamp",
    "canceled_at": "timestamp"
  }
}

프로젝트 설정

🚀 빠른 시작 (Vercel 배포 버튼 사용)

# 1. Vercel Deploy 버튼 클릭
# https://vercel.com/new/templates/next.js/subscription-starter

# 2. GitHub 저장소 생성 및 Vercel 배포
# 3. Supabase 프로젝트 자동 생성
# 4. 환경 변수 자동 설정

echo "✅ 1-클릭 배포 완료!"

📦 수동 설치

# 저장소 클론
git clone https://github.com/vercel/nextjs-subscription-payments.git
cd nextjs-subscription-payments

# 의존성 설치
pnpm install

# 환경 변수 파일 생성
cp .env.local.example .env.local
cp .env.example .env

🛠️ 개발 환경 설정

# package.json 스크립트 확인
{
  "scripts": {
    "dev": "next dev",
    "build": "next build", 
    "start": "next start",
    "lint": "next lint",
    "supabase:start": "supabase start",
    "supabase:stop": "supabase stop",
    "supabase:status": "supabase status",
    "supabase:reset": "supabase db reset",
    "supabase:link": "supabase link",
    "supabase:pull": "supabase db pull",
    "supabase:push": "supabase db push",
    "supabase:generate-types": "supabase gen types typescript --local > types_db.ts",
    "supabase:generate-migration": "supabase db diff -f migration_name",
    "supabase:generate-seed": "supabase db dump --data-only -f supabase/seed.sql",
    "stripe:login": "stripe login",
    "stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks"
  }
}

Supabase 설정

🗄️ 데이터베이스 설정

프로젝트 생성 및 초기화

# Supabase CLI 설치
npm install -g supabase

# 새 Supabase 프로젝트 생성
supabase init

# 로컬 Supabase 시작
pnpm supabase:start

# 데이터베이스 스키마 적용
supabase db reset

데이터베이스 스키마

-- supabase/migrations/001_initial_schema.sql

-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Create custom types
CREATE TYPE pricing_type AS ENUM ('one_time', 'recurring');
CREATE TYPE pricing_plan_interval AS ENUM ('day', 'week', 'month', 'year');
CREATE TYPE subscription_status AS ENUM (
  'trialing', 'active', 'canceled', 'incomplete', 
  'incomplete_expired', 'past_due', 'unpaid', 'paused'
);

-- Users table (extends Supabase auth.users)
CREATE TABLE users (
  id UUID REFERENCES auth.users NOT NULL PRIMARY KEY,
  full_name TEXT,
  avatar_url TEXT,
  billing_address JSONB,
  payment_method JSONB
);

-- Products table
CREATE TABLE products (
  id TEXT PRIMARY KEY,
  active BOOLEAN DEFAULT TRUE,
  name TEXT NOT NULL,
  description TEXT,
  image TEXT,
  metadata JSONB DEFAULT '{}'::jsonb
);

-- Prices table
CREATE TABLE prices (
  id TEXT PRIMARY KEY,
  product_id TEXT REFERENCES products(id) ON DELETE CASCADE,
  active BOOLEAN DEFAULT TRUE,
  description TEXT,
  unit_amount BIGINT,
  currency TEXT CHECK (char_length(currency) = 3),
  type pricing_type,
  interval pricing_plan_interval,
  interval_count INTEGER,
  trial_period_days INTEGER,
  metadata JSONB DEFAULT '{}'::jsonb
);

-- Subscriptions table
CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY,
  user_id UUID REFERENCES auth.users NOT NULL,
  status subscription_status,
  metadata JSONB DEFAULT '{}'::jsonb,
  price_id TEXT REFERENCES prices(id),
  quantity INTEGER,
  cancel_at_period_end BOOLEAN DEFAULT FALSE,
  created TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
  current_period_start TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
  current_period_end TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
  ended_at TIMESTAMP WITH TIME ZONE,
  cancel_at TIMESTAMP WITH TIME ZONE,
  canceled_at TIMESTAMP WITH TIME ZONE,
  trial_start TIMESTAMP WITH TIME ZONE,
  trial_end TIMESTAMP WITH TIME ZONE
);

-- Enable Row Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

-- Create policies
CREATE POLICY "Users can view own user data" ON users FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own user data" ON users FOR UPDATE USING (auth.uid() = id);

CREATE POLICY "Allow public read-only access to products" ON products FOR SELECT USING (true);
CREATE POLICY "Allow public read-only access to prices" ON prices FOR SELECT USING (true);

CREATE POLICY "Users can view own subscriptions" ON subscriptions FOR SELECT USING (auth.uid() = user_id);

-- Create functions
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.users (id, full_name, avatar_url)
  VALUES (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
  RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY definer;

-- Create trigger
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE PROCEDURE handle_new_user();

🔐 인증 설정

GitHub OAuth 설정

// 1. GitHub에서 OAuth App 생성
// Settings > Developer settings > OAuth Apps > New OAuth App

const githubOAuthConfig = {
  applicationName: "Your SaaS App",
  homepageURL: "https://your-app.vercel.app",
  authorizationCallbackURL: "https://your-app.vercel.app/auth/callback",
  // 로컬 개발용
  localCallbackURL: "http://localhost:3000/auth/callback"
};

// 2. Supabase에서 GitHub Provider 설정
// Authentication > Settings > Auth Providers > GitHub

Supabase 환경 변수

# .env.local
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key

📱 타입 생성

# TypeScript 타입 자동 생성
pnpm supabase:generate-types

# 생성된 타입 확인
cat types_db.ts

Stripe 설정

🏪 제품 및 가격 설정

Stripe 대시보드에서 제품 생성

// 제품 예시 구조
const products = [
  {
    name: "Basic Plan",
    description: "기본 기능을 포함한 스타터 플랜",
    prices: [
      {
        amount: 999, // $9.99
        currency: "usd",
        interval: "month"
      },
      {
        amount: 9999, // $99.99 (연간 할인)
        currency: "usd", 
        interval: "year"
      }
    ]
  },
  {
    name: "Pro Plan",
    description: "고급 기능을 포함한 프로 플랜",
    prices: [
      {
        amount: 2999, // $29.99
        currency: "usd",
        interval: "month"
      },
      {
        amount: 29999, // $299.99
        currency: "usd",
        interval: "year"
      }
    ]
  }
];

Stripe Fixtures로 테스트 데이터 생성

# Stripe CLI 로그인
stripe login

# 테스트 제품 및 가격 생성
stripe fixtures fixtures/stripe-fixtures.json
<!-- fixtures/stripe-fixtures.json -->
{
  "products": [
    {
      "id": "prod_starter",
      "name": "Starter",
      "description": "Perfect for small teams",
      "metadata": {
        "features": "5 users, 10GB storage, Basic support"
      }
    },
    {
      "id": "prod_pro", 
      "name": "Pro",
      "description": "For growing businesses",
      "metadata": {
        "features": "25 users, 100GB storage, Priority support"
      }
    }
  ],
  "prices": [
    {
      "id": "price_starter_monthly",
      "product": "prod_starter",
      "unit_amount": 999,
      "currency": "usd",
      "recurring": {
        "interval": "month"
      }
    },
    {
      "id": "price_starter_yearly",
      "product": "prod_starter", 
      "unit_amount": 9999,
      "currency": "usd",
      "recurring": {
        "interval": "year"
      }
    },
    {
      "id": "price_pro_monthly",
      "product": "prod_pro",
      "unit_amount": 2999,
      "currency": "usd",
      "recurring": {
        "interval": "month"
      }
    },
    {
      "id": "price_pro_yearly",
      "product": "prod_pro",
      "unit_amount": 29999,
      "currency": "usd",
      "recurring": {
        "interval": "year"
      }
    }
  ]
}

🎣 웹훅 설정

웹훅 엔드포인트 생성

// app/api/webhooks/route.ts
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { stripe } from '@/utils/stripe';
import {
  upsertProductRecord,
  upsertPriceRecord,
  manageSubscriptionStatusChange,
  deleteProductRecord,
  deletePriceRecord
} from '@/utils/supabase-admin';

const relevantEvents = new Set([
  'product.created',
  'product.updated',
  'product.deleted',
  'price.created', 
  'price.updated',
  'price.deleted',
  'checkout.session.completed',
  'customer.subscription.created',
  'customer.subscription.updated',
  'customer.subscription.deleted',
  'invoice.payment_succeeded',
  'invoice.payment_failed'
]);

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = headers().get('stripe-signature') as string;
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event: Stripe.Event;

  try {
    if (!sig || !webhookSecret) {
      console.error('Missing stripe signature or webhook secret');
      return new NextResponse('Webhook secret not found.', { status: 400 });
    }
    
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
    console.log(`🔔 Webhook received: ${event.type}`);
  } catch (err: any) {
    console.error(`❌ Webhook signature verification failed: ${err.message}`);
    return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 });
  }

  if (relevantEvents.has(event.type)) {
    try {
      switch (event.type) {
        case 'product.created':
        case 'product.updated':
          await upsertProductRecord(event.data.object as Stripe.Product);
          break;
        case 'price.created':
        case 'price.updated':
          await upsertPriceRecord(event.data.object as Stripe.Price);
          break;
        case 'price.deleted':
          await deletePriceRecord(event.data.object as Stripe.Price);
          break;
        case 'product.deleted':
          await deleteProductRecord(event.data.object as Stripe.Product);
          break;
        case 'customer.subscription.created':
        case 'customer.subscription.updated':
        case 'customer.subscription.deleted':
          const subscription = event.data.object as Stripe.Subscription;
          await manageSubscriptionStatusChange(
            subscription.id,
            subscription.customer as string,
            event.type === 'customer.subscription.created'
          );
          break;
        case 'checkout.session.completed':
          const checkoutSession = event.data.object as Stripe.Checkout.Session;
          if (checkoutSession.mode === 'subscription') {
            const subscriptionId = checkoutSession.subscription;
            await manageSubscriptionStatusChange(
              subscriptionId as string,
              checkoutSession.customer as string,
              true
            );
          }
          break;
        default:
          throw new Error('Unhandled relevant event!');
      }
    } catch (error) {
      console.error('Error processing webhook:', error);
      return new NextResponse('Webhook handler failed. View your nextjs function logs.', {
        status: 400
      });
    }
  } else {
    return new NextResponse(`Unsupported event type: ${event.type}`, {
      status: 400
    });
  }
  
  return new NextResponse(JSON.stringify({ received: true }));
}

로컬 웹훅 테스트

# 터미널 1: Next.js 개발 서버
pnpm dev

# 터미널 2: Stripe 웹훅 리스닝  
pnpm stripe:listen

# 출력에서 웹훅 시크릿 복사하여 .env.local에 추가
# STRIPE_WEBHOOK_SECRET=whsec_xxxxx

핵심 기능 구현

💳 구독 체크아웃

// utils/stripe-helpers.ts
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { getStripe } from '@/utils/stripe-client';
import { Price, ProductWithPrice } from '@/types';

interface CheckoutResponse {
  errorRedirect?: string;
  sessionId?: string;
}

export const getErrorRedirect = (
  path: string,
  errorMsgPrefix: string,
  errorMsg: string = 'An unknown error occurred.'
) => `${path}?error=${encodeURIComponent(`${errorMsgPrefix}: ${errorMsg}`)}`;

export const redirectToCheckout = async (price: Price): Promise<void> => {
  try {
    const supabase = createClientComponentClient();
    
    // 사용자 인증 확인
    const {
      data: { user }
    } = await supabase.auth.getUser();

    if (!user) {
      throw new Error('사용자가 로그인되어 있지 않습니다.');
    }

    // 체크아웃 세션 생성
    const { data: checkoutResponse, error } = await supabase.functions.invoke(
      'create-checkout-session',
      {
        body: {
          price: price,
          quantity: 1,
          metadata: {
            user_id: user.id
          }
        }
      }
    );

    if (error) {
      throw new Error(error.message);
    }

    const { sessionId } = checkoutResponse as CheckoutResponse;
    
    if (!sessionId) {
      throw new Error('체크아웃 세션 생성에 실패했습니다.');
    }

    // Stripe Checkout으로 리디렉션
    const stripe = await getStripe();
    const { error: stripeError } = await stripe!.redirectToCheckout({
      sessionId
    });

    if (stripeError) {
      throw new Error(stripeError.message);
    }
  } catch (error) {
    console.error('Checkout error:', error);
    return window.location.assign(
      getErrorRedirect(
        '/pricing',
        '체크아웃 오류',
        error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
      )
    );
  }
};

체크아웃 API Route

// app/api/create-checkout-session/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/utils/stripe';
import { createOrRetrieveCustomer } from '@/utils/supabase-admin';
import { getURL } from '@/utils/helpers';

export async function POST(req: NextRequest) {
  try {
    const { price, quantity = 1, metadata = {} } = await req.json();
    
    const supabase = createRouteHandlerClient({ cookies });
    const {
      data: { user }
    } = await supabase.auth.getUser();

    if (!user) {
      throw new Error('인증되지 않은 사용자입니다.');
    }

    // Stripe 고객 생성 또는 조회
    const customer = await createOrRetrieveCustomer({
      uuid: user.id,
      email: user.email || ''
    });

    // 체크아웃 세션 생성
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      billing_address_collection: 'required',
      customer,
      line_items: [
        {
          price: price.id,
          quantity
        }
      ],
      mode: 'subscription',
      allow_promotion_codes: true,
      subscription_data: {
        metadata
      },
      success_url: `${getURL()}/account`,
      cancel_url: `${getURL()}/pricing`
    });

    return NextResponse.json({ sessionId: session.id });
  } catch (error: any) {
    console.error('Error creating checkout session:', error);
    return new NextResponse(
      JSON.stringify({ error: error.message }),
      { status: 500 }
    );
  }
}

🎛️ 고객 포털

// utils/stripe-helpers.ts 계속
export const redirectToCustomerPortal = async (): Promise<void> => {
  try {
    const supabase = createClientComponentClient();
    
    const {
      data: { user }
    } = await supabase.auth.getUser();

    if (!user) {
      throw new Error('사용자가 로그인되어 있지 않습니다.');
    }

    const { data, error } = await supabase.functions.invoke(
      'create-portal-session'
    );

    if (error) {
      throw new Error(error.message);
    }

    window.location.assign(data.url);
  } catch (error) {
    console.error('Portal redirect error:', error);
    return window.location.assign(
      getErrorRedirect(
        '/account',
        '포털 오류',
        error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
      )
    );
  }
};

포털 API Route

// app/api/create-portal-session/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/utils/stripe';
import { createOrRetrieveCustomer } from '@/utils/supabase-admin';
import { getURL } from '@/utils/helpers';

export async function POST(req: NextRequest) {
  try {
    const supabase = createRouteHandlerClient({ cookies });
    const {
      data: { user }
    } = await supabase.auth.getUser();

    if (!user) {
      throw new Error('인증되지 않은 사용자입니다.');
    }

    const customer = await createOrRetrieveCustomer({
      uuid: user.id,
      email: user.email || ''
    });

    if (!customer) {
      throw new Error('고객 정보를 찾을 수 없습니다.');
    }

    const { url } = await stripe.billingPortal.sessions.create({
      customer,
      return_url: `${getURL()}/account`
    });

    return NextResponse.json({ url });
  } catch (error: any) {
    console.error('Error creating portal session:', error);
    return new NextResponse(
      JSON.stringify({ error: error.message }),
      { status: 500 }
    );
  }
}

👤 사용자 계정 관리

// components/AccountForm.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { User } from '@supabase/gotrue-js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';

interface Props {
  user: User;
}

export default function AccountForm({ user }: Props) {
  const router = useRouter();
  const supabase = createClientComponentClient();
  const [loading, setLoading] = useState(false);
  const [fullName, setFullName] = useState(user.user_metadata?.full_name ?? '');

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

    try {
      const { error } = await supabase.auth.updateUser({
        data: { full_name: fullName }
      });

      if (error) {
        throw error;
      }

      router.refresh();
    } catch (error) {
      console.error('프로필 업데이트 오류:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <Card>
        <CardHeader>
          <CardTitle>계정 정보</CardTitle>
        </CardHeader>
        <CardContent>
          <form onSubmit={updateProfile} className="space-y-4">
            <div>
              <label className="block text-sm font-medium mb-1">
                이메일 주소
              </label>
              <input
                type="email"
                value={user.email}
                disabled
                className="w-full p-2 border rounded-md bg-gray-50"
              />
            </div>
            
            <div>
              <label className="block text-sm font-medium mb-1">
                전체 이름
              </label>
              <input
                type="text"
                value={fullName}
                onChange={(e) => setFullName(e.target.value)}
                className="w-full p-2 border rounded-md"
                placeholder="전체 이름을 입력하세요"
              />
            </div>

            <Button
              type="submit"
              disabled={loading}
              className="w-full"
            >
              {loading ? '업데이트 중...' : '프로필 업데이트'}
            </Button>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}

📊 구독 상태 관리

// components/SubscriptionStatus.tsx
'use client';

import { useState } from 'react';
import { redirectToCustomerPortal } from '@/utils/stripe-helpers';
import { Subscription, Price, Product } from '@/types';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';

interface Props {
  subscription: Subscription & {
    prices: Price & {
      products: Product;
    };
  };
}

export default function SubscriptionStatus({ subscription }: Props) {
  const [loading, setLoading] = useState(false);

  const handlePortalRedirect = async () => {
    setLoading(true);
    await redirectToCustomerPortal();
    setLoading(false);
  };

  const formatDate = (dateString: string) => {
    return new Date(dateString).toLocaleDateString('ko-KR', {
      year: 'numeric',
      month: 'long',
      day: 'numeric'
    });
  };

  const getStatusColor = (status: string) => {
    switch (status) {
      case 'active':
        return 'text-green-600 bg-green-100';
      case 'trialing':
        return 'text-blue-600 bg-blue-100';
      case 'past_due':
        return 'text-yellow-600 bg-yellow-100';
      case 'canceled':
        return 'text-red-600 bg-red-100';
      default:
        return 'text-gray-600 bg-gray-100';
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>구독 정보</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="flex justify-between items-center">
          <span className="font-medium">플랜:</span>
          <span>{subscription.prices.products.name}</span>
        </div>

        <div className="flex justify-between items-center">
          <span className="font-medium">상태:</span>
          <span className={`px-2 py-1 rounded text-sm ${getStatusColor(subscription.status)}`}>
            {subscription.status.charAt(0).toUpperCase() + subscription.status.slice(1)}
          </span>
        </div>

        <div className="flex justify-between items-center">
          <span className="font-medium">요금:</span>
          <span>
            ${(subscription.prices.unit_amount / 100).toFixed(2)} / {subscription.prices.interval}
          </span>
        </div>

        <div className="flex justify-between items-center">
          <span className="font-medium">다음 결제일:</span>
          <span>{formatDate(subscription.current_period_end)}</span>
        </div>

        {subscription.cancel_at_period_end && (
          <div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
            <p className="text-yellow-800 text-sm">
              구독이 {formatDate(subscription.current_period_end)} 취소됩니다.
            </p>
          </div>
        )}

        <Button
          onClick={handlePortalRedirect}
          disabled={loading}
          className="w-full"
          variant="outline"
        >
          {loading ? '로딩 중...' : '구독 관리'}
        </Button>
      </CardContent>
    </Card>
  );
}

UI 구성요소

🎨 가격 책정 페이지

// components/Pricing.tsx
'use client';

import { useState } from 'react';
import { redirectToCheckout } from '@/utils/stripe-helpers';
import { ProductWithPrice } from '@/types';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { CheckIcon } from '@heroicons/react/24/outline';

interface Props {
  products: ProductWithPrice[];
}

export default function Pricing({ products }: Props) {
  const [billingInterval, setBillingInterval] = useState<'month' | 'year'>('month');
  const [loading, setLoading] = useState<string | null>(null);

  const handleCheckout = async (priceId: string) => {
    setLoading(priceId);
    
    const price = products
      .flatMap(product => product.prices)
      .find(price => price.id === priceId);

    if (price) {
      await redirectToCheckout(price);
    }
    
    setLoading(null);
  };

  const getFeatures = (productName: string): string[] => {
    switch (productName.toLowerCase()) {
      case 'starter':
        return [
          '5명의 팀 멤버',
          '10GB 저장 공간',
          '기본 고객 지원',
          '기본 분석',
          '모바일 앱'
        ];
      case 'pro':
        return [
          '25명의 팀 멤버',
          '100GB 저장 공간',
          '우선 고객 지원', 
          '고급 분석',
          '모바일 앱',
          'API 접근',
          '사용자 정의 통합'
        ];
      case 'enterprise':
        return [
          '무제한 팀 멤버',
          '무제한 저장 공간',
          '24/7 전담 지원',
          '엔터프라이즈 분석',
          '모든 플랫폼',
          '전체 API 접근',
          '사용자 정의 개발',
          'SSO 통합',
          '고급 보안'
        ];
      default:
        return [];
    }
  };

  return (
    <div className="max-w-6xl mx-auto p-6">
      {/* 헤더 */}
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold mb-4">
          간단하고 투명한 가격책정
        </h1>
        <p className="text-xl text-gray-600 mb-8">
          비즈니스 성장에 맞는 플랜을 선택하세요
        </p>
        
        {/* 빌링 주기 토글 */}
        <div className="flex items-center justify-center space-x-4">
          <span className={billingInterval === 'month' ? 'font-semibold' : 'text-gray-600'}>
            월간
          </span>
          <button
            onClick={() => setBillingInterval(billingInterval === 'month' ? 'year' : 'month')}
            className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
          >
            <span
              className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
                billingInterval === 'year' ? 'translate-x-6' : 'translate-x-1'
              }`}
            />
          </button>
          <span className={billingInterval === 'year' ? 'font-semibold' : 'text-gray-600'}>
            연간 <span className="text-green-600 text-sm">(20% 할인)</span>
          </span>
        </div>
      </div>

      {/* 가격 카드 */}
      <div className="grid md:grid-cols-3 gap-8">
        {products?.map((product) => {
          const price = product.prices.find(
            (price) => price.interval === billingInterval
          );
          
          if (!price) return null;

          const features = getFeatures(product.name);
          const isPopular = product.name.toLowerCase() === 'pro';

          return (
            <Card 
              key={product.id}
              className={`relative ${isPopular ? 'border-blue-500 ring-2 ring-blue-200' : ''}`}
            >
              {isPopular && (
                <div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
                  <span className="bg-blue-500 text-white px-4 py-1 rounded-full text-sm font-medium">
                    인기
                  </span>
                </div>
              )}
              
              <CardHeader className="text-center">
                <CardTitle className="text-2xl">{product.name}</CardTitle>
                <div className="mt-4">
                  <span className="text-4xl font-bold">
                    ${(price.unit_amount / 100).toFixed(0)}
                  </span>
                  <span className="text-gray-600">/{price.interval}</span>
                </div>
                <p className="text-gray-600 mt-2">{product.description}</p>
              </CardHeader>

              <CardContent>
                <Button
                  onClick={() => handleCheckout(price.id)}
                  disabled={loading === price.id}
                  className={`w-full mb-6 ${
                    isPopular 
                      ? 'bg-blue-500 hover:bg-blue-600' 
                      : 'bg-gray-900 hover:bg-gray-800'
                  }`}
                >
                  {loading === price.id ? '처리 중...' : '시작하기'}
                </Button>

                <ul className="space-y-3">
                  {features.map((feature, index) => (
                    <li key={index} className="flex items-center">
                      <CheckIcon className="h-5 w-5 text-green-500 mr-3 flex-shrink-0" />
                      <span className="text-sm">{feature}</span>
                    </li>
                  ))}
                </ul>
              </CardContent>
            </Card>
          );
        })}
      </div>

      {/* FAQ 섹션 */}
      <div className="mt-16 text-center">
        <h2 className="text-2xl font-bold mb-8">자주 묻는 질문</h2>
        <div className="grid md:grid-cols-2 gap-8 text-left">
          <div>
            <h3 className="font-semibold mb-2">언제든지 취소할  있나요?</h3>
            <p className="text-gray-600">
              , 언제든지 구독을 취소할  있습니다. 취소  현재 결제 주기 말까지 서비스를 이용할  있습니다.
            </p>
          </div>
          <div>
            <h3 className="font-semibold mb-2">플랜을 변경할  있나요?</h3>
            <p className="text-gray-600">
              언제든지 플랜을 업그레이드하거나 다운그레이드할  있습니다. 변경 사항은 즉시 적용됩니다.
            </p>
          </div>
          <div>
            <h3 className="font-semibold mb-2">환불 정책은 어떻게 되나요?</h3>
            <p className="text-gray-600">
              모든 요금제에 대해 30 환불 보장을 제공합니다. 만족하지 않으시면 전액 환불해드립니다.
            </p>
          </div>
          <div>
            <h3 className="font-semibold mb-2">기업용 플랜이 있나요?</h3>
            <p className="text-gray-600">
              , 대규모 조직을 위한 Enterprise 플랜을 제공합니다. 문의해 주시면 맞춤 견적을 제공해드립니다.
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}

🔐 인증 UI

// components/AuthUI.tsx
'use client';

import { useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';

export default function AuthUI() {
  const [loading, setLoading] = useState(false);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSignUp, setIsSignUp] = useState(false);
  const [message, setMessage] = useState('');

  const supabase = createClientComponentClient();

  const handleAuth = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setMessage('');

    try {
      if (isSignUp) {
        const { error } = await supabase.auth.signUp({
          email,
          password,
          options: {
            emailRedirectTo: `${location.origin}/auth/callback`
          }
        });
        
        if (error) throw error;
        setMessage('확인 이메일을 보냈습니다. 이메일을 확인해주세요.');
      } else {
        const { error } = await supabase.auth.signInWithPassword({
          email,
          password
        });
        
        if (error) throw error;
      }
    } catch (error: any) {
      setMessage(error.message);
    } finally {
      setLoading(false);
    }
  };

  const handleGitHubSignIn = async () => {
    setLoading(true);
    
    const { error } = await supabase.auth.signInWithOAuth({
      provider: 'github',
      options: {
        redirectTo: `${location.origin}/auth/callback`
      }
    });
    
    if (error) {
      setMessage(error.message);
    }
    
    setLoading(false);
  };

  return (
    <div className="max-w-md mx-auto p-6">
      <Card>
        <CardHeader>
          <CardTitle className="text-center">
            {isSignUp ? '회원가입' : '로그인'}
          </CardTitle>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleAuth} className="space-y-4">
            <div>
              <label className="block text-sm font-medium mb-1">
                이메일
              </label>
              <input
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="w-full p-2 border rounded-md"
                placeholder="your@email.com"
                required
              />
            </div>
            
            <div>
              <label className="block text-sm font-medium mb-1">
                비밀번호
              </label>
              <input
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="w-full p-2 border rounded-md"
                placeholder="••••••••"
                required
              />
            </div>

            <Button
              type="submit"
              disabled={loading}
              className="w-full"
            >
              {loading ? '처리 중...' : (isSignUp ? '회원가입' : '로그인')}
            </Button>
          </form>

          <div className="mt-4">
            <div className="relative">
              <div className="absolute inset-0 flex items-center">
                <div className="w-full border-t border-gray-300" />
              </div>
              <div className="relative flex justify-center text-sm">
                <span className="px-2 bg-white text-gray-500">또는</span>
              </div>
            </div>

            <Button
              onClick={handleGitHubSignIn}
              disabled={loading}
              variant="outline"
              className="w-full mt-4"
            >
              GitHub으로 계속하기
            </Button>
          </div>

          <div className="mt-4 text-center">
            <button
              onClick={() => setIsSignUp(!isSignUp)}
              className="text-sm text-blue-600 hover:underline"
            >
              {isSignUp 
                ? '이미 계정이 있으신가요? 로그인' 
                : '계정이 없으신가요? 회원가입'
              }
            </button>
          </div>

          {message && (
            <div className={`mt-4 p-3 rounded text-sm ${
              message.includes('error') || message.includes('Error')
                ? 'bg-red-50 text-red-700 border border-red-200'
                : 'bg-green-50 text-green-700 border border-green-200'
            }`}>
              {message}
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

배포 및 운영

🚀 Vercel 배포

# Vercel CLI 설치
npm i -g vercel

# 프로젝트 배포
vercel --prod

# 환경 변수 설정
vercel env add NEXT_PUBLIC_SUPABASE_URL
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY
vercel env add SUPABASE_SERVICE_ROLE_KEY
vercel env add NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
vercel env add STRIPE_SECRET_KEY
vercel env add STRIPE_WEBHOOK_SECRET
vercel env add NEXT_PUBLIC_SITE_URL

📊 환경 변수 체크리스트

# .env.local (개발 환경)
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

NEXT_PUBLIC_SITE_URL=http://localhost:3000
# 프로덕션 환경 변수
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_production_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_production_service_role_key

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

NEXT_PUBLIC_SITE_URL=https://your-app.vercel.app

🔄 CI/CD 파이프라인

# .github/workflows/deploy.yml
name: Deploy to Vercel

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm run test

      - name: Run linting
        run: npm run lint

      - name: Build project
        run: npm run build

      - name: Deploy to Vercel
        if: github.ref == 'refs/heads/main'
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: $
          vercel-org-id: $
          vercel-project-id: $
          vercel-args: '--prod'

고급 기능

📈 분석 및 추적

// utils/analytics.ts
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';

interface AnalyticsEvent {
  event_name: string;
  user_id?: string;
  properties?: Record<string, any>;
  timestamp?: string;
}

export class Analytics {
  private supabase = createClientComponentClient();

  async track(event: AnalyticsEvent) {
    try {
      const { data: { user } } = await this.supabase.auth.getUser();
      
      const eventData = {
        ...event,
        user_id: user?.id || event.user_id,
        timestamp: event.timestamp || new Date().toISOString()
      };

      // Supabase에 이벤트 저장
      const { error } = await this.supabase
        .from('analytics_events')
        .insert(eventData);

      if (error) {
        console.error('Analytics tracking error:', error);
      }

      // 추가 분석 서비스 (선택사항)
      if (typeof window !== 'undefined' && window.gtag) {
        window.gtag('event', event.event_name, {
          user_id: eventData.user_id,
          ...event.properties
        });
      }
    } catch (error) {
      console.error('Analytics error:', error);
    }
  }

  // 구독 이벤트 추적
  subscriptionStarted(planName: string, planPrice: number) {
    return this.track({
      event_name: 'subscription_started',
      properties: {
        plan_name: planName,
        plan_price: planPrice
      }
    });
  }

  subscriptionCancelled(planName: string) {
    return this.track({
      event_name: 'subscription_cancelled',
      properties: {
        plan_name: planName
      }
    });
  }

  checkoutStarted(planName: string) {
    return this.track({
      event_name: 'checkout_started',
      properties: {
        plan_name: planName
      }
    });
  }
}

// 사용 예시
export const analytics = new Analytics();

🔔 이메일 알림

// utils/email.ts
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

interface EmailTemplate {
  to: string;
  subject: string;
  template: string;
  variables?: Record<string, any>;
}

export class EmailService {
  async sendWelcomeEmail(userEmail: string, userName: string) {
    try {
      await resend.emails.send({
        from: 'noreply@yourdomain.com',
        to: userEmail,
        subject: '환영합니다! 🎉',
        html: `
          <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
            <h1>안녕하세요 ${userName}님!</h1>
            <p>저희 서비스에 가입해 주셔서 감사합니다.</p>
            <p>이제 모든 기능을 사용하실 수 있습니다:</p>
            <ul>
              <li>대시보드 접근</li>
              <li>프리미엄 기능 사용</li>
              <li>24/7 고객 지원</li>
            </ul>
            <a href="${process.env.NEXT_PUBLIC_SITE_URL}/dashboard" 
               style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0;">
              시작하기
            </a>
          </div>
        `
      });
    } catch (error) {
      console.error('이메일 발송 오류:', error);
    }
  }

  async sendSubscriptionConfirmation(userEmail: string, planName: string) {
    try {
      await resend.emails.send({
        from: 'noreply@yourdomain.com',
        to: userEmail,
        subject: '구독이 활성화되었습니다! ✅',
        html: `
          <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
            <h1>구독 확인</h1>
            <p><strong>${planName}</strong> 플랜 구독이 성공적으로 활성화되었습니다.</p>
            <p>지금부터 모든 프리미엄 기능을 이용하실 수 있습니다.</p>
            <a href="${process.env.NEXT_PUBLIC_SITE_URL}/account" 
               style="background: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0;">
              계정 관리
            </a>
          </div>
        `
      });
    } catch (error) {
      console.error('구독 확인 이메일 발송 오류:', error);
    }
  }

  async sendPaymentFailedEmail(userEmail: string) {
    try {
      await resend.emails.send({
        from: 'noreply@yourdomain.com',
        to: userEmail,
        subject: '결제 실패 알림 ⚠️',
        html: `
          <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
            <h1>결제 실패</h1>
            <p>구독 갱신 결제가 실패했습니다.</p>
            <p>서비스 중단을 방지하기 위해 결제 정보를 업데이트해 주세요.</p>
            <a href="${process.env.NEXT_PUBLIC_SITE_URL}/account" 
               style="background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0;">
              결제 정보 업데이트
            </a>
          </div>
        `
      });
    } catch (error) {
      console.error('결제 실패 이메일 발송 오류:', error);
    }
  }
}

export const emailService = new EmailService();

🛡️ 보안 및 권한 관리

// middleware.ts
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(req: NextRequest) {
  const res = NextResponse.next();
  const supabase = createMiddlewareClient({ req, res });

  const {
    data: { session },
  } = await supabase.auth.getSession();

  // 인증이 필요한 경로
  const protectedPaths = ['/account', '/dashboard'];
  const isProtectedPath = protectedPaths.some(path => 
    req.nextUrl.pathname.startsWith(path)
  );

  // 구독이 필요한 경로
  const subscriptionPaths = ['/dashboard', '/premium'];
  const isSubscriptionPath = subscriptionPaths.some(path =>
    req.nextUrl.pathname.startsWith(path)
  );

  // 인증되지 않은 사용자
  if (isProtectedPath && !session) {
    return NextResponse.redirect(new URL('/auth', req.url));
  }

  // 구독 확인
  if (isSubscriptionPath && session) {
    const { data: subscription } = await supabase
      .from('subscriptions')
      .select('status')
      .eq('user_id', session.user.id)
      .eq('status', 'active')
      .single();

    if (!subscription) {
      return NextResponse.redirect(new URL('/pricing', req.url));
    }
  }

  return res;
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

테스트

🧪 단위 테스트

// __tests__/components/Pricing.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import Pricing from '@/components/Pricing';
import { redirectToCheckout } from '@/utils/stripe-helpers';

// Mock
jest.mock('@/utils/stripe-helpers');
const mockRedirectToCheckout = redirectToCheckout as jest.MockedFunction<typeof redirectToCheckout>;

const mockProducts = [
  {
    id: 'prod_1',
    name: 'Basic',
    description: 'Basic plan',
    prices: [
      {
        id: 'price_1',
        unit_amount: 999,
        interval: 'month',
        currency: 'usd'
      }
    ]
  }
];

describe('Pricing Component', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('renders pricing plans correctly', () => {
    render(<Pricing products={mockProducts} />);
    
    expect(screen.getByText('Basic')).toBeInTheDocument();
    expect(screen.getByText('$9')).toBeInTheDocument();
    expect(screen.getByText('시작하기')).toBeInTheDocument();
  });

  it('handles checkout click', async () => {
    mockRedirectToCheckout.mockResolvedValue();
    
    render(<Pricing products={mockProducts} />);
    
    const checkoutButton = screen.getByText('시작하기');
    fireEvent.click(checkoutButton);
    
    await waitFor(() => {
      expect(mockRedirectToCheckout).toHaveBeenCalledWith(mockProducts[0].prices[0]);
    });
  });

  it('toggles billing interval', () => {
    render(<Pricing products={mockProducts} />);
    
    const toggle = screen.getByRole('button', { name: /billing toggle/i });
    fireEvent.click(toggle);
    
    // 연간 할인 텍스트 확인
    expect(screen.getByText(/20% 할인/)).toBeInTheDocument();
  });
});

🔧 E2E 테스트

// e2e/subscription-flow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Subscription Flow', () => {
  test('complete subscription purchase', async ({ page }) => {
    // 홈페이지 방문
    await page.goto('/');
    
    // 가격 페이지로 이동
    await page.click('text=Pricing');
    await expect(page.locator('h1')).toContainText('가격책정');
    
    // 플랜 선택
    await page.click('[data-testid="basic-plan-button"]');
    
    // Stripe Checkout 페이지 확인
    await expect(page).toHaveURL(/checkout\.stripe\.com/);
    
    // 테스트 카드 정보 입력
    await page.fill('[data-elements-stable-field-name="cardNumber"]', '4242424242424242');
    await page.fill('[data-elements-stable-field-name="cardExpiry"]', '12/25');
    await page.fill('[data-elements-stable-field-name="cardCvc"]', '123');
    await page.fill('[data-elements-stable-field-name="billingName"]', 'Test User');
    
    // 결제 완료
    await page.click('[data-testid="hosted-payment-submit-button"]');
    
    // 성공 페이지 확인
    await expect(page).toHaveURL('/account');
    await expect(page.locator('text=구독이 활성화되었습니다')).toBeVisible();
  });

  test('customer portal access', async ({ page }) => {
    // 로그인 (구독이 있는 사용자)
    await page.goto('/auth');
    await page.fill('[data-testid="email"]', 'test@example.com');
    await page.fill('[data-testid="password"]', 'password');
    await page.click('[data-testid="sign-in-button"]');
    
    // 계정 페이지로 이동
    await page.goto('/account');
    
    // 구독 관리 버튼 클릭
    await page.click('text=구독 관리');
    
    // Stripe 고객 포털 확인
    await expect(page).toHaveURL(/billing\.stripe\.com/);
  });
});

문제 해결

🔧 일반적인 문제들

1. 웹훅 처리 오류

// utils/webhook-logger.ts
import { createServiceRoleClient } from '@/utils/supabase-admin';

export async function logWebhookEvent(
  eventType: string,
  eventData: any,
  success: boolean,
  error?: string
) {
  const supabase = createServiceRoleClient();
  
  await supabase.from('webhook_logs').insert({
    event_type: eventType,
    event_data: eventData,
    success,
    error,
    created_at: new Date().toISOString()
  });
}

// webhook endpoint에서 사용
export async function POST(req: NextRequest) {
  try {
    // ... webhook 처리 로직
    
    await logWebhookEvent(event.type, event.data, true);
    return NextResponse.json({ received: true });
  } catch (error) {
    await logWebhookEvent(event.type, event.data, false, error.message);
    throw error;
  }
}

2. 구독 동기화 문제

# 구독 상태 수동 동기화 스크립트
#!/bin/bash

echo "🔄 Stripe 구독 동기화 시작..."

# Stripe에서 모든 구독 조회
stripe subscriptions list --limit=100 > subscriptions.json

# Node.js 스크립트로 데이터베이스 업데이트
node scripts/sync-subscriptions.js

echo "✅ 동기화 완료"

3. 환경 변수 검증

// utils/env-validation.ts
const requiredEnvVars = [
  'NEXT_PUBLIC_SUPABASE_URL',
  'NEXT_PUBLIC_SUPABASE_ANON_KEY',
  'SUPABASE_SERVICE_ROLE_KEY',
  'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
  'STRIPE_SECRET_KEY',
  'STRIPE_WEBHOOK_SECRET'
];

export function validateEnvironmentVariables() {
  const missing = requiredEnvVars.filter(envVar => !process.env[envVar]);
  
  if (missing.length > 0) {
    throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
  }
  
  console.log('✅ All environment variables are set');
}

// app/layout.tsx에서 실행
if (process.env.NODE_ENV === 'development') {
  validateEnvironmentVariables();
}

결론

Next.js SaaS Stripe Starter는 현대적인 SaaS 애플리케이션 개발을 위한 완전한 솔루션을 제공합니다. 이 가이드를 통해 다음과 같은 성과를 달성할 수 있습니다:

🎯 주요 성과

  • 빠른 개발: 검증된 아키텍처로 개발 시간 단축
  • 확장성: 사용자 증가에 따른 안정적인 확장
  • 보안: 최신 보안 표준 및 모범 사례 적용
  • 사용자 경험: 직관적이고 반응성 있는 인터페이스
  • 비즈니스 성장: 안정적인 구독 결제 시스템

🚀 다음 단계

next_steps = {
  immediate: [
    "프로덕션 환경 배포",
    "도메인 연결 및 SSL 설정",
    "모니터링 시스템 구축"
  ],
  
  growth: [
    "A/B 테스트 구현",
    "고급 분석 도구 통합",
    "국제화 (i18n) 지원"
  ],
  
  scale: [
    "마이크로서비스 아키텍처 고려",
    "CDN 최적화",
    "성능 모니터링 강화"
  ]
}

💡 최종 권장사항

  1. 점진적 배포: 기본 기능부터 시작하여 점진적으로 확장
  2. 사용자 피드백: 실제 사용자 피드백을 통한 지속적 개선
  3. 모니터링: 성능과 오류를 지속적으로 모니터링
  4. 보안: 정기적인 보안 감사 및 업데이트
  5. 문서화: 팀 내 지식 공유를 위한 문서화

Next.js SaaS Stripe Starter를 기반으로 여러분만의 성공적인 SaaS 애플리케이션을 구축해보세요!


더 알아보기: