Next.js 이커머스 실전: 장바구니와 Stripe 결제 완전 구현 가이드

Stripe Webhook 설정을 27번째 점검하고 있는데, 사용자는 돈이 빠져나갔지만 주문 상태가 여전히 ‘결제 대기’라고 항의합니다. 테스트 환경에서는 잘 돌아가던 기능이 왜 프로덕션에만 가면 망가질까요?
처음 Next.js 이커머스 프로젝트를 만들 때는 화면과 스타일을 구현하는 일이 가장 어려울 줄 알았습니다. 하지만 직접 시작해 보니 장바구니 상태 관리, 결제 연동, 주문 흐름 어느 하나 함정이 없는 곳이 없었습니다. Redux는 너무 무거워 배우기 어렵고, Context API는 성능이 걱정되며, Stripe 문서는 온통 영어라 부담스럽고, Webhook이 무엇인지조차 막막했습니다.
더 답답한 점은 온라인 튜토리얼 대부분이 장바구니나 결제 중 하나만 설명할 뿐, 전체 흐름을 하나로 연결해 명확히 알려주는 경우가 드물다는 것입니다. ‘상태 관리 라이브러리는 무엇을 골라야 하지?’, ‘Webhook은 정확히 무슨 일을 하지?’, ‘주문 상태와 결제 상태는 어떻게 맞추지?’ 같은 의문이 생길 수밖에 없습니다.
이 글에서는 이런 난관을 한 번에 해결하려 합니다. Zustand로 장바구니 상태를 관리하고, 가볍고 사용하기 쉬운 Stripe로 결제를 연동하며, 유일하게 신뢰할 수 있는 방식인 Webhook으로 주문을 처리합니다. 각 단계마다 완전한 코드가 있어 그대로 복사해 실행할 수 있습니다.
‘드디어 됐다’는 성취감을 느껴본 적이 있나요? 이 글을 따라가면 직접 경험할 수 있습니다.
장바구니 상태 관리에 Zustand를 선택하는 이유
2025년 상태 관리 선택: 이제 고민은 그만
솔직히 상태 관리 라이브러리를 고르는 일은 정말 골치 아픕니다. Redux 문서는 사전처럼 두껍고, Context API의 성능 문제는 검색만 해도 수두룩하며, Zustand는 너무 새로워 보여 선뜻 쓰기 어렵습니다. 저도 세 가지 사이를 계속 오가다가 몇 가지 데이터를 보고서야 결정을 내렸습니다.
2021년부터 Zustand는 Star 증가 속도가 가장 빠른 React 상태 관리 라이브러리가 되었습니다. 2025년에 이르러 함수형 설계, hooks 친화성, 간결하고 세련된 API라는 설계 철학은 충분히 검증되었습니다. 무엇보다 Redux처럼 action, reducer, dispatch, middleware 같은 수많은 개념을 이해할 필요가 없어 학습 곡선이 매우 완만합니다.
그렇다면 무엇을 선택해야 할까요? 간단히 정리하면 다음과 같습니다.
- 소규모 프로젝트(페이지 10개 미만): Context API면 충분합니다.
- 중형 프로젝트(페이지 10~50개): 가볍고 충분한 Zustand가 적합합니다.
- 대규모 프로젝트(페이지 50개 이상, 여러 팀 협업): 도구 체계가 잘 갖춰진 Redux Toolkit이 적합합니다.
장바구니는 Zustand와 특히 잘 맞습니다. 상품 목록, 장바구니 아이콘, 결제 페이지가 모두 데이터를 공유해야 하고, 새로고침 후에도 데이터를 유지해야 하며, 관련 컴포넌트만 업데이트해 성능도 챙겨야 하기 때문입니다. Zustand는 이 모든 요구를 해결하면서 Redux보다 코드량이 절반가량 적습니다.
Zustand 장바구니 실전 코드
이론은 충분히 살펴봤으니 바로 코드를 작성해 보겠습니다. 먼저 의존성을 설치합니다.
npm install zustand
그다음 장바구니 Store(/store/cartStore.js)를 만듭니다.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCartStore = create(
persist(
(set, get) => ({
// 상태
items: [], // [{ id, name, price, quantity, image }]
// 계산 속성
get total() {
return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
get count() {
return get().items.reduce((sum, item) => sum + item.quantity, 0)
},
// 메서드
addItem: (product) => set((state) => {
const existing = state.items.find(item => item.id === product.id)
if (existing) {
// 이미 존재하면 수량 +1
return {
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
} else {
// 새 상품을 장바구니에 추가
return { items: [...state.items, { ...product, quantity: 1 }] }
}
}),
removeItem: (productId) => set((state) => ({
items: state.items.filter(item => item.id !== productId)
})),
updateQuantity: (productId, quantity) => set((state) => ({
items: state.items.map(item =>
item.id === productId ? { ...item, quantity } : item
)
})),
clearCart: () => set({ items: [] })
}),
{
name: 'shopping-cart', // localStorage의 key
}
)
)
코드가 길어 보이지만 로직은 간단합니다. items 배열에 상품을 저장하고, total과 count로 총액과 총수량을 계산하며, 여러 메서드로 추가·삭제·수정을 처리합니다. persist 미들웨어가 데이터를 자동으로 localStorage에 저장하므로 페이지를 새로고침해도 사라지지 않습니다.
컴포넌트에서 사용하는 방법도 매우 간단합니다.
import { useCartStore } from '@/store/cartStore'
function ProductCard({ product }) {
const addItem = useCartStore(state => state.addItem)
return (
<button onClick={() => addItem(product)}>
장바구니에 담기
</button>
)
}
function CartIcon() {
const count = useCartStore(state => state.count)
return <div>장바구니 ({count})</div>
}
useCartStore(state => state.addItem) 형식은 선택자입니다. addItem 메서드만 구독하므로 장바구니의 다른 데이터가 바뀌어도 재렌더링되지 않습니다. 이것이 Zustand 성능의 비결인 정밀 구독입니다.
Redux의 useSelector와 useDispatch를 사용해 본 적이 있다면 Zustand가 훨씬 간결하다는 것을 알 수 있습니다. action type이나 reducer 함수 없이 Store 안에 메서드를 바로 작성하면 됩니다.
‘프로젝트가 이미 Redux를 쓰는데 Zustand로 바꿔야 하나요?‘라고 물을 수 있습니다. 그럴 필요는 없습니다. Redux Toolkit도 충분히 좋은 선택입니다. 오픈 소스 이커머스 프로젝트 C-Shopping도 Redux Toolkit과 RTK Query로 데이터 흐름을 추적하고 안정성을 확보합니다. 다만 새 프로젝트라면 학습 비용이 낮고 개발 속도가 빠른 Zustand를 개인적으로 더 권합니다.
Stripe 결제 연동 전체 흐름
먼저 Stripe 결제 흐름 이해하기
처음 Stripe 문서를 볼 때는 의문투성이였습니다. Checkout Session은 무엇이고 Payment Intent는 무엇인지, 왜 Stripe 페이지로 이동해야 하는지, 자체 웹사이트 안에서 결제를 끝낼 수는 없는지 궁금했습니다.
지금 돌아보면 흐름은 꽤 명확합니다.
- 프론트엔드: 사용자가 ‘결제하기’를 누르면 API를 호출해 Checkout Session을 생성합니다.
- 백엔드: Session을 만들고 session.id를 반환합니다.
- 프론트엔드: session.id를 받아 Stripe.js로 Stripe 호스팅 결제 페이지로 이동합니다.
- 사용자: Stripe 페이지에서 신용카드 정보를 입력하고 결제를 완료합니다.
- Stripe: 결제 성공 후 백엔드로 Webhook을 전송합니다.
- 백엔드: Webhook을 받아 주문 생성, 재고 차감, 메일 발송을 처리합니다.
- Stripe: 사용자를 웹사이트의 success_url로 리디렉션합니다.
핵심은 결제 성공 로직을 절대 프론트엔드에서 처리하지 않는 것입니다. 결제 후 사용자가 완료 버튼을 누르지 않고 브라우저를 닫거나 네트워크가 끊길 수 있고, 고의로 리디렉션하지 않을 수도 있기 때문입니다. 유일하게 신뢰할 수 있는 방식은 뒤에서 자세히 설명할 Webhook입니다.
Stripe Checkout Session 생성
먼저 의존성을 설치합니다.
npm install stripe @stripe/stripe-js
그다음 환경 변수(.env.local)를 설정합니다.
STRIPE_SECRET_KEY=sk_test_xxxxx # 백엔드용이며 절대 프론트엔드에 노출하면 안 됨
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx # 프론트엔드용
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Webhook 서명 검증용
API 라우트(/pages/api/create-checkout.js)를 만듭니다.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
try {
const { items } = req.body // 장바구니 데이터
// Stripe에서 요구하는 line_items 형식 생성
const lineItems = items.map(item => ({
price_data: {
currency: 'usd',
product_data: {
name: item.name,
images: [item.image],
},
unit_amount: Math.round(item.price * 100), // Stripe는 센트 단위 사용
},
quantity: item.quantity,
}))
// Checkout Session 생성
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment', // 일회성 결제(구독은 'subscription')
success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/cart`,
metadata: {
// 사용자 정의 데이터를 저장하면 이후 Webhook에서 가져올 수 있음
userId: req.user?.id || 'guest',
},
})
res.status(200).json({ sessionId: session.id })
} catch (err) {
console.error('Checkout Session 생성 실패:', err)
res.status(500).json({ error: err.message })
}
}
몇 가지 세부 사항을 확인해야 합니다.
- Stripe는 센트 단위를 사용하므로
unit_amount에 100을 곱합니다(99.99달러 = 9999센트). success_url의{CHECKOUT_SESSION_ID}는 자리표시자이며, Stripe가 실제 session_id로 자동 치환합니다.metadata에는 사용자 ID나 주문 메모 같은 자체 데이터를 저장할 수 있으며 Webhook에서 가져올 수 있습니다.
프론트엔드에서 Checkout 호출
결제 페이지(/pages/checkout.js)는 다음과 같이 구현합니다.
import { loadStripe } from '@stripe/stripe-js'
import { useCartStore } from '@/store/cartStore'
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
export default function CheckoutPage() {
const { items, total } = useCartStore()
const handleCheckout = async () => {
try {
// 백엔드 API를 호출해 Session 생성
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
})
const { sessionId } = await response.json()
// Stripe 결제 페이지로 이동
const stripe = await stripePromise
const { error } = await stripe.redirectToCheckout({ sessionId })
if (error) {
console.error('결제 페이지 이동 실패:', error)
alert(error.message)
}
} catch (err) {
console.error('결제 시작 실패:', err)
alert('결제에 실패했습니다. 잠시 후 다시 시도해 주세요.')
}
}
return (
<div>
<h1>결제</h1>
{items.map(item => (
<div key={item.id}>
{item.name} x {item.quantity} = ${item.price * item.quantity}
</div>
))}
<div>합계: ${total}</div>
<button onClick={handleCheckout}>결제하기</button>
</div>
)
}
‘결제하기’를 누르면 사용자는 Stripe 호스팅 결제 페이지로 이동합니다. Stripe가 폼, 신용카드 인증, 사기 탐지를 모두 처리하므로 많은 작업을 줄일 수 있습니다.
‘결제 페이지 스타일을 바꿀 수 있나요?’ 물론입니다. Stripe는 색상, logo, 글꼴을 사용자 지정할 수 있지만 전체 레이아웃은 고정되어 있습니다. UI를 완전히 제어하려면 결제 폼을 프론트엔드에 삽입하는 Stripe Elements를 사용할 수 있지만 복잡도가 훨씬 높아 초보자에게는 권하지 않습니다.
결제 성공 후 이동
결제가 끝나면 Stripe가 설정한 success_url로 사용자를 리디렉션합니다. 이 페이지에서 주문 상세 정보를 보여줄 수 있습니다.
// /pages/success.js
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
export default function SuccessPage() {
const router = useRouter()
const { session_id } = router.query
const [order, setOrder] = useState(null)
useEffect(() => {
if (session_id) {
// 백엔드에서 주문 정보 가져오기
fetch(`/api/order?session_id=${session_id}`)
.then(res => res.json())
.then(data => setOrder(data))
}
}, [session_id])
if (!order) return <div>불러오는 중...</div>
return (
<div>
<h1>결제가 완료되었습니다!</h1>
<p>주문 번호: {order.id}</p>
<p>금액: ${order.total}</p>
</div>
)
}
다만 이 페이지는 표시 용도일 뿐이며 실제 주문은 반드시 Webhook에서 생성해야 합니다. 이제 Webhook 구현을 살펴보겠습니다.
Webhook 주문 처리와 상태 동기화
Webhook이 중요한 이유
처음 결제 기능을 만들었을 때는 사용자가 success 페이지로 돌아오면 결제가 끝났다고 순진하게 생각해 주문 로직을 모두 그곳에 작성했습니다. 그런데 테스트 중 사용자가 결제 직후 브라우저를 닫자 주문이 생성되지 않았고, 환불 여부조차 알 수 없어 크게 당황했습니다.
Stripe 공식 문서를 살펴본 뒤에야 Webhook이 유일하게 신뢰할 수 있는 주문 처리 방식임을 이해했습니다. 이유는 다음과 같습니다.
- 사용자 리디렉션은 신뢰할 수 없음: 브라우저 종료, 네트워크 단절, 완료 버튼 미클릭 등 여러 상황이 생길 수 있습니다.
- 보안 요구 사항: 주문 생성, 재고 차감, 배송 같은 민감한 작업은 반드시 백엔드에서 수행해야 하며 프론트엔드가 제어하면 안 됩니다.
- Stripe 공식 권장 사항: 모든 핵심 비즈니스 로직을 Webhook에서 처리해야 합니다.
Webhook은 Stripe 서버가 자체 서버를 호출해 ‘결제가 성공했습니다’ 또는 ‘구독이 취소되었습니다’라고 알려주는 방식입니다. 알림을 받으면 그에 맞는 처리를 수행합니다.
Webhook 엔드포인트 생성
Next.js에 /pages/api/stripe-webhook.js를 만듭니다.
import Stripe from 'stripe'
import { buffer } from 'micro'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
// 핵심 설정: Next.js 기본 body 파싱 비활성화
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).send('Method not allowed')
}
const buf = await buffer(req)
const sig = req.headers['stripe-signature']
let event
try {
// Webhook 서명 검증(매우 중요!)
event = stripe.webhooks.constructEvent(buf, sig, webhookSecret)
} catch (err) {
console.error('Webhook 서명 검증 실패:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// 이벤트 유형별 처리
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data.object)
break
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(event.data.object)
break
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object)
break
default:
console.log(`처리하지 않은 이벤트 유형: ${event.type}`)
}
res.status(200).json({ received: true })
}
async function handleCheckoutSessionCompleted(session) {
console.log('결제 성공!', session.id)
// metadata 또는 session.id 조회를 통해 장바구니 데이터 가져오기
const userId = session.metadata.userId
const sessionId = session.id
const total = session.amount_total / 100 // 달러 단위로 변환
// 기존 주문 확인(멱등성 보장)
const existingOrder = await db.order.findUnique({
where: { stripeSessionId: sessionId }
})
if (existingOrder) {
console.log('주문이 이미 존재하므로 생성을 건너뜁니다')
return
}
// 주문 생성
const order = await db.order.create({
data: {
userId,
stripeSessionId: sessionId,
status: 'paid',
total,
// ... 기타 필드
}
})
// 재고 차감
await updateInventory(order.items)
// 확인 메일 발송
await sendOrderConfirmationEmail(userId, order)
console.log('주문 생성 성공:', order.id)
}
async function handlePaymentIntentSucceeded(paymentIntent) {
// 결제 입금 확인
console.log('결제 확인:', paymentIntent.id)
}
async function handleInvoicePaymentFailed(invoice) {
// 구독 결제 실패 처리
console.log('결제 실패:', invoice.id)
// 알림 메일 발송, 서비스 일시 중지 등
}
핵심 사항은 세 가지입니다.
- bodyParser를 반드시 비활성화: Stripe는 서명을 검증할 때 원본 요청 본문(raw body)이 필요합니다. Next.js가 body를 먼저 파싱하면 검증에 실패합니다.
- 서명을 반드시 검증:
stripe.webhooks.constructEvent()는 요청이 실제 Stripe에서 왔는지 확인해 악의적인 제3자의 위조 요청을 막습니다. - 멱등성 처리: 네트워크 문제나 재시도 메커니즘으로 Stripe가 Webhook을 중복 전송할 수 있으므로 코드는 중복 호출을 안전하게 처리해야 합니다.
stripeSessionId에 고유 인덱스를 두면 같은 결제로 여러 주문이 생기지 않습니다.
로컬에서 Webhook 테스트
Stripe가 localhost를 직접 호출할 수 없으므로 로컬 개발에서는 Stripe CLI가 필요합니다. 먼저 CLI를 설치합니다.
# Mac
brew install stripe/stripe-cli/stripe
# Windows(Scoop 사용)
scoop install stripe
# 또는 공식 웹사이트에서 다운로드
# https://stripe.com/docs/stripe-cli
로그인한 뒤 Webhook을 수신합니다.
stripe login
stripe listen --forward-to localhost:3000/api/stripe-webhook
CLI가 whsec_xxxxx와 비슷한 임시 webhook secret을 제공하면 .env.local에 복사합니다.
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
다른 터미널에서 테스트 이벤트를 발생시킵니다.
stripe trigger checkout.session.completed
CLI와 Next.js 콘솔에 로그가 나타나면 Webhook이 정상적으로 수신된 것입니다. 이제 주문 생성 로직을 디버깅할 수 있습니다.
저도 이 단계에서 오랫동안 서명 검증 실패를 겪었습니다. 원인은 Next.js의 bodyParser를 끄지 않아 body가 미리 파싱된 것이었습니다. 반드시 export const config 설정을 추가해야 합니다.
주문 상태 관리
주문 상태는 대략 다음과 같이 바뀝니다.
결제 대기 → 결제 완료 → 상품 준비 중 → 배송 완료 → 처리 완료
↓
취소/환불
데이터베이스에서는 enum으로 상태를 저장합니다.
// schema.prisma
model Order {
id String @id @default(cuid())
stripeSessionId String @unique // 멱등성 보장
userId String
status OrderStatus @default(PENDING)
total Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum OrderStatus {
PENDING // 결제 대기
PAID // 결제 완료
PREPARING // 상품 준비 중
SHIPPED // 배송 완료
COMPLETED // 처리 완료
CANCELLED // 취소
REFUNDED // 환불
}
Webhook이 checkout.session.completed를 받으면 상태를 PAID로 바꿉니다. 이후 배송과 완료 같은 상태는 백오피스 관리 시스템에서 수동 또는 자동으로 업데이트합니다.
오류 처리
데이터베이스 연결이 끊기거나 외부 서비스에 장애가 생기면 Webhook이 실패할 수 있습니다. Stripe에는 재시도 메커니즘이 있지만 실패 로그도 직접 기록하는 것이 좋습니다.
async function handleCheckoutSessionCompleted(session) {
try {
// 비즈니스 로직
} catch (error) {
console.error('주문 처리 실패:', error)
// 로그 시스템(Sentry, LogRocket 등)에 기록
await logError({
type: 'webhook_error',
event: 'checkout.session.completed',
sessionId: session.id,
error: error.message,
})
throw error // Stripe가 실패를 인식하고 자동 재시도하도록 오류 전달
}
}
Webhook이 실패하면 Stripe가 3일 동안 자동으로 재시도합니다. 그동안 Stripe Dashboard에서 실패한 Webhook을 확인하고 수동으로 다시 전송할 수도 있습니다.
전체 주문 흐름 실전
이제 필요한 요소를 모두 준비했습니다. 하나로 연결해 전체 주문 흐름이 어떻게 동작하는지 살펴보겠습니다.
사용자가 주문하는 전체 경로
- 상품 페이지: 사용자가 ‘장바구니에 담기’를 누르면 Zustand Store가 업데이트되고 장바구니 아이콘 숫자가 1 증가합니다.
- 장바구니 페이지: 장바구니 내용을 확인하고 수량을 조절한 뒤 ‘결제하기’를 누릅니다.
- 결제 페이지: 주문 요약을 확인하고 ‘결제하기’ 버튼을 누릅니다.
- 프론트엔드: 장바구니 데이터를 담아
/api/create-checkout을 호출합니다. - 백엔드: Stripe Session을 생성하고 sessionId를 반환합니다.
- 프론트엔드: Stripe 호스팅 결제 페이지로 이동합니다.
- 사용자: 신용카드 정보를 입력하고 ‘Pay’를 누릅니다.
- Stripe: 결제를 처리하고 성공하면
/api/stripe-webhook으로 Webhook을 보냅니다. - 백엔드 Webhook: 서명 검증 → 주문 생성 → 재고 차감 → 메일 발송 순서로 처리합니다.
- Stripe: 사용자를
/success?session_id=xxx로 리디렉션합니다. - 프론트엔드: success 페이지에서
/api/order?session_id=xxx를 호출해 주문 상세 정보를 표시합니다.
전체 흐름은 복잡해 보이지만 각 단계는 명확합니다. 핵심은 9단계를 반드시 Webhook에서 수행하고 11단계에 의존하지 않는 것입니다.
데이터베이스 설계 핵심
model Order {
id String @id @default(cuid())
stripeSessionId String @unique // 멱등성
userId String
status OrderStatus @default(PENDING)
total Float
items OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
quantity Int
price Float // 이후 상품 가격이 바뀌어도 주문에 영향을 주지 않도록 주문 당시 가격 저장
order Order @relation(fields: [orderId], references: [id])
product Product @relation(fields: [productId], references: [id])
}
OrderItem의 price 필드에는 Product에 연결된 현재 가격이 아니라 주문 당시 가격을 저장합니다. 그래야 상품 가격이 나중에 올라도 과거 주문은 원래 가격으로 계산됩니다.
경계 상황 처리
1. 재고가 부족하면 어떻게 하나요?
Checkout Session을 만들기 전에 확인합니다.
// /pages/api/create-checkout.js
const { items } = req.body
// 재고 확인
for (const item of items) {
const product = await db.product.findUnique({ where: { id: item.id } })
if (product.stock < item.quantity) {
return res.status(400).json({ error: `${product.name} 재고가 부족합니다` })
}
}
// 재고가 충분하면 Session 생성을 계속 진행...
2. 결제는 성공했지만 Webhook이 실패하면 어떻게 하나요?
Stripe가 3일 동안 자동 재시도합니다. Stripe Dashboard에서 Webhook을 수동으로 다시 전송할 수도 있습니다. 또는 정기 작업으로 ‘결제는 성공했지만 주문이 생성되지 않은’ Session을 찾아 주문을 보완할 수 있습니다.
3. 사용자가 결제했지만 배송하지 못한 채 시간이 지나면 어떻게 하나요?
배송 전에 재고를 다시 확인합니다. 재고가 없다면 사용자에게 연락해 환불하거나 다른 상품으로 교환합니다.
프로덕션 배포 시 주의 사항
테스트 환경에서 잘 동작한다고 바로 프로덕션에 올리면 안 됩니다. 반드시 주의해야 할 몇 가지 함정이 있습니다.
환경 변수 설정
프로덕션 키는 테스트 환경과 다르므로 혼동하지 마세요.
# .env.production
STRIPE_SECRET_KEY=sk_live_xxxxx # test가 아니라 live인지 확인
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # 프로덕션 Webhook Secret
Vercel이나 다른 플랫폼에 배포할 때 환경 변수를 설정해야 합니다. Secret Key는 절대 Git에 커밋하지 마세요.
Webhook 엔드포인트 설정
테스트 환경에서는 Stripe CLI 전달을 사용했지만 프로덕션에서는 Stripe Dashboard에서 직접 설정해야 합니다.
- Stripe Dashboard에 로그인합니다.
- ‘Developers’ → ‘Webhooks’로 이동합니다.
- ‘Add endpoint’를 누릅니다.
- 프로덕션 URL
https://yourdomain.com/api/stripe-webhook을 입력합니다. checkout.session.completed,payment_intent.succeeded등 수신할 이벤트를 선택합니다.- 저장한 뒤
whsec_xxxxx형태의 Signing secret을 복사해 환경 변수에 설정합니다.
저도 첫 배포 때 이 설정을 빠뜨려 프로덕션에서 주문이 전혀 생성되지 않았고, 한참을 조사한 뒤에야 Webhook 자체가 수신되지 않았음을 발견했습니다.
보안 체크리스트
배포 전에 다음 항목을 점검하세요.
- ✅ 모든 결제 로직을 백엔드에서 처리합니다(프론트엔드는 이동만 담당).
- ✅ Webhook 서명을 검증합니다(
stripe.webhooks.constructEvent). - ✅ 프론트엔드 가격 변조를 막기 위해 결제 금액과 주문 금액이 일치하는지 확인합니다.
- ✅
stripeSessionId고유 인덱스로 멱등성을 구현합니다. - ✅ Sentry, Datadog 등으로 모든 결제 관련 로그를 기록합니다.
- ✅ Webhook 실패율과 결제 성공률에 이상 알림을 설정합니다.
세 번째 항목은 특히 중요합니다. Session 생성 시 이미 백엔드에서 가격을 정했더라도 Webhook에서 다시 검증해야 합니다. 프론트엔드를 우회해 Stripe API를 직접 호출할 가능성이 낮더라도 보안에는 사소한 문제가 없습니다.
모니터링과 알림
프로덕션에는 모니터링 시스템을 연동하는 것이 좋습니다.
// /pages/api/stripe-webhook.js
import * as Sentry from '@sentry/nextjs'
export default async function handler(req, res) {
try {
// ... Webhook 로직
} catch (error) {
Sentry.captureException(error, {
tags: {
type: 'stripe_webhook',
event: event.type,
},
})
throw error
}
}
다음 지표를 모니터링하는 것이 좋습니다.
- Webhook 실패율(5%를 넘으면 알림)
- 결제 성공률(갑자기 떨어지면 Stripe 장애나 설정 오류일 수 있음)
- 주문 생성 시간(3초를 넘으면 원인 조사)
정리: 처음부터 프로덕션까지의 전체 경로
핵심 단계를 다시 살펴보겠습니다.
- 장바구니 상태 관리: Zustand를 사용하고, 대규모 프로젝트는 Redux Toolkit을 선택하며 persist 미들웨어로 데이터를 영속화합니다.
- 결제 연동: Stripe Checkout Session을 생성하고 호스팅 결제 페이지로 이동해 폼 검증 부담을 줄입니다.
- 주문 처리: Webhook에서 주문 생성, 재고 차감, 메일 발송을 수행해 신뢰성을 보장합니다.
- 프로덕션 배포: 환경 변수를 설정하고 Webhook 엔드포인트를 등록하며 모니터링과 알림을 연동합니다.
반드시 기억해야 할 원칙은 세 가지입니다.
- 결제 로직은 반드시 백엔드에 둡니다: 프론트엔드는 신뢰할 수 없습니다.
- Webhook이 유일하게 신뢰할 수 있는 출처입니다: 사용자 리디렉션은 신뢰할 수 없습니다.
- 보안이 언제나 최우선입니다: 서명 검증, 재생 공격 방지, 로그 기록을 지킵니다.
처음 이커머스 결제를 구현한다면 Stripe 테스트 환경에서 전체 흐름을 먼저 실행해 보세요. 테스트 카드 번호는 4242 4242 4242 4242를 사용할 수 있으며 유효기간과 CVV에는 임의의 값을 입력하면 됩니다. 테스트 환경에 문제가 없을 때 프로덕션으로 전환합니다.
마지막으로 몇 가지 심화 자료를 추천합니다.
- Stripe 공식 문서: https://stripe.com/docs(영어이지만 매우 자세합니다.)
- Next.js + Stripe 전체 튜토리얼: Pedro Alonso의 2025년 가이드(‘Stripe Next.js 15 complete guide’ 검색)
- 오픈 소스 프로젝트 참고: Redux Toolkit과 Stripe를 사용하는 C-Shopping 이커머스 플랫폼
이 글이 시행착오를 줄이는 데 도움이 되길 바랍니다. 주문이 자동으로 생성되고 재고가 정확히 차감되며 사용자에게 확인 메일까지 도착하는 모습을 처음 봤을 때의 성취감은 정말 특별합니다. 힘내세요!
Next.js 이커머스 장바구니와 Stripe 결제 전체 구현 과정
상태 관리, 결제 연동, 주문 처리를 포함해 이커머스 장바구니와 결제 시스템을 처음부터 구축하는 상세 절차입니다.
⏱️ Estimated time: 2 hr
- 1
Step 1: 의존성을 설치하고 Zustand 장바구니 구성
Zustand 상태 관리 라이브러리 설치:
• npm install zustand
장바구니 Store 생성(/store/cartStore.js):
• 상품을 저장할 items 배열 정의
• total 및 count 계산 속성 추가
• addItem, removeItem, updateQuantity, clearCart 메서드 구현
• persist 미들웨어로 localStorage에 저장
핵심 설정:
• persist 미들웨어가 자동으로 영속화하므로 새로고침해도 데이터가 사라지지 않음
• 선택자 구독(useCartStore(state => state.addItem))으로 불필요한 재렌더링 방지
적합한 상황: 가벼운 상태 관리가 필요한 중소형 프로젝트(10~50페이지) - 2
Step 2: Stripe Checkout Session API 생성
Stripe 의존성 설치:
• npm install stripe @stripe/stripe-js
환경 변수 설정(.env.local):
• STRIPE_SECRET_KEY=sk_test_xxxxx(백엔드용, 노출 금지)
• NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx(프론트엔드용)
• STRIPE_WEBHOOK_SECRET=whsec_xxxxx(Webhook 서명 검증용)
API 라우트 생성(/pages/api/create-checkout.js):
• 장바구니 items 데이터 수신
• Stripe line_items 형식으로 변환(unit_amount에 100을 곱해야 함)
• checkout.sessions 생성(success_url 및 cancel_url 설정)
• metadata에 userId 같은 사용자 정의 데이터 저장
• 프론트엔드에 sessionId 반환
핵심 사항:
• Stripe는 센트 단위를 사용하므로 가격에 100을 곱함
• success_url에는 {CHECKOUT_SESSION_ID} 자리표시자 사용
• metadata에 비즈니스 데이터를 저장하면 Webhook에서 가져올 수 있음 - 3
Step 3: 프론트엔드에서 Stripe Checkout 호출
결제 페이지 구현(/pages/checkout.js):
• loadStripe로 Stripe.js 로드
• /api/create-checkout을 호출해 Session 생성
• stripe.redirectToCheckout()으로 결제 페이지 이동
오류 처리:
• catch로 네트워크 오류 포착
• stripe.redirectToCheckout 반환값의 error 확인
• 사용자에게 이해하기 쉬운 오류 메시지 표시
결제 페이지 설명:
• Stripe 호스팅 결제 페이지를 사용하므로 직접 폼을 만들 필요가 없음
• 신용카드 인증과 사기 탐지를 자동 처리
• 색상, logo, 글꼴 사용자 지정 가능
주의 사항:
• 결제 성공 로직은 절대 프론트엔드에서 처리하지 않음
• 사용자가 브라우저를 닫거나 완료 버튼을 누르지 않을 수 있음
• 실제 주문 생성은 반드시 Webhook에서 수행 - 4
Step 4: 주문 처리를 위한 Webhook 엔드포인트 구성
Webhook API 생성(/pages/api/stripe-webhook.js):
필수 설정:
• export const config = { api: { bodyParser: false } }(body 파싱 비활성화)
• buffer(req)로 원본 요청 본문 획득
• stripe.webhooks.constructEvent()로 서명 검증
처리할 이벤트 유형:
• checkout.session.completed: 결제 성공 후 주문 생성
• payment_intent.succeeded: 결제 입금 확인
• invoice.payment_failed: 구독 결제 실패
멱등성 보장:
• stripeSessionId가 이미 존재하는지 확인
• 데이터베이스에 unique 인덱스 추가
• Webhook 중복 호출로 여러 주문이 생성되는 것을 방지
비즈니스 로직:
• 주문 레코드 생성(status: 'paid')
• 재고 차감(updateInventory)
• 확인 메일 발송(sendOrderConfirmationEmail)
• 로그와 오류 기록
로컬 테스트:
• stripe login(CLI 로그인)
• stripe listen --forward-to localhost:3000/api/stripe-webhook
• stripe trigger checkout.session.completed(테스트 이벤트)
핵심 주의 사항:
• bodyParser를 비활성화하지 않으면 서명 검증 실패
• 악의적으로 위조된 요청을 막으려면 반드시 서명 검증
• Webhook 실패 시 Stripe가 3일 동안 자동 재시도 - 5
Step 5: 프로덕션 배포 및 보안 구성
환경 변수 설정:
• 프로덕션 키(sk_live_xxxxx 및 pk_live_xxxxx) 사용
• 플랫폼(Vercel/Netlify)에 환경 변수 설정
• Secret Key를 절대 Git에 커밋하지 않음
Stripe Dashboard 설정:
• Developers → Webhooks로 이동
• 프로덕션 엔드포인트(https://yourdomain.com/api/stripe-webhook) 추가
• 수신할 이벤트(checkout.session.completed 등) 선택
• Signing secret을 복사해 환경 변수에 설정
보안 체크리스트:
• ✅ 모든 결제 로직을 백엔드에서 수행
• ✅ Webhook 서명 검증
• ✅ 결제 금액과 주문 금액 일치 여부 검증
• ✅ 멱등성 구현(stripeSessionId 고유 인덱스)
• ✅ 모든 결제 로그 기록
• ✅ 모니터링 알림 설정(Webhook 실패율 >5% 시 알림)
모니터링 지표:
• Webhook 실패율
• 결제 성공률
• 주문 생성 시간(3초 초과 시 원인 조사)
모니터링 시스템 연동:
• Sentry/LogRocket으로 오류 기록
• 알림 규칙 구성
• Stripe Dashboard의 Webhook 로그 정기 확인
테스트 절차:
• 테스트 카드 번호 4242 4242 4242 4242 사용
• 전체 흐름(장바구니 → 결제 → Webhook → 주문 생성) 검증
• 실패 상황(재고 부족, Webhook 실패 등) 테스트
FAQ
Redux와 Zustand 중 무엇을 선택해야 하나요? 제 프로젝트에는 어떤 것이 맞나요?
• 소규모 프로젝트(10페이지 미만): Context API면 충분하며 별도의 상태 관리 라이브러리가 필요하지 않음
• 중형 프로젝트(10~50페이지): 가볍고 학습 비용과 코드량이 적은 Zustand가 더 적합
• 대규모 프로젝트(50페이지 이상, 여러 팀 협업): 도구 체계와 디버깅 기능, 커뮤니티가 성숙한 Redux Toolkit
구체적인 상황:
• 새 프로젝트와 빠른 반복 개발: 빠르게 익히고 결과를 낼 수 있는 Zustand
• 기존 Redux 프로젝트: 바꿀 필요 없이 Redux Toolkit을 계속 사용
• 팀이 상태 관리에 익숙하지 않음: 학습 곡선이 완만한 Zustand
장바구니에는 컴포넌트 간 공유, 영속 저장, 성능 최적화가 필요하므로 Zustand를 권장합니다.
결제 성공 로직을 프론트엔드에서 처리하면 안 되는 이유는 무엇인가요?
신뢰성 문제:
• 결제 직후 사용자가 브라우저를 닫을 수 있음
• 네트워크 중단으로 리디렉션이 실패할 수 있음
• 사용자가 완료 버튼을 누르지 않고 상품을 얻으려 할 수 있음
보안 위험:
• 프론트엔드 코드는 변조하거나 우회할 수 있음
• 주문 생성과 재고 차감 같은 민감한 작업을 노출하면 안 됨
• 악의적인 사용자가 결제 성공 상태를 위조하는 것을 막을 수 없음
올바른 방법:
• 모든 핵심 비즈니스 로직을 Webhook에서 수행
• Stripe 서버가 사용자 브라우저를 거치지 않고 백엔드에 직접 알림
• Webhook 서명 검증으로 안전성과 신뢰성 확보
• Stripe도 Webhook을 주문 처리의 유일하게 신뢰할 수 있는 출처로 권장
프론트엔드 success 페이지는 표시 용도로만 사용하고 비즈니스 로직은 처리하지 않습니다.
Webhook 서명 검증이 계속 실패할 때는 어떻게 해야 하나요?
가장 흔한 원인(90%):
• Next.js bodyParser가 비활성화되지 않음
• 해결: API 라우트에 export const config = { api: { bodyParser: false } } 추가
그 밖의 원인:
• Webhook Secret 설정 오류(.env.local의 STRIPE_WEBHOOK_SECRET 확인)
• 잘못된 환경 키 사용(테스트와 프로덕션의 secret은 다름)
• 미들웨어가 요청 본문을 변경함(body를 처리하는 전역 미들웨어 확인)
디버깅 단계:
1. bodyParser: false가 설정되었는지 확인
2. req.headers['stripe-signature']를 출력해 존재 여부 확인
3. Stripe CLI로 테스트: stripe listen --forward-to localhost:3000/api/stripe-webhook
4. CLI가 출력한 상세 오류 확인
5. CLI가 제공한 임시 webhook secret을 사용하는지 확인
로컬 테스트 주의 사항:
• 로컬 개발에서는 Stripe CLI로 전달해야 함
• CLI가 임시 webhook secret(whsec_xxxxx)을 제공
• CLI를 다시 시작할 때마다 새 secret이 생성되므로 .env.local 업데이트 필요
Webhook 중복 호출로 여러 주문이 생성되는 것을 어떻게 막나요?
데이터베이스 계층:
• stripeSessionId 필드에 unique 인덱스 추가
• Prisma 예시: stripeSessionId String @unique
• 데이터베이스가 중복 삽입을 자동 거부
코드 계층:
• 주문 생성 전에 기존 주문 조회
• findUnique({ where: { stripeSessionId } }) 사용
• 이미 있으면 새 주문을 만들지 않고 바로 반환
예시 코드:
```javascript
const existingOrder = await db.order.findUnique({
where: { stripeSessionId: sessionId }
})
if (existingOrder) {
console.log('주문이 이미 존재하므로 생성을 건너뜁니다')
return
}
// 존재하지 않을 때만 새 주문 생성
const order = await db.order.create({ ... })
```
멱등성이 필요한 이유:
• 네트워크 문제나 재시도 메커니즘으로 Stripe가 Webhook을 중복 전송할 수 있음
• 코드가 중복 호출을 안전하게 처리해야 함
• 하나의 결제로 여러 주문이나 재고 중복 차감이 발생하는 것을 방지
추가 권장 사항:
• 모든 Webhook 호출 로그 기록
• 중복 호출 빈도 모니터링
• 알림 메커니즘 설정
프로덕션 배포 후 주문이 생성되지 않을 때는 어떻게 조사하나요?
1. Webhook 수신 여부 확인:
• Stripe Dashboard → Developers → Webhooks에 로그인
• Webhook 호출 기록과 상태(성공/실패) 확인
• 호출 기록이 없으면 엔드포인트 설정 문제
2. 엔드포인트 설정 확인:
• URL이 올바른지 확인(https://yourdomain.com/api/stripe-webhook)
• checkout.session.completed 이벤트를 선택했는지 확인
• 엔드포인트가 활성화되어 있는지 확인
3. 환경 변수 확인:
• STRIPE_WEBHOOK_SECRET 설정이 올바른지 확인
• 테스트가 아닌 프로덕션 secret을 사용하는지 확인
• 배포 플랫폼(Vercel/Netlify)에 환경 변수가 설정되었는지 확인
4. Webhook 엔드포인트 코드 확인:
• bodyParser 비활성화 여부
• 서명 검증의 정확성
• 오류 로그 출력 여부
5. 애플리케이션 로그 확인:
• 서버 로그(Vercel Logs/CloudWatch 등) 확인
• 오류 스택 확인
• Webhook 처리 함수가 실행되었는지 확인
6. 수동 테스트:
• Stripe Dashboard에서 실패한 Webhook 찾기
• 'Resend'를 눌러 수동 재전송
• 성공 여부와 오류 메시지 관찰
흔한 오류:
• 프로덕션에 Webhook 엔드포인트를 추가하지 않음
• 테스트 환경의 webhook secret 사용
• 배포 플랫폼 방화벽이 Stripe 요청을 차단
해결 후 검증:
• 테스트 카드 번호로 전체 결제 흐름 테스트
• 주문 생성, 재고 차감, 메일 발송이 모두 정상인지 확인
6분 읽기 · 게시일: 2026년 1월 7일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js E2E 테스트: Playwright 자동화 테스트 실전 가이드
수동 테스트에서 자동화 E2E 테스트로 전환한 실전 경험을 바탕으로 Playwright 설정, Page Object Model, API 테스트, CI/CD 통합 등 핵심 시나리오를 설명합니다.
45편 중 32편
다음
Next.js 파일 업로드 완벽 가이드: S3/Qiniu Cloud 사전 서명 URL 직접 업로드
Next.js에서 사전 서명 URL로 S3와 Qiniu Cloud에 직접 업로드하는 방법을 알아봅니다. 4MB 제한을 우회해 최대 5GB 파일을 지원하며, 전체 코드 예제와 성능 최적화, 프로덕션 모범 사례를 제공합니다.
45편 중 34편



댓글
GitHub로 로그인하여 댓글을 남기세요