Java例外処理の理解ポイント:エンジニアが現場で直面する問題と10の工夫

Published on: | Last updated:

「例外処理は try-catch を増やせば堅牢になる」ってやつ。たぶんそれ、聞いた中で一番でかい誤解。

Java の例外処理は、Checked例外(コンパイル時に処理を強制される例外)Unchecked例外(RuntimeException 系で宣言不要な例外)を分けて、Spring Boot の @RestControllerAdvice(例外を一箇所でHTTP応答に変換する仕組み)で統一すると、保守性と障害対応が一気に上がる。MDC(ログに requestId などの文脈を混ぜる機構)まで入れると追跡が現実的になる。

  • Checked と Unchecked を用途で割る。気分で混ぜない
  • Exception を雑に catch しない。握りつぶしは事故
  • メッセージに「文脈」を入れる。ID。入力値。状態
  • Spring は ControllerAdvice に寄せる。コントローラを静かにする
  • ログはレベルとスタックトレース。MDC はほぼ必須
図1: 例外が起きた時の現実的な流れ
図1: 例外が起きた時の現実的な流れ

try-catch を足すほど安全になるは幻想

例外処理は「回復できる失敗」と「コードのバグ」を切り分ける設計で決まる。Java は Checked例外と Unchecked例外を使い分ける前提の言語仕様になっている。

疑ってる人の感覚、分かる。現場だと「とりあえず catch(Exception)」で火を消した気になる。

消えてない。煙が見えなくなっただけ。

ネットワーク断。DB切断。ファイル読めない。こういう外部要因は、呼び出し側がリトライやフォールバックを組める余地がある。Checked を使う理由が残る。

NullPointerException。IllegalArgumentException。配列範囲外。これはプログラムの状態が壊れてるサイン。回復させようとすると、壊れた状態を引きずる。

落ちた方がマシな場面がある。Fail-fast(不正な状態なら早く止める方針)。これ、口当たり悪いけど運用は楽。

catch(Exception) は「問題を解決」じゃない。「問題を隠す」だ。

Checked と Unchecked の分け方で迷子になる人へ

Checked例外は「外部依存の失敗で、呼び出し側が別ルートを選べる時」に寄せる。Unchecked例外は「契約違反やバグで、早期に伝播させたい時」に寄せる。

ここが曖昧だと、例外の種類が増えるだけで、設計が痩せる。

よくある崩れ方。Checked を乱用して throws が増殖。呼び出し側が握りつぶす。空の catch。ログだけ吐いて継続。最悪。

逆もある。全部 RuntimeException にして「上で何とかして」って投げる。上が何もしない。500 の嵐。

このへん、バランスの話に見えるけど、実際は回復戦略があるかだけ。

回復戦略がある。リトライ。代替API。デフォルト値。キューに積む。そういうのが書けるなら捕まえる。

書けないなら、捕まえない。

冷たい。けど、障害対応で泣かない。

図2: 例外タイプのざっくり地図
図2: 例外タイプのざっくり地図

意味のある例外を投げる。型と名前で会話する

汎用の Exception を投げる設計は、呼び出し側に「何が起きたか」を押し付ける。ドメイン例外を作って RuntimeException を継承させると、境界が見える。

ここは割とすぐ効く。地味に。

例。ユーザーがいない。残高不足。商品が在庫切れ。これは業務ルールの失敗。だから UserNotFoundException みたいに名前で言う。

「Something went wrong」ってメッセージ、読む側の時間を奪うだけ。

メッセージに入れるのは文脈。userId。orderId。入力値。処理してたファイルパス。状態。

ただし個人情報や秘密情報は入れない。ここで雑に入れると、ログが漏えい媒体になる。

あと、例外チェーン。cause を渡す。スタックトレースが繋がる。後で効く。夜中に効く。

public User findUserById(long userId) {
    return userRepository.findById(userId)
        .orElseThrow(() -> new UserNotFoundException("User with ID " + userId + " not found"));
}

// Custom exception
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) { super(message); }
}

「例外クラス増やすの面倒」って声、分かる。

増やすと、未来の自分が楽。今の自分がちょい損。

うるさいぐらい具体的な名前にすると、API の契約になる。

例外はスタックトレースより前に「型」で語る。型は契約。

握りつぶし。ログだけ。これが一番危ない

例外を catch したなら、回復・変換・伝播のどれかをやる。ログだけ吐いて続行は「静かな失敗」を作る。

静かな失敗は、あとでデータを壊す。

バグ調査が地獄になる。再現しない。ログはある。原因は不明。よくある。

catch(Exception e) も同類。ブラックホール。OutOfMemoryError みたいな「回復できない」領域まで吸う。

捕まえるなら狙って捕まえる。IOException。SQLException。IllegalArgumentException。対象を限定する。

// 悪い例: 何でも飲み込む
try {
    riskyOperation();
} catch (Exception e) {
    log.error("error: {}", e.getMessage());
    // 何事もなかったかのように続行
}

// まだマシ: 意味のある範囲で捕まえる
try {
    callExternal();
} catch (java.io.IOException e) {
    log.warn("network issue: {}", e.getMessage(), e);
    throw new ServiceUnavailableException("External service unreachable", e);
}

「落としたくない」って気持ちも分かる。

落とさずに壊す方が、もっと高くつく。

図3: 例外の扱い A vs B
図3: 例外の扱い A vs B

Spring Boot は ControllerAdvice に寄せる。API を同じ顔にする

Spring Boot の @RestControllerAdvice は、例外をHTTPステータスとエラーボディに一括変換する。@ExceptionHandler で例外型ごとのルールを固定すると、APIの失敗が予測可能になる。

コントローラに try-catch を散らすと、返す形式が揺れる。404 が返ったり 200 になったり。地味に信用が落ちる。

統一したい。失敗の表情を揃えたい。

RFC 7807 の ProblemDetail を使う設計もある。Spring Framework 6 系だと ProblemDetail が入ってくる。採用するとクライアント側が楽。

例外は三段に分けると扱いやすい。

  • ドメイン例外。UserNotFoundException。400/404 に寄せる
  • 外部依存の失敗。ServiceUnavailableException。503 に寄せる
  • 未知の例外。Exception。500。ログは必ずスタックトレース
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(org.springframework.http.HttpStatus.NOT_FOUND)
    public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
        return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(org.springframework.http.HttpStatus.BAD_REQUEST)
    public ErrorResponse handleBadRequest(IllegalArgumentException ex) {
        return new ErrorResponse("INVALID_INPUT", ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneric(Exception ex) {
        log.error("unexpected error: {}", ex.getMessage(), ex);
        return new ErrorResponse("INTERNAL_SERVER_ERROR", "An unexpected error occurred.");
    }
}

ここで一個、反対意見を先に潰す。

「結局 catch(Exception) してるじゃん」ってやつ。

うん。最後の砦として置く。そこだけ。そこ以外は狙い撃ち。

500 はスタックトレースを残す。クライアントには詳細を出しすぎない。セキュリティ。

API の例外処理は「どこで捕まえるか」より「どんな形で返すか」が信用を決める。

リソースは try-with-resources。ログは MDC。ドキュメントは throws

try-with-resources は AutoCloseable を確実に閉じる。finally 手書きより事故が減る。抑制例外も扱える。

ファイル。ソケット。ストリーム。DB 接続。閉じ忘れは、地味に死ぬ。

// try-with-resources
try (java.io.BufferedReader reader =
         new java.io.BufferedReader(new java.io.FileReader("file.txt"))) {
    String line = reader.readLine();
} catch (java.io.IOException e) {
    // handle
}

ログ。レベル。ERROR には例外オブジェクトを渡す。スタックトレースが残る。

MDC(Mapped Diagnostic Context)は、requestId や userId をログに混ぜる仕組み。分散システムだと、これがないと追えない。

「何が起きた?」より「このリクエストの流れどれ?」が先。

ツールは色々ある。最低限でも SLF4J + Logback の組み合わせで MDC を回す。Spring なら Filter か Interceptor で仕込むことが多い。

メトリクスの話に飛ぶ。SRE が見たいのは例外の件数じゃなくて例外率。リクエスト数に対する割合。あとエンドポイント別。ステータス別。ここを見ないと「増えた」の意味が分からない。

もう一つ。ドキュメント。

Javadoc の @throws は、読む人の未来を助ける。Checked だけ書く文化もあるけど、契約として投げる RuntimeException も書いた方が揉めない。

/**
 * @throws UserNotFoundException if no user with the given ID exists.
 * @throws IllegalArgumentException if userId is negative.
 */
public User getUserById(long userId) { ... }

この辺、地味。すぐ褒められない。

でも障害対応の睡眠が増える。これが現実の報酬。

日本で買い物する時の落とし穴回避ガイド。例外処理の本と道具

書籍やツールを買って「分かった気になる」罠がある。値段より、買った後に手が動くかで選ぶ。

地元の販路での現実ライン。こんな感じ。

  • ドラッグストアの書棚。たまにIT本が置いてある店もあるけど、例外処理に刺さる本は薄い確率。時間コストが高い
  • 家電量販店の本コーナー。ヨドバシ。ビック。ここは Java/Spring の棚がまだ生きてる店がある。実物をパラ見できるのが強い
  • 通販。Amazon。楽天ブックス。honto。ここは版が新しい本を拾える。レビューは鵜呑みにしない。版数と発行年を見る

目安の価格帯: 技術書はだいたい 3,000〜5,000円帯が多い。薄い本で安いのは「概念だけ」で終わる率が上がる。逆に分厚すぎる本は読む前に積む😶

落とし穴のチェック。

  • Spring Boot 2 前提で止まってる。Spring Framework 6 や Jakarta 移行の空気がない
  • 例外処理が「try-catch の書き方」だけ。設計と運用の話がない
  • ログの章が log4j.properties の話で終わる。今の現場とズレる

ツール枠で一個だけ。公式資料の場所。

Spring の公式リファレンス。JDK の JavaDoc。ここは無料で強い。書籍を買う前に、欲しい答えがそこにないかを見る。

買うのは、その後でいい。

図4: 今日からの手順メモ
図4: 今日からの手順メモ

FAQ

ポイント: ここは直答だけ置く。現場で揉めるやつ。

Checked例外はもう使わない方がいい?

Checked例外は外部依存の失敗で回復戦略が書ける場面に限定して使うと整理できる。

catch(Exception) は絶対ダメ?

catch(Exception) は最後の砦としてグローバルハンドラにだけ置き、通常の処理では具体的な例外だけ捕まえる。

例外メッセージに何を書けばいい?

例外メッセージは entity ID、入力値、処理対象、状態を入れ、cause を渡してチェーンを残す。

Spring Boot の例外レスポンスは何を返すのが無難?

Spring Boot は @RestControllerAdvice で例外型をHTTPステータスとエラーコードにマッピングし、必要なら ProblemDetail 形式で統一する。

MDC はいつ必要?

MDC は requestId や userId をログに付与して関連ログを束ねるため、複数サービスや非同期処理があると必須になる。

反対意見、たぶんこう。

「例外処理なんて後で直せばよくない?」

直せる。時間があるなら。

でも実際は、障害が起きた瞬間に設計のツケが一括で来る。例外はその請求書。

あなたのチームだと、今いちばん多いのはどれ?

  • ログだけ吐いて続行
  • try-catch の散布
  • 例外型が雑
  • レスポンス形式がバラバラ

そこ、意見割れるはず。割れていい。

割れたままにすると、次の障害で揉める。

Related to this topic:

Comments