POSTからリダイレクトを経てGETへ進む矢印の図。PRGで防げる再送を見極める

実装・エラー解決

Spring Bootでフォームの二重送信を防ぐには?PRGで防げること・防げないこと

登録完了画面でブラウザを更新すると「フォームを再送信しますか」と聞かれ、同じ申請が二件できた。ここでボタンを押せなくするJavaScriptを足しても、通信再試行や別タブからの送信は止まりません。

対策は、どの再送を止めたいかで選びます。正常なPOSTの後に結果ページへ移動させるPRG(Post/Redirect/Get)は、更新時の再送を減らす第一歩です。しかし二つのPOSTが同時に届く場合は、サーバー側の仕組みも必要です。以下は Java 21、Spring Boot 3.5系の架空の申請フォームです。

「二重送信」を三つに分ける

重複登録の起点と対策。再読み込み:PRGでPOST再送を回避。連打:ボタン制御だけに頼らない。通信再試行:重複キーで判定。保存処理:一意制約も検討する
重複登録の起点と対策。PRGで防げる範囲を見極める
起点何が起きるか主な対策
POST結果画面を更新ブラウザが直前のPOSTを再送しようとする成功後にPRG
ボタンを素早く2回押す二つのPOSTが結果を見る前に届くUI抑止に加え、サーバーで同一操作を識別
応答前にタイムアウトし再試行保存は成功したのに利用者には失敗に見える冪等キーなどで同じ操作の結果を返す

画面のボタン無効化は操作感には役立ちますが、整合性を保証する場所ではありません。重複が金銭・在庫・通知に関わるなら、ブラウザに任せず保存側で担保します。

成功時はPRGにする

保存後にテンプレート名を返すと、現在のページがPOSTの結果になります。成功後に redirect: を返し、詳細画面をGETで開けば、そこで更新してもGETが繰り返されます。

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
class RequestPageController {
    private final RequestService service;

    RequestPageController(RequestService service) {
        this.service = service;
    }

    @PostMapping("/requests")
    String create(@ModelAttribute RequestForm form, RedirectAttributes redirect) {
        long id = service.create(form);
        redirect.addFlashAttribute("notice", "申請を登録しました");
        return "redirect:/requests/" + id;
    }

    @GetMapping("/requests/{id}")
    String detail(@PathVariable long id, Model model) {
        model.addAttribute("request", service.find(id));
        return "requests/detail";
    }
}

RequestService、RequestForm とテンプレートは既存アプリ側の型へ置き換えてください。flashAttribute はリダイレクト先で一度表示する通知に使います。登録内容や機密値をURLのクエリに載せる用途ではありません。入力エラー時にどう画面へ戻すかは別に設計し、成功したPOSTだけをこの詳細GETへ移します。

PRGだけで止まらない二件を再現する

テストでは、まず保存処理への同じPOSTを二回送ります。両方のリクエストが保存を呼べば、PRGの後で二つの詳細URLへ移るだけです。結果ページを更新して問題が消えても、ボタン連打の問題が残る理由はここにあります。

同じ操作を一度だけ成立させたいなら、フォーム表示時に操作IDを発行し、POSTで送ります。保存側に operation_key の一意制約を設け、同じキーの二度目は新規保存せず既存の結果へ案内します。実装時は、キーをどの利用者・操作へ結び付けるか、失敗時に再利用できるか、保存とキー記録を同じトランザクションにするかまで決めます。単純にセッションの「使用済み」フラグを見るだけでは、同時リクエストの競合に弱い場合があります。

業務上「顧客IDと対象月の申請は一件だけ」と決まっているなら、その組み合わせのDB一意制約も有効です。ただし業務キーの一意制約は、別件として二件作れる業務での連打対策とは意味が違います。操作の重複と業務データの重複を混ぜないことです。

確認する順番

  1. 登録後のURLが詳細GETへ変わり、更新しても件数が増えないか。
  2. 同じPOSTを二回送ったとき、件数が増えるか。
  3. 同じ操作IDを同時に送ったとき、一件だけ成立するか。
  4. 保存成功・応答失敗の後に再試行して、利用者へ既存結果を返せるか。

最初の一項目しか試さないと、PRGの成功を「二重登録を完全に防いだ」と誤認します。まず更新による再送を直し、重複が許されない操作には保存側の保証を加えてください。

参考資料

-実装・エラー解決
-,