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

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

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

各Controllerで個別に例外処理を実装すると、開発者によってエラーレスポンスの形式が異なってしまいます。あるエンドポイントは{"error": "message"}を返し、別のエンドポイントは{"errorMessage": "message"}を返すといった不統一が生じ、クライアント側はエンドポイント毎に異なるエラーハンドリングを実装する必要が出てきます。同じような例外処理を繰り返し書くのはDRY原則にも反し、仕様変更時の修正漏れリスクも高まります。

例外処理を一箇所に集約して統一的なエラーレスポンスを返すことで、これらの問題をまとめて解決できます。

例外種別 × HTTPステータス × ハンドラー早見表

本記事で扱う主要パターンを一覧化すると次のようになります。詳細は各セクションで解説します。

例外種別推奨HTTPステータスハンドラーメソッド主な用途
MethodArgumentNotValidException400 Bad RequesthandleMethodArgumentNotValid (override)@Valid バリデーション失敗
IllegalArgumentException400 Bad Request@ExceptionHandler(IllegalArgumentException.class)引数不正
カスタム BusinessException400 Bad Request@ExceptionHandler(BusinessException.class)業務ルール違反
カスタム ResourceNotFoundException404 Not Found@ExceptionHandler(ResourceNotFoundException.class)リソース不存在
HttpRequestMethodNotSupportedException405 Method Not AllowedhandleHttpRequestMethodNotSupported (override)未対応HTTPメソッド
HttpMediaTypeNotSupportedException415 Unsupported Media TypeResponseEntityExceptionHandler 既定未対応Content-Type
その他 Exception500 Internal Server Error@ExceptionHandler(Exception.class)予期しないシステムエラー

この表をベースに、@RestControllerAdvice + ResponseEntityExceptionHandler 継承で全パターンを統一的に実装するのが本記事のゴールです。

@ControllerAdviceと@ExceptionHandlerの基本

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

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

@ExceptionHandlerには処理したい例外の型を指定します。@ExceptionHandler({Exception1.class, Exception2.class})のように配列で書けば、複数の例外タイプを1つのメソッドでまとめて処理することも可能です。戻り値を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(ステータスの説明)、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, status, error, message, 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を自動生成できて簡潔に書けます。このクラスを使うことで、全ての例外で統一されたJSON構造のエラーレスポンスを返せます。

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

@Validアノテーションによるバリデーションが失敗すると、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には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);
    }
}

Controllerからは次のように使います。

@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");
    }
}

ハンドラー側では、ResourceNotFoundExceptionには404 Not Found(リソースが存在しない)、BusinessExceptionには400 Bad Request(業務ルール違反)を割り当てます。

@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);
    }
}

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

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

@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);
    }
}

本番環境では、スタックトレースや内部的なエラーメッセージをクライアントに返してはいけません。攻撃者に内部実装の情報を与えてしまうためです。上記の例のように、クライアントには汎用的なメッセージのみを返し、詳細はサーバーログに出力します。Spring Bootのデフォルトエラーページについても、application-prod.propertiesserver.error.include-stacktrace=neverを設定しておくと、本番プロファイルでスタックトレースを確実に非表示にできます。

ResponseEntityExceptionHandlerの活用

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

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

継承しない場合、これらの標準例外にはSpring MVCデフォルトのハンドリングが適用され、独自の統一フォーマットでは返されません。handleMethodArgumentNotValidhandleHttpRequestMethodNotSupportedなどのprotectedメソッドをオーバーライドすることで、標準例外のレスポンスを自作のErrorResponse形式に差し替えられます。具体的な実装は後述の完全な実装例で示します。

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

例外の種類に応じて、適切なHTTPステータスコードを返すことが重要です。400 Bad Requestはバリデーションエラーや業務ルール違反などリクエスト側に問題がある場合、404 Not FoundはユーザーIDや商品IDなど指定されたリソースが存在しない場合に使います。500 Internal Server Errorは、データベース接続エラーや予期しない実行時エラーなど、サーバー側の問題で処理が完了できなかった場合です。

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

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

なお、Spring Securityを使った認証・認可エラー(401/403)のハンドリングは、別途専用の設定が必要になるため本記事では扱いません。

Spring Boot 3.x標準のProblemDetail(RFC 7807)を活用する

Spring Framework 6 / Spring Boot 3.0以降では、エラーレスポンスの標準フォーマットとして RFC 7807 Problem Details for HTTP APIs に準拠した ProblemDetail クラスが提供されています。type(エラー識別URI)、title(短い要約)、statusdetail(詳細メッセージ)、instance(発生箇所のURI)のフィールドを持ち、propertiesマップで任意の拡張プロパティも追加できます。

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setType(URI.create("https://springboot-123.example.com/errors/resource-not-found"));
        problem.setTitle("Resource Not Found");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

レスポンスは Content-Type: application/problem+json で返り、以下のような構造になります。

{
  "type": "https://springboot-123.example.com/errors/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "ID: 1 のユーザーが見つかりません",
  "instance": "/api/users/1",
  "timestamp": "2025-06-01T10:30:00Z"
}

Spring Boot 3.x の ResponseEntityExceptionHandler は、Spring MVC標準例外を ProblemDetail 形式で返すよう刷新されています。application.properties に以下を設定するだけで、MethodArgumentNotValidException などの組み込み例外がRFC 7807準拠で返却されます。

spring.mvc.problemdetails.enabled=true

使い分けの目安として、Spring Boot 3.xの新規プロジェクトでは ProblemDetail を第一候補にしましょう。標準フォーマットなのでクライアント側ライブラリとの相互運用性が高いです。既存クライアントとの互換性が必要な場合は、本記事の ErrorResponse パターンで既存スキーマを維持します。どちらを選んでも、@RestControllerAdvice + @ExceptionHandler の構造はそのまま流用できます。

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

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

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

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

@WebMvcTestでController層のみをテスト対象にし、@MockBeanでサービス層の振る舞いを差し替えます。ステータスコードだけでなく、errors配列の中身や特定フィールドのエラーメッセージまで検証しておくと、レスポンス仕様の変更にすぐ気づけて実践的です。

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

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

複数の@ExceptionHandlerが定義されている場合、より具体的な例外タイプが優先されます。IllegalArgumentExceptionExceptionの両方のハンドラーがあれば、IllegalArgumentExceptionの発生時は前者が呼ばれます。また、@ControllerAdviceクラス自体を複数定義した場合の順序は保証されないため、@Orderアノテーションで明示的に制御してください。数値が小さいほど優先度が高くなります。

非同期処理の例外は捕捉できない

@AsyncメソッドやCompletableFuture内で発生した例外は、リクエスト処理とは別のスレッドで発生するため、@ControllerAdviceでは捕捉できません。@Asyncの例外はAsyncUncaughtExceptionHandlerを実装して処理し、CompletableFuture.exceptionally().handle()で明示的に例外処理を行ってください。

機密情報の保護

エラーレスポンスに、データベース接続文字列、内部的なファイルパス、SQL文、スタックトレースなどの機密情報を含めないでください。攻撃者に悪用される可能性があります。エラーレスポンスはクライアントがエラーに対処するための情報、ログは開発者が問題を調査するための情報と目的が異なるため、別々に設計するのがポイントです。

国際化対応

エラーメッセージを多言語対応したい場合は、messages_ja.propertiesなどのプロパティファイルを用意し、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);
    }
}

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

まとめ

この記事では、Spring BootのREST APIで統一的なエラーレスポンスを返す方法を解説しました。@RestControllerAdvice@ExceptionHandlerで例外処理を一箇所に集約し、バリデーションエラーは400でフィールド毎の詳細を、業務例外は400/404を、システムエラーは500を返してログに詳細を残す、という役割分担が基本形です。ResponseEntityExceptionHandlerを継承すればSpring MVC標準例外も統一フォーマットにでき、Spring Boot 3.xならProblemDetailで業界標準フォーマットにも乗れます。

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

あわせて読みたい関連記事