404 Not Found

404 Not Found


nginx

グローバル例外処理

グローバル例外処理によりAPIエラーレスポンスの形式が標準化されます。クライアントは多様なエラー形式に対処する必要がなくなり, デバッグと統合がより効率的になります。

1. 学ぶ内容


2. フロントエンド開発者の実話

(1) ペインポイント:エラーレスポンス形式の不統一

Bobはフロントエンド開発者で, OrderFlow APIとの統合中に問題に遭遇しました。一部のエンドポイントは404でプレーンテキスト"Not Found"を返し, 一部は500でHTMLエラーページを返し, 一部はビジネス例外{"error": "xxx"}を返し, 別のものは{"message": "xxx"}を返していました。各エンドポイントで異なるエラー処理ロジックを書く必要があり, コード全体にtry-catchブロックが散在し, 見落としがちでページが真っ白になることもありました。

(2) @RestControllerAdviceによる解決策

統一例外ハンドラにより, すべてのエラーレスポンスが一貫した形式に従うようにします。

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage(), Instant.now()));
    }
}

(3) 成果

Aliceがグローバル例外処理を実装した後, すべてのエラーレスポンス形式が{code, message, timestamp, details}に標準化され, Bobのフロントエンドは単一の統一エラー処理関数だけで済むようになり, 統合効率が5倍に向上しました。


3. @RestControllerAdviceの仕組み

(1) 例外処理のディスパッチプロセス

100%
graph TD
    A[Controllerが例外をスロー] --> B{Spring DispatcherServlet}
    B --> C["@RestControllerAdvice<br/>@ExceptionHandlerをスキャン"]
    C --> D{例外型がマッチ?}
    D -->|はい| E["@ExceptionHandlerを実行<br/>ErrorResponseを返却"]
    D -->|いいえ| F["Springデフォルト<br/>エラーレスポンス"]
    E --> G["クライアントが受信<br/>一貫したJSON"]
    F --> H["クライアントが受信<br/>不統一なレスポンス"]
アノテーション 用途 配置場所
@RestControllerAdvice グローバル例外処理クラス クラスに付与
@ExceptionHandler 指定した例外型の処理 メソッドに付与
@ResponseStatus レスポンスステータスコードの指定 例外クラスまたはメソッドに付与

(1) ▶ サンプル:基本的なグローバル例外ハンドラ

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            "RESOURCE_NOT_FOUND", ex.getMessage(), Instant.now(), null);
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(
            BusinessException ex) {
        ErrorResponse error = new ErrorResponse(
            "BUSINESS_ERROR", ex.getMessage(), Instant.now(), null);
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        ErrorResponse error = new ErrorResponse(
            "INTERNAL_ERROR", "予期しないエラーが発生しました", Instant.now(), null);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

出力:

TEXT
// 実行成功

4. カスタムビジネス例外体系

(1) 例外クラスの階層

100%
graph TD
    A[RuntimeException] --> B[BusinessException<br/>基底ビジネス例外]
    B --> C[ResourceNotFoundException<br/>404 Not Found]
    B --> D[InsufficientStockException<br/>422 ビジネスルール違反]
    B --> E[OrderStateException<br/>422 無効な状態遷移]
    A --> F[ValidationException<br/>400 Bad Request]

(1) ▶ サンプル:カスタム例外クラス

JAVA
// 基底ビジネス例外
public class BusinessException extends RuntimeException {
    private final String errorCode;

    public BusinessException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public String getErrorCode() { return errorCode; }
}

// リソース未検出
public class ResourceNotFoundException extends BusinessException {
    public ResourceNotFoundException(String resource, Long id) {
        super("RESOURCE_NOT_FOUND",
            resource + " not found with id: " + id);
    }
}

// 在庫不足
public class InsufficientStockException extends BusinessException {
    public InsufficientStockException(Long productId, int available, int requested) {
        super("INSUFFICIENT_STOCK",
            String.format("Product %d: available=%d, requested=%d",
                productId, available, requested));
    }
}

// 無効な注文状態
public class OrderStateException extends BusinessException {
    public OrderStateException(Long orderId, String current, String target) {
        super("INVALID_ORDER_STATE",
            String.format("Order %d: cannot transition from %s to %s",
                orderId, current, target));
    }
}

出力:

TEXT
// 実行成功

5. エラーレスポンスDTO設計

(1) ErrorResponse設計の原則

(1) ▶ サンプル:ErrorResponseとValidationErrorResponse

JAVA
public record ErrorResponse(
    String code,
    String message,
    Instant timestamp,
    String traceId,
    List<FieldError> details
) {
    public record FieldError(
        String field,
        String message,
        Object rejectedValue
    ) {}
}

// 便利なファクトリメソッド
public class ErrorResponse {
    public static ErrorResponse of(String code, String message, String traceId) {
        return new ErrorResponse(code, message, Instant.now(), traceId, null);
    }

    public static ErrorResponse withDetails(String code, String message,
            String traceId, List<FieldError> details) {
        return new ErrorResponse(code, message, Instant.now(), traceId, details);
    }
}

出力:

TEXT
// 実行成功
フィールド 説明
code String エラーコード (機械可読)
message String エラーメッセージ (人間可読)
timestamp Instant 発生時刻
traceId String トレースID (ログと関連付け)
details List フィールドレベルのエラー詳細 (検証エラー用)

6. 検証エラーの特別な処理

(1) ▶ サンプル:MethodArgumentNotValidExceptionの処理

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {
        String traceId = generateTraceId();

        List<ErrorResponse.FieldError> details = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(fe -> new ErrorResponse.FieldError(
                fe.getField(),
                fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "無効な値です",
                fe.getRejectedValue()
            ))
            .toList();

        ErrorResponse error = ErrorResponse.withDetails(
            "VALIDATION_ERROR",
            "入力検証に失敗しました",
            traceId,
            details
        );

        log.warn("検証失敗 [traceId={}]: {}", traceId, details);
        return ResponseEntity.badRequest().body(error);
    }
}

出力:

TEXT
// 実行成功
💻 出力:

JSON
{
  "code": "VALIDATION_ERROR",
  "message": "入力検証に失敗しました",
  "timestamp": "2024-01-15T10:00:00Z",
  "traceId": "abc-123-def",
  "details": [
    {"field": "quantity", "message": "数量は1以上である必要があります", "rejectedValue": 0},
    {"field": "customerEmail", "message": "メールアドレスの形式が不正です", "rejectedValue": "abc"}
  ]
}

7. 例外ログとトレースID

(1) ▶ サンプル:トレースID付きの完全な例外ハンドラ

JAVA
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request) {
        String traceId = generateTraceId();
        log.warn("リソース未検出 [traceId={}]: {}", traceId, ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), traceId));
    }

    @ExceptionHandler(InsufficientStockException.class)
    public ResponseEntity<ErrorResponse> handleInsufficientStock(
            InsufficientStockException ex,
            HttpServletRequest request) {
        String traceId = generateTraceId();
        log.warn("在庫不足 [traceId={}]: {}", traceId, ex.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), traceId));
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(
            BusinessException ex,
            HttpServletRequest request) {
        String traceId = generateTraceId();
        log.warn("ビジネスエラー [traceId={}]: {}", traceId, ex.getMessage());
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), traceId));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(
            Exception ex,
            HttpServletRequest request) {
        String traceId = generateTraceId();
        log.error("予期しないエラー [traceId={}]", traceId, ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of("INTERNAL_ERROR",
                "予期しないエラーが発生しました。TraceId: " + traceId, traceId));
    }

    private String generateTraceId() {
        return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
    }
}

出力:

TEXT
// 実行成功
エラー型 HTTPステータスコード ログレベル 説明
ResourceNotFoundException 404 WARN リソースが存在しない。クライアント側の問題
InsufficientStockException 409 WARN ビジネス競合
BusinessException 422 WARN ビジネスルール違反
MethodArgumentNotValidException 400 WARN 入力検証失敗
Exception 500 ERROR 予期しないエラー。調査が必要

8. 総合サンプル:OrderFlowの完全な例外処理システム

JAVA
// ErrorResponse.java
package com.orderflow.exception;

import java.time.Instant;
import java.util.List;

public record ErrorResponse(
    String code, String message, Instant timestamp,
    String traceId, List<FieldError> details
) {
    public record FieldError(String field, String message, Object rejectedValue) {}

    public static ErrorResponse of(String code, String message, String traceId) {
        return new ErrorResponse(code, message, Instant.now(), traceId, null);
    }

    public static ErrorResponse withDetails(String code, String message,
            String traceId, List<FieldError> details) {
        return new ErrorResponse(code, message, Instant.now(), traceId, details);
    }
}

// BusinessException階層
public class BusinessException extends RuntimeException {
    private final String errorCode;
    public BusinessException(String errorCode, String message) {
        super(message); this.errorCode = errorCode;
    }
    public String getErrorCode() { return errorCode; }
}

public class ResourceNotFoundException extends BusinessException {
    public ResourceNotFoundException(String resource, Long id) {
        super("RESOURCE_NOT_FOUND", resource + " not found with id: " + id);
    }
}

public class InsufficientStockException extends BusinessException {
    public InsufficientStockException(Long productId, int avail, int req) {
        super("INSUFFICIENT_STOCK",
            "Product " + productId + ": available=" + avail + ", requested=" + req);
    }
}

// GlobalExceptionHandler.java
package com.orderflow.exception;

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.*;
import java.util.*;

@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        String tid = tid();
        log.warn("[{}] {}", tid, ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), tid));
    }

    @ExceptionHandler(InsufficientStockException.class)
    public ResponseEntity<ErrorResponse> handleStock(InsufficientStockException ex) {
        String tid = tid();
        log.warn("[{}] {}", tid, ex.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), tid));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        String tid = tid();
        List<ErrorResponse.FieldError> details = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(f -> new ErrorResponse.FieldError(f.getField(),
                f.getDefaultMessage() != null ? f.getDefaultMessage() : "", f.getRejectedValue()))
            .toList();
        log.warn("[{}] 検証失敗: {}", tid, details);
        return ResponseEntity.badRequest()
            .body(ErrorResponse.withDetails("VALIDATION_ERROR", "検証に失敗しました", tid, details));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        String tid = tid();
        log.error("[{}] 予期しないエラー", tid, ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of("INTERNAL_ERROR", "予期しないエラー。参照: " + tid, tid));
    }

    private String tid() {
        return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
    }
}

❓ よくある質問

Q @ControllerAdviceと@RestControllerAdviceの違いは何ですか?
A @RestControllerAdvice = @ControllerAdvice + @ResponseBody。REST APIプロジェクトでは常に@RestControllerAdviceを使用してください。メソッドの戻り値が自動的にJSONにシリアライズされます。
Q @ExceptionHandlerのマッチング順序はどうなりますか?
A Springは最も具体的な例外型のマッチを選択します。BusinessExceptionとExceptionの両方のハンドラが登録されている場合, BusinessExceptionがスローされるとBusinessExceptionハンドラが優先されます。
Q 複数の@RestControllerAdviceクラスを共存させるにはどうすればよいですか?
A @Orderで優先度を制御できます。@Order(Ordered.HIGHEST_PRECEDENCE)が優先的にマッチされます。異なるAdviceクラスで異なる型の例外を処理できます。
Q 本番環境で例外スタックトレースを返すべきですか?
A いいえ。本番環境ではエラーコードと一般的なメッセージのみを返し, 内部実装を暴露しないでください。スタックトレース情報はログのみに記録します。予期しない例外にはtraceIdを返し, 運用がログから問題を特定できるようにします。
Q Spring Securityの例外はどう処理しますか?
A Spring Securityの例外 (AccessDeniedExceptionなど)はフィルタチェーン内でスローされ, @RestControllerAdviceを通過しません。AuthenticationEntryPointとAccessDeniedHandlerをカスタマイズする必要があります。
Q traceIdとMDCの関係は何ですか?
A traceIdはレスポンスでログとクライアントを関連付けるために使い, MDCはログフレームワーク内で同じリクエストのすべてのログを関連付けるために使います。両方に同じ値を使用することを推奨します。FilterでMDC.put("traceId", id)を設定してください。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度 ⭐):OrderFlowにGlobalExceptionHandlerを実装し, ResourceNotFoundExceptionBusinessExceptionを処理し, 標準化されたErrorResponse形式を返してください。

  2. 応用問題 (難易度 ⭐⭐):MethodArgumentNotValidExceptionのハンドラを追加し, フィールドレベルのエラー詳細を抽出してください。traceIdの生成を実装し, ログで使用してください。

  3. チャレンジ問題 (難易度 ⭐⭐⭐):リクエスト入口でtraceIdを生成しMDCに設定するServlet Filterを作成し, すべてのログに自動的にtraceIdが含まれるようにしてください。GlobalExceptionHandlerはMDCからtraceIdを読み取り, エンドツーエンドのログトレースを実現してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%