1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Javaの良いコード・悪いコード #8】握り潰さない「例外処理」の3原則

1
Posted at

はじめに

株式会社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つのコストがあります。

  1. デバッグコスト:エラーが発生してもログに残らないため、原因究明が困難
  2. 保守コスト:本来呼び出し側で扱うべき業務エラーが、隠蔽される
  3. 信頼性コスト:「動いているように見えるが、実は壊れている」状態を生む

特に厄介なのが 信頼性コスト です。
ユーザーは「金額が0円になっている」と感じても、システムからは 「エラーが起きた」という情報すら出てきません。気づいたときには手遅れになっています。


押さえるべきは3原則

新人〜2年目がまず身につけるべき例外処理の原則は、以下の3つです。

  1. 例外を握り潰さない
  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しない)

NullPointerExceptionIllegalArgumentException「コードのバグ」を表す例外 です。
これらを 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)を実装したクラスが対象です。

種類 クラス例
ファイル FileReaderBufferedReaderFileWriter
DB接続 ConnectionPreparedStatementResultSet
ストリーム InputStreamOutputStream
ネットワーク SocketServerSocket

「閉じる必要があるもの」は全部 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 を書かず、呼び出し側に伝える
  • NumberFormatExceptionRuntimeException なので 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

ポイント

  • InvalidAgeExceptionException を継承(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) IOExceptionNumberFormatException を個別に 必要な例外だけcatch
e.printStackTrace() 業務例外に変換して再スロー スタックトレースは cause で保持
BufferedReader reader = new ... try-with-resourcesに リソース解放を自動化

ポイント

  • 業務例外を1つ定義(CsvParseException)して、下位の例外をすべてそこに集約
  • cause を渡すことで、元のスタックトレースを保持
  • try-with-resources で BufferedReaderclose を自動化
  • 呼び出し側は1種類の例外をcatchすれば、CSV関連のエラーをすべて拾える

まとめ

新人〜2年目が押さえるべき例外処理の3原則は、以下の3つです。

  1. 例外を握り潰さない:catchで何もしない・printStackTraceで終わりは禁止
  2. 業務例外とプログラミングエラーを区別する:catchするのは業務例外だけ
  3. try-with-resources でリソース解放を自動化する:手動closeは書かない

例外処理は 「エラーを隠す技術」 ではなく、「エラーを適切に伝える技術」 です。
握り潰されたエラーは、必ず本番でより大きなバグとして帰ってきます。

catchを書いたら、必ず「ここで何をすべきか」を考える 習慣をつけましょう。


次回予告

次回(#9)は 「ログ出力」 を扱います。

  • System.out.println をやめてLoggerを使うべき理由
  • ログレベル(DEBUG / INFO / WARN / ERROR)の使い分け
  • 個人情報・機密情報を漏らさないログ設計

を、Before / After 形式で解説していきます。


参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?