Next.js + Stripe 결제 통합: Checkout부터 구독까지 완전 구현

프론트엔드

StripeNext.js결제구독Webhook

이 글은 누구를 위한 것인가

  • Next.js 앱에 Stripe 결제를 처음 추가하는 팀
  • Stripe Webhook을 안전하게 처리하는 방법을 배우려는 개발자
  • 월정액 구독 기능을 구현하려는 SaaS 개발 팀

들어가며

Stripe는 결제 처리의 표준이다. PCI 규정 준수, 국제 카드 지원, 구독 관리를 모두 담당한다. Next.js Server Actions와 결합하면 안전하고 간결한 결제 플로우를 구현할 수 있다.

이 글은 bluefoxdev.kr의 Stripe 결제 통합 가이드 를 참고하여 작성했습니다.


1. 결제 아키텍처

[Stripe 결제 흐름]

Hosted Checkout (권장, 빠른 구현):
  1. 서버: Checkout Session 생성
  2. 클라이언트: Stripe 호스팅 결제 페이지로 이동
  3. 결제 완료: success_url로 리다이렉트
  4. Webhook: payment_intent.succeeded 이벤트 처리

Custom Payment (Stripe Elements):
  1. 서버: PaymentIntent 생성
  2. 클라이언트: client_secret으로 카드 입력 UI
  3. Stripe.js로 결제 확인
  4. Webhook: 완료 이벤트 처리

구독(Subscription):
  Customer → Subscription → Invoice → PaymentIntent
  trial_period_days로 무료 체험
  customer_portal로 사용자 구독 관리

[중요 보안 원칙]
  Stripe Secret Key: 절대 클라이언트 노출 금지
  Webhook 서명 검증: Stripe-Signature 헤더 반드시 확인
  amount: 반드시 서버에서 계산 (클라이언트 값 신뢰 금지)
  idempotency key: 중복 결제 방지

2. Stripe 결제 구현

// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-01-27.acacia',
  typescript: true,
});

// 가격 ID (Stripe 대시보드에서 생성)
export const PLANS = {
  BASIC: {
    priceId: process.env.STRIPE_BASIC_PRICE_ID!,
    name: '베이직',
    price: 9900,
  },
  PRO: {
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    name: '프로',
    price: 29900,
  },
} as const;
// app/actions/checkout.ts
'use server';

import { redirect } from 'next/navigation';
import { stripe, PLANS } from '@/lib/stripe';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

// 일회성 결제 - Hosted Checkout
export async function createCheckoutSession(items: { id: string; quantity: number }[]) {
  const session = await auth();
  if (!session?.user) redirect('/login');
  
  // 서버에서 가격 계산 (클라이언트 값 신뢰 안 함)
  const products = await db.product.findMany({
    where: { id: { in: items.map(i => i.id) } },
  });
  
  const lineItems = items.map(item => {
    const product = products.find(p => p.id === item.id);
    if (!product) throw new Error(`상품 없음: ${item.id}`);
    
    return {
      price_data: {
        currency: 'krw',
        product_data: { name: product.name },
        unit_amount: product.price,  // Stripe는 최소 단위 (원화는 원)
      },
      quantity: item.quantity,
    };
  });
  
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: lineItems,
    customer_email: session.user.email!,
    success_url: `${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/cart`,
    metadata: {
      userId: session.user.id,
      items: JSON.stringify(items),
    },
    // 중복 결제 방지
    payment_intent_data: {
      metadata: { userId: session.user.id },
    },
  });
  
  redirect(checkoutSession.url!);
}

// 구독 결제
export async function createSubscriptionCheckout(planKey: keyof typeof PLANS) {
  const session = await auth();
  if (!session?.user) redirect('/login');
  
  const plan = PLANS[planKey];
  
  // 기존 Stripe Customer 찾기 또는 생성
  let customerId = await getOrCreateStripeCustomer(session.user.id, session.user.email!);
  
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: customerId,
    line_items: [{ price: plan.priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    subscription_data: {
      trial_period_days: 14,  // 14일 무료 체험
      metadata: { userId: session.user.id, plan: planKey },
    },
  });
  
  redirect(checkoutSession.url!);
}

async function getOrCreateStripeCustomer(userId: string, email: string): Promise<string> {
  const user = await db.user.findUnique({ where: { id: userId } });
  
  if (user?.stripeCustomerId) return user.stripeCustomerId;
  
  const customer = await stripe.customers.create({ email, metadata: { userId } });
  
  await db.user.update({
    where: { id: userId },
    data: { stripeCustomerId: customer.id },
  });
  
  return customer.id;
}
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;
  
  let event: Stripe.Event;
  
  // 웹훅 서명 검증 (반드시 필요!)
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('웹훅 서명 검증 실패:', err);
    return new Response('Invalid signature', { status: 400 });
  }
  
  // 이벤트 처리
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      
      if (session.mode === 'payment') {
        // 주문 생성
        await db.order.create({
          data: {
            userId: session.metadata!.userId,
            stripeSessionId: session.id,
            amount: session.amount_total!,
            status: 'completed',
          },
        });
      }
      
      if (session.mode === 'subscription') {
        // 구독 활성화
        const subscriptionId = session.subscription as string;
        const subscription = await stripe.subscriptions.retrieve(subscriptionId);
        
        await db.user.update({
          where: { id: session.metadata!.userId },
          data: {
            plan: session.metadata!.plan,
            stripeSubscriptionId: subscriptionId,
            subscriptionStatus: subscription.status,
            currentPeriodEnd: new Date(subscription.current_period_end * 1000),
          },
        });
      }
      break;
    }
    
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      
      await db.user.updateMany({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          subscriptionStatus: subscription.status,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        },
      });
      break;
    }
    
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      
      await db.user.updateMany({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          plan: 'FREE',
          subscriptionStatus: 'canceled',
          stripeSubscriptionId: null,
        },
      });
      break;
    }
    
    case 'invoice.payment_failed': {
      // 결제 실패 알림 이메일 전송
      const invoice = event.data.object as Stripe.Invoice;
      await sendPaymentFailedEmail(invoice.customer_email!);
      break;
    }
  }
  
  return new Response('OK');
}

async function sendPaymentFailedEmail(email: string) {
  // 이메일 전송 로직
}

import Stripe from 'stripe';
// 고객 포털 - 구독 관리 셀프 서비스
export async function createCustomerPortalSession() {
  const session = await auth();
  if (!session?.user) redirect('/login');
  
  const user = await db.user.findUnique({ where: { id: session.user.id } });
  if (!user?.stripeCustomerId) redirect('/pricing');
  
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
  });
  
  redirect(portalSession.url);
}

마무리

Stripe 통합의 핵심은 Webhook 처리다. 결제 완료를 success_url 리다이렉트로만 처리하면 사용자가 브라우저를 닫거나 네트워크 오류가 생길 때 주문이 누락된다. Webhook은 Stripe 서버에서 직접 호출하므로 신뢰할 수 있는 완료 신호다. 서명 검증은 절대 생략하지 않는다. 금액은 항상 서버에서 계산하고, 클라이언트에서 전달받은 금액은 절대 신뢰하지 않는다.