← /blog # State 2025.11.18 10 min

결제 플로우를 상태머신으로 모델링하기

Stripe 결제는 happy path만 다루면 안 된다. 취소·실패·중단·중복 경로를 명시적으로 다루는 단계별 상태 설계.

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 처리 실패
  • 중복 결제 요청
  • 쿠폰 만료 (결제 진행 중 만료)

상태 머신으로 모델링하기

결제 플로우를 상태 머신으로 모델링했다. 가능한 상태를 열거하고, 상태 간 전이를 명시적으로 정의한다. "이 상태에서 이 이벤트가 발생하면 저 상태로 간다"는 규칙이 전부다.

typescript types/checkout.ts
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' };

전이 함수

상태와 이벤트를 받아서 다음 상태를 반환하는 순수 함수. 이 함수 하나에 모든 비즈니스 규칙이 집중된다.

typescript checkoutMachine.ts
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를 붙여서, 서버 사이드에서도 어떤 상태 전이를 거쳤는지 추적할 수 있게 했다.

typescript api/checkout.ts
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에 상태를 기록하면 서버 사이드 디버깅이 가능하다
  • 각 실패 상태에 구체적인 복구 전략을 정의하라