クレカ決済にみる外部システム連携のための堅牢な設計
こんにちは、Y.C.です。今携わっているサービスでは決済処理の開発を担当することが多く、その度により良い設計について頭を悩ませてきました。外部システムと連携する際、どうすればデータの整合性を保ち、変更に強いコードにすることができるでしょうか?今回は、そのために今まで考えてきたことを言語化して、これから外部連携サービスを実装される方の判断材料を提供すると共に、未来の自分がさらなる改善をおこなっていくための礎としたいと思います。
結論ファースト
外部とのやりとりの度にDB レコードが取りうる値を全て状態として定義し、処理を進めるごとにアトミックに状態遷移することで、いつどこで異常が発生してもデータ整合性を保ち後から処理を再開できるようになります。
外部システム連携の難しさ
システム内部に閉じた処理を行う場合、DB のトランザクションを利用することで処理の成否を all or nothing で制御することが出来ます。しかし、複数のシステムに跨る処理はアトミックに制御できません。さらに、途中でシステムやネットワークに異常が発生し処理が継続出来なくなる可能性もあります。決済のように複数システム間で厳密にデータの整合性を維持する必要のある場合、あらゆるステップで処理が失敗する可能性を考慮に入れなくてはなりません。
以降で、具体的にクレジットカード決済で 3D セキュア認証を行う場合を例に取り設計を考えてみます。
シーケンス図
3D セキュア認証がある場合のシーケンス図としては、最低でもこの程度は複雑になるはずです。API コールを 2 回、リダイレクトを 1 回挟んでいます。ここで、決済の開始から終了までを DB のトランザクションで制御するのは好ましくないです。まずリダイレクト中にトランザクションを維持するが困難ですし、クライアントからのリクエストを受けてレスポンスを返すまでの間トランザクションを張るとしても以下の問題があります。
- トランザクションを長時間維持することの問題
- DB との connection を、API がタイムアウトするまでの長時間占有してしまう恐れがあります。決済リクエストごとに connection が必要となると、同時に捌けるリクエスト数が大きく制限されます。
- 予期せずレコードのロックをとってしまう可能性があります。 例えば MySQL の場合、トランザクション中 に insert したレコードに外部キーがあると、その参照先のレコードに共有ロックがかかってしまい更新できなくなります。
- 整合性の問題
- 決済に成功したら 成功処理をして commit、失敗したら失敗処理をして commit すべきですが、決済 API がタイムアウトしたりレスポンスが異常な場合は成功したのか失敗したのか不明なため、どちらも行ってはいけません。
これを解決するため、外部とやり取りをする度に都度 レコードを update して commit します。こうするといつシステム異常やネットワーク異常が発生してもどこまで処理を進めたか分かるため、後から処理を続けることができます。
正常フローだけで 4 回 commit があるので、失敗状態まで含めて 6 つの状態が必要になります。
状態遷移図
必要な状態を状態遷移図で表現しました。決済に失敗すると初期状態に戻るのではなく、決済失敗状態に遷移します。状態が循環しないようにすることで、ある状態から別の状態へ遷移が必ず 1 度だけ実行されます。つまり、API コールやリダイレクトといった外部とのやりとりが exactly once であることを保証します。決済成功状態なのに決済 API がコール出来ていないという事態や、逆に決済 API をコールしたのに初期状態のままであるような事態にはならないわけです。
黒字で示した状態は、通常の操作がされる限り長時間その状態であり続ける可能性のある安定した状態です。ここでは定常状態と呼びたいと思います。3DS 認証中が定常状態というのは違和感があるかもしれませんが、ユーザが 3DS 認証を諦めて離脱するとここで停止してしまいます。またこの段階では決済は確実に成功していないので、このまま放置して問題ありません。
赤字で示したのが API コール中しか存在しない状態で、過渡状態と呼びたいと思います。通常であれば、過渡状態は 1 秒も持続しないでしょう。もしタイムアウト時間以上経過している場合は、本当にタイムアウトしたか、どこかで異常が発生し処理が停止した可能性があります。こうなると、決済が成功したのか、失敗したのか、あるいは止まっているのか分かりません。ここでユーザが再度支払おうとすると、初期状態ではないので reject します。二重払いされないという意味では安全ですが、可能な限り早急に追加の対応を行い、定常状態に遷移する必要があります。
異常への対応
実際に支払えているなら決済成功処理を、支払えていないなら決済失敗処理を行う必要があります。そのために以下のような手法が考えられます。
-
手動で実際の決済状態を確認して状態遷移させる
-
頻度が多いと大変ですし、人がすばやく反応できないと長時間過渡状態が維持されてしまいます。
-
-
ユーザが再度支払おうとしたら決済状態確認 API をコールする
-
先ほどは reject するとしましたが、決済状態確認 API をコールし決済の成否を確定させることができれば、それに従い定常状態に遷移することができます。
-
ユーザが再度支払おうとしなければ意味がないです。
-
-
決済 API がタイムアウトしたり異常なレスポンスが返った場合に自動リトライする
-
すぐ決済処理を続行できる可能性があります。
-
決済 API に冪等性が必要です。ない場合は代わりに決済状態確認 API を利用します。
-
こちらがシステムダウンした場合は意味がないです。
-
決済代行サービス側やネットワーク上で問題があった場合、短時間のうちにリトライしても意味がないです。
-
-
バッチで決済状態確認 API をコールする
-
過渡状態のままタイムアウト時間経過している場合に決済状態確認 API をコールし、結果に応じて定常状態に遷移させます。
-
即時性はありませんが確実性は高いです。
-
どの手法も完全ではないため、決済状態確認バッチを優先的に導入して確実性を担保しつつ、他の手法も併せて即時性を高めるのが良いと思います。
API 異常レスポンスの判定の罠
そもそもレスポンスが異常かどうか、どう判断すればいいでしょうか?普通に考えれば、レスポンスが仕様通りかをチェックすればいいです。
このように言葉で書くのは簡単なのですが、チェックが甘いと異常レスポンスを見逃してしまう恐れがあります。例えば、決済 API のレスポンスが成功かどうかをステータスコードだけでチェックするような場合です。成功かどうかが知りたいだけで、レスポンスボディのパラメータは不要というわけです。しかしステータスコードが 200 系であってもパラメータがおかしいことはあります。(ありました)
また逆にチェックが厳しすぎるというケースもあります。先のシーケンス図の例で、カード会社が返す 3D セキュア認証結果に仕様にないパラメータが付加されていることがありました。これを異常とみなすと、実際は正しく決済できるのに処理を中断してしまうことになります。
異常レスポンス判定は、仕様を満たしているかは厳しくチェックしつつ、仕様外のパラメータがあっても気にしないという塩梅が良いと思います。
実装パターン
先ほどの状態遷移図では決済の状態だけに着目していましたが、同時に購入対象に依存する処理を行う場合もあります。またクレジットカード以外の決済方法を導入することも見据えておいた方が良いかもしれません。それを踏まえ、状態遷移のロジックを以下に分解します。
- 購入対象に依存する処理(ex. 在庫を減らす)
- 決済方法に依存する処理(ex. 後続の API コールに必要なパラメータを記録する)
- 両方に依存する処理(ex. 決済手数料や消費税といったお金の流れを記録する)
これらの処理は購入対象 * 決済方法 の数だけ発生し、愚直に実装すると非常にコード量が増え、かつ重複処理が大量に発生してしまいます。そこで、これらを如何にそつなく無駄なく表現するかが問われてきます。
具体的に、購入対象 A, B, C, 決済手段 X, Y がある場合を、愚直、継承、移譲の 3 パターンで実装する様子を見てみましょう。先に結論を書いておくと、やはり移譲を用いるのが再利用性が高くいいと思います。購入対象や決済手段が増えない想定であっても、依存する対象ごとに処理を分けて書いておくと見通しが良くなるのでおすすめです。
以降、
購入対象に依存する処理: t(targetMethod)
決済対象に依存する処理: p(paymentMethod)
両方に依存する処理: tp(targetPaymentMethod)
ある購入対象 T をある決済手段 P で購入する場合に実際に使うクラス:TPProcess
とします。
愚直
Process クラスを愚直に実装した例です。同一の処理が複数クラスに重複してしまっています。
継承
t のクラスを p 毎に継承し Process クラスを作成した例です。各 t は一回ずつしか登場しませんが、p は依然として重複しています。ここから p をまとめようとすると、p 同士に共通する処理を t 側に書き、各 p に固有の処理をオーバーライドする、、、などと一気に複雑になっていきます。
なお、多重継承ができる言語であれば、t と p は完全に独立した機能なので、t のクラスと p のクラスの両方を別々に実装し、双方を継承した Process クラスを作成することが出来そうです。
移譲
各 t, p, tp をそれぞれ一つのクラスに実装し、Process クラスではそれらを道具として使うという構成です。ぱっと見では非常にコード量が増えていますが、1 つのクラスが 1 つの責務に集中しており、Process 側はそれらをポコポコ組み合わせるだけなので、拡張性が高いです。
概念としては Process クラスは 購入対象 * 決済方法 の数だけ必要ですが、DI(Dependency Injection) を用いれば実装上は 1 つのクラスで済みます。
まとめ
抽象的な状態遷移から、実装パターンまで、決済システムにおける設計についてまとめてみました。過去、実際に今回紹介したような設計で決済処理を実装してきましたが、データ不整合は一度も発生していません。決済に限らず、更新系 API のコールの前後それぞれで commit する、という考え方は 広く活かせるものだと思うので、参考にしてみて下さい。