토스 결제 위젯을 네이티브 앱에 녹이는 과정

김민석·2025년 10월 10일
0

Tech Deep Dive

목록 보기
50/58

Intro

  • 네이티브 앱 안에 토스 결제 위젯을 넣었더니, 약관 동의 상태나 결제 요청 타이밍이 꼬여 오류가 났습니다.
  • 저는 공식 SDK를 감싸는 컴포넌트를 만들어 위젯 로딩과 결제 요청을 안정화했습니다.

핵심 아이디어 요약

  • PaymentWidgetProvider로 토스 SDK 컨텍스트를 구성하고, 결제/약관 위젯을 각각 렌더링했습니다.
  • 약관 동의 상태를 주기적으로 확인해 동의하지 않은 상태에서 결제를 요청하지 않도록 했습니다.
  • 글로벌 함수(requestTossPayment)를 등록해 다른 화면에서도 결제를 트리거할 수 있게 했습니다.

준비와 선택

  • @tosspayments/widget-sdk-react-native를 사용했고, 환경 변수로 제공되는 clientKey를 체크해 오류를 미리 잡았습니다.
  • 위젯 로딩 실패를 대비해 4015 오류(없는 variant)를 감지하고 기본 옵션으로 재시도했습니다.
  • UI는 tailwind 스타일 유틸과 기본 색상을 공유해 디자인 시스템을 유지했습니다.

구현 여정

  1. 프로바이더 구성: PaymentWidgetProvider로 래핑하고 customerKey, clientKey를 넣었습니다.
  2. 위젯 렌더링: 위젯 로딩 후 renderPaymentMethodsrenderAgreement를 호출했습니다.
  3. 결제 요청 등록: paymentWidgetControl이 준비되면 global.requestTossPayment에 결제 함수를 등록했습니다.
  4. 약관 동의 체크: setInterval로 약관 동의 여부를 확인하고 콜백에 전달했습니다.
  5. 오류 재시도: 4015 오류가 발생하면 variant 없는 렌더링으로 두 번까지 재시도했습니다.
// src/shared/components/TossPaymentWidget/TossPaymentWidget.tsx:45-257
function TossPaymentWidgetInner({
  amount,
  orderId,
  orderName,
  onSuccess,
  onFail,
  onError,
  onAgreementChange,
}: TossPaymentWidgetProps) {
  const paymentWidgetControl = usePaymentWidget();
  const [paymentMethodWidgetControl, setPaymentMethodWidgetControl] =
    useState<PaymentMethodWidgetControl | null>(null);
  const [agreementWidgetControl, setAgreementWidgetControl] =
    useState<AgreementWidgetControl | null>(null);
  const [retryCount, setRetryCount] = useState(0);

  const renderPaymentMethods = async (variantKey?: string) => {
    try {
      const control = await paymentWidgetControl.renderPaymentMethods(
        'payment-methods',
        {
          value: amount,
          currency: TOSS_RN_CONFIG.WIDGET_DEFAULTS.CURRENCY,
          country: TOSS_RN_CONFIG.WIDGET_DEFAULTS.COUNTRY,
        },
        variantKey ? { variantKey } : {},
      );
      setPaymentMethodWidgetControl(control);
    } catch (error: any) {
      if (error.code === '4015' && variantKey && retryCount < 2) {
        setRetryCount(prev => prev + 1);
        await renderPaymentMethods();
      } else {
        onError?.(error);
      }
    }
  };

  React.useEffect(() => {
    if (
      paymentWidgetControl &&
      paymentMethodWidgetControl &&
      agreementWidgetControl
    ) {
      (global as any).requestTossPayment = async () => {
        const agreement = await agreementWidgetControl.getAgreementStatus();
        if (agreement.agreedRequiredTerms !== true) {
          errorMessage('약관에 동의해주세요.');
          return;
        }
        const result = await paymentWidgetControl.requestPayment?.({
          orderId,
          orderName,
        });
        if (result?.success) {
          onSuccess?.(result.success);
          return result.success;
        } else if (result?.fail) {
          onFail?.(result.fail);
          throw new Error(result.fail.message || '결제에 실패했습니다.');
        }
      };
    }
    return () => {
      if ((global as any).requestTossPayment) delete (global as any).requestTossPayment;
    };
  }, [
    paymentWidgetControl,
    paymentMethodWidgetControl,
    agreementWidgetControl,
    orderId,
    orderName,
    onSuccess,
    onFail,
    onError,
  ]);

  React.useEffect(() => {
    if (!agreementWidgetControl) return;
    const checkAgreementStatus = async () => {
      const agreement = await agreementWidgetControl.getAgreementStatus();
      onAgreementChange?.(agreement.agreedRequiredTerms);
    };
    checkAgreementStatus();
    const interval = setInterval(
      checkAgreementStatus,
      TOSS_RN_CONFIG.AGREEMENT_CHECK_INTERVAL,
    );
    return () => clearInterval(interval);
  }, [agreementWidgetControl, onAgreementChange]);

  return (
    <View style={[tw`w-full bg-white`, { minHeight: TOSS_RN_CONFIG.WIDGET_MIN_HEIGHT }]}>
      <PaymentMethodWidget
        selector='payment-methods'
        onLoadEnd={() => renderPaymentMethods('schoolmeetupapply')}
      />
      <AgreementWidget
        selector='agreement'
        onLoadEnd={() => renderAgreement('AGREEMENT')}
      />
    </View>
  );
}

export default function TossPaymentWidget(props: TossPaymentWidgetProps) {
  const clientKey = process.env.EXPO_PUBLIC_TOSS_CLIENT_KEY;
  if (!clientKey) {
    return (
      <View style={[tw`flex items-center justify-center bg-gray-95 p-4`, { minHeight: TOSS_RN_CONFIG.WIDGET_MIN_HEIGHT }]}>
        <Text style={tw`text-center text-gray-40`}>결제 위젯을 불러올 수 없습니다.</Text>
      </View>
    );
  }

  return (
    <View style={[tw`w-full bg-white`, { minHeight: TOSS_RN_CONFIG.WIDGET_MIN_HEIGHT }]}>
      <PaymentWidgetProvider
        clientKey={clientKey}
        customerKey={TOSS_RN_CONFIG.WIDGET_DEFAULTS.CUSTOMER_KEY}
      >
        <TossPaymentWidgetInner {...props} />
      </PaymentWidgetProvider>
    </View>
  );
}

결과와 회고

  • 결제 중 약관 동의를 빼먹으면 즉시 토스트로 안내할 수 있어 사용자 오류가 크게 줄었습니다.
  • 글로벌 결제 함수를 등록해 결제 버튼이 다른 위치에 있어도 전체 프로세스를 공유할 수 있었습니다.
  • 앞으로는 결제 요청 Promise를 더 정교하게 래핑해 리트라이 UI를 제공해 보려 합니다.
  • 여러분은 네이티브 앱에서 결제 위젯을 어떻게 다루고 계신가요? 같이 이야기해요.

Reference

profile
동업자와 함께 창업 3년차입니다. Nextjs 위주의 프로젝트를 주로 하며, React Native, Supabase, Nestjs를 주로 사용합니다. 인공지능 야간 대학원을 다니고 있습니다.

0개의 댓글