java-recipes

ホーム ロギング・例外処理 › L-02

L-02: スタックトレース・例外チェーン

例外が発生したとき、スタックトレースを正しくログに記録することは障害調査の基本です。e.printStackTrace() を使ってはいけない理由と、層ごとに例外をラップする「例外チェーン」の設計方法を学びましょう。

いつ使うか

  • DB アクセス層・サービス層・コントローラー層など、アーキテクチャ層ごとに例外をラップしたいとき
  • 障害調査のために根本原因(Root Cause)をログに残したいとき
  • 低レベルの例外(SQLException など)を上位層に公開せず、アプリ固有の例外に変換したいとき
  • ユーザー向けのエラーメッセージと、開発者向けの詳細ログを分けて管理したいとき

例外チェーンの仕組み

new ServiceException("msg", cause) のように コンストラクタに原因例外を渡すと、getCause() で辿れる「例外チェーン」が形成されます。

ServiceException: ユーザー表示名の取得に失敗しました

  ↓ getCause()

  DataAccessException: ユーザー取得失敗: id=200

    ↓ getCause()

    RuntimeException: DB接続タイムアウト(根本原因)

サンプルコード

ExceptionChainSample.java
import java.util.logging.*;

public class ExceptionChainSample {

    private static final Logger logger = Logger.getLogger(ExceptionChainSample.class.getName());

    // カスタム例外クラス
    static class DataAccessException extends Exception {
        public DataAccessException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static class ServiceException extends Exception {
        public ServiceException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    // DB アクセス層: 低レベルの例外を DataAccessException にラップ
    public static String findUser(int id) throws DataAccessException {
        try {
            if (id <= 0) {
                throw new IllegalArgumentException("IDは1以上を指定してください: " + id);
            }
            if (id > 100) {
                throw new RuntimeException("DB接続タイムアウト(シミュレーション)");
            }
            return "User-" + id;
        } catch (Exception e) {
            // 原因例外(cause)を保持したままラップ
            throw new DataAccessException("ユーザー取得失敗: id=" + id, e);
        }
    }

    // サービス層: DataAccessException を ServiceException にラップ
    public static String getUserDisplayName(int id) throws ServiceException {
        try {
            String user = findUser(id);
            return user.toUpperCase();
        } catch (DataAccessException e) {
            throw new ServiceException("ユーザー表示名の取得に失敗しました", e);
        }
    }

    // 例外チェーンを辿って全原因を表示する
    public static void printExceptionChain(Throwable e) {
        Throwable current = e;
        int depth = 0;
        while (current != null) {
            System.out.println("  " + "  ".repeat(depth) + current.getClass().getSimpleName()
                + ": " + current.getMessage());
            current = current.getCause(); // getCause() で原因例外を遡る
            depth++;
        }
    }

    // NG: e.printStackTrace() を直接使う(本番コードでは使用禁止)
    // e.printStackTrace(); // 標準エラーにしか出ない・ログレベル制御不可

    // OK: Logger でスタックトレースを記録する
    public static void goodLogging(String message, Exception e) {
        logger.log(Level.SEVERE, message, e); // スタックトレースごとログに記録
    }

    public static void main(String[] args) {
        System.out.println("=== 正常ケース ===");
        try {
            System.out.println(getUserDisplayName(1));
        } catch (ServiceException e) {
            logger.log(Level.SEVERE, "サービスエラー", e);
        }

        System.out.println("\n=== 例外チェーンのデモ ===");
        try {
            getUserDisplayName(200); // DB タイムアウトをシミュレート
        } catch (ServiceException e) {
            System.out.println("例外チェーン:");
            printExceptionChain(e);
            goodLogging("ユーザー取得でエラーが発生しました", e);
        }

        System.out.println("\n=== 引数エラーの例外チェーン ===");
        try {
            getUserDisplayName(-1); // 不正 ID
        } catch (ServiceException e) {
            System.out.println("例外チェーン:");
            printExceptionChain(e);
        }
    }
}

Java 8 では例外クラスを static inner class として定義するのが一般的です。DataAccessException → ServiceException と層ごとにラップすることで、呼び出し元はどの層で何が起きたかを型で判断できます。原因例外(cause)を必ずコンストラクタに渡してください。

よくあるミス・注意点

⚠️ e.printStackTrace() を本番コードで使ってはいけない理由

e.printStackTrace() には3つの問題があります。 ①標準エラー出力(System.err)にしか出力されないため、 ログファイルには記録されません。 ②ログレベル(SEVERE / WARNING / INFO)が制御できないため、重要度が分かりません。 ③ログフォーマットが統一されず、ログ収集ツールでの検索・集計が困難になります。 必ず logger.log(Level.SEVERE, "メッセージ", e) を使ってください。

⚠️ 例外をラップするとき cause を必ず渡す

throw new ServiceException("msg") のように cause を渡さないと、元の例外のスタックトレースが完全に失われます。 根本原因が分からなくなるため、必ずthrow new ServiceException("msg", e) と書いてください。

⚠️ getMessage() だけ取るのは最悪のパターン

catch (Exception e) { throw new RuntimeException(e.getMessage()); } は最悪のパターンです。getMessage() だけを取ると 元の例外クラス・スタックトレースが完全に失われ、障害調査が非常に困難になります。 必ず throw new RuntimeException(e) またはthrow new RuntimeException("msg", e) と書いてください。

⚠️ ユーザー向けメッセージと開発者向けメッセージを分けて設計する

例外のメッセージには2つの読者がいます。 デバッグを行う開発者向けには「どの ID で何が起きたか」などの技術的詳細を含め、 エンドユーザー向けには「しばらく時間を置いてから再度お試しください」など分かりやすい日本語を返すよう、 層を分けて管理しましょう。

テストする観点

  • 例外チェーンの深さが期待通りであること(境界値: 深さ1・深さ2・深さ3)
  • getCause() を辿ると根本原因に到達できること
  • 不正な ID(0 以下・100 超)を渡したとき、それぞれ異なる根本原因例外が返ること
  • 正常な ID を渡したとき、例外が発生しないこと(境界値: id=1, id=100)
  • logger.log() に例外を渡したとき、LogRecord.getThrown() に例外が設定されること
  • cause を渡さずにラップした場合、getCause() が null を返すこと(誤実装の検出)

GitHub でソースコードを見る →