Spring BootのPRGパターンでフォームの二重送信を防ぐ方法|新人エンジニア向けに解説

こんにちは。ゆうせいです。

今回は、Spring Bootでフォームの二重送信を防ぐためによく使われるPRGパターンについて、新人エンジニア向けに解説します。

Webアプリケーションで登録画面を作っていると、次のような問題が起きることがあります。

登録ボタンを押す
        ↓
登録完了画面が表示される
        ↓
ブラウザで再読み込みする
        ↓
同じ登録処理がもう一度実行される

このように、同じフォーム送信が再実行されてしまう問題を、フォームの二重送信と呼びます。

たとえば、ユーザー登録、商品登録、注文確定、問い合わせ送信などで二重送信が起きると困りますよね。

注文ボタンを1回押しただけのつもりなのに、再読み込みで同じ注文が2件入ったら大問題です。

その対策としてよく使われるのが、PRGパターンです。

PRGパターンとは何か

PRGは、Post Redirect Getの略です。

日本語で言うと、「POSTしたあとにRedirectし、そのあとGETで画面を表示する」という流れです。

文字意味役割
PPostフォームの送信、登録処理
RRedirect別のURLへ移動させる
GGet完了画面や一覧画面を表示する

たとえるなら、役所の窓口で申請書を提出したあと、受付番号をもらって待合室へ移動するようなものです。

申請書の提出がPOSTです。

待合室へ案内されるのがRedirectです。

待合室で「受付完了」と表示されるのがGETです。

この流れにすると、完了画面で再読み込みしても、再実行されるのはGETだけです。

登録処理であるPOSTは再実行されません。

PRGパターンを使わない悪い例

まず、PRGパターンを使わない悪い例を見てみましょう。

@Controller
public class UserController {

    @GetMapping("/users/new")
    public String showForm() {
        return "users/new";
    }

    @PostMapping("/users")
    public String register(@RequestParam String name,
                           @RequestParam String email,
                           Model model) {

        System.out.println("DBに登録しました: " + name + ", " + email);

        model.addAttribute("message", "登録が完了しました。");

        return "users/complete";
    }
}




このコードでは、POST処理のあとに、そのまま完了画面を表示しています。

return "users/complete";

一見すると問題なさそうに見えます。

しかし、ブラウザのURLはPOST送信したURLのままです。

その状態で再読み込みすると、ブラウザが「さっきのPOSTをもう一度送りますか?」という動きになります。

つまり、登録処理が再実行される可能性があります。

この状態は、注文書を窓口に提出したあと、同じ注文書を手に持ったままその場に残っているようなものです。

もう一度「提出しますか?」と言われたら、また注文が入ってしまいます。

PRGパターンを使った良い例

次に、PRGパターンを使った良い例です。

@Controller
public class UserController {

    @GetMapping("/users/new")
    public String showForm() {
        return "users/new";
    }

    @PostMapping("/users")
    public String register(@RequestParam String name,
                           @RequestParam String email) {

        System.out.println("DBに登録しました: " + name + ", " + email);

        return "redirect:/users/complete";
    }

    @GetMapping("/users/complete")
    public String complete() {
        return "users/complete";
    }
}




重要なのは、POST処理の戻り値です。

return "redirect:/users/complete";

redirect:を付けると、Spring Bootはブラウザに対して「/users/completeへ移動してください」と指示します。

その結果、ブラウザはGETで/users/completeへアクセスします。

完了画面で再読み込みしても、再実行されるのはGETの/users/completeです。

登録処理のPOST /usersは再実行されません。

PRGパターンの流れ

PRGパターンの流れを整理します。

順番URLHTTPメソッド処理内容
1/users/newGET入力フォームを表示する
2/usersPOST登録処理を行う
3/users/completeGET完了画面を表示する

ポイントは、登録処理を行うURLと、完了画面を表示するURLを分けることです。

POSTのあとに、直接HTMLテンプレートを返さない。

POSTのあとに、redirectする。

このルールを守るだけで、再読み込みによる二重送信をかなり防げます。

入力画面のThymeleafサンプル

次に、入力画面を作ります。

ファイルの場所です。

src/main/resources/templates/users/new.html

new.htmlです。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ユーザー登録</title>
</head>
<body>

<h1>ユーザー登録</h1>

<form th:action="@{/users}" method="post">

    <div>
        <label for="name">名前</label>
        <input type="text" id="name" name="name">
    </div>

    <div>
        <label for="email">メールアドレス</label>
        <input type="email" id="email" name="email">
    </div>

    <button type="submit">登録</button>

</form>

</body>
</html>




このフォームは、POSTで/usersへ送信します。

<form th:action="@{/users}" method="post">

Controller側では、@PostMapping("/users")で受け取ります。

@PostMapping("/users")
public String register(@RequestParam String name,
                       @RequestParam String email) {

HTMLのname属性とControllerの@RequestParamが対応しています。

HTMLController
name="name"@RequestParam String name
name="email"@RequestParam String email

完了画面のThymeleafサンプル

完了画面を作ります。

ファイルの場所です。

src/main/resources/templates/users/complete.html

complete.htmlです。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登録完了</title>
</head>
<body>

<h1>登録が完了しました</h1>

<p>ユーザー登録が正常に完了しました。</p>

<p>
    <a th:href="@{/users/new}">続けて登録する</a>
</p>

</body>
</html>




この画面はGETで表示されます。

完了画面で再読み込みしても、登録処理は再実行されません。

再読み込みされるのは、GET /users/completeだけです。

RedirectAttributesで完了メッセージを渡す

PRGパターンでは、POSTのあとにredirectします。

そのため、普通のModelに入れた値はredirect先に引き継がれません。

悪い例です。

@PostMapping("/users")
public String register(@RequestParam String name,
                       @RequestParam String email,
                       Model model) {

    model.addAttribute("message", "登録が完了しました。");

    return "redirect:/users/complete";
}




この場合、messageはredirect先で使えません。

redirectをまたぐメッセージを渡したい場合は、RedirectAttributesを使います。

import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
public class UserController {

    @GetMapping("/users/new")
    public String showForm() {
        return "users/new";
    }

    @PostMapping("/users")
    public String register(@RequestParam String name,
                           @RequestParam String email,
                           RedirectAttributes redirectAttributes) {

        System.out.println("DBに登録しました: " + name + ", " + email);

        redirectAttributes.addFlashAttribute(
                "message",
                "登録が完了しました。"
        );

        return "redirect:/users/complete";
    }

    @GetMapping("/users/complete")
    public String complete() {
        return "users/complete";
    }
}

RedirectAttributesのaddFlashAttributeを使うと、redirect先で一時的に値を使えます。


Flash Attributeは、redirect後の1回だけ使えるメッセージのようなものです。

たとえるなら、窓口で「完了メッセージが書かれた紙」を渡され、次の受付で一度だけ見せるようなものです。

RedirectAttributesを使った完了画面

complete.htmlでmessageを表示します。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登録完了</title>
</head>
<body>

<h1>登録完了</h1>

<p th:if="${message}" th:text="${message}"></p>

<p>
    <a th:href="@{/users/new}">続けて登録する</a>
</p>

</body>
</html>




th:if="${message}"は、messageがあるときだけ表示します。

th:text="${message}"は、messageの内容を画面に表示します。

一覧画面へリダイレクトするパターン

実務では、登録後に完了画面ではなく一覧画面へ戻すことも多いです。

@PostMapping("/users")
public String register(@RequestParam String name,
                       @RequestParam String email,
                       RedirectAttributes redirectAttributes) {

    System.out.println("DBに登録しました: " + name + ", " + email);

    redirectAttributes.addFlashAttribute(
            "message",
            "ユーザーを登録しました。"
    );

    return "redirect:/users";
}

@GetMapping("/users")
public String list(Model model) {

    model.addAttribute("userList", List.of());

    return "users/list";
}




この場合の流れは次のとおりです。

GET /users/new
        ↓
POST /users
        ↓
redirect:/users
        ↓
GET /users

登録後に一覧画面へ戻すと、登録されたデータを一覧で確認できるため、業務アプリではよく使われます。

一覧画面のサンプル

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ユーザー一覧</title>
</head>
<body>

<h1>ユーザー一覧</h1>

<p th:if="${message}" th:text="${message}"></p>

<p>
    <a th:href="@{/users/new}">新規登録</a>
</p>

<table border="1">
    <tr>
        <th>ID</th>
        <th>名前</th>
        <th>メールアドレス</th>
    </tr>
    <tr th:each="user : ${userList}">
        <td th:text="${user.userId}"></td>
        <td th:text="${user.name}"></td>
        <td th:text="${user.email}"></td>
    </tr>
</table>

</body>
</html>




登録完了メッセージは、addFlashAttributeで渡されたmessageを表示します。

再読み込みしても、一覧画面のGETが再実行されるだけです。

登録POSTは再実行されません。

PRGパターンとバリデーション

フォームには入力チェックも必要です。

@ValidとBindingResultを使う場合、エラーがあるときはredirectしてはいけません。

エラーがある場合は、入力画面のテンプレートをそのまま返します。

@PostMapping("/users")
public String register(
        @Valid @ModelAttribute("userForm") UserForm userForm,
        BindingResult bindingResult,
        RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) {
        return "users/new";
    }

    System.out.println("DBに登録しました: " + userForm.getName());

    redirectAttributes.addFlashAttribute(
            "message",
            "ユーザーを登録しました。"
    );

    return "redirect:/users";
}




エラー時は、次のようにします。

return "users/new";

正常時は、次のようにします。

return "redirect:/users";
状況戻り値理由
入力エラーありreturn "users/new";エラー内容と入力値を画面に残したい
登録成功return "redirect:/users";二重送信を防ぎたい

入力エラー時までredirectすると、BindingResultのエラー情報が失われやすくなります。

PRGパターンは、登録成功後に使うと覚えてください。

PRGパターンで防げる二重送信

PRGパターンで防ぎやすいのは、再読み込みによる二重送信です。

二重送信の原因PRGで防げるか説明
完了画面でF5キーを押す防ぎやすいGETが再実行されるだけになる
ブラウザの再読み込みボタンを押す防ぎやすいPOST再送信を避けられる
戻るボタン後に再送信するある程度防げる画面状態によって注意が必要
登録ボタンを高速で2回押す完全には防げない2回POSTが飛ぶ可能性がある

ここはとても大事です。

PRGパターンは万能ではありません。

再読み込みによる二重送信には強いですが、登録ボタンを素早く2回押すケースまでは完全に防げません。

二重クリック対策には、別の工夫も必要です。

二重クリック対策も入れる

登録ボタンを押した直後にボタンを無効化すると、連続クリックを減らせます。

<form th:action="@{/users}" method="post" onsubmit="disableSubmitButton()">

    <div>
        <label for="name">名前</label>
        <input type="text" id="name" name="name">
    </div>

    <div>
        <label for="email">メールアドレス</label>
        <input type="email" id="email" name="email">
    </div>

    <button type="submit" id="submitButton">登録</button>

</form>

<script>
function disableSubmitButton() {
    document.getElementById("submitButton").disabled = true;
}
</script>




このJavaScriptにより、フォーム送信時に登録ボタンが押せなくなります。

ただし、JavaScriptはブラウザ側の対策です。

ユーザーや攻撃者が回避できる可能性があります。

そのため、重要な処理ではサーバー側の対策も必要です。

重要な処理ではトークンも使う

注文確定や決済のように重要な処理では、PRGパターンだけでなく、トークンによる二重送信防止を使うことがあります。

トークンとは、一度だけ使える合言葉のようなものです。

入力画面を表示するときにトークンを発行する
        ↓
hiddenでフォームに埋め込む
        ↓
POST時にトークンを確認する
        ↓
使ったトークンは無効化する
        ↓
同じトークンで再送信されたら拒否する

たとえるなら、イベント会場の入場券です。

一度入場したチケットは、もう一度使えません。

同じチケットで2回入ろうとしたら止められます。

この仕組みを使うと、同じフォーム送信をサーバー側で拒否できます。

DB側の制約も大切

二重送信対策では、DB側の制約も重要です。

たとえば、同じメールアドレスのユーザーを2件登録したくないなら、emailにUNIQUE制約を付けます。

CREATE TABLE users (
    user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);




UNIQUE制約は、「同じ値を2回入れない」というDB側のルールです。

アプリ側で頑張ってチェックしていても、同時アクセスや予期しない再送信が起きることがあります。

最後の砦として、DB側でも重複を防ぐ設計にしておきましょう。

対策守れる範囲
PRGパターン再読み込みによるPOST再送信を防ぎやすい
ボタン無効化ユーザーの連続クリックを減らす
トークン同じフォーム送信をサーバー側で防ぎやすい
UNIQUE制約DBに同じデータが入ることを防ぐ

PRGだけで安心しすぎないでください。

重要な処理では、複数の対策を組み合わせます。

削除処理でもPRGを使う

登録だけでなく、削除処理でもPRGパターンを使います。

@PostMapping("/users/delete")
public String delete(@RequestParam long userId,
                     RedirectAttributes redirectAttributes) {

    System.out.println("ユーザーを削除しました: " + userId);

    redirectAttributes.addFlashAttribute(
            "message",
            "ユーザーを削除しました。"
    );

    return "redirect:/users";
}




削除後に直接画面を返すのではなく、一覧画面へredirectします。

削除処理は特に危険です。

再読み込みで同じ削除処理が走ると、エラーになったり、別の不整合につながったりします。

更新処理でもPRGを使う

更新処理でも同じです。

@PostMapping("/users/update")
public String update(@RequestParam long userId,
                     @RequestParam String name,
                     @RequestParam String email,
                     RedirectAttributes redirectAttributes) {

    System.out.println("ユーザーを更新しました: " + userId);

    redirectAttributes.addFlashAttribute(
            "message",
            "ユーザー情報を更新しました。"
    );

    return "redirect:/users/" + userId;
}




更新後は、詳細画面へredirectしています。

return "redirect:/users/" + userId;

この流れにすると、更新後に再読み込みしても、詳細画面のGETが再実行されるだけです。

URL設計の例

PRGパターンでは、URL設計も整理しておくとわかりやすくなります。

処理HTTPメソッドURL戻り先
登録画面表示GET/users/newusers/new
登録処理POST/usersredirect:/users
一覧表示GET/usersusers/list
編集画面表示GET/users/edit?userId=1users/edit
更新処理POST/users/updateredirect:/users/1
詳細表示GET/users/1users/detail
削除処理POST/users/deleteredirect:/users

画面表示はGET。

登録、更新、削除はPOST。

POSTのあとはredirect。

この型を覚えると、Spring Bootの画面遷移がきれいに整理できます。

PRGパターンでよくあるミス

POST後にテンプレート名を返してしまう

悪い例です。

@PostMapping("/users")
public String register() {
    return "users/complete";
}




良い例です。

@PostMapping("/users")
public String register() {
    return "redirect:/users/complete";
}




POST後は、直接テンプレートを返さずredirectしましょう。

redirect先をPOSTのURLにしてしまう

悪い例です。

return "redirect:/users";

この書き方自体が悪いわけではありません。

ただし、redirect先の/usersがGETで表示できるようになっていないと困ります。

次のように、GET /usersを用意してください。

@GetMapping("/users")
public String list(Model model) {
    return "users/list";
}




RedirectAttributesではなくModelを使ってしまう

悪い例です。

model.addAttribute("message", "登録しました。");
return "redirect:/users";




良い例です。

redirectAttributes.addFlashAttribute("message", "登録しました。");
return "redirect:/users";




redirect先へ一時メッセージを渡したいなら、RedirectAttributesを使いましょう。

入力エラー時にもredirectしてしまう

悪い例です。

if (bindingResult.hasErrors()) {
    return "redirect:/users/new";
}




良い例です。

if (bindingResult.hasErrors()) {
    return "users/new";
}




入力エラー時は、エラー情報を表示するために入力画面テンプレートをそのまま返します。

PRGパターンを使うべき処理

処理PRGを使うべきか理由
ユーザー登録使うべき同じユーザーが重複登録される可能性がある
商品登録使うべき同じ商品が重複登録される可能性がある
注文確定必須レベル二重注文は大きな問題になる
問い合わせ送信使うべき同じ問い合わせが複数送信される可能性がある
更新処理使うべき再読み込みで同じ更新処理が走る可能性がある
削除処理使うべき再送信による不整合を避けたい

データを変更する処理では、基本的にPRGパターンを使うと考えてください。

登録、更新、削除のPOST後はredirect!

この習慣を早めにつけましょう。

PRGパターンのメリット

メリット説明
二重送信を防ぎやすい再読み込みでPOSTが再実行されにくくなる
URLが整理される処理用URLと表示用URLを分けられる
ユーザー体験が良くなる再送信確認の警告が出にくくなる
Controllerの役割が明確になるPOSTは処理、GETは表示に分けられる

PRGパターンのデメリット

デメリット説明
画面遷移が1回増えるPOST後にredirectしてGETするため
Modelの値を直接渡せないRedirectAttributesを使う必要がある
完全な二重送信防止ではない高速二重クリックには別対策が必要
初心者には流れが少し難しいPOSTとGETのURLを分けて考える必要がある

PRGパターンは便利ですが、万能ではありません。

再読み込み対策としてPRG。

二重クリック対策としてボタン無効化。

重要処理にはトークン。

重複データ対策としてDB制約。

このように、複数の対策を組み合わせる考え方が大切です。

最小構成の完成コード

最後に、PRGパターンの最小構成をまとめます。

package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
public class UserController {

    @GetMapping("/users/new")
    public String showForm() {
        return "users/new";
    }

    @PostMapping("/users")
    public String register(@RequestParam String name,
                           @RequestParam String email,
                           RedirectAttributes redirectAttributes) {

        System.out.println("DBに登録しました: " + name + ", " + email);

        redirectAttributes.addFlashAttribute(
                "message",
                "ユーザーを登録しました。"
        );

        return "redirect:/users";
    }

    @GetMapping("/users")
    public String list() {
        return "users/list";
    }
}




このコードのポイントです。

コード意味
GET /users/new登録フォームを表示する
POST /users登録処理を行う
redirect:/users登録後に一覧画面へ移動する
GET /users一覧画面を表示する
addFlashAttributeredirect先へ一時メッセージを渡す

まとめ

PRGパターンは、フォームの二重送信を防ぐための基本的な設計パターンです。

Post Redirect Getの名前どおり、POSTで処理したあと、redirectして、GETで画面を表示します。

ポイント内容
PRGPost Redirect Getの略
目的再読み込みによる二重送信を防ぎやすくする
POST後直接テンプレートを返さずredirectする
メッセージRedirectAttributesのaddFlashAttributeを使う
入力エラー時redirectせず入力画面を返す
重要処理トークンやDB制約も組み合わせる

一言でまとめるなら、PRGパターンは「POSTで処理したあとにGET画面へ逃がすことで、再読み込みによる再POSTを防ぐ仕組み」です。

新人エンジニアは、まず次の流れを覚えてください。

GETで入力画面を表示する
        ↓
POSTで登録処理を行う
        ↓
redirectで一覧画面や完了画面へ移動する
        ↓
GETで結果画面を表示する

今後の学習では、HTTPメソッド、redirect、RedirectAttributes、Flash Attribute、@ValidとBindingResult、トークンによる二重送信防止、DBのUNIQUE制約を順番に学ぶとよいです。まずは小さな登録画面で、POST後に直接テンプレートを返す場合とredirectする場合を比べ、ブラウザの再読み込みで動きがどう変わるか確認してみましょう!

セイ・コンサルティング・グループでは新人エンジニア研修のアシスタント講師を募集しています。

投稿者プロフィール

山崎講師
山崎講師代表取締役
セイ・コンサルティング・グループ株式会社代表取締役。
岐阜県出身。
2000年創業、2004年会社設立。
IT企業向け人材育成研修歴業界歴20年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。

学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。