이 글은 누구를 위한 것인가
- 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 서버에서 직접 호출하므로 신뢰할 수 있는 완료 신호다. 서명 검증은 절대 생략하지 않는다. 금액은 항상 서버에서 계산하고, 클라이언트에서 전달받은 금액은 절대 신뢰하지 않는다.