はじめに
例外処理は「とりあえずtry-catchで囲めば動く」ため、書き方の良し悪しが後回しになりがちな領域です。しかし、throw ex;によるスタックトレースの消失、空のcatchによる握りつぶし、例外を制御フローに使うことによる性能劣化は、いずれも障害発生時の原因調査を難しくしたり、本番の負荷時にだけ問題として表面化したりします。本記事では実務でよく見かける3つのアンチパターンと、その改善方法を扱います。
前提知識 / 用語整理
- スタックトレース: 例外がどのメソッド呼び出しの連鎖で発生したかを示す情報。原因調査の一次情報になる
-
InnerException: 例外を別の例外でラップする際に、元の例外を保持するためのプロパティ -
例外フィルター(
when):catch節に条件を付け、条件に一致した場合のみ捕捉する構文(C# 6以降) -
TryParse系メソッド: 失敗時に例外を投げず、boolで成否を返すAPIの総称(int.TryParseなど)
落とし穴1: throw ex;でスタックトレースが失われる
再現コード
public void Process()
{
try
{
LoadData(); // 内部で例外が発生する
}
catch (Exception ex)
{
_logger.LogError(ex, "処理に失敗しました");
throw ex; // ❌ スタックトレースがここで上書きされる
}
}
なぜ起きるか
throw ex;は捕捉した例外オブジェクトを「新たにここでスローする」動作になり、スタックトレースがthrow ex;を書いた行から始まる内容に置き換わります。本来の発生元であるLoadData内部の呼び出し履歴が失われるため、ログにはProcessメソッドの行しか残らず、原因箇所の特定が困難になります。ローカルではデバッガで追えるため気づきにくく、本番のログだけで調査する場面で問題になります。
正しい書き方
public void Process()
{
try
{
LoadData();
}
catch (Exception ex)
{
_logger.LogError(ex, "処理に失敗しました");
throw; // ◎ 元のスタックトレースを保持したまま再スローする
}
}
// ◎ 例外の種類を変えて上位に伝えたい場合は、元の例外をInnerExceptionに渡す
catch (SqlException ex)
{
throw new DataAccessException("ユーザー取得に失敗しました", ex);
}
(ただし、ログ出力して再スローする書き方は、上位層でも同じ例外をログ出力すると二重ログになります。ログは例外を最終的に処理する境界層1か所に集約する設計を検討してください)
落とし穴2: 例外の握りつぶしとcatch (Exception)の乱用
再現コード
// ❌ 空のcatchで例外を握りつぶす
public void SaveLog(string message)
{
try
{
File.AppendAllText(_logPath, message);
}
catch (Exception)
{
// 何もしない
}
}
なぜ起きるか
空のcatchは、ディスク容量不足・権限エラー・パス誤りなどあらゆる原因の失敗を無言で消してしまいます。処理が失敗した事実が記録も通知もされないため、「ログが出ていない」「データが保存されていない」といった症状だけが後から発覚し、原因を辿る手掛かりが残りません。またcatch (Exception)は想定していない例外(プログラムのバグに起因するNullReferenceExceptionなど)まで捕捉してしまい、本来は早期に落とすべき不具合を隠します。
正しい書き方
// ◎ 想定する例外だけを捕捉し、記録した上で扱いを明示する
public void SaveLog(string message)
{
try
{
File.AppendAllText(_logPath, message);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "ログファイルへの書き込みに失敗しました: {Path}", _logPath);
}
}
// ◎ 同じ例外型でも条件で切り分けたい場合は例外フィルターを使う
catch (SqlException ex) when (ex.Number == 1205) // デッドロック
{
// リトライ処理など
}
握りつぶしが許容できるケース(例えば補助的なログ書き込みの失敗でメイン処理を止めたくない場合)でも、最低限「失敗した事実をログに残す」ことを守ってください。どの例外を想定するかはドキュメントで確認し、それ以外は上位に伝播させるのが基本方針です。
落とし穴3: 例外を制御フローに使ってしまう
再現コード
// ❌ 入力の検証を例外の発生に頼る
public int? ParseAge(string input)
{
try
{
return int.Parse(input);
}
catch (FormatException)
{
return null;
}
}
なぜ起きるか
.NETでは例外のスローとキャッチに、通常の戻り値でのやり取りより大きな実行コストがかかります(スタックトレースの構築やスタックの巻き戻しが発生するため)。不正な入力が頻繁に来ることが想定される処理(ユーザー入力、外部ファイルのパース、CSV取り込みなど)で例外に頼ると、正常系の処理では現れなかった遅さが、データ件数や不正値の割合が増えた時点で顕在化します。件数が少ない開発環境では差が見えないため、本番の大量データ処理で初めて問題になる典型例です。
正しい書き方
// ◎ 失敗が想定内ならTryParseで例外を発生させない
public int? ParseAge(string input)
{
return int.TryParse(input, out var age) ? age : null;
}
TryParseのように例外を伴わない代替APIが用意されている場合は、そちらを優先します。代替APIがない自前のメソッドでも、「失敗が日常的に起こり得るか」を基準に、日常的ならboolや結果オブジェクトで返し、想定外の異常事態にだけ例外を使うよう使い分けてください(例外を使う判断自体が悪いわけではなく、想定内の失敗を例外にすることが問題です)。
ベストプラクティスまとめ
-
例外を再スローするときは
throw ex;ではなくthrow;を使う -
別の例外型に変換する場合は、元の例外を
InnerExceptionに渡す -
空の
catchを書かない。握りつぶす場合でも最低限ログに記録する -
catch (Exception)は最上位の境界層(ミドルウェア、バッチのエントリポイントなど)に限定し、それ以外は想定する例外型を指定する -
条件付きで捕捉したい場合は例外フィルター(
when)を使う -
失敗が日常的に起こり得る処理には
TryParse系のAPIや戻り値での表現を使い、例外に頼らない
おわりに
例外処理の良し悪しは、正常に動いている間は見えず、障害が起きた時に「ログから原因が分かるか」「性能が保てているか」という形で現れます。throw;・握りつぶしの禁止・例外を制御フローにしない、の3点を意識するだけで、障害時の調査しやすさは大きく変わります。