
こんにちは、ギフティでエンジニアをしている shirai (@shilo_113) です。私は普段、Ruby on Rails を使用した toC 向けプロダクトの開発を担当しています。
今回、9/26(金)〜9/27(土)に JP Tower Hall で開催された Kaigi on Rails 2025 に参加してきました。
本記事では、その中でも特に印象的だった株式会社スマートバンクの三谷さんの「2重リクエスト完全攻略HANDBOOK」について内容を振り返ります。 その上で弊社ギフティのとあるプロダクトにおける2重リクエスト対策を具体的な事例を交えて紹介します!
セッションの概要
それでは、セッションの内容を詳しく振り返ってみます。
そもそも2重リクエストとは
2重リクエストとはユーザーの意図やシステム的な要因に関わらず、本来一度だけ実行されるべき処理が複数回実行されてしまうことです。例えばサブミットボタンが複数回押された場合、登録画面がリロードされた場合や外部サービスから複数回同じデータが送信された場合など幅広いシチュエーションで発生する可能性があります。2重リクエストによって掲示板に同じ内容が2回投稿されてしまったり、決済を扱うサービスであれば同じ商品が2回購入されてしまい、2重で残高が減るといったことも起こり得るという説明がありました。
2重リクエストの対策は意外と難しい
続いて「この2重リクエストの対策は意外に難しい」と説明がありました。なぜなら上記のように2重リクエストが起こりうるシチュエーションは幅広いため、それらのシチュエーション全てに対応できる対策は存在しないためです。そのため、クライアント側(フロントエンド)とサーバー側(バックエンド)の両方で多重に防御を仕掛ける必要があるとのことでした。
例えば、クライアント側での制御(サブミットボタンの非活性化など)だけではブラウザの再送信や外部からのリクエストには対応できません。じゃあバックエンド側で排他制御やテーブル設計すればいいでしょというと、その場合はその場合でロックがうまく取れなかったり、意図的な2重リクエストなのか事故による2重リクエストなのかといったことを判断できなかったりすることがあります。 このように現実のアプリケーションに対策を適応するためには、実は考慮すべき要素が非常に多いという説明がありました。
2重リクエストに対する防御策
セッションではさまざまなシチュエーションに応じた防御策がクライアント側とサーバー側に分けて9つ紹介されました。いずれの防御策も重要でユースケースやアプリケーションの特性に応じて防御策を組み合わせて対策することが重要だと感じました。
スマートバンク社ではどうしているか
Fintech 領域でサービスを展開するスマートバンク社では特に決済やトランザクションの確実性が求められるため、上記の対策の中でも「冪等性」の担保が重視されているような印象を受けました。
Idempotency-Key ヘッダーやワンタイムトークン、レートリミット等を機能や処理の特徴に合わせて採用しており、実サービスで有効に機能している事例として紹介いただきました。特に Idempotency-Key ヘッダーは冪等な API サーバーを構築できる点と事故的な2重リクエストと意図的な2重リクエストをリクエストに含まれる Key の値から判断できるというのが信頼性向上の観点から非常に魅力的に感じました。
弊社プロダクトにおける2重リクエスト対策の一例
三谷さんのセッションを受けて、改めて私が普段担当しているプロダクトの2重リクエスト対策を振り返ってみました。このプロダクトは toC 向けの eギフト の EC サイトになるので、特に決済・購入処理においてセッションで紹介されていた防御策を組み合わせて実装しています。toC 向けギフトサービスという特性上、「ユーザー体験の維持」と「不正な2重処理の防止」の両立が重要であり、そのために以下を始めとした多重防御を採用しています。
では実際に決済・購入処理まわりに関していくつかの対策を見ていきます。
決済ボタンの非活性化(クライアント側)
モバイル環境では通信の遅延などによりユーザーが「押したのに反応しない」と感じて再度ボタンを押してしまい、意図せずに2重リクエストを発生させてしまうことがあります。
私たちのプロダクトでは決済ボタンクリック時には決済ボタンを非活性化し、そのようなユーザーによる意図しない2重リクエストを防いでいます。
実装自体はシンプルで、disabled属性を付与することで二度押しを防ぎつつ、UXを損なわないよう視覚的なフィードバックも行っています。以下のように、クリック直後に状態を切り替えることで、ユーザーに「処理中」であることを明確に伝えています。
execOrderFunc: function(){ return () => { this.isSubmitButtonDisabled = true this.submitButtonText = "処理中..." } }
この方法の大きなメリットは、実装コストが非常に低く、ほとんどのプロダクトに容易に導入できる点です。副作用も少なく、ユーザー体験を損なわずに効果的な第一層の防御策として機能します。 ただし、この方法だけではブラウザの再送信や外部からの重複リクエストには対応できません。そのため、あくまで他の対策との組み合わせが前提となるクライアント側での初期的な防御として位置づけられると思います。
PRG(Post-Redirect-Get)パターン(クライアント側)
決済ボタンクリックし、フォームを送信した後は必ず PRG パターンを採用し、ブラウザのリロードボタンから処理が再実行されないように制御しています。具体的には決済ボタンをクリック時の POST リクエストの完了時に直接処理結果の画面表示を行うのではなく、結果表示ページへのリダイレクトレスポンスをクライアントに返しています。そうすることで処理完了後にリロードしても基本、元の POST リクエストの2重リクエストを起こさないようにしています。
ただし、発表にもあった通り、戻るボタンで前画面に戻ってからもう一度決済ボタンを押される場合やリダイレクトレスポンスがクライアント側に返る前までにリロードされてしまう場合はこの対策では防ぎきれないので、後述の別の対策(ワンタイムトークン)で対応しています。
排他制御(バックエンド側)
このプロダクトは eギフト を販売する EC サイトなので基本的に在庫という概念がなく、在庫の引き当てはありません。また基本的に決済機能に関しては外部決済サービス(決済代行会社)を利用しています。そのため、通常の決済処理における排他制御やユニーク制約は、基本的には外部決済サービスのAPI側に委ねています。
一方で外部決済サービスを使用しない決済方法もあります。例えば定期的にプロモーションコード1を配布し、そのプロモーションコードを利用した決済機能に関しては外部決済サービスを利用せずに自前で決済処理を実装しています。この処理の中でレコードに悲観的ロックをかけて2重リクエストの排他制御を行っています。これにより同じプロモーションコードを使用した複数の決済リクエストが同時に DB を操作し、データ不整合を起こすことを防いでいます。
promotion_code = PromotionCode.find_by(code: promotion_code) promotion_code.with_lock do raise PromotionCode::ExchangedError, promotion_code.code if promotion_code.exchanged? promotion_code.update!( { (中略) code_status: CODE_STATUS[:exchanged], (中略) } ) end
ワンタイムトークン(クライアント・バックエンド側)
このプロダクトではワンタイムトークンによる2重リクエスト対策も行っています。実は上記にまとめた対策だけでは、決済ボタンをクリックした後からサーバー側でユニークな注文 ID を採番するまでのほんの短時間で2重リクエストがあった場合、それらのリクエストは別の注文 ID が採番されてしまい、2回決済されてしまうという現象が起こります。それを防ぐためにワンタイムトークンによる決済の2重リクエスト防止を行っています。

- 決済情報入力画面を表示するリクエストにおいてサーバー側でワンタイムトークンを発行し、データストアに保存するとともに、クライアント側にワンタイムトークンを返します。
- クライアント側に返したワンタイムトークンは決済ボタンを押下した時の決済リクエストにパラメータとして含まれます。
- サーバー側でそのワンタイムトークンがデータストアに存在してあるかを確認します。
- 存在していればデータストアからそのワンタイムトークンのレコードを削除し(=使用済みにし)、後続の決済処理を進めます。
- 存在してなければ2重リクエストや不正なリクエストと判定し、エラーをクライアント側に返しています。
こちらの対策は三谷さんの説明にもあったように2重リクエスト防止だけではなく、リクエスト内容の盗聴などによる決済のリプレイ攻撃も防ぐことができるというメリットがあります。とはいえワンタイムトークンの発行や消し込みのフローの開発コストなどは多少かかる対策になるので、私たちのプロダクトでは決済・購入に関わる一番クリティカルな部分でこちらのワンタイムトークンの対策を採用しています。
その他(レートリミットの導入検討)
発表にもあった「レートリミットで一定時間内でのリクエスト頻度を制限する」仕組みは私たちのプロダクトでは現状、導入していませんが、今後の導入検討の余地があるかなと感じました。
例えば同一ユーザーからの決済リクエストを10秒以内に複数回受け付けないようにするだけでも、アプリケーションとは別レイヤーでユーザーによる連打やブラウザの再送信といった事故的な2重決済を防ぐ効果が期待できます。レートリミットは比較的シンプルに導入できるうえ、バックエンドでの排他制御ほど複雑な実装を必要としない点もメリットです。
ただし、過剰に制限をかけてしまうと「ユーザーが本当に再試行したいケース(通信エラー後の再送など)」までブロックしてしまう可能性があるため、ここに関してはビジネス要件に応じた慎重な検討や設計が今後必要になると思います。
まとめ
本記事では三谷さんの発表内容を参考に、弊社プロダクトにおける2重リクエストの対策の一例を振り返ってみました。こうしてみるとやはり、2重リクエストはクライアント側、サーバー側での多重防御が不可欠であるということが再認識できました。
また、個人的にはそもそもこのような技術的な対策だけではなく、三谷さんがセッションの中で説明されていた 2重リクエストは単なるバグではなく、ビジネス的な損失に直結するセキュリティ・信頼性の問題である というマインドセットを持つことも非常に重要だなと改めて感じました。
三谷さん、とても素晴らしい発表ありがとうございました!
また来年の Kaigi on Rails 2026 もぜひ参加したいと思います。 渋谷の1000人規模の大きいホール、楽しみですね!
最後にギフティでは Rails で世の中に貢献したい!Kaigi on Rails に参加したい!というモチベーションのある Rails エンジニアを募集中です。 気になる方は、是非一度カジュアル面談でもなんでも良いので一度お話ししましょう!
- ユーザーはそのプロモーションコードを決済情報入力画面で入力すると、そのプロモーションコードに設定されている金額のクーポン券として決済することができます。↩