Spring Bootで@ValidとBindingResultを使ってサーバー側バリデーションを行う方法|新人エンジニア向けに解説

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

今回は、Spring Bootで@ValidとBindingResultを使って、サーバー側バリデーションを行う方法を新人エンジニア向けに解説します。

バリデーションとは、入力チェックのことです。

たとえば、ユーザー登録画面で「名前が空欄ではないか」「メールアドレスの形式になっているか」「パスワードが短すぎないか」を確認する処理です。

HTMLのrequiredやtype="email"でも入力チェックはできますが、ブラウザ側のチェックだけに頼ってはいけません。

ユーザーはブラウザの開発者ツールや直接リクエスト送信によって、画面の制限を回避できるからです。

そのため、最終的な入力チェックは必ずサーバー側、つまりSpring Boot側でも行います。

Spring Bootでは、Bean Validationの仕組みを利用できます。Spring Boot公式ドキュメントでは、Hibernate ValidatorなどのJSR-303実装がクラスパス上にあると、Bean Validationによる検証機能を利用できると説明されています。

@ValidとBindingResultを一言で説明すると

用語一言でいうと役割
@Valid入力チェックを実行する合図Formクラスに書いたルールを確認する
BindingResultエラー結果を入れる箱入力チェックで見つかったエラーを保持する

@Validは「この入力データをチェックしてください」というスイッチです。

BindingResultは「チェック結果を書き込む検査表」です。

学校の健康診断でたとえるなら、@Validは「検査を開始してください」という指示で、BindingResultは「身長、体重、視力、異常ありなしを書き込む用紙」です。

今回作るサンプル

今回は、ユーザー登録画面を例にします。

入力項目は、名前、メールアドレス、年齢です。

入力項目チェック内容
名前空欄禁止、20文字以内
メールアドレス空欄禁止、メールアドレス形式
年齢18歳以上

流れは次のようになります。

入力画面を表示
        ↓
ユーザーがフォームを送信
        ↓
@Validで入力チェック
        ↓
BindingResultにエラーが入る
        ↓
エラーがあれば入力画面に戻す
        ↓
エラーがなければ登録処理へ進む

pom.xmlにバリデーション用の依存関係を追加する

Spring Bootで@Validや@NotBlankなどを使うには、spring-boot-starter-validationを追加します。

Spring Boot公式ドキュメントでも、Hibernate Validatorは通常spring-boot-starter-validationによって提供されると説明されています。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>




この依存関係がないと、@NotBlankや@Sizeなどを書いても、期待どおりにチェックが動かないことがあります。

新人エンジニアが最初につまずきやすいポイントなので、まずpom.xmlを確認してください。

Formクラスを作る

まず、画面から送られてくる入力値を受け取るFormクラスを作ります。

Formクラスとは、HTMLフォームの入力値を受け取るためのJavaクラスです。

package com.example.demo.form;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class UserRegisterForm {

    @NotBlank(message = "名前を入力してください。")
    @Size(max = 20, message = "名前は20文字以内で入力してください。")
    private String name;

    @NotBlank(message = "メールアドレスを入力してください。")
    @Email(message = "メールアドレスの形式で入力してください。")
    private String email;

    @NotNull(message = "年齢を入力してください。")
    @Min(value = 18, message = "18歳以上を入力してください。")
    private Integer age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

     public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}




Spring Boot 3系では、バリデーション関連のimportはjavax.validationではなくjakarta.validationを使います。

たとえば、@NotBlankはjakarta.validation.constraints.NotBlankからimportします。

バリデーションアノテーションの意味

アノテーション意味使用例
@NotBlanknull、空文字、空白だけを禁止する名前、メールアドレス
@NotNullnullを禁止する年齢、ID、日付
@Size文字数や要素数を制限する名前は20文字以内
@Emailメールアドレス形式を確認するメールアドレス
@Min最小値を指定する18歳以上

Hibernate Validatorは、Bean Validationのリファレンス実装であり、アノテーションベースの制約を使って検証ルールを表現できると説明されています。

新人エンジニアは、まず文字列には@NotBlank、数値には@NotNullと@Minや@Maxを使う、と覚えると入りやすいです。

なぜageはintではなくIntegerなのか

今回のageはIntegerにしています。

private Integer age;




intではありません。

理由は、未入力をnullとして扱いたいからです。

intはnullを持てません。

Integerはnullを持てます。

nullを持てるかフォーム入力での扱いやすさ
int持てない未入力との相性が悪い
Integer持てる未入力チェックがしやすい

フォーム入力では、数値でもIntegerを使うことが多いです。

「入力されていない状態」を表現しやすいからです。

Controllerを作る

次に、Controllerを作ります。

ここで@ValidとBindingResultを使います。

package com.example.demo.controller;

import com.example.demo.form.UserRegisterForm;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

@Controller
public class UserRegisterController {

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

        model.addAttribute("userRegisterForm", new UserRegisterForm());

        return "users/register";
    }

    @PostMapping("/users/register")
    public String register(
            @Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
            BindingResult bindingResult,
            Model model) {

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

        System.out.println("名前: " + form.getName());
        System.out.println("メールアドレス: " + form.getEmail());
        System.out.println("年齢: " + form.getAge());

        return "redirect:/users/register/complete";
    }

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




Spring MVCでは、@Validや@Validatedを付けた@ModelAttributeなどの引数に対してBean Validationを適用できます。Spring Frameworkの検証ドキュメントでは、@Validまたは@Validatedでアノテーションされた@ModelAttribute、@RequestBody、@RequestPartのメソッドパラメーターに対して検証が適用されると説明されています。

Controllerの重要ポイント

特に大切なのは、次の部分です。

@Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
BindingResult bindingResult,

@Validを付けることで、UserRegisterFormに書いた@NotBlankや@Emailなどのルールが実行されます。

BindingResultには、入力チェックの結果が入ります。

BindingResultの公式APIドキュメントでは、BindingResultはErrorsインターフェースを拡張し、Validatorを適用できるエラー登録機能とバインディング結果の保持機能を持つと説明されています。

エラーがあるかどうかは、hasErrorsで確認します。

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

エラーがあれば、登録処理へ進まず、入力画面に戻します。

エラーがなければ、DB登録などの次の処理へ進みます。

BindingResultは@Validのすぐ後ろに置く

BindingResultは、@Validを付けたFormオブジェクトのすぐ後ろに置く必要があります。

良い例です。

public String register(
        @Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
        BindingResult bindingResult,
        Model model) {

悪い例です。

public String register(
        @Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
        Model model,
        BindingResult bindingResult) {

BindingResultの位置が離れていると、Springが「どのFormのエラー結果なのか」を正しく対応づけられません。

Spring Frameworkの検証ドキュメントでも、ErrorsまたはBindingResultが対象パラメーターの直後にある場合に関連して扱われることが説明されています。

検査用紙は、検査対象のすぐ隣に置く!

このイメージで覚えてください。

Thymeleafの入力画面を作る

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

ファイルの場所は、次のようにします。

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

register.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/register}"
      th:object="${userRegisterForm}"
      method="post">

    <div>
        <label for="name">名前</label>
        <input type="text" id="name" th:field="*{name}">
        <p th:if="${#fields.hasErrors('name')}"
           th:errors="*{name}"></p>
    </div>

    <div>
        <label for="email">メールアドレス</label>
        <input type="email" id="email" th:field="*{email}">
        <p th:if="${#fields.hasErrors('email')}"
           th:errors="*{email}"></p>
    </div>

    <div>
        <label for="age">年齢</label>
        <input type="number" id="age" th:field="*{age}">
        <p th:if="${#fields.hasErrors('age')}"
           th:errors="*{age}"></p>
    </div>

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

</form>

</body>
</html>




th:object="${userRegisterForm}"は、このフォーム全体がuserRegisterFormと結びつくという意味です。

th:field="*{name}"は、userRegisterFormのnameプロパティと入力欄を結びつけます。

th:errors="*{name}"は、nameに関するエラーメッセージを表示します。

th:fieldとFormクラスの対応

ThymeleafFormクラス対応するgetterとsetter
th:field="*{name}"private String namegetName、setName
th:field="*{email}"private String emailgetEmail、setEmail
th:field="*{age}"private Integer agegetAge、setAge

Thymeleafのプロパティ名とFormクラスのプロパティ名は一致させてください。

たとえば、FormクラスがuserNameなのに、HTMLで*{name}と書くと、うまく対応できません。

入力エラー時の動き

たとえば、名前を空欄、メールアドレスをabc、年齢を10にして送信したとします。

その場合、次のようなチェックに引っかかります。

入力項目入力値発生するエラー
名前空欄名前を入力してください。
メールアドレスabcメールアドレスの形式で入力してください。
年齢1018歳以上を入力してください。

Controllerでは、bindingResult.hasErrors()がtrueになります。

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

そのため、登録完了画面へ進まず、再び入力画面を表示します。

Thymeleaf側では、th:errorsによってエラーメッセージが表示されます。

完了画面を作る

登録成功後に表示する完了画面です。

ファイルの場所です。

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/register}">登録画面へ戻る</a>
</p>

</body>
</html>




実務では、hasErrorsがfalseだった場合にServiceやDAOを呼び出してDBへ保存します。

入力チェックを通過してから登録処理を行う流れを守ってください。

なぜBindingResultが必要なのか

BindingResultを使う理由は、入力エラーを例外で終わらせず、画面に戻してユーザーに修正してもらうためです。

BindingResultがないと、入力チェックエラーが発生したときに例外扱いになり、400エラーなどとして処理される場合があります。

BindingResultがあれば、Controllerの中でエラーを確認し、同じ入力画面に戻せます。

BindingResultありBindingResultなし
エラーをControllerで確認できる例外として処理されやすい
入力画面に戻せるエラー画面になることがある
項目別メッセージを表示しやすいユーザーに何を直すべきか伝えにくい

BindingResultは、ユーザーに「どこを直せばよいか」を伝えるための橋渡し役です。

サーバー側バリデーションとHTMLバリデーションの違い

種類場所特徴
HTMLバリデーションブラウザ側すぐに入力ミスを知らせられる
JavaScriptバリデーションブラウザ側画面上で細かい制御ができる
サーバー側バリデーションSpring Boot側最終防衛ラインになる

HTMLやJavaScriptのチェックは、ユーザー体験を良くするために便利です。

ただし、ブラウザ側のチェックは回避される可能性があります。

サーバー側バリデーションは、DB登録前の最後の門番です。

門番を置かずに倉庫であるDBへ荷物を入れると、壊れた荷物や危険な荷物まで保管してしまいます。

実務でよく使うアノテーション

アノテーション使う場面
@NotBlank文字列の必須入力名前、タイトル、住所
@NotNull数値や日付の必須入力年齢、金額、カテゴリID
@Size文字数制限名前は20文字以内
@Emailメール形式メールアドレス
@Min最小値年齢は18以上
@Max最大値数量は100以下
@Pattern正規表現郵便番号、電話番号

文字列の必須チェックでは、@NotNullではなく@NotBlankを使う場面が多いです。

@NotNullはnullだけを禁止します。

空文字や空白だけの入力を防ぎたいなら、@NotBlankが向いています。

エラーメッセージを直接書く方法

一番わかりやすいのは、アノテーションのmessageに直接書く方法です。

@NotBlank(message = "名前を入力してください。")
@Size(max = 20, message = "名前は20文字以内で入力してください。")
private String name;

学習段階では、この書き方が理解しやすいです。

ただし、項目が増えるとFormクラスがメッセージだらけになります。

実務では、messages.propertiesなどにまとめることもあります。

messages.propertiesで管理する方法

メッセージをプロパティファイルで管理することもできます。

Formクラスです。

@NotBlank(message = "{user.name.notBlank}")
@Size(max = 20, message = "{user.name.size}")
private String name;

src/main/resources/messages.propertiesです。

user.name.notBlank=名前を入力してください。
user.name.size=名前は20文字以内で入力してください。

メッセージを外に出すと、文言修正がしやすくなります。

日本語、英語など、多言語対応を考える場合にも便利です。

ServiceやDAOはいつ呼ぶべきか

ServiceやDAOは、バリデーションエラーがない場合だけ呼びます。

良い例です。

@PostMapping("/users/register")
public String register(
        @Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
        BindingResult bindingResult) {

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

    userService.register(form);

    return "redirect:/users/register/complete";
}




悪い例です。

@PostMapping("/users/register")
public String register(
        @Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
        BindingResult bindingResult) {

    userService.register(form);

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

    return "redirect:/users/register/complete";
}




悪い例では、エラー確認より先に登録処理を呼んでいます。

つまり、入力値が間違っていてもDB登録へ進む可能性があります。

必ず、hasErrorsを先に確認してください。

よくあるミス1:BindingResultの順番が違う

BindingResultは、@Valid対象のすぐ後ろに置きます。

正しい順番です。

@Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
BindingResult bindingResult,
Model model

間違った順番です。

@Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
Model model,
BindingResult bindingResult

順番が違うと、エラーを受け取れず、期待どおりに画面へ戻せないことがあります。

よくあるミス2:GET側でFormをModelに入れていない

入力画面を表示するGETメソッドでは、FormオブジェクトをModelに入れます。

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

    model.addAttribute("userRegisterForm", new UserRegisterForm());

    return "users/register";
}

これを忘れると、Thymeleafのth:object="${userRegisterForm}"が参照できず、画面表示でエラーになることがあります。

よくあるミス3:th:objectの名前が違う

Controller側です。

model.addAttribute("userRegisterForm", new UserRegisterForm());

HTML側です。

<form th:object="${userRegisterForm}">

この2つの名前は一致させます。

悪い例です。

model.addAttribute("form", new UserRegisterForm());
<form th:object="${userRegisterForm}">

Controllerではformという名前なのに、HTMLではuserRegisterFormを探しています。

宅配便で宛名が違うようなものです。

荷物は届きません。

よくあるミス4:importがjavaxになっている

Spring Boot 3系では、jakarta.validationを使います。

良い例です。

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;

古い記事では、次のようなimportが出てくることがあります。

import javax.validation.Valid;
import javax.validation.constraints.NotBlank;

Spring Bootのバージョンや依存関係によっては、javax系ではうまく動かないことがあります。

新しいSpring Bootで学習している場合は、jakartaかどうか確認してください。

よくあるミス5:エラー時にredirectしてしまう

バリデーションエラー時は、基本的にredirectではなく、入力画面のテンプレート名を返します。

良い例です。

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

悪い例です。

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

redirectすると、新しいGETリクエストになり、BindingResultのエラー情報が失われやすくなります。

エラーを表示したい場合は、同じテンプレートをそのまま返すと覚えてください。

よくあるミス6:getterとsetterがない

Formクラスにはgetterとsetterを用意します。

ThymeleafやSpringのデータバインディングは、プロパティ名とgetter、setterを使って値を扱います。

悪い例です。

public class UserRegisterForm {

    @NotBlank
    private String name;
}

良い例です。

public class UserRegisterForm {

    @NotBlank
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

新人エンジニアは、Formクラスにはprivateフィールド、getter、setterをセットで書くと覚えてください。

型変換エラーもBindingResultに入る

年齢にabcのような文字を入力した場合、Integerへ変換できません。

このような型変換エラーも、BindingResultで扱えます。

たとえば、ageに文字列abcが送られると、SpringはIntegerへ変換しようとして失敗します。

その結果、ageのエラーとして扱われます。

ただし、エラーメッセージが英語っぽくなる場合があります。

その場合は、messages.propertiesで型変換エラー用のメッセージを設定します。

typeMismatch.userRegisterForm.age=年齢は数値で入力してください。

typeMismatchは、型変換に失敗したときのエラーコードです。

数値項目を扱う画面では、型変換エラーのメッセージも整えておくと親切です。

カスタムチェックを追加したい場合

@NotBlankや@Emailだけでは足りない場合があります。

たとえば、「メールアドレスがすでに登録済みか」を確認したい場合です。

このようなチェックは、DB確認が必要なので、Formクラスのアノテーションだけで完結しにくいです。

その場合は、ControllerやServiceで追加チェックし、BindingResultにエラーを追加できます。

@PostMapping("/users/register")
public String register(
        @Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
        BindingResult bindingResult) {

    if (userService.existsByEmail(form.getEmail())) {
        bindingResult.rejectValue(
                "email",
                "duplicate",
                "このメールアドレスはすでに登録されています。"
        );
    }

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

    userService.register(form);

    return "redirect:/users/register/complete";
}




rejectValueは、特定フィールドにエラーを追加するメソッドです。

この例では、email項目に「このメールアドレスはすでに登録されています。」というエラーを追加しています。

入力形式のチェックはアノテーション。

DBを見ないと判断できないチェックはService。

このように分けると設計しやすくなります。

@ValidとBindingResultを使った基本形

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

@PostMapping("/xxx")
public String submit(
        @Valid @ModelAttribute("formName") XxxForm form,
        BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return "入力画面のテンプレート名";
    }

    // 正常時の処理

    return "redirect:/完了URL";
}




この形を覚えると、ユーザー登録、商品登録、検索条件入力、問い合わせフォームなど、多くの画面に応用できます。

全体の役割分担

部品役割
Formクラス入力値を受け取り、チェックルールを書く
@ValidFormクラスのチェックルールを実行する
BindingResultチェック結果とエラー情報を持つ
Controllerエラーがあれば画面へ戻し、なければ次へ進める
Thymeleaf入力欄とエラーメッセージを表示する
Service正常時の登録処理やDB確認を行う

バリデーションは、1つの場所だけで完結するものではありません。

Form、Controller、Thymeleafがチームで動いています。

サッカーでたとえるなら、Formが守るルール、@Validが審判、BindingResultが判定結果、Controllerが次のプレーを決める監督です。

まとめ

Spring Bootでサーバー側バリデーションを行う基本は、Formクラスにチェックルールを書き、Controllerで@ValidとBindingResultを使うことです。

ポイント内容
依存関係spring-boot-starter-validationを追加する
Formクラス@NotBlank、@Email、@Sizeなどを書く
@Valid入力チェックを実行する
BindingResultエラー情報を受け取る
hasErrorsエラーがあるか確認する
Thymeleafth:errorsでエラーメッセージを表示する

一言でまとめるなら、@Validは「チェック開始」、BindingResultは「チェック結果」です。

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

Formクラスにルールを書く
        ↓
Controllerで@Validを付ける
        ↓
BindingResultをすぐ後ろに置く
        ↓
hasErrorsで確認する
        ↓
エラーなら入力画面へ戻す
        ↓
正常なら登録処理へ進む

今後の学習では、@NotBlank、@Size、@Email、@Min、@Max、BindingResult、th:errors、messages.properties、型変換エラー、重複チェックを順番に学ぶとよいです。まずは小さな登録フォームを作り、わざと空欄や不正なメールアドレスを入力して、エラー表示の動きを確認してください!

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

投稿者プロフィール

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

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