0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Boot入門⑧】例外処理とエラーハンドリング ― @ExceptionHandlerと@ControllerAdviceでエラーを制御する

0
Posted at

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

前回(第7回)では、Spring Securityによる認証・認可の仕組みを学びました。ここまでのシリーズを通じて、CRUD、画面表示、データベース連携、セキュリティと、Webアプリケーションに必要な要素を一通り実装してきました。

しかし、これまでのコードにはエラーが発生したときの対応が不十分です。たとえば、存在しないIDのTODOを取得しようとすると500エラーが返り、ユーザーには「Whitelabel Error Page」という無骨な画面が表示されるだけでした。

第8回では、Spring Bootの例外処理とエラーハンドリングを体系的に学びます。

今回学ぶこと

  • Spring Bootのデフォルトエラー処理(Whitelabel Error Page)の仕組み
  • @ExceptionHandler によるコントローラーレベルの例外ハンドリング
  • @ControllerAdvice / @RestControllerAdvice によるグローバル例外ハンドリング
  • カスタム例外クラスの作成
  • バリデーションエラーのハンドリング
  • REST APIとThymeleaf画面の両方に対応するエラーハンドリング
  • Servlet/JSPの <error-page> 設定との対比

本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。環境構築がまだの方は第1回を先にご覧ください。


1. Spring Bootのデフォルトエラー処理

Whitelabel Error Pageとは

Spring Bootアプリケーションでエラーが発生すると、デフォルトで以下のような画面が表示されます。

Whitelabel Error Page

This application has no explicit mapping for /error, so you are seeing this as a fallback.

There was an unexpected error (type=Not Found, status=404).

これは BasicErrorController が提供するデフォルトのエラーページです。ブラウザからのリクエスト(Accept: text/html)にはHTMLを、APIクライアントからのリクエスト(Accept: application/json)にはJSONを返します。

デフォルトエラー処理の仕組み

リクエスト → DispatcherServlet → コントローラー → 例外発生!
                                                    ↓
                                           例外がキャッチされない
                                                    ↓
                                    サーブレットコンテナが /error に転送
                                                    ↓
                                         BasicErrorController が処理
                                                    ↓
                                    ブラウザ → Whitelabel Error Page(HTML)
                                    API    → エラーJSON

server.error プロパティ

application.properties でデフォルトエラー処理の挙動をカスタマイズできます。

# エラーレスポンスに例外メッセージを含めるか(デフォルト: never)
server.error.include-message=always

# エラーレスポンスにスタックトレースを含めるか(デフォルト: never)
server.error.include-stacktrace=on_param

# エラーレスポンスにバインディングエラーを含めるか(デフォルト: never)
server.error.include-binding-errors=always

# エラーレスポンスに例外クラス名を含めるか(デフォルト: false)
server.error.include-exception=true

# Whitelabel Error Pageを無効化するか(デフォルト: true)
server.error.whitelabel.enabled=false

server.error.include-message=alwaysserver.error.include-stacktrace=always を本番環境で設定すると、内部実装の情報がクライアントに漏洩するリスクがあります。開発環境のみで使用してください。

Servlet/JSPとの対比

Servlet/JSPでは、web.xml にエラーページを設定していました。

<!-- Servlet/JSP: web.xmlでのエラーページ設定 -->
<error-page>
    <error-code>404</error-code>
    <location>/WEB-INF/error/404.jsp</location>
</error-page>
<error-page>
    <error-code>500</error-code>
    <location>/WEB-INF/error/500.jsp</location>
</error-page>
<error-page>
    <exception-type>java.lang.Exception</exception-type>
    <location>/WEB-INF/error/error.jsp</location>
</error-page>

Spring Bootでは、web.xml は不要です。代わりに以下の方法でエラーハンドリングを行います。

方式 Servlet/JSP Spring Boot
ステータスコード別エラーページ <error-page> + <error-code> src/main/resources/templates/error/404.html
例外クラス別ハンドリング <error-page> + <exception-type> @ExceptionHandler
全体のエラーハンドリング web.xml に集約 @ControllerAdvice
エラー情報の取得 request.getAttribute("javax.servlet.error.status_code") メソッド引数で自動注入

2. @ExceptionHandler(コントローラーレベル)

基本的な使い方

@ExceptionHandler を使うと、特定のコントローラー内で発生した例外をキャッチして処理できます。

package com.example.hellospring.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.NoSuchElementException;

@RestController
@RequestMapping("/api/demo")
public class DemoController {

    @GetMapping("/{id}")
    public Map<String, Object> getItem(@PathVariable Long id) {
        if (id <= 0) {
            throw new IllegalArgumentException("IDは1以上の正の整数を指定してください");
        }
        if (id > 100) {
            throw new NoSuchElementException("ID " + id + " のアイテムは存在しません");
        }
        return Map.of("id", id, "name", "アイテム" + id);
    }

    /**
     * このコントローラー内で発生したIllegalArgumentExceptionをハンドリング
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, Object>> handleIllegalArgument(
            IllegalArgumentException ex) {
        Map<String, Object> body = Map.of(
                "status", 400,
                "error", "Bad Request",
                "message", ex.getMessage()
        );
        return ResponseEntity.badRequest().body(body);
    }

    /**
     * このコントローラー内で発生したNoSuchElementExceptionをハンドリング
     */
    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(
            NoSuchElementException ex) {
        Map<String, Object> body = Map.of(
                "status", 404,
                "error", "Not Found",
                "message", ex.getMessage()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

リクエストとレスポンスの例:

# 正常系
$ curl http://localhost:8080/api/demo/1
{"id":1,"name":"アイテム1"}

# 不正なID(IllegalArgumentException → 400)
$ curl http://localhost:8080/api/demo/-1
{"status":400,"error":"Bad Request","message":"IDは1以上の正の整数を指定してください"}

# 存在しないID(NoSuchElementException → 404)
$ curl http://localhost:8080/api/demo/999
{"status":404,"error":"Not Found","message":"ID 999 のアイテムは存在しません"}

@ExceptionHandlerの特徴

  • スコープ:宣言されたコントローラー内でのみ有効
  • 複数の例外型@ExceptionHandler({TypeA.class, TypeB.class}) で複数指定可能
  • 引数:例外オブジェクトのほか、HttpServletRequestWebRequest なども受け取れる
  • 戻り値ResponseEntityString(ビュー名)、ModelAndView など柔軟に指定可能

@ExceptionHandler はコントローラー内に定義するため、同じ例外ハンドリングを複数のコントローラーに書くとコードが重複します。この問題は次節の @ControllerAdvice で解決できます。


3. @ControllerAdvice / @RestControllerAdvice(グローバル)

グローバル例外ハンドリングとは

@ControllerAdvice は、アプリケーション全体で有効な例外ハンドラーを定義するアノテーションです。すべてのコントローラーで発生した例外を一箇所で処理できます。

@RestControllerAdvice@ControllerAdvice + @ResponseBody を組み合わせたもので、戻り値を自動的にJSONに変換します。REST APIのエラーハンドリングに使います。

@ControllerAdvice   = 画面(Thymeleaf)向け(ビュー名を返す)
@RestControllerAdvice = REST API向け(JSONを返す)

カスタム例外クラスの作成

まず、アプリケーション固有の例外クラスを作成します。

package com.example.hellospring.exception;

/**
 * リソースが見つからない場合にスローされる例外
 */
public class ResourceNotFoundException extends RuntimeException {

    private final String resourceName;
    private final String fieldName;
    private final Object fieldValue;

    public ResourceNotFoundException(String resourceName, String fieldName,
                                     Object fieldValue) {
        super(String.format("%s が見つかりません(%s: %s)",
                resourceName, fieldName, fieldValue));
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;
    }

    public String getResourceName() {
        return resourceName;
    }

    public String getFieldName() {
        return fieldName;
    }

    public Object getFieldValue() {
        return fieldValue;
    }
}
package com.example.hellospring.exception;

/**
 * ビジネスルール違反時にスローされる例外
 */
public class BusinessException extends RuntimeException {

    public BusinessException(String message) {
        super(message);
    }

    public BusinessException(String message, Throwable cause) {
        super(message, cause);
    }
}

エラーレスポンスDTO

エラーレスポンスの形式を統一するためのDTOを作成します。Java 16以降の record を使います。

package com.example.hellospring.dto;

import com.fasterxml.jackson.annotation.JsonInclude;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 統一エラーレスポンス
 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorResponse(
        int status,
        String error,
        String message,
        String path,
        LocalDateTime timestamp,
        List<FieldError> fieldErrors
) {
    /**
     * フィールドごとのバリデーションエラー
     */
    public record FieldError(
            String field,
            String message,
            Object rejectedValue
    ) {
    }

    /**
     * フィールドエラーなしのErrorResponseを生成
     */
    public static ErrorResponse of(int status, String error, String message,
                                   String path) {
        return new ErrorResponse(status, error, message, path,
                LocalDateTime.now(), null);
    }

    /**
     * フィールドエラー付きのErrorResponseを生成
     */
    public static ErrorResponse of(int status, String error, String message,
                                   String path, List<FieldError> fieldErrors) {
        return new ErrorResponse(status, error, message, path,
                LocalDateTime.now(), fieldErrors);
    }
}

@JsonInclude(JsonInclude.Include.NON_NULL) を付けることで、null のフィールド(たとえばバリデーションエラーがない場合の fieldErrors)はJSONに含まれなくなります。

グローバル例外ハンドラーの実装

package com.example.hellospring.exception;

import com.example.hellospring.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /**
     * リソースが見つからない場合(404)
     */
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {
        log.warn("リソースが見つかりません: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.NOT_FOUND.value(),
                "Not Found",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    /**
     * ビジネスルール違反(400)
     */
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        log.warn("ビジネスルール違反: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.badRequest().body(body);
    }

    /**
     * 不正な引数(400)
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(
            IllegalArgumentException ex, HttpServletRequest request) {
        log.warn("不正な引数: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.badRequest().body(body);
    }

    /**
     * その他の例外(500)
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        log.error("予期しないエラーが発生しました", ex);

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "サーバー内部でエラーが発生しました。時間をおいて再度お試しください。",
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(body);
    }
}

@ExceptionHandler の優先順位

複数のハンドラーが適用可能な場合、Springは以下の優先順位で選択します。

  1. コントローラー内の @ExceptionHandler が最優先
  2. @ControllerAdvice 内の @ExceptionHandler がフォールバック
  3. 例外クラスの継承関係が近いハンドラーが優先(ResourceNotFoundExceptionRuntimeExceptionException の順に検索)
例外発生
  ↓
コントローラー内に @ExceptionHandler あり? → YES → コントローラーのハンドラーを実行
  ↓ NO
@ControllerAdvice 内に @ExceptionHandler あり? → YES → Adviceのハンドラーを実行
  ↓ NO
デフォルトエラー処理(BasicErrorController)

4. バリデーションエラーのハンドリング

MethodArgumentNotValidException

第4回で学んだBean Validationのエラーは、REST APIでは MethodArgumentNotValidException としてスローされます。これをグローバル例外ハンドラーで処理し、フィールドごとのエラーメッセージをJSON形式で返します。

まず、バリデーション付きのリクエストDTOを用意します。

package com.example.hellospring.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

/**
 * TODO作成リクエスト
 */
public record TodoCreateRequest(
        @NotBlank(message = "タイトルは必須です")
        @Size(max = 100, message = "タイトルは100文字以内で入力してください")
        String title,

        @Size(max = 500, message = "説明は500文字以内で入力してください")
        String description
) {
}

次に、GlobalExceptionHandler にバリデーションエラーのハンドラーを追加します。

package com.example.hellospring.exception;

import com.example.hellospring.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /**
     * バリデーションエラー(400)
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {
        log.warn("バリデーションエラー: {}", ex.getMessage());

        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fe -> new ErrorResponse.FieldError(
                        fe.getField(),
                        fe.getDefaultMessage(),
                        fe.getRejectedValue()
                ))
                .toList();

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Validation Failed",
                "入力値に誤りがあります",
                request.getRequestURI(),
                fieldErrors
        );
        return ResponseEntity.badRequest().body(body);
    }

    /**
     * リソースが見つからない場合(404)
     */
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {
        log.warn("リソースが見つかりません: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.NOT_FOUND.value(),
                "Not Found",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    /**
     * ビジネスルール違反(400)
     */
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        log.warn("ビジネスルール違反: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.badRequest().body(body);
    }

    /**
     * 不正な引数(400)
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(
            IllegalArgumentException ex, HttpServletRequest request) {
        log.warn("不正な引数: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.badRequest().body(body);
    }

    /**
     * その他の例外(500)
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        log.error("予期しないエラーが発生しました", ex);

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "サーバー内部でエラーが発生しました。時間をおいて再度お試しください。",
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(body);
    }
}

バリデーションエラーのレスポンス例:

$ curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "", "description": ""}'
{
  "status": 400,
  "error": "Validation Failed",
  "message": "入力値に誤りがあります",
  "path": "/api/todos",
  "timestamp": "2026-04-13T10:30:00.123456",
  "fieldErrors": [
    {
      "field": "title",
      "message": "タイトルは必須です",
      "rejectedValue": ""
    }
  ]
}

Servlet/JSPとの対比

Servlet/JSPでバリデーションエラーを返す場合は、手動でエラーメッセージを組み立てて request.setAttribute() でJSPに渡していました。

// Servlet/JSP: 手動でバリデーション → エラーメッセージを組み立て
List<String> errors = new ArrayList<>();
if (title == null || title.isBlank()) {
    errors.add("タイトルは必須です");
}
if (!errors.isEmpty()) {
    request.setAttribute("errors", errors);
    request.getRequestDispatcher("/WEB-INF/todo/form.jsp").forward(request, response);
    return;
}

Spring Bootでは、@Valid + @ExceptionHandler の組み合わせで宣言的に処理でき、エラーメッセージの組み立ても自動化されます。


5. 実践例: TODO APIにエラーハンドリングを追加

ここまで学んだ内容を統合して、第5回で作成したTODO APIにエラーハンドリングを追加します。

カスタム例外クラス

package com.example.hellospring.exception;

/**
 * TODOが見つからない場合にスローされる例外
 */
public class TodoNotFoundException extends ResourceNotFoundException {

    public TodoNotFoundException(Long id) {
        super("Todo", "id", id);
    }
}

本記事では、第5回で作成したTodoエンティティに description(説明)フィールドを追加し、TodoRepositoryexistsByTitle() メソッドを追加しています。第5回のコードをベースに変更を加えてください。

サービス層

package com.example.hellospring.service;

import com.example.hellospring.entity.Todo;
import com.example.hellospring.exception.BusinessException;
import com.example.hellospring.exception.TodoNotFoundException;
import com.example.hellospring.repository.TodoRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional
public class TodoService {

    private final TodoRepository todoRepository;

    public TodoService(TodoRepository todoRepository) {
        this.todoRepository = todoRepository;
    }

    /**
     * 全件取得
     */
    @Transactional(readOnly = true)
    public List<Todo> findAll() {
        return todoRepository.findAll();
    }

    /**
     * ID指定で取得(見つからなければ例外)
     */
    @Transactional(readOnly = true)
    public Todo findById(Long id) {
        return todoRepository.findById(id)
                .orElseThrow(() -> new TodoNotFoundException(id));
    }

    /**
     * 新規作成
     */
    public Todo create(String title, String description) {
        // 同じタイトルが既に存在する場合はビジネスルール違反
        if (todoRepository.existsByTitle(title)) {
            throw new BusinessException(
                    "タイトル「" + title + "」のTODOは既に存在します");
        }

        Todo todo = new Todo();
        todo.setTitle(title);
        todo.setDescription(description);
        todo.setCompleted(false);
        return todoRepository.save(todo);
    }

    /**
     * 更新
     */
    public Todo update(Long id, String title, String description,
                       boolean completed) {
        Todo todo = findById(id); // 見つからなければTodoNotFoundException
        todo.setTitle(title);
        todo.setDescription(description);
        todo.setCompleted(completed);
        return todoRepository.save(todo);
    }

    /**
     * 削除
     */
    public void delete(Long id) {
        Todo todo = findById(id); // 見つからなければTodoNotFoundException
        todoRepository.delete(todo);
    }
}

エンティティ

package com.example.hellospring.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "todos")
public class Todo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(length = 500)
    private String description;

    @Column(nullable = false)
    private boolean completed;

    // デフォルトコンストラクタ(JPA必須)
    public Todo() {
    }

    // getter / setter
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public boolean isCompleted() {
        return completed;
    }

    public void setCompleted(boolean completed) {
        this.completed = completed;
    }
}

リポジトリ

package com.example.hellospring.repository;

import com.example.hellospring.entity.Todo;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TodoRepository extends JpaRepository<Todo, Long> {

    boolean existsByTitle(String title);
}

コントローラー

package com.example.hellospring.controller;

import com.example.hellospring.dto.TodoCreateRequest;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.service.TodoService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/todos")
public class TodoApiController {

    private final TodoService todoService;

    public TodoApiController(TodoService todoService) {
        this.todoService = todoService;
    }

    @GetMapping
    public List<Todo> findAll() {
        return todoService.findAll();
    }

    @GetMapping("/{id}")
    public Todo findById(@PathVariable Long id) {
        return todoService.findById(id);
    }

    @PostMapping
    public ResponseEntity<Todo> create(
            @Valid @RequestBody TodoCreateRequest request) {
        Todo created = todoService.create(
                request.title(), request.description());
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        todoService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

コントローラーには例外ハンドリングのコードが一切ありません。例外はすべて GlobalExceptionHandler が処理します。

動作確認

# 1. TODO作成(正常系)
$ curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Spring Bootを学ぶ", "description": "例外処理の章"}'
{"id":1,"title":"Spring Bootを学ぶ","description":"例外処理の章","completed":false}

# 2. 存在しないIDを取得(TodoNotFoundException → 404)
$ curl http://localhost:8080/api/todos/999
{"status":404,"error":"Not Found","message":"Todo が見つかりません(id: 999)","path":"/api/todos/999","timestamp":"2026-04-13T10:30:00.123456"}

# 3. バリデーションエラー(MethodArgumentNotValidException → 400)
$ curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": ""}'
{"status":400,"error":"Validation Failed","message":"入力値に誤りがあります","path":"/api/todos","timestamp":"2026-04-13T10:30:00.123456","fieldErrors":[{"field":"title","message":"タイトルは必須です","rejectedValue":""}]}

# 4. 重複タイトル(BusinessException → 400)
$ curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Spring Bootを学ぶ", "description": "2つ目"}'
{"status":400,"error":"Bad Request","message":"タイトル「Spring Bootを学ぶ」のTODOは既に存在します","path":"/api/todos","timestamp":"2026-04-13T10:30:00.123456"}

エラーレスポンスの統一フォーマット

すべてのエラーが同じ構造で返されていることに注目してください。

{
  "status": "HTTPステータスコード(数値)",
  "error": "エラー種別(文字列)",
  "message": "人間が読めるエラーメッセージ",
  "path": "リクエストURI",
  "timestamp": "発生日時",
  "fieldErrors": "バリデーションエラーの詳細(任意)"
}

この統一フォーマットにより、フロントエンド側はstatusフィールドを見てエラー種別を判別し、messageをユーザーに表示する、といった一貫した処理が書けます。


6. Thymeleaf画面のエラーページ

REST APIのエラーハンドリングに加え、Thymeleaf画面向けのカスタムエラーページも作成します。

カスタム404ページ

Spring Bootは、src/main/resources/templates/error/ ディレクトリ内にHTTPステータスコードに対応するテンプレートを配置すると、自動的にそれを使用します。

src/main/resources/templates/error/404.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ページが見つかりません</title>
    <style>
        body {
            font-family: 'Helvetica Neue', Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background-color: #f5f5f5;
        }
        .error-container {
            text-align: center;
            padding: 40px;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
        }
        .error-code {
            font-size: 72px;
            font-weight: bold;
            color: #e74c3c;
            margin: 0;
        }
        .error-message {
            font-size: 20px;
            color: #555;
            margin: 16px 0;
        }
        .back-link {
            display: inline-block;
            margin-top: 20px;
            padding: 10px 24px;
            background-color: #3498db;
            color: white;
            text-decoration: none;
            border-radius: 4px;
        }
        .back-link:hover {
            background-color: #2980b9;
        }
    </style>
</head>
<body>
<div class="error-container">
    <p class="error-code">404</p>
    <p class="error-message">お探しのページは見つかりませんでした。</p>
    <p>URLが正しいかご確認ください。</p>
    <a class="back-link" th:href="@{/}">トップページに戻る</a>
</div>
</body>
</html>

カスタム500ページ

src/main/resources/templates/error/500.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>サーバーエラー</title>
    <style>
        body {
            font-family: 'Helvetica Neue', Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background-color: #f5f5f5;
        }
        .error-container {
            text-align: center;
            padding: 40px;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
        }
        .error-code {
            font-size: 72px;
            font-weight: bold;
            color: #e74c3c;
            margin: 0;
        }
        .error-message {
            font-size: 20px;
            color: #555;
            margin: 16px 0;
        }
        .back-link {
            display: inline-block;
            margin-top: 20px;
            padding: 10px 24px;
            background-color: #3498db;
            color: white;
            text-decoration: none;
            border-radius: 4px;
        }
        .back-link:hover {
            background-color: #2980b9;
        }
    </style>
</head>
<body>
<div class="error-container">
    <p class="error-code">500</p>
    <p class="error-message">サーバー内部でエラーが発生しました。</p>
    <p>時間をおいて再度お試しください。</p>
    <a class="back-link" th:href="@{/}">トップページに戻る</a>
</div>
</body>
</html>

テンプレートの命名規則

Spring Bootは templates/error/ ディレクトリ内のテンプレートを以下の優先順位で検索します。

  1. 完全一致404.html500.html など
  2. シリーズ一致4xx.html(400番台すべて)、5xx.html(500番台すべて)
src/main/resources/templates/error/
├── 404.html    ← 404 のときに使用される
├── 500.html    ← 500 のときに使用される
├── 4xx.html    ← 404.html がない場合のフォールバック(401, 403等も対象)
└── 5xx.html    ← 500.html がない場合のフォールバック(502, 503等も対象)

Servlet/JSPとの対比

項目 Servlet/JSP Spring Boot + Thymeleaf
設定場所 web.xml<error-page> templates/error/ ディレクトリにHTMLを配置
ステータスコード指定 <error-code>404</error-code> ファイル名(404.html
シリーズ一括指定 不可(1つずつ定義が必要) 4xx.html5xx.html で一括対応
エラー情報へのアクセス request.getAttribute("javax.servlet.error.*") ${status}${error}${message}
テンプレートエンジン JSP + EL式 Thymeleaf

Servlet/JSPでは web.xml にすべてのエラーコードを列挙する必要がありましたが、Spring Bootではテンプレートファイルを配置するだけでカスタムエラーページが有効になります。設定ファイルの編集は不要です。

REST APIと画面の両方に対応する

ここで一つ注意点があります。@RestControllerAdvice はレスポンスをすべてJSON形式で返すため、Thymeleaf画面からのリクエストに対してもJSONが返ってしまいます。

REST APIと画面の両方を持つアプリケーションでは、リクエストの Accept ヘッダーに基づいて処理を分けるか、パッケージで対象コントローラーを限定します。

package com.example.hellospring.exception;

import com.example.hellospring.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;

/**
 * REST APIコントローラー専用のグローバル例外ハンドラー
 * basePackages で対象を限定し、Thymeleafコントローラーには影響しない
 */
@RestControllerAdvice(basePackages = "com.example.hellospring.controller.api")
public class ApiExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {
        log.warn("リソースが見つかりません: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.NOT_FOUND.value(),
                "Not Found",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fe -> new ErrorResponse.FieldError(
                        fe.getField(),
                        fe.getDefaultMessage(),
                        fe.getRejectedValue()
                ))
                .toList();

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Validation Failed",
                "入力値に誤りがあります",
                request.getRequestURI(),
                fieldErrors
        );
        return ResponseEntity.badRequest().body(body);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.badRequest().body(body);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        log.error("予期しないエラーが発生しました", ex);

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "サーバー内部でエラーが発生しました。時間をおいて再度お試しください。",
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(body);
    }
}
package com.example.hellospring.exception;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

/**
 * Thymeleaf画面コントローラー専用のグローバル例外ハンドラー
 */
@ControllerAdvice(basePackages = "com.example.hellospring.controller.web")
public class WebExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(WebExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public String handleResourceNotFound(
            ResourceNotFoundException ex, Model model) {
        log.warn("リソースが見つかりません: {}", ex.getMessage());
        model.addAttribute("message", ex.getMessage());
        return "error/404";
    }

    @ExceptionHandler(Exception.class)
    public String handleException(Exception ex, Model model) {
        log.error("予期しないエラーが発生しました", ex);
        model.addAttribute("message",
                "サーバー内部でエラーが発生しました。");
        return "error/500";
    }
}

@RestControllerAdvice(basePackages = "...") で対象パッケージを限定する方法のほか、@ControllerAdvice(assignableTypes = {TodoApiController.class}) で特定のコントローラークラスを対象にする方法もあります。


練習問題

問題1: カスタム例外クラスの作成 ⭐

商品(Product) を扱うアプリケーションで、在庫不足を表すカスタム例外 InsufficientStockException を作成してください。以下の要件を満たすこと。

  • RuntimeException を継承する
  • フィールド:productId(Long型)、requestedQuantity(int型)、availableStock(int型)
  • メッセージ例:"商品ID 5 の在庫が不足しています(要求: 10, 在庫: 3)"
模範解答
package com.example.hellospring.exception;

/**
 * 在庫不足を表す例外
 */
public class InsufficientStockException extends RuntimeException {

    private final Long productId;
    private final int requestedQuantity;
    private final int availableStock;

    public InsufficientStockException(Long productId,
                                      int requestedQuantity,
                                      int availableStock) {
        super(String.format(
                "商品ID %d の在庫が不足しています(要求: %d, 在庫: %d)",
                productId, requestedQuantity, availableStock));
        this.productId = productId;
        this.requestedQuantity = requestedQuantity;
        this.availableStock = availableStock;
    }

    public Long getProductId() {
        return productId;
    }

    public int getRequestedQuantity() {
        return requestedQuantity;
    }

    public int getAvailableStock() {
        return availableStock;
    }
}

問題2: グローバル例外ハンドラーの実装 ⭐⭐

問題1で作成した InsufficientStockException をハンドリングする @RestControllerAdvice クラスを実装してください。以下の要件を満たすこと。

  • HTTPステータスコード:409 Conflict を返す
  • レスポンス形式は本記事の ErrorResponse recordを使用
  • ログ出力(log.warn)を含める
模範解答
package com.example.hellospring.exception;

import com.example.hellospring.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ProductExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(ProductExceptionHandler.class);

    @ExceptionHandler(InsufficientStockException.class)
    public ResponseEntity<ErrorResponse> handleInsufficientStock(
            InsufficientStockException ex,
            HttpServletRequest request) {
        log.warn("在庫不足: {}", ex.getMessage());

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.CONFLICT.value(),
                "Conflict",
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
    }
}

動作確認:

在庫不足が発生した場合のレスポンス例は以下のとおりです。

{
  "status": 409,
  "error": "Conflict",
  "message": "商品ID 5 の在庫が不足しています(要求: 10, 在庫: 3)",
  "path": "/api/orders",
  "timestamp": "2026-04-13T10:30:00.123456"
}

問題3: バリデーションエラーのハンドリング ⭐⭐⭐

以下の OrderRequest に対するバリデーションエラーを適切にハンドリングする @ExceptionHandler メソッドを実装してください。エラーレスポンスにはフィールドごとのエラーメッセージを含め、HTTPステータスコードは400を返すこと。

package com.example.hellospring.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

public record OrderRequest(
        @NotNull(message = "商品IDは必須です")
        @Positive(message = "商品IDは正の整数を指定してください")
        Long productId,

        @NotBlank(message = "届け先住所は必須です")
        String shippingAddress,

        @NotNull(message = "数量は必須です")
        @Min(value = 1, message = "数量は1以上を指定してください")
        Integer quantity
) {
}

OrderRequest{"productId": -1, "shippingAddress": "", "quantity": 0} を送信したとき、3件すべてのバリデーションエラーがレスポンスに含まれることを確認してください。

模範解答
package com.example.hellospring.exception;

import com.example.hellospring.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;

@RestControllerAdvice
public class OrderExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fe -> new ErrorResponse.FieldError(
                        fe.getField(),
                        fe.getDefaultMessage(),
                        fe.getRejectedValue()
                ))
                .toList();

        ErrorResponse body = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                "Validation Failed",
                "入力値に誤りがあります",
                request.getRequestURI(),
                fieldErrors
        );
        return ResponseEntity.badRequest().body(body);
    }
}

コントローラー(テスト用):

package com.example.hellospring.controller;

import com.example.hellospring.dto.OrderRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<Map<String, String>> createOrder(
            @Valid @RequestBody OrderRequest request) {
        return ResponseEntity.ok(Map.of("status", "注文を受け付けました"));
    }
}

リクエストとレスポンス例:

$ curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"productId": -1, "shippingAddress": "", "quantity": 0}'
{
  "status": 400,
  "error": "Validation Failed",
  "message": "入力値に誤りがあります",
  "path": "/api/orders",
  "timestamp": "2026-04-13T10:30:00.123456",
  "fieldErrors": [
    {
      "field": "productId",
      "message": "商品IDは正の整数を指定してください",
      "rejectedValue": -1
    },
    {
      "field": "shippingAddress",
      "message": "届け先住所は必須です",
      "rejectedValue": ""
    },
    {
      "field": "quantity",
      "message": "数量は1以上を指定してください",
      "rejectedValue": 0
    }
  ]
}

fieldErrors の順序はSpringの内部処理に依存するため、実際のレスポンスでは順番が異なる場合があります。テストで順序を検証する場合は、フィールド名でソートしてから比較してください。


まとめ

本記事で学んだ内容を整理します。

概念 用途 スコープ
@ExceptionHandler 例外をキャッチして処理 コントローラー内
@ControllerAdvice 画面向けグローバル例外ハンドラー アプリケーション全体(または指定パッケージ)
@RestControllerAdvice API向けグローバル例外ハンドラー アプリケーション全体(または指定パッケージ)
カスタム例外 ドメイン固有のエラーを表現 -
ErrorResponse DTO エラーレスポンスの統一フォーマット -
templates/error/*.html ステータスコード別カスタムエラーページ 画面表示

Servlet/JSPとの総合対比

比較項目 Servlet/JSP Spring Boot
エラーページ定義 web.xml<error-page> templates/error/ にHTMLを配置
例外ハンドリング try-catch の羅列、またはFilter @ExceptionHandler + @ControllerAdvice
レスポンス統一 自前で実装 ErrorResponse DTOで統一
バリデーションエラー if 文の羅列 Bean Validation + MethodArgumentNotValidException
ログ出力 e.printStackTrace() SLF4J(Logger
設定変更の容易さ web.xml の編集・再デプロイ application.properties の変更

次回予告

次回(第9回)は**テストの書き方(JUnit + MockMvc)**を学びます。今回作成した例外ハンドリングが正しく動作するかをテストで検証する方法を扱います。


Spring Boot入門シリーズ 全10回(予定):

  1. Servlet/JSPからの移行と環境構築
  2. コントローラとルーティング
  3. Thymeleafによるビュー
  4. フォーム処理とバリデーション
  5. Spring Data JPA(データベース連携)
  6. RESTful API設計
  7. Spring Security(認証・認可)
  8. 例外処理とエラーハンドリング(本記事)
  9. テストの書き方(JUnit + MockMvc)
  10. 総合演習:掲示板アプリをSpring Bootで再構築

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?