happy path만 다루면 결제 코드의 절반만 쓴 것이다. 나머지 절반은 실패, 취소, 중단, 중복이 차지한다.
Stripe는 단순하지 않다
어웨어랩의 멤버십 결제 플로우는 이런 순서였다 — 플랜 선택 → 결제 주기(월/연) 선택 → 쿠폰 적용 → Stripe Checkout. 여기까지는 직선이다. 문제는 이 직선 위에 비선형 경로가 겹친다는 것이다.
- 플랜 선택 후 뒤로가기 → 쿠폰 상태는?
- 결제 중간에 브라우저를 닫으면?
- Stripe webhook이 늦게 도착하면?
- 같은 사용자가 두 탭에서 동시에 결제하면?
- 쿠폰이 적용된 상태에서 플랜을 바꾸면 가격이 맞는가?
이런 질문들을 if/else로 처리하기 시작하면, 금방 관리할 수 없는 분기가 쌓인다.
if/else가 무너지는 지점
처음에는 각 단계마다 상태 플래그를 두었다 — isPlanSelected, isCouponApplied, isCheckoutStarted, isPaymentComplete. 단계가 직선으로만 진행되면 괜찮지만, 실제로는 비선형 전이가 존재한다.
사용자가 결제 주기 선택 화면에서 뒤로가기를 누르면 isPlanSelected만 false로 돌려야 할까, isCouponApplied도 초기화해야 할까? 이런 판단을 매번 if/else로 하면, 실패 상태의 목록만 정리해도 이렇다:
- Stripe Checkout 세션 만료
- 결제 수단 인증 실패 (3D Secure)
- 잔액 부족
- Webhook 처리 실패
- 중복 결제 요청
- 쿠폰 만료 (결제 진행 중 만료)
상태 머신으로 모델링하기
결제 플로우를 상태 머신으로 모델링했다. 가능한 상태를 열거하고, 상태 간 전이를 명시적으로 정의한다. "이 상태에서 이 이벤트가 발생하면 저 상태로 간다"는 규칙이 전부다.
type CheckoutState =
| 'idle'
| 'plan_selected'
| 'period_selected'
| 'coupon_applied'
| 'checkout_pending'
| 'checkout_success'
| 'checkout_failed'
| 'checkout_cancelled'
| 'checkout_expired';
type CheckoutEvent =
| { type: 'SELECT_PLAN'; planId: string }
| { type: 'SELECT_PERIOD'; period: 'monthly' | 'yearly' }
| { type: 'APPLY_COUPON'; couponCode: string }
| { type: 'START_CHECKOUT' }
| { type: 'CHECKOUT_COMPLETE'; sessionId: string }
| { type: 'CHECKOUT_FAIL'; reason: string }
| { type: 'CANCEL' }
| { type: 'BACK' }
| { type: 'RETRY' };전이 함수
상태와 이벤트를 받아서 다음 상태를 반환하는 순수 함수. 이 함수 하나에 모든 비즈니스 규칙이 집중된다.
function transition(state: CheckoutState, event: CheckoutEvent): CheckoutState {
switch (state) {
case 'idle':
if (event.type === 'SELECT_PLAN') return 'plan_selected';
return state;
case 'plan_selected':
if (event.type === 'SELECT_PERIOD') return 'period_selected';
if (event.type === 'BACK') return 'idle';
return state;
case 'period_selected':
if (event.type === 'APPLY_COUPON') return 'coupon_applied';
if (event.type === 'START_CHECKOUT') return 'checkout_pending';
if (event.type === 'BACK') return 'plan_selected';
return state;
case 'coupon_applied':
if (event.type === 'START_CHECKOUT') return 'checkout_pending';
if (event.type === 'BACK') return 'period_selected';
return state;
case 'checkout_pending':
if (event.type === 'CHECKOUT_COMPLETE') return 'checkout_success';
if (event.type === 'CHECKOUT_FAIL') return 'checkout_failed';
if (event.type === 'CANCEL') return 'checkout_cancelled';
return state;
case 'checkout_failed':
if (event.type === 'RETRY') return 'checkout_pending';
if (event.type === 'BACK') return 'period_selected';
return state;
default:
return state;
}
}이 함수를 보면 어떤 상태에서 어떤 이벤트가 유효한지가 한눈에 보인다. checkout_pending 상태에서 BACK은 허용되지 않는다 — 결제가 진행 중이면 뒤로가기를 막아야 하기 때문이다.
Stripe metadata로 상태 추적
Stripe의 PaymentIntent에 metadata를 붙여서, 서버 사이드에서도 어떤 상태 전이를 거쳤는지 추적할 수 있게 했다.
const paymentIntent = await stripe.paymentIntents.create({
amount: calculateAmount(plan, period, coupon),
currency: 'usd',
metadata: {
planId: plan.id,
period,
couponCode: coupon?.code ?? '',
checkoutState: 'checkout_pending',
initiatedAt: new Date().toISOString(),
userId: user.id,
},
});Webhook에서 결제 결과를 받을 때 metadata를 읽으면, 어떤 플랜과 쿠폰 조합으로 결제가 시작되었는지 확인할 수 있다. 상태 머신의 전이 기록이 Stripe 안에 남는 셈이다.
상태별 롤백과 재시도
각 실패 상태에 대해 구체적인 복구 전략을 정의했다.
- checkout_failed (잔액 부족) → 사용자에게 다른 결제 수단 안내, 상태를 period_selected로 롤백
- checkout_failed (3D Secure 실패) → RETRY 이벤트로 재시도, 동일 세션 유지
- checkout_expired (세션 만료) → 새 세션 생성, 쿠폰 유효성 재검증
- checkout_cancelled (사용자 취소) → idle로 롤백, 선택 정보는 유지
디버깅이 달라진다
상태 머신의 가장 큰 이점은 디버깅이다. 버그 리포트에 "결제가 안 됩니다"라고만 적혀 있어도, Stripe metadata에서 checkoutState와 전이 기록을 보면 어디서 멈췄는지 바로 알 수 있다. if/else 분기에서는 "12번째 조건문에서 뭔가 잘못된 것 같다"고 추측해야 했다.
- happy path만 다루면 결제 코드의 절반이다 — 나머지 절반은 실패 경로
- 상태와 전이를 명시적으로 정의하면 "불가능한 상태"를 타입으로 막을 수 있다
- 전이 함수는 순수 함수이므로 테스트가 쉽다
- Stripe metadata에 상태를 기록하면 서버 사이드 디버깅이 가능하다
- 각 실패 상태에 구체적인 복구 전략을 정의하라