Spring Bootにおける堅牢なエラーハンドリング設計:実践ガイド

エラー対応は単なる例外処理ではない——信頼性の設計原則

システムが深夜にアラートを発する原因の多くは、不適切なエラー処理に起因します。NullPointerExceptionやSQLExceptionがログに乱立しても、その背後にある根本原因や影響範囲が不明瞭だと、復旧は遅れ、再発防止も困難になります。Spring Bootでは、例外を単なる「障害」ではなく、状態遷移の一部と捉え、意図的に設計することで、運用負荷を劇的に軽減できます。

エラー設計の3つの柱

  • 可観測性:エラー発生時に必要な文脈(トレースID、リクエストID、パラメータ摘要)を自動付与
  • 分離性:ビジネスロジックとエラー表現ロジックを完全に分離し、コントローラー層で例外をキャッチしない
  • 一貫性:HTTPステータス、エラーコード、レスポンス構造を全エンドポイントで統一

Spring Bootのデフォルト挙動を見直す

Spring BootはErrorControllerBasicErrorControllerにより、未処理例外を自動的にHTMLまたはJSON形式で返却しますが、この仕様は開発用であり、本番環境では制御不能な情報漏洩リスクがあります。以下のように、明示的なグローバルハンドラを定義することで、レスポンス内容・ステータス・ヘッダーを完全にカスタマイズ可能です:

@RestControllerAdvice
public class UnifiedErrorHandler {

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

    @ExceptionHandler(BusinessRuleViolationException.class)
    public ResponseEntity<ErrorResponse> handleBusinessRuleViolation(
            BusinessRuleViolationException ex, HttpServletRequest request) {
        String traceId = MDC.get("traceId");
        ErrorResponse error = ErrorResponse.builder()
                .code("BUSINESS_RULE_VIOLATION")
                .message(ex.getMessage())
                .details(ex.getDetails())
                .timestamp(Instant.now().toString())
                .traceId(traceId)
                .build();
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        List<String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(f -> f.getField() + ": " + f.getDefaultMessage())
                .collect(Collectors.toList());
        ErrorResponse error = ErrorResponse.builder()
                .code("VALIDATION_FAILED")
                .message("リクエストデータに不備があります")
                .details(Map.of("fieldErrors", errors))
                .build();
        return ResponseEntity.badRequest().body(error);
    }
}

階層化されたカスタム例外クラス設計

例外は「何が起こったか」ではなく、「誰が責任を持ち、どう対応すべきか」を伝えるインターフェースです。以下の設計では、例外の種類ごとに責務を明確に分離しています:

// ルート例外:すべてのアプリケーション例外はこれを継承
public abstract class ApplicationFault extends RuntimeException {
    private final FaultCode faultCode;
    private final Map<String, Object> context;

    protected ApplicationFault(FaultCode code, String message) {
        super(message);
        this.faultCode = code;
        this.context = new HashMap<>();
    }

    public ApplicationFault withContext(String key, Object value) {
        this.context.put(key, value);
        return this;
    }

    // 省略:getterなど
}

// 業務ルール違反(例:在庫不足、権限不足)
public class BusinessRuleViolationException extends ApplicationFault {
    public BusinessRuleViolationException(String message) {
        super(FaultCode.BUSINESS_RULE_VIOLATION, message);
    }
}

// 外部サービス連携失敗(タイムアウト/HTTPエラーなど)
public class ExternalServiceFailureException extends ApplicationFault {
    private final String serviceName;
    private final int httpStatus;

    public ExternalServiceFailureException(String serviceName, int status, String message) {
        super(FaultCode.EXTERNAL_SERVICE_FAILURE, message);
        this.serviceName = serviceName;
        this.httpStatus = status;
    }
}

// 持続層エラー(DB接続/トランザクションなど)
public class PersistenceLayerException extends ApplicationFault {
    public PersistenceLayerException(String message, Throwable cause) {
        super(FaultCode.PERSISTENCE_ERROR, message);
        initCause(cause);
    }
}

標準化されたエラーレスポンス構造

フロントエンドやモバイルアプリがエラーを適切に処理するには、構造の予測可能性が不可欠です。以下のErrorResponseは、RESTful APIのエラー通信プロトコルとして機能します:

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
    private String code;                    // システム内部コード(例: "VALIDATION_FAILED")
    private String message;                   // ユーザー向けメッセージ(国際化対応可能)
    private Map<String, Object> details;     // 補足情報(フィールド名、値、推奨アクションなど)
    private String timestamp;                 // ISO-8601形式のタイムスタンプ
    private String traceId;                   // 分散トレーシング用ID
    private String requestId;                 // 各リクエスト固有の識別子
}

エラーコードの列挙型設計

文字列によるエラーコードは保守性が低いため、列挙型で厳密に管理します。各コードはHTTPステータスと意味論的カテゴリを内包し、API仕様書との整合性を保証します:

public enum FaultCode {
    SUCCESS(200, "正常終了"),
    VALIDATION_FAILED(400, "リクエスト検証失敗"),
    AUTHENTICATION_REQUIRED(401, "認証が必要です"),
    ACCESS_DENIED(403, "アクセスが拒否されました"),
    RESOURCE_NOT_FOUND(404, "要求されたリソースが存在しません"),
    CONFLICT(409, "業務ルールに違反しています"),
    EXTERNAL_SERVICE_FAILURE(503, "外部サービスが利用できません"),
    PERSISTENCE_ERROR(500, "データ永続化に失敗しました"),
    UNEXPECTED_ERROR(500, "予期せぬエラーが発生しました");

    private final int httpStatus;
    private final String description;

    FaultCode(int httpStatus, String description) {
        this.httpStatus = httpStatus;
        this.description = description;
    }

    public int getHttpStatus() { return httpStatus; }
    public String getDescription() { return description; }
}

ログ出力のベストプラクティス

エラー発生時のログは、単なるスタックトレースではなく、再現・分析・修正の起点となるべきです。MDC(Mapped Diagnostic Context)を活用して、トレースIDやリクエストIDを自動付与し、ログを横断的に検索可能にします:

@ExceptionHandler(ApplicationFault.class)
public ResponseEntity<ErrorResponse> handleApplicationFault(
        ApplicationFault ex, HttpServletRequest request) {
    String traceId = Optional.ofNullable(MDC.get("traceId"))
            .orElse(UUID.randomUUID().toString());
    
    logger.error("ApplicationFault occurred [traceId={}, path={}, method={}]", 
            traceId, 
            request.getRequestURI(), 
            request.getMethod(), 
            ex); // exを最後に渡すことで、スタックトレースを含む

    return ResponseEntity
            .status(ex.getFaultCode().getHttpStatus())
            .body(ErrorResponse.builder()
                    .code(ex.getFaultCode().name())
                    .message(ex.getMessage())
                    .details(ex.getContext())
                    .traceId(traceId)
                    .timestamp(Instant.now().toString())
                    .build());
}

タグ: spring-boot rest-api error-handling Java exception-handling

9月14日 11:06 投稿