가격표를 넘어 결제 경험을 설계한 과정

크레딧 상태, 상품 노출, Stripe Checkout, 구독 관리를 하나의 결제 여정으로 연결한 프론트엔드 설계 과정을 정리합니다.

#frontend#payment#stripe#state-management

처음에는 결제 기능을 “가격 카드를 보여주고 버튼을 Stripe에 연결하는 작업”이라고 생각했다. 하지만 실제 구현 범위는 가격 페이지에 머물지 않았다.

사용자는 결제 전후에 여러 상태를 거친다.

  • 로그인하지 않은 사용자
  • 무료 크레딧을 받은 사용자
  • 크레딧이 남아 있는 사용자
  • 크레딧을 모두 사용한 사용자
  • 월간 요금제를 이용하는 사용자
  • 결제 기능이 아직 공개되지 않은 환경의 사용자

모바일과 데스크톱에서 가격 페이지의 구성도 달랐다. 일회성 크레딧 상품과 월간 요금제는 서로 다른 방식으로 노출되어야 했고, 로그인 전에는 무료 가입 혜택도 함께 보여줘야 했다.

문제는 이런 조건이 화면마다 개별적으로 구현되면서 시작됐다. 동일한 사용자가 어떤 화면에서는 크레딧을 보유한 것으로, 다른 화면에서는 크레딧이 없는 것으로 표시될 수 있었다. 가격 페이지의 구매 버튼, 오디오 편집 기능의 사용 제한, Account 화면의 잔액 정보도 하나의 사용자 여정으로 연결되지 않았다.

“결제 프론트엔드는 결제 버튼을 구현하는 일이 아니라, 결제 전후의 사용자 상태를 일관되게 연결하는 일이었다.”

1. 왜 가격 페이지 하나로는 부족했을까

초기에는 가격 페이지와 구매 버튼을 중심으로 범위를 보았다. 그러나 결제 경험은 가격 탐색 전의 인증 상태에서 시작해 구매 후의 잔액과 구독 관리까지 이어졌다.

특히 크레딧 소진은 단순한 에러 상태가 아니었다. 사용자가 작업 중에 만나는 상품 선택 시점이자, 유료 기능과 무료 기능 사이의 다음 행동을 결정하는 시점이었다. 따라서 가격, 잔여 사용량, 기능 제한, 구매 유도를 화면별 조건문이 아닌 하나의 상태 흐름으로 다뤄야 했다.

2. 왜 크레딧 0과 데이터 미수신을 구분해야 했을까

가장 먼저 해결해야 했던 문제는 잔여 크레딧 상태였다.

기존에는 서버 응답에 잔여 크레딧이 없을 때도 기본값 0을 사용했다. 이 방식에서는 다음 두 상태를 구분할 수 없다.

  • 0: 사용자가 크레딧을 모두 사용한 상태
  • null: 서버가 아직 크레딧 정보를 전달하지 않았거나 프로필 조회가 완료되지 않은 상태

두 상태를 모두 0으로 처리하면 데이터가 아직 준비되지 않았을 뿐인데도 기능을 제한하거나 구매 안내를 노출할 수 있다. 이는 결제 실패가 아니라 상태 모델링 오류로 인한 잘못된 이용 제한이다.

이를 해결하기 위해 잔여 크레딧을 다음과 같이 모델링했다.

type RemainingCredits = number | null;
 
const remainingCredits = ref<RemainingCredits>(null);
 
const creditState = computed(() => {
  if (remainingCredits.value === null) {
    return 'unknown';
  }
 
  if (remainingCredits.value === 0) {
    return 'exhausted';
  }
 
  return 'available';
});

null을 단순한 빈 값이 아니라 “아직 판단할 수 없는 상태”로 정의했다. 이후 프로필 응답에 숫자가 포함된 경우에만 잔여 크레딧을 갱신하도록 했다.

const applyUserProfile = (profile: UserProfile) => {
  remainingCredits.value = typeof profile.remainingCredits === 'number' ? profile.remainingCredits : null;
};

이 구분을 통해 화면은 데이터 미수신과 실제 소진에 서로 다른 행동을 적용할 수 있게 되었다.

3. 어떻게 크레딧 소진을 결제 전환으로 연결했을까

크레딧을 모두 사용한 고객에게 단순히 기능을 차단하는 것만으로는 부족했다. 사용자는 작업 도중 다음 행동을 바로 선택할 수 있어야 했다.

오디오 편집 화면에 크레딧 소진 안내를 추가하고 두 가지 경로를 제공했다.

  1. 가격 페이지에서 크레딧 구매하기
  2. 자동 생성되는 20초 분량을 무료로 계속 이용하기
const goToPricing = () => {
  router.push('/pricing');
};
 
const continueWithFreeMode = () => {
  isCreditNoticeVisible.value = false;
  maxDurationSeconds.value = FREE_DURATION_SECONDS;
};

여기서 중요한 선택은 구매를 강제하지 않은 것이다. 결제하지 않으면 작업을 완전히 중단시키는 대신, 제한된 무료 기능을 유지했다.

이 구조는 결제 전환과 제품 경험을 함께 고려한 결과다. 사용자는 현재 작업의 맥락을 잃지 않고 유료 기능과 무료 기능 중 하나를 선택할 수 있다. 서비스 관점에서는 구매 경로를 명확히 제공하면서도 비결제 사용자의 이탈을 강제하지 않는다.

정량적인 전환율 변화는 측정하지 않았기 때문에 “결제 전환율을 개선했다”고 단정할 수는 없다. 다만 구매 CTA가 사용 맥락 안에 배치되면서, 가격 페이지로 이어지는 전환 경로가 명확해졌다고는 말할 수 있다.

4. 어떻게 여러 가격 상품을 하나의 상태로 관리했을까

가격 페이지에는 무료 가입 혜택, 일회성 크레딧, 월간 무제한 요금제가 함께 존재했다. 상품마다 별도의 컴포넌트를 만들면 가격이나 노출 정책이 변경될 때 여러 파일을 수정해야 한다.

그래서 상품의 종류와 표현 방식을 데이터로 정의했다.

type PricingItem =
  | {
      type: 'credit';
      priceId: string;
      credits: number;
      priceText: string;
    }
  | {
      type: 'plan';
      planId: string;
      title: string;
      priceText: string;
    };

화면에서는 로그인 여부와 선택한 탭을 조합해 렌더링에 필요한 상태를 한 번만 계산했다.

const pricingUi = computed(() => {
  const isAuthenticated = Boolean(authToken.value);
  const isCreditMode = selectedMode.value === 'credits';
 
  return {
    isAuthenticated,
    isCreditMode,
    showSignupBenefit: !isAuthenticated,
    creditProducts: isAuthenticated ? paidCreditProducts.value : publicCreditProducts.value,
    showMonthlyPlan: true,
  };
});

모바일과 데스크톱은 레이아웃만 다르게 구성하고, 상품 선택과 노출 규칙은 동일한 계산 결과를 사용했다.

이 방식을 선택한 이유는 반응형 화면마다 조건문이 복제되는 것을 막기 위해서였다. 상품 정책이 바뀌어도 상태 계산부를 중심으로 변경할 수 있어 모바일과 데스크톱 사이의 동작 불일치 가능성을 낮출 수 있었다.

5. 왜 Stripe Checkout을 직접 결제 폼보다 선택했을까

가격 페이지에서 선택한 상품은 Stripe Price ID와 연결했다. 프론트엔드는 선택된 Price ID를 서버에 전달하고, 서버가 생성한 결제 세션의 URL로 이동하도록 구성했다.

const startCheckout = async (priceId: string) => {
  const response = await http.post('/stripe/order', {
    price_id: priceId,
  });
 
  const checkoutUrl = response.data?.url;
 
  if (checkoutUrl) {
    window.location.assign(checkoutUrl);
  }
};

여기서 프론트엔드는 카드번호나 결제 인증정보를 직접 처리하지 않는다. 결제 세션은 서버가 생성하고 실제 결제정보 입력은 Stripe의 Hosted Checkout에서 이루어진다.

이 방식을 선택한 이유는 다음과 같다.

  • 민감한 결제정보를 프론트엔드 상태에 보관하지 않는다.
  • 상품 가격과 결제 세션 생성 규칙을 서버에서 통제할 수 있다.
  • 프론트엔드는 상품 선택과 Checkout 이동에 집중할 수 있다.
  • 이후 결제 제공자 설정이 변경되더라도 화면의 변경 범위를 제한할 수 있다.

다만 이 구현만으로 결제 완료를 확정할 수는 없다. 결제 성공 여부와 크레딧 지급은 서버의 결제 완료 처리와 웹훅 검증이 필요하다. 프론트엔드가 Checkout URL로 이동했다는 사실은 결제 성공의 충분조건이 아니다.

6. 결제 기능이 준비되지 않았을 때 어떻게 처리했을까

결제 API가 완전히 준비되지 않은 상태에서 구매 버튼을 그대로 노출하면 사용자는 동작하지 않는 기능을 만나게 된다.

실제 결제 연결을 비활성화한 환경에서는 Checkout 요청 대신 준비 중이라는 안내를 표시하도록 변경했다.

const handlePurchase = () => {
  showToast({
    message: 'Payment is not available yet.',
    tone: 'info',
  });
};

처음에는 연결 코드를 유지하는 것이 작업 진행 상황을 보여주는 방법이라고 생각했다. 하지만 준비되지 않은 결제 경로를 사용자에게 노출하는 것은 기능이 없는 것보다 좋지 않았다.

그래서 화면과 상품 구조는 유지하되, 실제 결제 요청은 비활성화했다. 결제 기능의 구현 여부와 출시 가능 상태를 별개의 조건으로 다룬 것이다.

7. 어떻게 결제 이후의 관리 화면까지 확장했을까

결제는 Checkout에서 끝나지 않는다. 사용자는 결제 이후에도 다음 정보를 확인하거나 변경해야 한다.

  • 현재 잔액
  • 이용 중인 요금제
  • 결제 주기와 다음 결제일
  • 등록된 결제수단
  • 결제 성공·실패 내역
  • 구독 해지 시 종료되는 혜택

이를 위해 Account 화면에 결제 관리 영역을 구성했다. 청구 내역은 재사용 가능한 테이블과 페이지네이션으로 분리하고, 데이터 조회 로직도 화면에서 분리했다.

const visibleInvoices = computed(() => {
  const start = currentOffset.value;
  const end = start + pageSize.value;
 
  return invoices.value.slice(start, end);
});

구독 해지는 즉시 실행하지 않고, 종료되는 혜택과 이용 가능 기간을 확인한 뒤 결정할 수 있도록 확인 모달을 배치했다. Escape 키 처리와 배경 스크롤 잠금도 함께 적용해 모달의 기본적인 접근성과 사용성을 보완했다.

현재 청구 내역은 목 데이터이며, 결제수단 수정과 구독 해지 API는 연결 전 단계다. 따라서 이 부분은 “구독 관리 기능을 운영 환경에 출시했다”기보다 실제 API 응답으로 교체할 수 있는 UI와 상태 구조를 마련했다고 표현하는 것이 정확하다.

8. 결과: 결제 페이지가 아니라 수익화 흐름을 만들었다

이번 작업을 통해 가격 탐색부터 결제 이후 관리까지 이어지는 프론트엔드 흐름을 하나의 사용자 상태로 연결했다.

가격 탐색
→ 상품 선택
→ Checkout 이동
→ 크레딧 상태 반영
→ 크레딧 소진 안내
→ 추가 구매 또는 무료 이용
→ 결제 내역 및 구독 관리

구체적으로 다음 기반을 마련했다.

  • 로그인 상태에 따른 가격 상품 노출
  • 일회성 크레딧과 월간 요금제의 통합 관리
  • 크레딧 미수신과 실제 소진 상태의 분리
  • 사용 맥락 안에서의 구매 및 무료 이용 선택
  • Stripe Checkout으로 이동하는 결제 세션 연동 경로
  • 청구 내역과 구독 해지 UI의 API 연동 구조
  • 결제 기능 비활성화 환경의 사용자 안내

측정된 매출이나 전환율 데이터는 없기 때문에 비즈니스 성과를 수치로 주장할 수는 없다. 대신 상태 불일치로 인한 잘못된 기능 제한 위험을 낮추고, 결제 API가 준비되었을 때 연결 범위를 명확하게 제한했다는 점이 확인 가능한 결과다.

“좋은 결제 UX는 돈을 받는 순간만 설계하지 않는다. 사용자가 왜 결제하고, 결제하지 않았을 때 무엇을 할 수 있으며, 결제 후 무엇을 관리해야 하는지까지 하나의 흐름으로 설계한다.”

처음에는 가격 카드를 만드는 작업이었다. 구현을 진행하면서 그것이 상품, 인증, 사용량, 기능 제한, 외부 결제, 구독 관리가 만나는 경계라는 사실을 알게 되었다.

결국 결제 기능의 완성도는 버튼의 개수가 아니라, 서로 다른 사용자 상태가 얼마나 예측 가능한 다음 행동으로 연결되는지에 달려 있었다.

    관련 글

    댓글 0

    닉네임과 댓글은 공개됩니다.

    0/1000

    댓글을 불러오는 중입니다…