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

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

연결문서