TechFeedTechFeed
Startup / Product

Stripe 결제 연동 실전 튜토리얼 — Next.js Checkout, Webhook, 구독 결제 완전 구현

Next.js App Router 환경에서 Stripe 일회성 결제, Webhook 서명 검증, 구독 결제, 고객 포털을 단계별로 구현하는 튜토리얼. 서버 액션, API Route, 멱등성 처리까지 실제 동작하는 코드로 설명한다.

한 줄 요약: Stripe를 Next.js 앱에 연동하는 전 과정을 단계별로 설명한다. 일회성 결제, Webhook 이벤트 처리, 구독 결제, 고객 포털까지 실제 동작하는 코드로 구현한다.

이 글이 필요한 사람
  • SaaS 또는 이커머스 프로젝트에서 Stripe 결제를 처음 붙이는 개발자
  • Webhook 처리나 구독 결제 구현에서 막히고 있는 팀
  • Next.js App Router 환경에서 서버 액션과 API Route를 함께 쓰는 구조가 궁금한 개발자

※ 2026년 4월 기준, Stripe API v2025-04-30, Next.js 15.x App Router 기준으로 작성. 공식 문서: stripe.com/docs

Stripe 계정 세팅과 SDK 설치

Stripe 대시보드에서 계정을 만들면 테스트 환경과 라이브 환경이 분리된 키를 각각 발급해준다. 테스트 키(sk_test_..., pk_test_...)로 개발하고, 실제 배포 시 라이브 키로 교체하면 된다.

대시보드 → Developers → API Keys에서 비밀 키(Secret Key)와 공개 가능한 키(Publishable Key)를 복사한다. 비밀 키는 절대 클라이언트 사이드에 노출하면 안 된다.

패키지 설치 (npm)
npm install stripe @stripe/stripe-js @stripe/react-stripe-js

stripe는 서버 사이드 SDK, @stripe/stripe-js는 브라우저에서 Stripe.js를 로드하는 래퍼, @stripe/react-stripe-js는 React 컴포넌트 라이브러리다. 세 패키지를 모두 설치해야 일회성 결제와 카드 입력 UI를 함께 구현할 수 있다.

환경 변수는 다음과 같이 구성한다. Next.js에서 NEXT_PUBLIC_ 접두사가 붙은 변수만 클라이언트에서 접근 가능하다.

.env.local
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx
주의: STRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRET은 서버에서만 사용한다. .env.local.gitignore에 반드시 포함하고, Vercel 등 배포 환경의 환경 변수 패널에 별도로 입력해야 한다.

Stripe 인스턴스 초기화 — 서버와 클라이언트 분리

Next.js에서 Stripe 인스턴스를 여러 곳에서 반복 생성하면 API 연결이 낭비된다. 서버용과 클라이언트용 인스턴스를 각각 싱글턴으로 관리한다.

lib/stripe.ts — 서버용 인스턴스
import Stripe from 'stripe' if (!process.env.STRIPE_SECRET_KEY) { throw new Error('STRIPE_SECRET_KEY is not set') } export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2025-04-30', typescript: true, })
lib/stripe-client.ts — 클라이언트용 Promise
import { loadStripe } from '@stripe/stripe-js' let stripePromise: ReturnType<typeof loadStripe> export function getStripe() { if (!stripePromise) { stripePromise = loadStripe( process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! ) } return stripePromise }

서버용 인스턴스는 API Route나 서버 액션에서만 import하고, 클라이언트용 getStripe()는 React 컴포넌트에서 결제 UI를 렌더링할 때만 사용한다. 두 파일을 섞어서 import하면 비밀 키가 번들에 포함될 수 있으니 주의한다.

Stripe 대시보드 API 키 관리 화면
Stripe 대시보드의 Developers → API Keys에서 테스트 키와 라이브 키를 분리해 관리한다

일회성 결제 구현 — Checkout Session API

Stripe Checkout은 결제 페이지 전체를 Stripe가 호스팅하는 방식이다. PCI 규정 준수 부담 없이 카드 정보를 처리할 수 있고, 모바일 최적화도 기본 제공된다. Next.js App Router에서는 서버 액션으로 세션을 생성하고, 클라이언트에서 해당 URL로 리디렉션한다.

app/actions/checkout.ts — 서버 액션
'use server' import { redirect } from 'next/navigation' import { stripe } from '@/lib/stripe' export async function createCheckoutSession(priceId: string) { const session = await stripe.checkout.sessions.create({ mode: 'payment', // 일회성 결제 line_items: [ { price: priceId, quantity: 1, }, ], success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/cancel`, // 고객 이메일 자동 수집 billing_address_collection: 'auto', }) if (!session.url) { throw new Error('Checkout session URL is missing') } redirect(session.url) }

success_url{CHECKOUT_SESSION_ID}를 붙이면 결제 완료 후 세션 ID를 쿼리 파라미터로 받을 수 있다. 이 ID로 주문 정보를 조회하거나 Webhook과 연계해 DB를 업데이트한다.

클라이언트 컴포넌트에서는 폼 액션으로 연결하면 된다.

app/components/BuyButton.tsx
'use client' import { createCheckoutSession } from '@/app/actions/checkout' export function BuyButton({ priceId }: { priceId: string }) { return ( <form action={createCheckoutSession.bind(null, priceId)}> <button type="submit" className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700" > 구매하기 </button> </form> ) }
Stripe Checkout 결제 페이지 화면
Stripe Checkout은 카드 정보 입력부터 3D Secure 인증까지 자동으로 처리해준다

Webhook 처리 — 결제 완료 이벤트를 안전하게 받기

사용자가 결제를 마쳤다고 해서 서버가 바로 알 수 있는 건 아니다. 사용자가 브라우저를 닫거나 네트워크가 끊겨도 Stripe는 Webhook으로 결제 결과를 서버에 Push한다. payment_intent.succeeded, checkout.session.completed 같은 이벤트를 구독해 DB 업데이트, 이메일 발송, 라이선스 발급 등을 처리한다.

Webhook 엔드포인트는 반드시 서명 검증을 해야 한다. Stripe가 보내는 Stripe-Signature 헤더와 STRIPE_WEBHOOK_SECRET을 대조해 위변조를 막는다.

app/api/webhook/route.ts — Webhook 처리
import { NextRequest, NextResponse } from 'next/server' import { headers } from 'next/headers' import { stripe } from '@/lib/stripe' import type Stripe from 'stripe' export async function POST(req: NextRequest) { const body = await req.text() const headersList = await headers() const signature = headersList.get('stripe-signature') if (!signature) { return NextResponse.json({ error: 'No signature' }, { status: 400 }) } let event: Stripe.Event try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ) } catch (err) { console.error('Webhook signature verification failed:', err) return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) } // 이벤트 타입별 처리 switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session await handleCompletedCheckout(session) break } case 'payment_intent.payment_failed': { const paymentIntent = event.data.object as Stripe.PaymentIntent console.error('Payment failed:', paymentIntent.id) break } default: // 처리하지 않는 이벤트는 무시 break } return NextResponse.json({ received: true }) } async function handleCompletedCheckout(session: Stripe.Checkout.Session) { // 실제 구현: DB에 주문 저장, 이메일 발송 등 console.log('결제 완료:', session.id, session.customer_email) }
로컬 Webhook 테스트: stripe listen --forward-to localhost:3000/api/webhook 명령으로 Stripe CLI가 실제 이벤트를 로컬 서버로 포워딩한다. Stripe CLI 설치 후 stripe login으로 인증하면 된다. Stripe CLI 다운로드: stripe.com/docs/stripe-cli

Webhook 엔드포인트를 Next.js App Router의 Route Handler로 만들 때 중요한 점이 있다. Stripe 서명 검증에는 원본 바이트 스트림이 필요하므로 req.text()로 body를 읽어야 한다. req.json()으로 파싱하면 서명 검증이 실패한다.

구독 결제 구현 — 월정액 SaaS 과금 구조

구독 결제는 일회성과 달리 mode: "subscription"으로 Checkout 세션을 생성한다. Stripe 대시보드에서 Product와 Price를 먼저 만들고 Price ID를 코드에 넣는 방식이 표준이다.

아래는 Free, Pro, Enterprise 세 플랜을 제공하는 SaaS의 구독 세션 생성 예시다.

app/actions/subscription.ts — 구독 세션 생성
'use server' import { redirect } from 'next/navigation' import { stripe } from '@/lib/stripe' import { auth } from '@/lib/auth' // 자체 인증 시스템 const PLAN_PRICE_IDS = { pro: 'price_1234567890abcdef', // 월 $29 enterprise: 'price_abcdef1234567890', // 월 $99 } as const type Plan = keyof typeof PLAN_PRICE_IDS export async function createSubscriptionSession(plan: Plan) { const session = await auth() if (!session?.user) redirect('/login') const checkoutSession = await stripe.checkout.sessions.create({ mode: 'subscription', line_items: [ { price: PLAN_PRICE_IDS[plan], quantity: 1, }, ], customer_email: session.user.email ?? undefined, // 구독 취소/갱신 시 Portal로 연결 subscription_data: { metadata: { userId: session.user.id, }, }, success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?subscribed=true`, cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`, }) if (!checkoutSession.url) { throw new Error('Failed to create checkout session') } redirect(checkoutSession.url) }

구독 모델에서 Webhook으로 처리해야 할 이벤트가 더 많다. 갱신 성공은 invoice.paid, 갱신 실패는 invoice.payment_failed, 구독 해지는 customer.subscription.deleted를 각각 구독해서 DB 상태를 동기화한다.

고객 포털 연동 — 구독 관리를 Stripe에 위임하기

구독 플랜 변경, 결제 카드 교체, 구독 취소를 직접 구현하면 복잡하다. Stripe Customer Portal을 연동하면 이 모든 기능을 Stripe가 호스팅하는 페이지에서 처리할 수 있다.

Stripe 대시보드 → Billing → Customer portal에서 포털 설정을 먼저 활성화해야 한다. 허용할 기능(플랜 변경, 카드 수정, 취소 등)을 체크하고 저장하면 된다.

app/actions/portal.ts — 고객 포털 세션 생성
'use server' import { redirect } from 'next/navigation' import { stripe } from '@/lib/stripe' import { auth } from '@/lib/auth' import { db } from '@/lib/db' // 자체 DB 클라이언트 export async function openCustomerPortal() { const session = await auth() if (!session?.user) redirect('/login') // DB에 저장된 Stripe Customer ID를 조회 const user = await db.user.findUnique({ where: { id: session.user.id }, select: { stripeCustomerId: true }, }) if (!user?.stripeCustomerId) { redirect('/pricing') // 아직 구독이 없으면 pricing 페이지로 } const portalSession = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`, }) redirect(portalSession.url) }
Stripe Customer ID 저장 시점: 처음 구독을 완료하면 checkout.session.completed Webhook에서 session.customer 필드로 Customer ID를 받을 수 있다. 이 값을 사용자 레코드에 저장해두면 이후 포털 연결과 구독 조회에 재사용할 수 있다.
Stripe Customer Portal 구독 관리 화면
Stripe Customer Portal에서 플랜 변경, 카드 수정, 구독 취소를 별도 구현 없이 처리할 수 있다

테스트 환경 설정과 배포 전 체크리스트

Stripe는 테스트 카드 번호를 제공한다. 4242 4242 4242 4242는 항상 성공, 4000 0000 0000 9995는 잔액 부족으로 실패하는 카드다. 만료일은 미래 날짜, CVC는 임의 3자리를 입력하면 된다.

아래 체크리스트를 배포 전에 확인한다.

멱등성(Idempotency) 주의: Stripe는 네트워크 오류 시 같은 Webhook 이벤트를 최대 5회 재전송한다. event.id를 DB에 저장해두고, 이미 처리된 이벤트는 조기 반환해야 중복 주문, 중복 이메일 발송 등의 버그를 막을 수 있다.
Stripe결제 연동Next.jsWebhook구독 결제App RouterSaaSCustomer Portal서버 액션TypeScript

관련 포스트

SaaS 제품 출시 전 체크리스트 2026 — 기술·보안·결제·법적 52개 항목2026-04-16개발자 포트폴리오 사이트 만들기 20262026-03-128개 멀티레포를 Turborepo 모노레포로 합친 스타트업의 6개월 — 빌드 시간 72% 단축 기록2026-04-06PostHog vs Mixpanel vs Amplitude 2026 — 프로덕트 분석 도구 완전 비교2026-04-16