- 決済 SDKを再利用可能なReact共通コンポーネントとして実装した経験 -
1. SDK共通コンポーネントとして実装するに至った経緯
実際の決済画面で処理すべきビジネスロジックは、単にSDKを呼び出すだけにとどまらない幅広いものでした。
-
会員および非会員顧客キーの設定
-
決済金額の変更内容のリアルタイム反映
-
バックエンドでの決済データ生成および連携
-
インラインおよびポップアップ決済方式の同時サポート
-
決済成功時および失敗時のリダイレクト処理
-
決済エラー発生時のバックエンド状態更新
-
重複決済リクエスト防止ロジックの適用
-
React Native WebView環境への対応
このようなロジックを各画面で個別に実装すると、重複コードが増えるだけでなく、決済処理ルールの一貫性を維持することが非常に難しくなります。例えば、ある画面ではSDKエラーをバックエンドに記録する一方で、別の画面では単にコンソールに出力するだけといった、実装の断片化が発生する可能性があります。
また、サービスごとに求められる決済UIが異なる点も考慮する必要がありました。決済規約や決済手段の構成は多様で、決済ボタンの位置やデザインもそれぞれ異なるため、これらを共通コンポーネント内部に固定された形で組み込むことは現実的に不可能でした。
そこで、共通コンポーネントがSDK連携と決済状態の管理を担い、実際に決済を実行するタイミングは各画面で柔軟に決定できる構成を設計することにしました。
2. 期待される効果
共通コンポーネントの実装において目標とした効果は、次のとおりです。
第一に、画面とSDK間の結合度を最小限に抑えました。各画面はSDKの複雑な初期化処理を知る必要がなく、注文番号、注文名、金額、リダイレクトURLなどの主要データだけを渡すように設計しました。
第二に、決済処理ルールの一貫性を確保しました。決済データの生成から最終承認、失敗状態の保存に至るまでの全工程を共通領域で処理し、画面ごとの動作の違いによるエラーを防ぐことを目指しました。
第三に、インライン方式とポップアップ方式を単一のインターフェースに統合しました。利用者は決済方式だけをオプションとして選択し、実行時には同じようにexecutePayment()メソッドを呼び出すことで、一貫した開発体験を得られます。
第四に、保守効率を向上させました。SDKのオプションが変更されたり、エラー処理ポリシーが修正されたりした場合でも、すべての画面を一つずつ修正するのではなく、共通コンポーネント1か所だけを更新して迅速に対応できます。
3. 実装方法
3.1 外部に決済実行インターフェースを提供
決済ボタンのUI構成は各サービス画面の裁量に委ねつつ、共通コンポーネントは決済実行ロジックのみをインターフェースとして公開する必要がありました。そのため、Reactの`forwardRef`と`useImperativeHandle`を活用しました。
export interface RequestPaymentContainerRef {
executePayment: () => Promise<void>;
}
useImperativeHandle(ref, () => ({
executePayment: async () => {
if (!innerPaymentRef.current) {
throw new Error('Payment component is not ready.');
}
await innerPaymentRef.current.executePayment();
},
}));
◈ コンポーネントを使用する上位画面では、公開された`ref`を通じて簡単に決済をトリガーできます。
const paymentRef = useRef<RequestPaymentContainerRef>(null)
const handlePayment = async () => {
await paymentRef.current?.executePayment();
};
◈ 特にexecutePaymentは、呼び出し側が実行失敗を明確に認識できるよう、Promise<void>形式で提供しました。決済コンポーネントの準備が整っていない状態で呼び出された場合は例外を発生させ、上位画面で適切な案内処理を行えるようにしました。
3.2 インライン決済とポップアップ決済の分離
インライン決済ではページ内に決済手段と規約を直接レンダリングしますが、ポップアップ決済ではexecutePayment()の呼び出し時に決済ウィンドウを表示し、データを生成します。
このように異なるメカニズムを1つのコンポーネントで管理すると、複雑度が非常に高くなります。そのため、`RequestPayment`と`RequestPaymentPopup`に役割を分け、上位コンテナで選択的に使用する構成にしました。
内部実装は異なっていても、外部に公開するインターフェースは同一に保つことで、多態性を確保しました。
3.3 成功・失敗時のリダイレクト構成
SDKの決済ウィンドウで決済が成功したからといって、すべてのビジネスロジックが完了したわけではありません。リダイレクト先のURLから受け取った`paymentKey`などの情報をバックエンドの承認APIに渡し、最終確定する段階が必ず必要です。
全体のプロセスは次のように進行します。
-
バックエンドに事前決済情報を登録し、一意の決済IDを発行してもらいます。
-
発行されたIDを`orderId`に指定してSDK決済をリクエストします。
-
PG社の決済ウィンドウでの処理後、指定された成功・失敗URLへ遷移します。
-
成功時は、バックエンドの最終承認APIを呼び出します。
-
失敗時は、バックエンドの決済状態を失敗に更新します。
-
すべての後処理が完了した後、最終目的地の画面へ遷移します。
このために、`success-redirect`と`fail-redirect`という中間ブリッジルートを設計しました。この段階でビジネスロジックを完結させることで、「PG決済の成功」と「内部データ処理の完了」を厳密に区別し、データの整合性を高めました。
4. 実装時に考慮すべき点
4.1 初期化と決済可能な状態
SDKインスタンスが生成されたからといって、すぐに決済できるわけではありません。インライン方式の場合、決済手段および規約UIのレンダリングが完全に完了している必要があります。
そのため、SDKオブジェクトであるwidgetsと、実際のレンダリング完了状態を示すreadyを厳密に分けて管理しました。決済事故を防ぐため、レンダリングが完了するまではボタンを無効化するなどの防御的な処理を適用しました。
また、propsで渡された金額が変更された場合はSDK内部の状態も同期する必要があるため、widgets.setAmount()を適切に再呼び出しするロジックを組み込みました。
4.2 重複承認リクエストの防止
成功リダイレクト画面がマウントされた際に承認APIを呼び出しますが、このとき、更新や戻る操作などによって同一のリクエストが重複して発生する可能性があります。
フロントエンドの状態管理だけでこれを完全に防ぐことは困難です。そのため、ネットワークの再試行や複数タブ環境まで考慮し、バックエンドおよびPG社APIのレベルで冪等性(Idempotency)を保証するように設計することが不可欠です。
4.3 URLとルーティング環境
共通コンポーネントはさまざまなルーティング環境で利用されるため、`basePath`の正規化処理が重要です。クエリパラメータを連結する際には、区切り文字(`?`または`&`)の選択やエラーメッセージのエンコードなど、細やかな処理によって決済フローが中断されないようにする必要があります。
一見単純に見える作業ですが、埋め込み環境や複雑なパス構造では、こうした基本的な処理が決済の成否を左右する重要な要素となります。
4.4 ポップアップとWebView環境
ポップアップ方式では実行時にイベントリスナーを登録するため、ボタンの連続クリックによるリスナーの重複登録を防ぐ必要があります。実行中の状態管理とアンマウント時のクリーンアップ処理を徹底的に実装しました。
特にReact Native WebView環境向けに、`appScheme`の注入機能をサポートしました。ハードコーディングを避け、アプリケーション設定から動的に環境を構成できるようにすることで、拡張性にも配慮しました。
5. 結論
決済SDKでシンプルな決済画面を実装することは、難しくありませんでした。しかし真の挑戦は、初期化からバックエンドの状態管理に至る複雑なワークフローを、組織全体で一貫して適用することでした。
今回のプロジェクトを通じて、決済機能を役割ごとに細分化し、`forwardRef`によってカプセル化されたインターフェースを提供することで、使いやすさを最大限に高めました。
共通コンポーネントの導入後、新しいサービスに決済機能を追加する際に発生するコストとヒューマンエラーが目に見えて減少し、システム全体の保守性も向上しました。
もちろん、技術的な完成度はフロントエンドだけにとどまりません。バックエンドの冪等性処理と状態補正ロジックが組み合わさって初めて、信頼できる決済システムが完成すると考えています。
再利用可能なコンポーネントとは、単にコードの重複をなくすだけでなく、外部ライブラリの複雑さを隔離し、サービス全体に一貫したビジネス価値を提供するものだということを実感した、貴重な経験でした。
chnsik