【入門】Spring Bootの例外処理|@ControllerAdvice / @ExceptionHandlerの使い方

Spring BootでAPIやWebアプリを作り始めると、早い段階でぶつかりやすいのが例外処理です。

存在しないデータを取得しようとしたときや、入力値が不正だったとき、アプリはそのままだとエラーになります。
このとき、ただエラーにするだけでは使いにくいですし、利用者から見ても何が起きたのかわかりにくくなります。

最初のうちは、try-catch をその場その場で書けばよさそうに見えます。
ただ、処理が増えてくると、同じようなエラー対応があちこちに散らばってしまい、可読性が落ちてしまいます。

Spring Bootでは、こうした例外処理をまとめて扱う仕組みがあります。
それが @ExceptionHandler@ControllerAdvice です。

この記事では、Spring Bootの例外処理の基本を整理しながら、
@ExceptionHandler / @ControllerAdvice の使い方を初学者向けにわかりやすく説明します。

目次

Spring Bootの例外処理の基本

例外処理が必要になる理由

例外処理が必要になるのは、アプリでは想定どおりに進まないケースが必ずあるからです。

たとえば、次のような場面です。

  • 指定したIDのデータが存在しない
  • 入力値の形式が正しくない
  • 必須項目が送られていない
  • DBアクセス中にエラーが起きる
  • 想定していない不具合が発生する

こうしたときに何も準備していないと、利用者には不親切なエラー画面や、
わかりにくいレスポンスが返ることがあります。

開発者にとっても、どこで何が起きたのか追いづらくなります。

そのため、例外処理では「エラーを止める」だけでなく、どう返すか / どう記録するか を考える必要があります。

try-catchを各所に書く問題

最初に思いつきやすいのは、各メソッドで try-catch を書く方法です。

@GetMapping("/users/{id}")
public ResponseEntity<String> getUser(@PathVariable Long id) {
    try {
        User user = userService.findById(id);
        return ResponseEntity.ok(user.getName());
    } catch (Exception e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body("エラーが発生しました");
    }
}

一見これでも動きます。
ただ、実際の開発ではあまり扱いやすい形ではありません。

理由は次の通りです。

  • 同じような catch が何度も出てくる
  • エラー時のレスポンスがばらつきやすい
  • 本来の処理より例外対応が目立ってしまう
  • 修正が必要なときに複数箇所を直すことになる

try-catch を毎回書く方法は、小さいサンプルならよくても、規模が大きくなるにつれてつらくなりやすいです。

共通化して扱う考え方

そこでSpring Bootでは、例外処理を共通化してまとめて扱う考え方がよく使われます。

流れとしては、次のようなイメージです。

  1. ControllerやServiceで例外が発生する
  2. 例外を共通の場所で受け取る
  3. 例外の種類に応じてレスポンスを返す

こうすると、各Controllerでは本来の処理だけを書きやすくなります。
エラー時のレスポンスも統一しやすくなります。

この共通化を支えるのが、@ExceptionHandler@ControllerAdvice です。

@ExceptionHandler / @ControllerAdviceの使い方

@ExceptionHandlerで例外取得

@ExceptionHandler は、特定の例外が発生したときに呼ばれるメソッドを定義するためのアノテーションです。

たとえば、ユーザーが見つからなかったときに UserNotFoundException を投げるとします。

public class UserNotFoundException extends RuntimeException {

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

Service側では、データが見つからなければ例外を投げます。

@Service
public class UserService {

    public User findById(Long id) {
        // サンプル用。実際にはDBから取得する想定
        if (id != 1L) {
            throw new UserNotFoundException("ユーザーが見つかりません。");
        }

        User user = new User();
        user.setId(1L);
        user.setName("mikan");
        return user;
    }
}

Controllerでは、無理に try-catch を書かず、そのままServiceを呼び出します。

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

そして、例外を受け取るメソッドを用意します。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<String> handleUserNotFound(UserNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(e.getMessage());
    }
}

このコードでは、UserNotFoundException が発生したときに handleUserNotFound が呼ばれます。
そして、HTTPステータス 404 Not Found とメッセージを返しています。

ここで大事なのは、例外が起きた後の処理を別の場所に分けられることです。
Controllerは本来の処理に集中でき、例外時の返し方は共通の場所で管理できます。

@ControllerAdviceで共通化

@ControllerAdvice は、複数のControllerに共通する処理をまとめるためのアノテーションです。
例外処理で使うと、アプリ全体の例外対応を1か所に集めやすくなります。

REST APIでJSONを返すことが前提なら、@RestControllerAdvice を使うのが便利です。
これは @ControllerAdvice@ResponseBody の役割をまとめたものです。

先ほどの例外処理クラスをもう一度見ると、こうなっています。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<String> handleUserNotFound(UserNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(e.getMessage());
    }
}

このクラスがあることで、特定のControllerだけでなく、
アプリ全体で発生した UserNotFoundException をまとめて扱いやすくなります。

役割を整理すると、次のようになります。

@ExceptionHandlerでは、どの例外を受け取るかを決める

@ControllerAdvice / @RestControllerAdviceでは、その例外処理を共通化する範囲を広げる

最初は名前が似ていて混乱しやすいですが、例外を受け取るのが @ExceptionHandler
それを全体で使いやすくするのが @ControllerAdvice と覚えるとわかりやすいです。

レスポンス統一

実務では、エラー時のレスポンス形式をそろえることがよくあります。
画面側やAPI利用者にとって、その方が扱いやすいからです。

たとえば、次のようなエラーレスポンス用クラスを用意します。

public class ErrorResponse {

    private String code;
    private String message;

    public ErrorResponse(String code, String message) {
        this.code = code;
        this.message = message;
    }

    public String getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

そして、例外処理側でこの形式を返します。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        ErrorResponse response = new ErrorResponse(
                "USER_NOT_FOUND",
                e.getMessage()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(response);
    }
}

このようにしておくと、利用者はエラー時の形式を予測しやすくなります。
また、Controllerごとにバラバラの返し方になるのも防ぎやすいです。

初学者のうちは「文字列を返せばいいのでは」と思うかもしれません。
もちろん学習段階ではそれでも構いません。
ただ、実務ではあとから扱いやすいように、エラーの形式をそろえる意識を持っておくとかなり役立ちます。

実務で意識したいポイント

ここでは、使い方だけで終わらず、実務でつまずきやすい点も整理します。
@ExceptionHandler / @ControllerAdvice を使えても、考え方が曖昧だと運用しにくくなりやすいです。

想定内の例外と想定外の例外を分ける

例外はすべて同じように扱えばよいわけではありません。
実務では、想定内の例外想定外の例外 を分けて考えることが大切です。

想定内の例外は、たとえば次のようなものです。

  • データが見つからない
  • 入力値が不正
  • 権限がない
  • 必須項目が不足している

こうしたものは、利用者の操作や入力によって起こりうるため、あらかじめ「こう返す」と決めやすいです。

一方で想定外の例外は、たとえば次のようなものです。

  • NullPointerException
  • DB接続障害
  • 想定していない不具合
  • ライブラリ内部の異常

こうしたものまで細かく利用者向けに説明しようとすると、かえって危険です。
そのため、想定外の例外は詳細をそのまま返さず、共通のメッセージで返すことが多いです。

たとえば、最後の保険として広めの例外も受け取れます。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        ErrorResponse response = new ErrorResponse(
                "USER_NOT_FOUND",
                e.getMessage()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        ErrorResponse response = new ErrorResponse(
                "INTERNAL_SERVER_ERROR",
                "予期しないエラーが発生しました。"
        );

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(response);
    }
}

この形にしておくと、想定外の例外でアプリの返し方がばらつくのを防ぎやすくなります。

エラーメッセージをそのまま返さない

初学者のうちは、e.getMessage() をそのまま返したくなりがちです。
学習中の動作確認としてはわかりやすいですが、実務では注意が必要です。

理由は、内部情報が利用者に見えてしまうことがあるからです。

たとえば、次のような情報はそのまま返さない方が安全です。

  • SQL文
  • テーブル名 / カラム名
  • スタックトレース
  • フレームワーク内部の詳細
  • サーバー構成が推測できる情報

利用者に必要なのは、「何が起きたかを理解できる最低限の情報」です。
開発者向けの詳しい情報は、レスポンスではなくログで確認できるようにする方が安全です。

つまり、利用者向けメッセージ開発者向け情報 は分けて考えるのが基本です。

ログ出力と役割を分けて考える

例外処理では、レスポンスを返すことだけでなく、ログをどう残すかも大切です。

ただし、ここで気をつけたいのは、何でもかんでも同じレベルでログに出さないことです。

たとえば、存在しないIDの指定のような想定内エラーは、毎回大きくエラーログを出す必要がない場合もあります。
一方で、想定外の例外は原因調査のためにしっかり記録したいです。

  • 想定内の例外は利用者にわかりやすく返すことを重視
  • 想定外の例外は利用者には共通メッセージを返し、詳細はログに残す

この役割分担ができると、例外処理がかなり実務的になります。

なお、ログ設計そのものは別で考えるべきテーマなので、この記事では深追いしません。
ただ、例外処理とログはセットで考えることが多い、という意識は持っておくとよいです。

まとめ

Spring Bootの例外処理では、try-catch を各所に書くのではなく、共通化して扱うのが基本です。

@ExceptionHandler は、特定の例外を受け取るためのアノテーションです。
@ControllerAdvice@RestControllerAdvice は、例外処理を複数のControllerで共通化するために使います。

この仕組みを使うことで、Controllerは本来の処理に集中しやすくなり、
エラー時のレスポンスも統一しやすくなります。

また、実務では単に例外を受け取るだけでなく、想定内か想定外かを分けること /
利用者向けメッセージと内部情報を分けること / ログとの役割を整理すること が大切です。

最初は少し難しく感じても、役割を分けて考えると整理しやすいです。

まずは @ExceptionHandler で例外を受け取り、
@RestControllerAdvice で共通化する流れを押さえるところから始めると理解しやすいです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次