Spring BootのREST APIで統一的なエラーレスポンスを返す方法 - @ControllerAdviceと@ExceptionHandlerの使い方


Spring BootでREST APIを開発する際、バリデーションエラーや業務エラー、システムエラーなど様々な例外が発生します。これらの例外を各Controllerで個別に処理していると、エラーレスポンスの形式が統一されず、クライアント側のエラーハンドリングが複雑になってしまいます。

この記事では、@ControllerAdvice@ExceptionHandlerを使って、REST APIで発生する例外を統一的なJSON形式で返す実装方法を解説します。バリデーションエラー、業務エラー、システムエラーそれぞれに適切なHTTPステータスコードを設定し、クライアントが扱いやすいエラーレスポンスを返す設計パターンを、具体的なコード例と共に紹介します。

REST APIにおける例外処理の課題

REST APIで例外処理を適切に行わないと、以下のような問題が発生します。

Controller毎に異なるエラーレスポンス

各Controllerで個別に例外処理を実装すると、開発者によってエラーレスポンスの形式が異なってしまいます。あるエンドポイントでは{"error": "message"}を返し、別のエンドポイントでは{"errorMessage": "message"}を返すといった不統一が生じます。

クライアント側の複雑化

エラーレスポンスの形式が統一されていないと、クライアント側は各エンドポイント毎に異なるエラーハンドリングロジックを実装する必要があります。これは保守性を著しく低下させます。

コードの重複

同じような例外処理を各Controllerで繰り返し実装することになり、DRY原則に反します。仕様変更時には全てのControllerを修正する必要があり、修正漏れのリスクも高まります。

統一的なエラーレスポンスのメリット

統一的なエラーレスポンス設計を導入することで、以下のメリットが得られます。

  • クライアント側で一貫したエラーハンドリングが可能
  • コードの保守性向上と重複排除
  • エラーレスポンス仕様の一元管理
  • テストコードの簡素化

@ControllerAdviceと@ExceptionHandlerの基本

Spring Bootでは、@ControllerAdvice@ExceptionHandlerを使うことで、アプリケーション全体で統一的な例外処理を実装できます。

@ControllerAdviceの役割

@ControllerAdviceは、複数のControllerに横断的に適用されるアドバイス(共通処理)を定義するためのアノテーションです。このアノテーションを付与したクラス内で例外処理を定義すると、アプリケーション内の全てのControllerで発生した例外を一箇所で処理できます。

@RestControllerAdviceとの違い

REST APIを開発する場合、@RestControllerAdviceを使うと便利です。@RestControllerAdvice@ControllerAdvice@ResponseBodyを組み合わせたアノテーションで、戻り値が自動的にJSONにシリアライズされます。@ControllerAdviceを使う場合は、各ハンドラーメソッドに@ResponseBodyを個別に付ける必要があります。

@ExceptionHandlerによる例外ハンドリング

@ExceptionHandlerは、特定の例外タイプを処理するメソッドに付与するアノテーションです。例外の型を指定することで、その型の例外が発生した際に自動的にそのメソッドが呼び出されます。

ResponseEntityによるレスポンス制御

ResponseEntityを使うことで、HTTPステータスコードやヘッダー、ボディを柔軟に制御できます。例外ハンドラーメソッドの戻り値としてResponseEntityを返すことで、例外の種類に応じた適切なHTTPレスポンスを構築できます。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

この例では、IllegalArgumentExceptionが発生した際に、400 Bad Requestのステータスコードと共に統一フォーマットのエラーレスポンスを返します。

統一的なエラーレスポンスの設計

クライアントが扱いやすいエラーレスポンスには、以下の情報を含めることが推奨されます。

基本情報項目

  • timestamp: エラー発生日時
  • status: HTTPステータスコード
  • error: HTTPステータスの説明(Bad Request、Not Found等)
  • message: エラーの詳細メッセージ
  • path: エラーが発生したリクエストパス

バリデーションエラー用の拡張フィールド

バリデーションエラーの場合は、どのフィールドでどのようなエラーが発生したかを詳細に返す必要があります。

  • errors: フィールド毎のエラー詳細リスト

エラーレスポンスクラスの実装

public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private List<FieldError> errors;

    public ErrorResponse(LocalDateTime timestamp, int status, String error, 
                        String message, String path) {
        this.timestamp = timestamp;
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
    }

    public ErrorResponse(LocalDateTime timestamp, int status, String error, 
                        String message, String path, List<FieldError> errors) {
        this.timestamp = timestamp;
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
        this.errors = errors;
    }

    // Getter実装(JSONシリアライゼーションに必須)
    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public String getMessage() { return message; }
    public String getPath() { return path; }
    public List<FieldError> getErrors() { return errors; }

    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String message;

        public FieldError(String field, Object rejectedValue, String message) {
            this.field = field;
            this.rejectedValue = rejectedValue;
            this.message = message;
        }

        // Getter実装
        public String getField() { return field; }
        public Object getRejectedValue() { return rejectedValue; }
        public String getMessage() { return message; }
    }
}

補足: 実務ではLombokの@Getter@Dataアノテーションを使うと、Getterメソッドを自動生成できて簡潔に書けます。Lombokを使う場合は、build.gradleまたはpom.xmlに依存関係を追加してください。

このクラスを使うことで、全ての例外で統一されたJSON構造のエラーレスポンスを返すことができます。

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

Spring Bootで@Validアノテーションを使ったバリデーションを行うと、検証に失敗した際にMethodArgumentNotValidExceptionが発生します。

MethodArgumentNotValidExceptionの発生

例えば、以下のようなDTOとControllerがある場合を考えます。

public class UserCreateRequest {
    @NotBlank(message = "ユーザー名は必須です")
    @Size(min = 3, max = 20, message = "ユーザー名は3文字以上20文字以内で入力してください")
    private String username;

    @NotBlank(message = "メールアドレスは必須です")
    @Email(message = "メールアドレスの形式が正しくありません")
    private String email;

    @NotNull(message = "年齢は必須です")
    @Min(value = 0, message = "年齢は0以上で入力してください")
    @Max(value = 150, message = "年齢は150以下で入力してください")
    private Integer age;

    // Getter/Setterは省略
}

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

    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody UserCreateRequest request) {
        // ユーザー作成処理
        return ResponseEntity.ok("User created successfully");
    }
}

バリデーションエラーが発生すると、MethodArgumentNotValidExceptionがスローされます。

バリデーションエラーの詳細を返す

MethodArgumentNotValidExceptionにはBindingResultが含まれており、そこからフィールド毎のエラー情報を取得できます。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            "入力値の検証に失敗しました",
            request.getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

これにより、以下のようなエラーレスポンスが返されます。

{
  "timestamp": "2025-01-15T10:30:00",
  "status": 400,
  "error": "Bad Request",
  "message": "入力値の検証に失敗しました",
  "path": "/api/users",
  "errors": [
    {
      "field": "username",
      "rejectedValue": "ab",
      "message": "ユーザー名は3文字以上20文字以内で入力してください"
    },
    {
      "field": "email",
      "rejectedValue": "invalid-email",
      "message": "メールアドレスの形式が正しくありません"
    }
  ]
}

バリデーションの詳細については、Spring Bootの@Validアノテーションでバリデーションを実装する方法Spring Bootの@Validatedアノテーションでグループ化とメソッドレベルバリデーションを実装する方法も参照してください。

カスタム業務例外のハンドリング

アプリケーション固有の業務エラーを表現するために、カスタム例外クラスを作成することが推奨されます。

カスタム例外クラスの設計

業務例外はRuntimeExceptionを継承して作成します。チェック例外にすると呼び出し側で常にtry-catchが必要になり、コードが煩雑になるためです。

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}

カスタム例外の使用例

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

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException(
                "ID: " + id + " のユーザーが見つかりません"));
        return ResponseEntity.ok(user);
    }

    @PostMapping("/{id}/activate")
    public ResponseEntity<String> activateUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException(
                "ID: " + id + " のユーザーが見つかりません"));
        
        if (user.isActive()) {
            throw new BusinessException("ユーザーは既にアクティブです");
        }
        
        userService.activate(user);
        return ResponseEntity.ok("User activated successfully");
    }
}

カスタム例外のハンドリング実装

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

HTTPステータスコードの使い分け

  • ResourceNotFoundException: 404 Not Foundを返す(リソースが存在しない)
  • BusinessException: 400 Bad Requestを返す(業務ルール違反)

システムエラーと予期しない例外のハンドリング

予期しない例外やシステムエラーに対しても、適切なエラーレスポンスを返す必要があります。

包括的な例外ハンドリング

Exceptionクラスを捕捉することで、個別にハンドリングされなかった全ての例外を処理できます。

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        
        // システムエラーは詳細をログに出力
        logger.error("予期しないエラーが発生しました: {}", ex.getMessage(), ex);
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "サーバー内部エラーが発生しました",
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

セキュリティ上の配慮

本番環境では、例外の詳細情報(スタックトレースや内部的なエラーメッセージ)をクライアントに返すべきではありません。攻撃者に内部実装の情報を与えてしまう可能性があるためです。

上記の例では、クライアントには汎用的なメッセージのみを返し、詳細はサーバーログに出力しています。開発環境では詳細情報を返したい場合は、環境変数やプロファイルで切り替える実装も検討できます。

ResponseEntityExceptionHandlerの活用

Spring MVCにはResponseEntityExceptionHandlerという基底クラスが用意されており、これを継承することで標準的な例外を統一フォーマットで処理できます。

ResponseEntityExceptionHandlerの役割

ResponseEntityExceptionHandlerは、Spring MVCが提供する以下のような標準例外に対するデフォルトハンドリングを提供します。

  • HttpRequestMethodNotSupportedException: サポートされていないHTTPメソッド
  • HttpMediaTypeNotSupportedException: サポートされていないContent-Type
  • MissingServletRequestParameterException: 必須リクエストパラメータの欠落
  • その他多数のSpring MVC標準例外

継承による拡張

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "入力値の検証に失敗しました",
            servletWebRequest.getRequest().getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    @Override
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "HTTPメソッド " + ex.getMethod() + " はサポートされていません",
            servletWebRequest.getRequest().getRequestURI()
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // その他のカスタム例外ハンドラー
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

ResponseEntityExceptionHandlerを継承することで、Spring MVCの標準例外も統一フォーマットで返すことができ、より包括的なエラーハンドリングが実現できます。

HTTPステータスコードの使い分け指針

例外の種類に応じて、適切なHTTPステータスコードを返すことが重要です。

400 Bad Request

以下の場合に使用します。

  • バリデーションエラー(必須項目の欠落、形式エラー等)
  • 業務ルール違反(既に処理済み、権限不足等)
  • リクエストパラメータの不正

クライアント側のリクエストに問題がある場合に使用します。

404 Not Found

指定されたリソースが存在しない場合に使用します。

  • ユーザーID、商品ID等のリソースが見つからない
  • 存在しないエンドポイントへのアクセス

500 Internal Server Error

以下の場合に使用します。

  • データベース接続エラー
  • 予期しない実行時エラー
  • 外部API呼び出しの失敗

サーバー側の問題により処理が完了できなかった場合に使用します。

その他のステータスコード

必要に応じて以下のステータスコードも使用できます。

  • 401 Unauthorized: 認証が必要
  • 403 Forbidden: 認証済みだが権限不足
  • 409 Conflict: リソースの競合(楽観的ロックエラー等)
  • 503 Service Unavailable: サービス一時停止中

※注意: 認証・認可エラー(401/403)の詳細なハンドリングは本記事の範囲外です。Spring Securityを使った認証・認可機構の例外処理は、別途専用の設定が必要になります。

実装例:完全なグローバル例外ハンドラー

ここまでの内容を統合した、完全な例外ハンドラークラスの実装例を示します。

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // Lombokの@Slf4jを使用してロガーを自動生成
    // Lombokを使わない場合: private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // バリデーションエラー
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "入力値の検証に失敗しました",
            servletWebRequest.getRequest().getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // HTTPメソッド不正
    @Override
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "HTTPメソッド " + ex.getMethod() + " はサポートされていません",
            servletWebRequest.getRequest().getRequestURI()
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // リソース不存在
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    // 業務例外
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // 不正な引数
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // その他全ての例外
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        
        log.error("予期しないエラーが発生しました: {}", ex.getMessage(), ex);
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "サーバー内部エラーが発生しました",
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

この実装により、アプリケーション全体で統一的なエラーレスポンスを返すことができます。

例外ハンドリングのテスト方法

例外ハンドリングが正しく動作することを確認するために、テストコードを書くことが重要です。

MockMvcを使ったテスト

@WebMvcTest(UserController.class)
class GlobalExceptionHandlerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private UserService userService;

    @Test
    void バリデーションエラーが発生した場合_400とエラー詳細が返ること() throws Exception {
        UserCreateRequest request = new UserCreateRequest();
        request.setUsername("ab"); // 3文字未満でエラー
        request.setEmail("invalid-email"); // メールアドレス形式エラー
        request.setAge(200); // 上限超過

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.status").value(400))
            .andExpect(jsonPath("$.message").value("入力値の検証に失敗しました"))
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors[*].field", 
                containsInAnyOrder("username", "email", "age")))
            .andExpect(jsonPath("$.errors[?(@.field=='username')].message")
                .value("ユーザー名は3文字以上20文字以内で入力してください"));
    }

    @Test
    void 存在しないリソースにアクセスした場合_404が返ること() throws Exception {
        when(userService.findById(99999L))
            .thenReturn(Optional.empty());

        mockMvc.perform(get("/api/users/99999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.message", 
                containsString("ユーザーが見つかりません")));
    }

    @Test
    void 業務エラーが発生した場合_400とエラーメッセージが返ること() throws Exception {
        User activeUser = new User();
        activeUser.setId(1L);
        activeUser.setActive(true);
        when(userService.findById(1L))
            .thenReturn(Optional.of(activeUser));

        mockMvc.perform(post("/api/users/1/activate"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.status").value(400))
            .andExpect(jsonPath("$.message", 
                containsString("既にアクティブです")));
    }

    @Test
    void サポートされていないHTTPメソッドの場合_405が返ること() throws Exception {
        mockMvc.perform(put("/api/users"))
            .andExpect(status().isMethodNotAllowed())
            .andExpect(jsonPath("$.status").value(405))
            .andExpect(jsonPath("$.message", 
                containsString("サポートされていません")));
    }
}

テストのポイント

  • @WebMvcTestを使ってController層のみをテスト対象にする
  • @MockBeanでサービス層をモック化し、テストケースに応じた振る舞いを設定
  • HTTPステータスコードが正しいことを検証
  • エラーレスポンスのJSON構造が期待通りであることを検証
  • バリデーションエラーの場合、errors配列に適切なフィールドエラーが含まれることを検証
  • 特定フィールドのエラーメッセージ内容まで詳細に検証することで、より実践的なテストになる

実装時の注意点とベストプラクティス

例外ハンドラーの優先順位

複数の@ExceptionHandlerが定義されている場合、より具体的な例外タイプが優先されます。例えば、IllegalArgumentExceptionExceptionの両方のハンドラーがある場合、IllegalArgumentExceptionが発生すると前者が優先されます。

ただし、継承関係にある例外の処理順序には注意が必要です。明示的に優先順位を制御したい場合は、@Orderアノテーションを使用できます。

機密情報の保護

エラーレスポンスに以下のような機密情報を含めないようにしてください。

  • データベース接続文字列
  • 内部的なファイルパス
  • スタックトレース(本番環境)
  • SQL文やクエリの詳細
  • 内部的なシステム構成情報

これらの情報は攻撃者に悪用される可能性があります。詳細情報はサーバーログにのみ出力し、クライアントには汎用的なメッセージを返すべきです。

ログ出力との使い分け

  • エラーレスポンス: クライアントがエラーを理解し、適切に対処するための情報
  • ログ出力: 開発者が問題を調査・解決するための詳細情報

この2つは目的が異なるため、別々に設計すべきです。特にシステムエラーの場合は、詳細なスタックトレースをログに出力しつつ、クライアントには簡潔なメッセージのみを返します。

国際化対応

エラーメッセージを多言語対応したい場合は、Spring BootのMessageSourceを活用できます。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @Autowired
    private MessageSource messageSource;

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, 
            HttpServletRequest request,
            Locale locale) {
        
        String message = messageSource.getMessage(
            "error.resource.notfound", 
            new Object[]{ex.getMessage()}, 
            locale
        );
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            message,
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

これにより、Accept-Languageヘッダーに応じた言語のエラーメッセージを返すことができます。Localeパラメータは、Spring MVCが自動的にリクエストヘッダーから解決して引数に渡してくれます。

まとめ

この記事では、Spring BootのREST APIで統一的なエラーレスポンスを返す方法を解説しました。

  • @ControllerAdvice@ExceptionHandlerを使うことで、アプリケーション全体で統一的な例外処理が可能
  • REST APIでは@RestControllerAdviceを使うと、自動的にJSON形式でレスポンスが返される
  • エラーレスポンスにはtimestampstatuserrormessagepathなどの基本情報を含める
  • バリデーションエラーはMethodArgumentNotValidExceptionを捕捉し、フィールド毎の詳細を返す
  • カスタム業務例外を作成し、適切なHTTPステータスコード(400/404等)で返す
  • システムエラーは500を返し、詳細はログにのみ出力する
  • ResponseEntityExceptionHandlerを継承することで、Spring MVCの標準例外も統一フォーマットで処理できる
  • 例外の種類に応じて適切なHTTPステータスコード(400/404/500等)を使い分ける
  • MockMvcと@MockBeanを使ってエラーハンドリングのテストを実装する

統一的なエラーレスポンス設計により、クライアント側の実装が簡素化され、API全体の保守性が向上します。この記事で紹介したパターンを基に、プロジェクトの要件に合わせてカスタマイズしてください。

よくある質問

@ControllerAdviceと@RestControllerAdviceの違いは何ですか?

@RestControllerAdvice@ControllerAdvice@ResponseBodyを組み合わせたアノテーションです。REST APIの場合は@RestControllerAdviceを使うことで、戻り値が自動的にJSONにシリアライズされます。@ControllerAdviceを使う場合は、各ハンドラーメソッドに@ResponseBodyを付ける必要があります。

複数の@ControllerAdviceクラスがある場合、優先順位はどうなりますか?

@Orderアノテーションで優先順位を制御できます。数値が小さいほど優先度が高くなります。例えば@Order(1)@Order(2)より先に評価されます。明示的に順序を指定しない場合、順序は保証されません。

@ExceptionHandlerで複数の例外タイプを同時に処理できますか?

可能です。@ExceptionHandler({Exception1.class, Exception2.class})のように配列で指定することで、複数の例外タイプを1つのメソッドで処理できます。ただし、それぞれに異なる処理が必要な場合は、メソッドを分けるべきです。

ResponseEntityExceptionHandlerを継承しない場合、どうなりますか?

Spring MVCの標準例外(例:HttpRequestMethodNotSupportedException)はデフォルトのハンドリングが適用され、統一フォーマットで返されません。独自のエラーレスポンス形式で統一したい場合は、ResponseEntityExceptionHandlerを継承するか、各標準例外に対して個別に@ExceptionHandlerを定義する必要があります。

非同期処理やCompletableFutureで発生した例外も@ControllerAdviceで捕捉できますか?

非同期処理の場合、例外が発生するスレッドが異なるため、@ControllerAdviceでは捕捉できない場合があります。@Asyncメソッド内の例外は、AsyncUncaughtExceptionHandlerを実装して処理する必要があります。CompletableFutureの場合は、.exceptionally().handle()で明示的に例外処理を行うべきです。

本番環境でスタックトレースを隠すにはどうすればよいですか?

環境変数やSpring Profilesを使って、本番環境では詳細情報を返さないように制御します。例えば、application-prod.propertiesserver.error.include-stacktrace=neverを設定することで、デフォルトエラーページのスタックトレースを非表示にできます。カスタムエラーハンドラーでも、環境に応じてメッセージ内容を切り替える実装を推奨します。

エラーメッセージを国際化(多言語対応)するにはどうすればよいですか?

Spring BootのMessageSourceを使用します。messages.properties(デフォルト)、messages_ja.properties(日本語)、messages_en.properties(英語)などのプロパティファイルを作成し、MessageSource経由でロケールに応じたメッセージを取得します。例外ハンドラーメソッドでLocaleパラメータを受け取り、messageSource.getMessage()でメッセージを解決することで、多言語対応が可能です。