@RestControllerAdvice でひと通り例外を拾えるようになると、次に困るのが「本番でこのハンドラ、本当に使えるの?」という話ですよね。スタックトレースをクライアントに返したくはない、でもサーバ側のログには残したい、しかも問い合わせが来たときにどのリクエストか追えるようにしたい。

この記事では、基本構文は理解している前提で、運用品質を上げるための3点 に絞って書きます。具体的には、ログ設計・traceIDのMDC付与・ProblemDetailの拡張プロパティです。

なお、@ExceptionHandler の基本構文や RFC 9457 そのものの解説は別記事に譲ります。気になる方は Spring Bootの例外ハンドリング基礎Problem Details (RFC 9457) ガイド を先に読んでおくとスムーズです。

なぜ基本構文だけでは足りないのか

基本のハンドラは「例外を捕まえてレスポンスを返す」までしかやってくれません。本番運用に持っていくと、だいたい次の3つで困ります。

  • クライアントには安全な情報だけ、ログには詳細を残す 分離
  • 問い合わせを追跡できる traceID
  • 拡張情報を載せられる ProblemDetailのカスタムプロパティ

逆に言うと、この3つを押さえれば運用品質はかなり上がります。順に見ていきましょう。

ベースとなる骨格

まずは拡張のベースになる骨格です。ResponseEntityExceptionHandler を継承しておくと、Spring標準の例外もまとめて扱えるので便利です。

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

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

    @ExceptionHandler(BusinessException.class)
    public ProblemDetail handleBusiness(BusinessException ex, HttpServletRequest req) {
        log.warn("business error: code={}, path={}", ex.getCode(), req.getRequestURI());
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
        return decorate(pd, req, ex.getCode());
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest req) {
        log.error("unexpected error at {}", req.getRequestURI(), ex);
        var pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "内部エラーが発生しました");
        return decorate(pd, req, "INTERNAL_ERROR");
    }
}

ポイントは、業務例外と予期せぬ例外でハンドラを分けていることです。詳細メッセージをそのままクライアントに返すかどうかは、例外の種類で明確に変えます。

ログ設計 - 4xxはWARN、5xxはERROR

ログレベルの方針はシンプルでよくて、クライアント起因(4xx)はWARN、サーバ起因(5xx)はERROR を基本にします。WARNにスタックトレースを毎回出すと本番ログが荒れるので、業務例外は1行サマリだけにとどめると見通しが良いです。

そして、クライアントに返す detail には 生のメッセージをそのまま入れない のが鉄則です。ex.getMessage() には SQL断片や内部パスが混ざりがちなので、業務例外は事前に整形したメッセージだけを detail に渡し、生情報はログ側で記録します。

} catch (DataAccessException ex) {
    log.error("DB access failed: sql={}", maskSql(extractSql(ex)), ex);
    throw new BusinessException("DATA_ERROR", "データ取得に失敗しました");
}

予期せぬ例外は逆にスタックトレースを必ず残します。そうしないと、後で原因を追えなくなります。

MDCでtraceIDを付ける

本番で一番ありがたいのが、レスポンスに載っている traceId でログを grep できることです。OncePerRequestFilter を一つ用意すれば十分です。

@Component
public class TraceIdFilter extends OncePerRequestFilter {
    public static final String KEY = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        var traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                .orElse(UUID.randomUUID().toString());
        MDC.put(KEY, traceId);
        res.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.remove(KEY);
        }
    }
}

finally での MDC.remove を忘れるとスレッドを使い回すサーブレットコンテナで前のリクエストの値が漏れるので、ここは必ず書きます。Micrometer Tracingを既に導入しているなら、Tracer#currentSpan 経由でもほぼ同じことができるので、その場合は自前生成は不要です。

Logback側のパターンに %X{traceId} を入れれば、ログ1行ごとにIDが出ます。

<pattern>%d{ISO8601} [%X{traceId:-}] %-5level %logger{36} - %msg%n</pattern>

ProblemDetailに拡張プロパティを足す

ここがこの記事の本題です。ProblemDetail#setProperty で、RFC 9457に沿った形のまま独自のフィールドを足せます。共通のヘルパーを1つ用意しておくのがおすすめです。

private ProblemDetail decorate(ProblemDetail pd, HttpServletRequest req, String errorCode) {
    pd.setProperty("traceId", MDC.get("traceId"));
    pd.setProperty("errorCode", errorCode);
    pd.setProperty("timestamp", Instant.now().toString());
    pd.setProperty("path", req.getRequestURI());
    return pd;
}

レスポンスはこんな形になります。

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "ユーザIDが不正です",
  "traceId": "a3f1b8e2-7c4d-4f2a-9b1e-2d6c8a1f3b50",
  "errorCode": "USER_INVALID_ID",
  "timestamp": "2026-05-21T03:12:45.812Z",
  "path": "/api/users/abc"
}

クライアントは errorCode でハンドリング分岐ができ、サポート対応のときは traceId をそのままログ検索に使えます。

標準例外も同じ形で返す

バリデーションエラーなど、Springが投げる例外もこの拡張プロパティを揃えたいですよね。ResponseEntityExceptionHandler をオーバーライドします。

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex, HttpHeaders headers,
        HttpStatusCode status, WebRequest request) {

    var pd = ProblemDetail.forStatus(status);
    pd.setDetail("入力値に誤りがあります");
    pd.setProperty("traceId", MDC.get("traceId"));
    pd.setProperty("errorCode", "VALIDATION_ERROR");
    pd.setProperty("timestamp", Instant.now().toString());
    pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
            .toList());
    return new ResponseEntity<>(pd, headers, status);
}

errors 配列にフィールド単位の詳細を載せておくと、フロント側のフォームエラー表示がだいぶ楽になります。

動作確認

仕上げに、traceId がレスポンスとログで一致しているか確認します。意図的に500を返すエンドポイントを一時的に作って叩いてみるのが一番早いです。

curl -s http://localhost:8080/api/_debug/boom | jq -r .traceId
# => a3f1b8e2-7c4d-4f2a-9b1e-2d6c8a1f3b50

grep a3f1b8e2-7c4d-4f2a-9b1e-2d6c8a1f3b50 logs/app.log

レスポンスの traceId でログを引いて、該当リクエストのスタックトレースまでたどり着けたら成功です。これができる状態を維持できれば、本番障害の初動はぐっと速くなります。

まとめ

@RestControllerAdvice は基本を覚えてからが本番で、ログ設計・traceID・ProblemDetail拡張の3点を押さえると一気に運用に耐える形になります。特に traceId をレスポンスとログで揃える仕組みは、入れておくと後悔しません。

ログ自体をJSON化して検索性をさらに上げたい場合は 構造化ログの実装ガイド も合わせて読んでみてください。