- JAVA
- 技術 💻
Javaの例外処理|実務で守りたいルールとよくある書き方の改善例
例外処理の目的は、エラーを「なかったこと」にすることではなく、原因がわかる形で、適切な場所に伝えることです。try-catchの文法そのものは難しくありませんが、書き方しだいで、障害が起きたときに原因がすぐわかるシステムにも、何が起きたのかまったく追えないシステムにもなります。
この記事では、実務で守りたい例外処理のルールを10個にまとめ、それぞれを直す前と後のコードと、実際の実行結果で解説します(Java 21で確認)。
例外処理のルール早見表
| # | ルール |
|---|---|
| 1 | 例外を握りつぶさない(空のcatchを書かない) |
| 2 | catch (Exception e) で広く捕まえすぎない |
| 3 | 例外を変換するときは、原因(cause)を引き継ぐ |
| 4 | リソースは try-with-resources で閉じる |
| 5 | finally の中で return しない |
| 6 | ログに出すか、再スローするか、どちらか一方にする |
| 7 | 業務のエラーは、意味のわかる独自の例外にする |
| 8 | メッセージには、調査に必要な値を入れる |
| 9 | 引数のチェックは、入口で早めに行う |
| 10 | 例外を「処理の分岐」に使わない |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Javaの例外処理|実務で守りたいルールとよくある書き方の改善例」
検査例外と非検査例外
| 種類 | 代表例 | コンパイラーのチェック | 主な用途 |
|---|---|---|---|
| 検査例外(checked) | IOException、SQLException |
catch か throws が必須 | 呼び出し側が対処できる、外部要因のエラー |
| 非検査例外(unchecked) | IllegalArgumentException、NullPointerException |
不要 | プログラムの誤り、前提条件の違反 |
| エラー(Error) | OutOfMemoryError |
不要 | 通常は捕まえない、JVMレベルの深刻な問題 |
非検査例外は RuntimeException を継承したものです。現在のJavaのフレームワーク(Spring Bootなど)では、業務のエラーは非検査例外で表し、共通の仕組みでまとめて処理するスタイルが主流です。
ルール1:例外を握りつぶさない
// 直す前
String s = null;
int len = -1;
try {
len = s.length();
} catch (Exception e) {
// 何もしない
}
System.out.println("len=" + len);
len=-1(NullPointerExceptionが起きたことに、誰も気づけない)
空のcatchは、エラーを「なかったこと」にします。処理はそのまま続き、間違ったデータで後続の処理が進むため、原因から遠く離れた場所で別の問題として現れます。対処できないなら、catchせずに呼び出し元へ伝えます。
ルール2:catch (Exception e) で広く捕まえすぎない
上の例では、本来は想定していない NullPointerException まで一緒に捕まえてしまっています。
// 直した後:想定している例外だけを捕まえる
try {
int qty = Integer.parseInt(input);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("数量は整数で入力してください: " + input, e);
}
複数の例外に同じ対処をするときは、マルチキャッチが使えます。
try {
// ...
} catch (NumberFormatException | ArithmeticException e) {
// 同じ対処
}
ルール3:例外を変換するときは、原因(cause)を引き継ぐ
// 直す前:原因が失われる
catch (IOException e) {
throw new DataAccessException("注文の読み込みに失敗");
}
// 直した後:原因を渡す
catch (IOException e) {
throw new DataAccessException("注文の読み込みに失敗", e);
}
実行結果の違いです。
直す前:DataAccessException: 注文の読み込みに失敗 / cause=null
直した後:DataAccessException: 注文の読み込みに失敗 / cause=java.io.IOException: 接続がタイムアウトしました
直した後のスタックトレースには、Caused by: として本当の原因が表示されます。
DataAccessException: 注文の読み込みに失敗
Caused by: java.io.IOException: 接続がタイムアウトしました
原因を渡さないと、「読み込みに失敗した」ことはわかっても、「なぜ失敗したのか(タイムアウト)」がわからなくなります。障害の調査で最も困るパターンの1つです。
ルール4:リソースは try-with-resources で閉じる
ファイル、DB接続、ネットワーク接続などは、使い終わったら必ず閉じる必要があります。
try (var r1 = new Res("A"); var r2 = new Res("B")) {
throw new IllegalStateException("本体の処理で失敗");
} catch (Exception e) {
System.out.println("caught: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println(" suppressed: " + s.getMessage());
}
}
閉じる処理(close)でも例外が起きるようにして実行した結果です。
open A
open B
close B
close A
caught: 本体の処理で失敗
suppressed: close失敗: B
suppressed: close失敗: A
| 確認できたこと | 内容 |
|---|---|
| 閉じる順番 | 開いたのと逆の順番(B→A)で閉じられる |
| 例外の優先 | 本体の例外が優先され、close の例外は消えずに getSuppressed() に残る |
finallyで閉じる古い書き方では、close の例外が本体の例外を上書きしてしまうことがありました。AutoCloseable を実装したものは、try-with-resources で閉じるのが基本です。
ルール5:finally の中で return しない
static int calc() {
try {
throw new IllegalStateException("本当のエラー");
} finally {
return -1;
}
}
result=-1
例外が起きたのに、何事もなかったように -1 が返りました。finallyの中の return は、try の中で起きた例外を消してしまいます。finallyは後片付けだけに使い、return や throw は書かないようにします。
ルール6:ログに出すか、再スローするか、どちらか一方にする
// 直す前:ログに出して、さらに再スロー
catch (IOException e) {
log.error("読み込みに失敗", e);
throw new DataAccessException("読み込みに失敗", e);
}
呼び出し元でも同じようにログを出すと、1つのエラーで、同じスタックトレースが何回もログに出ます。ログが読みにくくなり、エラーの件数の監視も正しくできなくなります。
| 方針 | 内容 |
|---|---|
| 再スローする場合 | ログには出さず、原因を付けて投げる。最終的に受け止めた場所で1回だけログに出す |
| ここで対処を終える場合 | ログに出して、処理を続ける(再スローしない) |
Spring Bootなどでは、共通の例外ハンドラーで1回だけログに出す形にすると、自然とこのルールが守れます。
ルール7:業務のエラーは、意味のわかる独自の例外にする
public class OrderNotFoundException extends RuntimeException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("注文が見つかりません: orderId=" + orderId);
this.orderId = orderId;
}
public long getOrderId() { return orderId; }
}
注文が見つかりません: orderId=42
| 独自の例外にする利点 | 内容 |
|---|---|
| 意味が伝わる | RuntimeException("not found") より、何が起きたかが名前でわかる |
| 対処を分けられる | 例外の種類ごとに、404を返す・再試行するなどの処理を決められる |
| 調査に必要な値を持てる | orderId のような値をフィールドとして持たせられる |
ただし、標準の例外で意味が足りる場合は、標準の例外を使います。引数が不正なら IllegalArgumentException、状態が不正なら IllegalStateException です。
ルール8:メッセージには、調査に必要な値を入れる
| 直す前 | 直した後 |
|---|---|
"エラーが発生しました" |
"注文が見つかりません: orderId=42" |
"不正な値です" |
"数量は1〜99で指定してください: quantity=0" |
"接続に失敗" |
"在庫APIへの接続に失敗しました: url=..., timeout=3s" |
「どの値で、何が起きたか」がメッセージだけでわかると、ログを見ただけで調査が進みます。ただし、パスワードや個人情報はメッセージに入れないようにします。
ルール9:引数のチェックは、入口で早めに行う
public Order create(String customerId, int quantity) {
Objects.requireNonNull(customerId, "customerId は必須です");
if (quantity < 1) {
throw new IllegalArgumentException("数量は1以上で指定してください: quantity=" + quantity);
}
// ...
}
customerId は必須です
おかしな値を、使われる前に入口で止めると、エラーの原因がすぐにわかります。奥の処理で NullPointerException が起きるより、ずっと調査が楽になります。
なお、Java 15以降では標準で、NullPointerException のメッセージが詳しくなり、何がnullだったかが表示されます。
Cannot invoke "String.trim()" because the return value of "java.util.Map.get(Object)" is null
ルール10:例外を「処理の分岐」に使わない
// 直す前:存在チェックに例外を使う
try {
Order order = service.get(id);
return true;
} catch (OrderNotFoundException e) {
return false;
}
// 直した後:存在確認用のメソッドやOptionalを使う
return repository.findById(id).isPresent();
例外は、想定外の事態を伝えるための仕組みです。「あるかないか」のような通常の分岐に使うと、コードの意図が読みにくくなり、性能にも影響します。見つからないことが普通にありうる場合は、Optional を返す設計にします。
Spring Bootでの共通のエラー処理
Webアプリでは、例外をControllerの中で1つずつ処理するのではなく、共通の例外ハンドラーでまとめて受け止めるのが基本です。
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception e) {
log.error("想定外のエラー", e); // ここで1回だけログに出す
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "システムエラーが発生しました");
}
}
- 業務のエラーは、種類ごとに適切なステータスコードで返す
- 想定外のエラーは、ここで1回だけログに出し、利用者には内部の情報(スタックトレースなど)を返さない
よくある質問
Q. 検査例外は使わない方がいいのですか? A. 呼び出し側がその場で対処できるエラー(ファイルがない、など)を表すのには、今でも有効です。一方で、対処できない例外を何層も throws で伝えるだけになっている場合は、原因を付けて非検査例外に変換すると、コードが読みやすくなります。
Q. e.printStackTrace() はダメですか?
A. 標準エラー出力に出るだけで、ログの仕組み(出力先、ログレベル、日時)に乗らないため、本番のアプリでは避けます。ロガーを使って log.error("メッセージ", e) のように出力します。
Q. Exception と Throwable を catch してもいいですか?
A. Throwable には OutOfMemoryError などのErrorも含まれるため、通常は捕まえません。Exception を捕まえるのは、共通の例外ハンドラーのような最後の受け皿に限ります。
まとめ
- 空のcatchで握りつぶさない。対処できないなら呼び出し元に伝える
- 捕まえるのは想定している例外だけ。広く捕まえすぎない
- 例外を変換するときは原因(cause)を渡す。
Caused byが調査の手がかりになる - リソースは try-with-resources。閉じる順は逆順、close の例外は suppressed に残る
- finallyでreturnしない。例外が消える
- ログか再スローか、どちらか一方。ログは最後に受け止めた場所で1回だけ
- 業務のエラーは意味のわかる独自の例外、メッセージには調査に必要な値を入れる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Javaの例外処理|実務で守りたいルールとよくある書き方の改善例」
株式会社RJCでは、Javaを使った開発の中で、障害に強く、原因を追いやすいコードの書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。