グローバル例外処理
グローバル例外処理によりAPIエラーレスポンスの形式が標準化されます。クライアントは多様なエラー形式に対処する必要がなくなり, デバッグと統合がより効率的になります。
1. 学ぶ内容
@RestControllerAdvice+@ExceptionHandlerによるグローバル例外処理- カスタムビジネス例外体系:
BusinessException/ResourceNotFoundException/ValidationException - エラーレスポンスDTO設計:code / message / timestamp / details
MethodArgumentNotValidExceptionの検証エラーに対する特別な処理- 例外ログとエラートレースIDの生成
2. フロントエンド開発者の実話
(1) ペインポイント:エラーレスポンス形式の不統一
Bobはフロントエンド開発者で, OrderFlow APIとの統合中に問題に遭遇しました。一部のエンドポイントは404でプレーンテキスト"Not Found"を返し, 一部は500でHTMLエラーページを返し, 一部はビジネス例外{"error": "xxx"}を返し, 別のものは{"message": "xxx"}を返していました。各エンドポイントで異なるエラー処理ロジックを書く必要があり, コード全体にtry-catchブロックが散在し, 見落としがちでページが真っ白になることもありました。
(2) @RestControllerAdviceによる解決策
統一例外ハンドラにより, すべてのエラーレスポンスが一貫した形式に従うようにします。
@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) 例外処理のディスパッチプロセス
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) ▶ サンプル:基本的なグローバル例外ハンドラ
@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);
}
}
出力:
// 実行成功
4. カスタムビジネス例外体系
(1) 例外クラスの階層
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) ▶ サンプル:カスタム例外クラス
// 基底ビジネス例外
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));
}
}
出力:
// 実行成功
5. エラーレスポンスDTO設計
(1) ErrorResponse設計の原則
(1) ▶ サンプル:ErrorResponseとValidationErrorResponse
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);
}
}
出力:
// 実行成功
| フィールド | 型 | 説明 |
|---|---|---|
code |
String | エラーコード (機械可読) |
message |
String | エラーメッセージ (人間可読) |
timestamp |
Instant | 発生時刻 |
traceId |
String | トレースID (ログと関連付け) |
details |
List | フィールドレベルのエラー詳細 (検証エラー用) |
6. 検証エラーの特別な処理
(1) ▶ サンプル:MethodArgumentNotValidExceptionの処理
@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);
}
}
出力:
// 実行成功
{
"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付きの完全な例外ハンドラ
@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);
}
}
出力:
// 実行成功
| エラー型 | HTTPステータスコード | ログレベル | 説明 |
|---|---|---|---|
ResourceNotFoundException |
404 | WARN | リソースが存在しない。クライアント側の問題 |
InsufficientStockException |
409 | WARN | ビジネス競合 |
BusinessException |
422 | WARN | ビジネスルール違反 |
MethodArgumentNotValidException |
400 | WARN | 入力検証失敗 |
Exception |
500 | ERROR | 予期しないエラー。調査が必要 |
8. 総合サンプル:OrderFlowの完全な例外処理システム
// 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);
}
}
❓ よくある質問
📖 まとめ
@RestControllerAdvice+@ExceptionHandlerですべてのController例外を統一的に処理- カスタム例外階層:
BusinessExceptionを基底クラスとし, サブクラスで異なるビジネスシナリオを区別 ErrorResponseは5つのフィールドを含む:code,message,timestamp,traceId,details- 検証エラーの特別処理:フィールドレベルのエラー詳細を
details配列に抽出 - 例外ログでWARNとERRORを使ってビジネス例外とシステム例外を区別
- traceIdがレスポンスとログを関連付け, 問題のトラブルシューティングを容易に
📝 練習問題
-
基本問題 (難易度 ⭐):OrderFlowに
GlobalExceptionHandlerを実装し,ResourceNotFoundExceptionとBusinessExceptionを処理し, 標準化されたErrorResponse形式を返してください。 -
応用問題 (難易度 ⭐⭐):
MethodArgumentNotValidExceptionのハンドラを追加し, フィールドレベルのエラー詳細を抽出してください。traceIdの生成を実装し, ログで使用してください。 -
チャレンジ問題 (難易度 ⭐⭐⭐):リクエスト入口でtraceIdを生成しMDCに設定するServlet Filterを作成し, すべてのログに自動的にtraceIdが含まれるようにしてください。GlobalExceptionHandlerはMDCからtraceIdを読み取り, エンドツーエンドのログトレースを実現してください。



