junyeokk
Blog
Stripe·2025. 09. 02

Stripe 구독 플랜 선택 UI 구현

SaaS 서비스에서 구독 결제를 도입할 때, 단순히 Stripe SDK를 연동하는 것만으로는 부족하다. 사용자가 플랜을 선택하고, 결제 주기를 고르고, 최종적으로 Stripe Checkout으로 넘어가는 플랜 선택 UI를 직접 설계해야 한다. 이 글에서는 프론트엔드에서 Stripe 구독 플랜 선택 흐름을 어떻게 구성하는지, 그 구조와 패턴을 정리한다.

왜 커스텀 플랜 선택 UI가 필요한가

Stripe는 기본적으로 Stripe Checkout이라는 호스팅 결제 페이지를 제공한다. 링크 하나로 결제 화면을 띄울 수 있어서 간편하지만, 사용자가 여러 플랜 중 하나를 고르고 결제 주기(월간/연간)를 선택하는 과정은 Checkout이 커버하지 않는다. 이 부분은 서비스 자체 UI에서 처리해야 한다.

[플랜 선택 UI (자체 구현)] → [Stripe Checkout (호스팅)] → [결제 완료 후 리다이렉트]

이 흐름에서 프론트엔드가 담당하는 건 첫 번째 단계다. 사용자에게 플랜 옵션을 보여주고, 선택 결과를 백엔드에 전달해서 Stripe Checkout Session URL을 받아오는 것까지가 프론트의 역할이다.

플랜 데이터 구조 설계

플랜 정보를 하드코딩할 수도 있지만, 실제로는 서버에서 플랜 목록을 받아오는 것클라이언트의 표시용 설정을 분리하는 게 좋다.

클라이언트 사이드 플랜 설정

UI에 표시할 정보(타이틀, 가격 텍스트, 동작 유형)는 클라이언트에서 정적으로 관리한다.

typescript
[플랜 선택 UI (자체 구현)] → [Stripe Checkout (호스팅)] → [결제 완료 후 리다이렉트]

핵심은 action 필드다. 이 값에 따라 "Continue" 버튼을 눌렀을 때의 동작이 완전히 달라진다:

  • free: 바로 리소스 생성 → 메인 페이지로 이동
  • stripe: 리소스 생성 → Stripe Checkout으로 리다이렉트
  • contact: 영업팀 연락 폼 표시

서버 사이드 플랜 데이터

Stripe에 등록된 실제 Product/Price 정보는 API를 통해 가져온다.

typescript
type PlanAction = 'free' | 'stripe' | 'contact';

type PlanConfig = {
  title: string;
  caption: string | React.ReactNode;
  action: PlanAction;
  showBilling: boolean;
  monthlyPrice?: number;
  annualPrice?: number;
};

const PLAN_CONFIGS: Record<string, PlanConfig> = {
  free: {
    title: 'Free',
    caption: '$0 / member / month',
    action: 'free',
    showBilling: false,
  },
  team: {
    title: 'Team',
    caption: '$1.99 / member / month',
    action: 'stripe',
    showBilling: true,
    monthlyPrice: 1.99,
    annualPrice: 64.9,
  },
  pro: {
    title: 'Pro',
    caption: '$7.99 / member / month',
    action: 'stripe',
    showBilling: true,
    monthlyPrice: 7.99,
    annualPrice: 95.88,
  },
  enterprise: {
    title: 'Enterprise',
    caption: 'Contact sales',
    action: 'contact',
    showBilling: false,
  },
};

클라이언트 설정의 title과 서버 플랜의 name을 매칭해서 실제 결제에 사용할 planId를 찾는다. 연간 플랜의 경우 "Team (Yearly)" 같은 네이밍 컨벤션으로 구분하는 패턴이 일반적이다.

typescript
type Plan = {
  id: string;
  name: string;
  priceId: string;
  amount: number;
  interval: 'month' | 'year';
};

// React Query로 캐싱
const { data: plans } = useQuery({
  queryKey: ['plans'],
  queryFn: () => api.getPlans(),
});

결제 주기 선택 (월간/연간)

유료 플랜을 선택했을 때만 결제 주기 옵션을 보여준다. 이건 showBilling 플래그로 제어한다.

tsx
const findTargetPlan = (planTitle: string, billing: 'monthly' | 'annual') => {
  if (billing === 'monthly') {
    return plans.find(p => p.name === planTitle);
  }
  return plans.find(p => p.name === `${planTitle} (Yearly)`);
};

연간 결제 할인율을 표시하는 건 전환율에 직접적인 영향을 준다. 보통 15~30% 할인을 적용하고, UI에서 "30% off" 같은 레이블을 명시한다.

플랜별 분기 처리

"Continue" 버튼 하나로 세 가지 다른 흐름을 처리해야 한다. 이걸 깔끔하게 구현하는 핵심은 액션 타입에 따른 분기다.

typescript
function BillingSelector({ plan, billing, onChange }: Props) {
  if (!plan.showBilling) return null;

  return (
    <RadioGroup value={billing} onValueChange={onChange}>
      <RadioItem
        value="monthly"
        title="Monthly billing"
        description={`$${plan.monthlyPrice} / member / month`}
      />
      <RadioItem
        value="annual"
        title="Annual billing"
        description={`$${plan.annualPrice} / member / year`}
      />
    </RadioGroup>
  );
}

왜 리소스를 먼저 생성하는가?

Stripe 결제 흐름에서 중요한 점이 하나 있다. 구독 결제 페이지로 보내기 전에 리소스를 먼저 만들어야 한다는 것이다. 그 이유:

  1. Stripe Checkout Session 생성 시 리소스 ID가 필요: 결제 성공 후 어떤 리소스에 구독을 연결할지 서버가 알아야 한다
  2. successUri에 리소스 경로 포함: 결제 완료 후 해당 리소스 페이지로 리다이렉트해야 한다
  3. 결제 실패/취소 시 처리: 리소스는 생성되었지만 구독이 없는 상태가 되는데, 이건 Free 플랜으로 자동 처리하면 된다
리소스 생성 → 아바타 업로드(선택) → Stripe Checkout Session 생성 → 리다이렉트

Stripe Checkout Session 연동

프론트엔드에서 직접 Stripe SDK를 쓰는 것이 아니라, 백엔드를 통해 Checkout Session을 생성하는 패턴이 표준이다.

API 흐름

[프론트] POST /api/resources/:id/subscription body: { planId, successUri, cancelUri } [백엔드] Stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], success_url: successUri, cancel_url: cancelUri, metadata: { resourceId }, }) [응답] { url: "https://checkout.stripe.com/c/pay/cs_..." } [프론트] router.push(url) // 브라우저가 Stripe 페이지로 이동

React Query의 useMutation으로 이 흐름을 래핑하면 깔끔하다:

typescript
const handleContinue = async () => {
  if (!selectedPlan) return;
  
  const config = PLAN_CONFIGS[selectedPlan];

  switch (config.action) {
    case 'free':
      // 리소스만 생성하고 끝
      await createResource({ name: resourceName });
      router.push('/dashboard');
      break;

    case 'stripe': {
      // 리소스 생성 → Stripe Checkout 리다이렉트
      const resource = await createResource({ name: resourceName });
      const targetPlan = findTargetPlan(config.title, billingType);
      
      const { url } = await api.startSubscription(resource.id, {
        planId: targetPlan.id,
        successUri: `${window.location.origin}/resource/${resource.id}`,
        cancelUri: `${window.location.origin}/resource/${resource.id}`,
      });
      
      router.push(url); // Stripe Checkout 페이지로 이동
      break;
    }

    case 'contact':
      // 영업팀 연락 폼
      openContactForm();
      break;
  }
};

onSuccess에서 바로 리다이렉트하기 때문에 별도의 로딩 처리가 필요 없다. 사용자 입장에서는 "Continue" 클릭 → 잠깐 로딩 → Stripe 결제 페이지로 자연스럽게 넘어간다.

Customer Portal (구독 관리)

한번 구독을 시작한 사용자가 플랜을 변경하거나 결제 수단을 바꾸거나 구독을 취소하려면 Stripe Customer Portal을 사용한다. 이것도 Checkout과 마찬가지로 서버에서 Session URL을 생성하고 리다이렉트하는 방식이다.

typescript
리소스 생성 → 아바타 업로드(선택) → Stripe Checkout Session 생성 → 리다이렉트
[설정 페이지] "Manage subscription" 클릭 → POST /api/resources/:id/portal { returnUri } → Stripe Customer Portal 페이지로 이동 → 사용자가 설정 변경 후 returnUri로 돌아옴

Customer Portal의 장점은 결제 관련 복잡한 UI(카드 변경, 인보이스 조회, 플랜 업/다운그레이드)를 Stripe가 전부 제공한다는 것이다. 직접 만들 필요가 없다.

전체 상태 관리

플랜 선택 UI에서 관리해야 하는 상태는 의외로 많지 않다:

typescript
[프론트] POST /api/resources/:id/subscription
  body: { planId, successUri, cancelUri }
  
[백엔드] Stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: successUri,
  cancel_url: cancelUri,
  metadata: { resourceId },
})

[응답] { url: "https://checkout.stripe.com/c/pay/cs_..." }

[프론트] router.push(url)  // 브라우저가 Stripe 페이지로 이동

이 두 가지 상태만으로 전체 UI를 제어할 수 있다:

  • selectedPlan이 null이면 → 아무 플랜도 선택 안 됨, Continue 버튼 비활성화
  • selectedPlan이 유료 플랜이면 → 결제 주기 선택 UI 표시
  • selectedPlan이 enterprise면 → 영업팀 연락 버튼 표시
  • billingType에 따라 → 가격 표시 변경, 서버에 전달할 planId 변경
tsx
const useStartSubscription = () => {
  const router = useRouter();

  return useMutation({
    mutationFn: ({ id, data }: {
      id: string;
      data: { planId: string; successUri: string; cancelUri: string };
    }) => api.startSubscription(id, data),
    onSuccess: (data) => {
      router.push(data.url); // Stripe Checkout으로 리다이렉트
    },
  });
};

할인 표시 패턴

원래 가격과 할인 가격을 동시에 보여주는 건 흔한 패턴이다. React에서 JSX를 caption 값으로 넘길 수 있으면 깔끔하게 처리된다.

tsx
const usePortalSubscription = () => {
  const router = useRouter();

  return useMutation({
    mutationFn: ({ resourceId, returnUri }: {
      resourceId: string;
      returnUri: string;
    }) => api.portalSubscription(resourceId, returnUri),
    onSuccess: (data) => {
      router.push(data.url);
    },
  });
};

타입 정의에서 captionstring | React.ReactNode로 선언해두면 이런 패턴을 자연스럽게 쓸 수 있다.

보류 상태 관리 (Pending Resource)

플랜 선택 페이지는 보통 리소스 생성 흐름의 2단계다. 1단계에서 입력한 정보(이름, 아바타 등)를 2단계까지 유지해야 한다. 이걸 URL 파라미터로 전달하기엔 데이터가 크고(아바타 파일 등), 전역 상태로 관리하기엔 페이지 새로고침에 취약하다.

localStorage 기반 보류 상태가 실용적인 해법이다:

typescript
[설정 페이지] "Manage subscription" 클릭
→ POST /api/resources/:id/portal { returnUri }
→ Stripe Customer Portal 페이지로 이동
→ 사용자가 설정 변경 후 returnUri로 돌아옴

1단계에서 save()로 저장하고, 2단계(플랜 선택)에서 읽어서 사용한 뒤, 리소스 생성 완료 후 clear()로 정리한다. 아바타 같은 파일은 IndexedDB나 blob URL로 관리하는 방식도 있다.

정리

Stripe 구독 플랜 선택 UI 구현의 핵심 포인트:

항목설명
플랜 설정 분리클라이언트 표시용 설정과 서버 플랜 데이터를 분리
액션 타입 분기free / stripe / contact로 CTA 동작 분기
리소스 선생성Stripe Checkout 전에 리소스를 먼저 만들어야 함
서버 경유Checkout Session URL은 반드시 백엔드에서 생성
Customer Portal구독 변경/취소는 Portal로 위임
보류 상태멀티 스텝 폼의 중간 데이터는 localStorage로 유지

프론트엔드 입장에서 Stripe 연동의 본질은 "적절한 시점에 서버에 요청 → URL 받아서 리다이렉트"다. 결제 자체의 복잡성은 Stripe가 처리하고, 우리는 어떤 플랜을, 어떤 주기로, 어떤 리소스에 연결할 것인지를 깔끔하게 설계하는 데 집중하면 된다.