はじめに
株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
この記事は、新人〜2年目のJavaエンジニア向けに 「良いコードと悪いコードの違い」 を、現場でよく見る具体例とともに解説していくシリーズの第8回です。
| 回 | テーマ |
|---|---|
| #1 | 命名 |
| #2 | コメントの書き方 |
| #3 | マジックナンバー・定数化 |
| #4 | Null処理 |
| #5 | 早期リターン |
| #6 | メソッド分割 |
| #7 | ループ処理 |
| #8(本記事) | 例外処理 |
| #9 | ログ出力 |
| #10 | クラス設計 |
第8回は 例外処理 です。新人〜2年目が最も誤って書いてしまうのが例外処理です。「とりあえず try-catch で囲んで通せばOK」 という発想で書かれたコードは、後から 本番障害の温床 になります。
この記事のゴール
この記事を読み終わると、以下ができるようになります。
- 「例外を握り潰す」とはどういうことか、なぜ危険か説明できる
- 業務例外とプログラミングエラーを区別して扱える
-
try-with-resourcesで安全にリソースを解放できる
「悪い例外処理」の本当のコスト
新人〜2年目のコードによく見られるのが、こんな構造です。
public int parseAmount(String input) {
try {
return Integer.parseInt(input);
} catch (Exception e) {
// 何もしない
}
return 0;
}
書いた本人は「エラーで落ちないようにする」ためにこう書いています。
しかし、これは「情報を捨てて、見えない場所にバグを埋める」コードです。
このコードには3つのコストがあります。
- デバッグコスト:エラーが発生してもログに残らないため、原因究明が困難
- 保守コスト:本来呼び出し側で扱うべき業務エラーが、隠蔽される
- 信頼性コスト:「動いているように見えるが、実は壊れている」状態を生む
特に厄介なのが 信頼性コスト です。
ユーザーは「金額が0円になっている」と感じても、システムからは 「エラーが起きた」という情報すら出てきません。気づいたときには手遅れになっています。
押さえるべきは3原則
新人〜2年目がまず身につけるべき例外処理の原則は、以下の3つです。
- 例外を握り潰さない
- 業務例外とプログラミングエラーを区別する
- try-with-resources でリソース解放を自動化する
順番に見ていきます。
原則① 例外を握り潰さない
「例外を握り潰す」とは、catch ブロックで何もしない(または無視する)コード のことです。
悪い例(握り潰し)
try {
int amount = Integer.parseInt(input);
} catch (Exception e) {
// 何もしない
}
このコードでは、input が数値でなくても エラーが起きていないように振る舞います。
ログにも残らず、呼び出し側にも伝わらず、問題が完全に隠蔽 されます。
悪い例(printStackTraceで終わり)
try {
int amount = Integer.parseInt(input);
process(amount);
} catch (Exception e) {
e.printStackTrace();
}
「ログには出している」つもりかもしれませんが、printStackTrace は 標準エラー出力に書くだけ で、本番ログには残らないことが多いです。
さらに、エラーが発生しても そのまま処理が続く ため、後続の処理で別のエラーを引き起こします。
良い例(再スロー or 業務例外への変換)
public int parseAmount(String input) throws InvalidAmountException {
try {
int amount = Integer.parseInt(input);
if (amount < 0) {
throw new InvalidAmountException("金額が負の値: " + amount);
}
return amount;
} catch (NumberFormatException e) {
throw new InvalidAmountException("数値変換に失敗: " + input, e);
}
}
下位層の NumberFormatException(数値変換エラー)を、上位層が扱える業務例外 InvalidAmountException に変換しています。
ポイントは2つです。
-
原因例外(
e)をcauseとして渡す:スタックトレースが失われない - エラー情報を含めたメッセージ:何が原因だったか分かるようにする
例外を握り潰してよい唯一のケース
「絶対に例外を握り潰してはいけない」かというと、例外的に許容される場面 もあります。
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // フラグを立て直す
}
InterruptedException(スレッド割り込み)は、フラグを立て直すだけで握り潰してよい ケースの代表例です。
ただし、これは 「握り潰している」のではなく「適切に対処している」 のだ、と理解しましょう。
原則② 業務例外とプログラミングエラーを区別する
Javaの例外は大きく 2種類 に分類できます。
| 種類 | クラス | 例 | 扱い |
|---|---|---|---|
| 業務例外 |
Exception(checked) |
残高不足、認証失敗、リソース未発見 | catch して対処する |
| プログラミングエラー |
RuntimeException(unchecked) |
NullPointer、IllegalArgument、IndexOutOfBounds | 基本catchしない(事前に防ぐ) |
業務例外(呼び出し側が対処すべき)
業務的に「起こりうる失敗」は、Exception を継承したカスタム例外で表します。
public class InsufficientBalanceException extends Exception {
public InsufficientBalanceException(String message) {
super(message);
}
}
public void withdraw(int balance, int amount) throws InsufficientBalanceException {
if (balance < amount) {
throw new InsufficientBalanceException("残高不足: 残高=" + balance + " / 引き出し額=" + amount);
}
// 処理
}
呼び出し側は、必ず catch するか throws で再宣言する必要があります(コンパイル時に強制される)。
try {
withdraw(1000, 5000);
} catch (InsufficientBalanceException e) {
showError(e.getMessage());
}
プログラミングエラー(基本的にcatchしない)
NullPointerException や IllegalArgumentException は 「コードのバグ」を表す例外 です。
これらを catch してハンドリングするのは、根本的に間違っています。
// 悪い例:NullPointerExceptionをcatchする
try {
user.getName();
} catch (NullPointerException e) {
return "名無し";
}
// 良い例:事前にチェックする(または Optional を使う - #4参照)
if (user == null) {
return "名無し";
}
return user.getName();
RuntimeException 系は 発生しないように事前にチェック するのが正しい対処です。
引数チェックには IllegalArgumentException を使う
メソッドが「想定外の引数」を受け取ったときは、IllegalArgumentException を投げます。
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("ageは0以上を指定してください: " + age);
}
this.age = age;
}
これは 「呼び出し側の使い方が間違っている」ことを伝えるサイン です。
正しいコードを書けば発生しないので、throws 宣言は不要(unchecked例外)です。
原則③ try-with-resources でリソース解放を自動化する
ファイル、DB接続、ネットワーク接続などの リソースを使うときは、必ず try-with-resources を使います。
悪い例(手動でclose)
public void readFile(String path) {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(path));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// 握り潰すしかない(finallyの中で例外が出ると厄介)
}
}
}
}
-
nullチェック、closeの例外処理がネスト - finally 内の例外は基本握り潰すしかない
- 「リソースが解放されないバグ」 を生みやすい
良い例(try-with-resources)
public void readFile(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
try (...) { ... } の括弧内で宣言したリソースは、ブロックを抜けるときに自動でclose されます。
例外が発生してもしなくても、確実に閉じられます。
try-with-resources の対象
AutoCloseable インターフェース(または Closeable)を実装したクラスが対象です。
| 種類 | クラス例 |
|---|---|
| ファイル |
FileReader、BufferedReader、FileWriter
|
| DB接続 |
Connection、PreparedStatement、ResultSet
|
| ストリーム |
InputStream、OutputStream
|
| ネットワーク |
Socket、ServerSocket
|
「閉じる必要があるもの」は全部 try-with-resources と覚えておきましょう。
複数リソースの宣言
セミコロンで区切って複数宣言できます。
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users");
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getString("name"));
}
}
ブロックを抜けるとき、宣言と逆順 に close されます(rs → stmt → conn)。
動作確認:3原則を全部適用したサンプル
3つの原則をすべて適用したコード例です。コピペでそのまま動かせます。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
public class ExceptionDemo {
public static void main(String[] args) {
// 原則①:握り潰さない(例外を伝える)
try {
int amount = parseAmount("100");
System.out.println("金額: " + amount);
} catch (InvalidAmountException e) {
System.out.println("業務エラー: " + e.getMessage());
}
try {
int amount = parseAmount("abc");
System.out.println("金額: " + amount);
} catch (InvalidAmountException e) {
System.out.println("業務エラー: " + e.getMessage());
}
// 原則②:適切な粒度(業務例外をキャッチ)
try {
withdrawFromAccount(1000, 5000);
} catch (InsufficientBalanceException e) {
System.out.println("残高不足: " + e.getMessage());
}
// 原則③:try-with-resources
readFromString("Hello\nWorld\n");
}
static int parseAmount(String input) throws InvalidAmountException {
try {
int amount = Integer.parseInt(input);
if (amount < 0) {
throw new InvalidAmountException("金額が負の値: " + amount);
}
return amount;
} catch (NumberFormatException e) {
throw new InvalidAmountException("数値変換に失敗: " + input, e);
}
}
static void withdrawFromAccount(int balance, int amount) throws InsufficientBalanceException {
if (balance < amount) {
throw new InsufficientBalanceException("残高: " + balance + " / 引き出し額: " + amount);
}
System.out.println("引き出し成功");
}
static void readFromString(String content) {
try (BufferedReader reader = new BufferedReader(new StringReader(content))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("読み込み: " + line);
}
} catch (IOException e) {
System.out.println("読み込みエラー: " + e.getMessage());
}
}
}
class InvalidAmountException extends Exception {
public InvalidAmountException(String message) { super(message); }
public InvalidAmountException(String message, Throwable cause) { super(message, cause); }
}
class InsufficientBalanceException extends Exception {
public InsufficientBalanceException(String message) { super(message); }
}
期待する出力
金額: 100
業務エラー: 数値変換に失敗: abc
残高不足: 残高: 1000 / 引き出し額: 5000
読み込み: Hello
読み込み: World
やってはいけないアンチパターン
| アンチパターン | 何が問題か | 対処 |
|---|---|---|
catch (Exception e) で全捕捉 |
プログラミングエラーまで握り潰す | 必要な例外だけcatch |
catch (Exception e) { } 空ブロック |
情報が完全に消える | ログ出力+再スローまたは業務例外へ変換 |
e.printStackTrace() で終わり |
本番ログに残らない、処理続行で別エラー | Loggerでログ出力+適切な対処 |
throws Exception を多用 |
何が起こりうるか曖昧 | 具体的な例外クラスを宣言 |
| NPEを catch する | バグを隠蔽する | 事前にnullチェック or Optional(#4) |
catchで null を return |
呼び出し側がさらにNPE | 業務例外を投げる |
| finally内でreturn | 本来の例外が失われる | finallyではreturnしない |
半日溶かした実話:catchの闇
「支払いが完了したのにメールが届かない」という調査依頼から始まった話です。
try {
// 50行ほどの処理
Order order = orderService.findById(orderId);
PaymentResult result = paymentService.process(order, amount);
notifyService.send(order.getCustomer(), result);
auditLog.record(order, result);
// ...
} catch (Exception e) {
// ログだけ
System.out.println("エラー発生");
}
ある日、本番で「支払いが完了したのにメールが届かない」「監査ログに記録がない」という現象が 散発的に発生 しました。
調査開始から原因特定まで、丸2日かかりました。
原因は単純でした:
-
notifyService.sendの中で稀にIOExceptionが発生していた - そのまま
catch (Exception e)に到達して、後続のauditLog.recordがスキップされていた - ログには「エラー発生」とだけ書かれていて、どこで何が起きたか分からない
修正後はこうしました:
Order order = orderService.findById(orderId); // throws OrderNotFoundException
PaymentResult result = paymentService.process(order, amount); // throws PaymentFailedException
try {
notifyService.send(order.getCustomer(), result);
} catch (NotifyException e) {
logger.warn("通知失敗(処理は継続): orderId={}", order.getId(), e);
}
auditLog.record(order, result); // 通知の成否に関わらず記録される
ポイント:
- 業務例外は メソッドごとに具体的な型 を投げる
- 「通知失敗は処理を止めない」と意図的に決めて、個別にcatch・ログ・継続
- 監査ログは どんな場合でも記録される位置 に配置
catch (Exception e) は便利ですが、便利すぎるぶん「やってはいけないこと」を見えなくします。
演習問題
難易度の見方
| マーク | 難易度 | 目安 |
|---|---|---|
| ⭐ | 基本 | 原則を覚えれば解ける |
| ⭐⭐ | 応用 | 複数の原則を組み合わせる |
まずは自分で考えてから、模範解答を見てください!
問題1:握り潰しを直す ⭐
次のメソッドは例外を握り潰しています。
握り潰さず、呼び出し側に伝えるように直してください。
public class Sample {
public static void main(String[] args) {
int value = parseInteger("100");
System.out.println("変換結果: " + value);
int invalid = parseInteger("abc");
System.out.println("変換結果: " + invalid); // ← これが0になってしまう
}
static int parseInteger(String input) {
try {
return Integer.parseInt(input);
} catch (Exception e) {
// 握り潰し
}
return 0;
}
}
模範解答
public class Exercise01 {
public static void main(String[] args) {
try {
int value = parseInteger("100");
System.out.println("変換成功: " + value);
} catch (NumberFormatException e) {
System.out.println("エラー: " + e.getMessage());
}
try {
int value = parseInteger("abc");
System.out.println("変換成功: " + value);
} catch (NumberFormatException e) {
System.out.println("エラー: " + e.getMessage());
}
}
static int parseInteger(String input) {
return Integer.parseInt(input);
}
}
期待する出力
変換成功: 100
エラー: For input string: "abc"
ポイント:
- メソッド側では try-catch を書かず、呼び出し側に伝える
-
NumberFormatExceptionはRuntimeExceptionなのでthrows宣言は不要 - 呼び出し側がエラーを認識して、何らかの対応(メッセージ表示など)ができる
問題2:業務例外を定義する ⭐
ユーザー登録メソッドに、年齢が負の値だった場合の業務例外 InvalidAgeException を定義してください。
正常系は「登録: name」、異常系は業務例外を投げて呼び出し側がcatchする形にします。
public class Sample {
public static void main(String[] args) {
registerUser("tanaka", 30);
registerUser("sato", -5); // ← 業務エラー
}
static void registerUser(String name, int age) {
// ここに実装
}
}
模範解答
public class Exercise02 {
public static void main(String[] args) {
try {
registerUser("tanaka", 30);
System.out.println("登録成功");
} catch (InvalidAgeException e) {
System.out.println("業務エラー: " + e.getMessage());
}
try {
registerUser("sato", -5);
System.out.println("登録成功");
} catch (InvalidAgeException e) {
System.out.println("業務エラー: " + e.getMessage());
}
}
static void registerUser(String name, int age) throws InvalidAgeException {
if (age < 0) {
throw new InvalidAgeException("年齢が不正です: " + age);
}
System.out.println("登録: " + name);
}
}
class InvalidAgeException extends Exception {
public InvalidAgeException(String message) { super(message); }
}
期待する出力
登録: tanaka
登録成功
業務エラー: 年齢が不正です: -5
ポイント:
-
InvalidAgeExceptionはExceptionを継承(checked例外) - 呼び出し側が
catchまたはthrowsを強制される - 業務として「起こりうる失敗」を型として表現できる
問題3:try-with-resources に書き直す ⭐
次のコードは手動で close していますが、リソース解放漏れの可能性があります。
try-with-resources で書き直してください。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
public class Sample {
public static void main(String[] args) {
String content = "line1\nline2\nline3\n";
BufferedReader reader = null;
try {
reader = new BufferedReader(new StringReader(content));
String line;
while ((line = reader.readLine()) != null) {
System.out.println("読み込み: " + line);
}
} catch (IOException e) {
System.out.println("読み込みエラー: " + e.getMessage());
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// 握り潰し
}
}
}
}
}
模範解答
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
public class Exercise03 {
public static void main(String[] args) {
String content = "line1\nline2\nline3\n";
try (BufferedReader reader = new BufferedReader(new StringReader(content))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("読み込み: " + line);
}
} catch (IOException e) {
System.out.println("読み込みエラー: " + e.getMessage());
}
}
}
期待する出力
読み込み: line1
読み込み: line2
読み込み: line3
ポイント:
-
try (...) { ... }の括弧内でリソースを宣言 -
finallyブロックもnullチェックも不要 -
close時の例外も自動的に処理される
問題4:CSVパース処理の例外設計 ⭐⭐
次のメソッドはCSVをパースしますが、例外処理がすべて不適切です。
3原則を踏まえてリファクタリングしてください。
仕様:
- 入力文字列を1行ずつ読み、各行を数値に変換して合計
- 数値変換失敗時は業務例外
CsvParseExceptionを投げる - IOExceptionも同じ業務例外で包む
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
public class Sample {
public static void main(String[] args) {
int total = processCsv("100\n200\n300\n");
System.out.println("合計: " + total);
int total2 = processCsv("100\nabc\n300\n");
System.out.println("合計: " + total2); // ← エラーで止まらない問題
}
static int processCsv(String content) {
int total = 0;
try {
BufferedReader reader = new BufferedReader(new StringReader(content));
String line;
while ((line = reader.readLine()) != null) {
try {
total += Integer.parseInt(line);
} catch (Exception e) {
// 握り潰し
}
}
} catch (Exception e) {
e.printStackTrace();
}
return total;
}
}
模範解答
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
public class Exercise04 {
public static void main(String[] args) {
try {
int total = processCsv("100\n200\n300\n");
System.out.println("合計: " + total);
} catch (CsvParseException e) {
System.out.println("CSVエラー: " + e.getMessage());
}
try {
int total = processCsv("100\nabc\n300\n");
System.out.println("合計: " + total);
} catch (CsvParseException e) {
System.out.println("CSVエラー: " + e.getMessage());
}
}
static int processCsv(String content) throws CsvParseException {
int total = 0;
try (BufferedReader reader = new BufferedReader(new StringReader(content))) {
String line;
while ((line = reader.readLine()) != null) {
try {
total += Integer.parseInt(line);
} catch (NumberFormatException e) {
throw new CsvParseException("数値変換失敗: " + line, e);
}
}
} catch (IOException e) {
throw new CsvParseException("CSV読み込み失敗", e);
}
return total;
}
}
class CsvParseException extends Exception {
public CsvParseException(String message) { super(message); }
public CsvParseException(String message, Throwable cause) { super(message, cause); }
}
期待する出力
合計: 600
CSVエラー: 数値変換失敗: abc
改善ポイント
| 元のコード | 改善後 | 理由 |
|---|---|---|
| 数値変換失敗を握り潰し |
CsvParseException を投げる |
呼び出し側でエラーを認識可能に |
catch (Exception e) |
IOException、NumberFormatException を個別に |
必要な例外だけcatch |
e.printStackTrace() |
業務例外に変換して再スロー | スタックトレースは cause で保持 |
BufferedReader reader = new ... |
try-with-resourcesに | リソース解放を自動化 |
ポイント:
- 業務例外を1つ定義(
CsvParseException)して、下位の例外をすべてそこに集約 -
causeを渡すことで、元のスタックトレースを保持 - try-with-resources で
BufferedReaderのcloseを自動化 - 呼び出し側は1種類の例外をcatchすれば、CSV関連のエラーをすべて拾える
まとめ
新人〜2年目が押さえるべき例外処理の3原則は、以下の3つです。
- 例外を握り潰さない:catchで何もしない・printStackTraceで終わりは禁止
- 業務例外とプログラミングエラーを区別する:catchするのは業務例外だけ
- try-with-resources でリソース解放を自動化する:手動closeは書かない
例外処理は 「エラーを隠す技術」 ではなく、「エラーを適切に伝える技術」 です。
握り潰されたエラーは、必ず本番でより大きなバグとして帰ってきます。
catchを書いたら、必ず「ここで何をすべきか」を考える 習慣をつけましょう。
次回予告
次回(#9)は 「ログ出力」 を扱います。
-
System.out.printlnをやめてLoggerを使うべき理由 - ログレベル(DEBUG / INFO / WARN / ERROR)の使い分け
- 個人情報・機密情報を漏らさないログ設計
を、Before / After 形式で解説していきます。
参考
- Effective Java 第3版 - 第10章「例外」(Joshua Bloch, ピアソン・エデュケーション)
- The try-with-resources Statement(Oracle公式)
- リーダブルコード 第4章「コメントすべきことを知る」(オライリー・ジャパン)
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!