Spring BootのPRGパターンでフォームの二重送信を防ぐ方法|新人エンジニア向けに解説
こんにちは。ゆうせいです。
今回は、Spring Bootでフォームの二重送信を防ぐためによく使われるPRGパターンについて、新人エンジニア向けに解説します。
Webアプリケーションで登録画面を作っていると、次のような問題が起きることがあります。
登録ボタンを押す
↓
登録完了画面が表示される
↓
ブラウザで再読み込みする
↓
同じ登録処理がもう一度実行される
このように、同じフォーム送信が再実行されてしまう問題を、フォームの二重送信と呼びます。
たとえば、ユーザー登録、商品登録、注文確定、問い合わせ送信などで二重送信が起きると困りますよね。
注文ボタンを1回押しただけのつもりなのに、再読み込みで同じ注文が2件入ったら大問題です。
その対策としてよく使われるのが、PRGパターンです。
PRGパターンとは何か
PRGは、Post Redirect Getの略です。
日本語で言うと、「POSTしたあとにRedirectし、そのあとGETで画面を表示する」という流れです。
| 文字 | 意味 | 役割 |
|---|---|---|
| P | Post | フォームの送信、登録処理 |
| R | Redirect | 別のURLへ移動させる |
| G | Get | 完了画面や一覧画面を表示する |
たとえるなら、役所の窓口で申請書を提出したあと、受付番号をもらって待合室へ移動するようなものです。
申請書の提出が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パターンの流れを整理します。
| 順番 | URL | HTTPメソッド | 処理内容 |
|---|---|---|---|
| 1 | /users/new | GET | 入力フォームを表示する |
| 2 | /users | POST | 登録処理を行う |
| 3 | /users/complete | GET | 完了画面を表示する |
ポイントは、登録処理を行う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が対応しています。
| HTML | Controller |
|---|---|
| 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/new | users/new |
| 登録処理 | POST | /users | redirect:/users |
| 一覧表示 | GET | /users | users/list |
| 編集画面表示 | GET | /users/edit?userId=1 | users/edit |
| 更新処理 | POST | /users/update | redirect:/users/1 |
| 詳細表示 | GET | /users/1 | users/detail |
| 削除処理 | POST | /users/delete | redirect:/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 | 一覧画面を表示する |
| addFlashAttribute | redirect先へ一時メッセージを渡す |
まとめ
PRGパターンは、フォームの二重送信を防ぐための基本的な設計パターンです。
Post Redirect Getの名前どおり、POSTで処理したあと、redirectして、GETで画面を表示します。
| ポイント | 内容 |
|---|---|
| PRG | Post 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年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。
学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。
最新の投稿
新人エンジニア研修講師2026年6月18日JavaのOptionalでnullを安全に扱う方法|NullPointerExceptionを防ぎ、DAOの戻り値をわかりやすくする
新人エンジニア研修講師2026年6月18日ローカルSMTPを使って問い合わせ完了メールを送る方法|新人エンジニア研修向けにSpring BootとJavaMailSenderを解説
新人エンジニア研修講師2026年6月18日DevToolsでHTTP通信・エラー・DOMを確認する方法|新人エンジニア向けにブラウザ開発者ツールを解説
新人エンジニア研修講師2026年6月18日Spring Bootの@RestControllerとは?Webアプリケーションを学んだ新人エンジニア向けにAPIをやさしく解説
