はじめに
この記事では、実務でデバッグするときに、原因を当てにいかず事実を狭めていく進め方を整理します。
デバッグというと、エラー文を見て原因を思いつき、すぐ修正する作業だと思われがちです。
しかし実際の現場では、その進め方だと再発しやすくなります。
安定するデバッグは、次の流れです。
- 現象を正確に書く
- 再現条件を固定する
- 層ごとに切り分ける
- エディタ機能やデバッガーで実行経路と値を確認する
- 原因候補を一つずつ消す
- 最後に再発防止まで戻す
特定の言語やフレームワークに依存しない、デバッグ全般で使える考え方としてまとめます。
最初に現象を言葉にする
最初にやることは、現象を原因と分けて書くことです。
この段階で原因を書き始めると、調査の視野が狭くなります。
例えば、次のような書き方は危険です。
- DB接続が失敗している
- フロントのイベントが壊れている
- Dockerのキャッシュが悪さをしている
- メモリ不足で落ちている
これらは現象ではなく、原因候補です。
最初は次のように、観測された事実だけを書きます。
- APIを呼ぶと500が返る
- ボタンを押しても一覧が更新されない
- 初回起動時だけビルドに失敗する
- バッチが途中で終了している
- 特定画面だけ400 Bad Requestになる
現象と原因候補を分けるだけで、調査はかなり安定します。
期待値との差分を見る
次に、期待値との差分を確認します。
単に「動かない」と言っても、何がどう違うのかが分からなければ調査できません。
確認したいのは、次のような差分です。
- 期待するレスポンスと実際のレスポンス
- 期待するDB更新と実際のDB更新
- 期待する画面状態と実際の画面状態
- 期待するログと実際に出ているログ
例えば「保存できない」だけでは粗すぎます。
次のように分けると、調査しやすくなります。
- リクエストは送信されているか
- サーバーに到達しているか
- バリデーションで落ちているか
- DB更新まで進んでいるか
- レスポンスは成功しているか
- 画面側の再描画だけが失敗しているか
期待値との差分を見ると、調査対象が自然に狭まります。
さらに、正常に動く処理と比較できるなら、比較対象を先に決めると効果的です。
- 正常系と異常系
- 別画面で同じAPIを使っている処理
- Web版とタブレット版
- 修正前と修正後
- 環境差
- 刷新前の現行実装と、刷新後の実装
特に刷新案件では、現行の仕様と新しい実装の挙動差を見比べることが重要です。
同じ機能でも、処理の分割やデータの流れが変わっているため、差分を明示すると原因候補をかなり絞りやすくなります。
正常系との差分は、原因特定の有力な手掛かりになります。
再現条件を固定する
デバッグで一番つらいのは、再現したりしなかったりする問題です。
ただし、再現しない問題にも、だいたい条件があります。
まずは次の情報を固定します。
- 実行環境
- ブランチ
- 入力値
- ユーザー権限
- DBデータ
- ブラウザ
- 実行時刻
- 操作手順
特に業務アプリでは、ユーザー権限やテストデータの違いで結果が変わることが多いです。
同じ画面を見ているつもりでも、ログインユーザーや組織、権限、対象データが違えば別の条件です。
再現条件を固定できると、原因調査だけでなく、修正後の確認も楽になります。
本来の操作経路で再現できる場合は、まずその経路を優先します。
DB直接更新での再現が必要な場面もありますが、その場合は飛ばした処理を明確に意識します。
処理経路を飛ばすと、原因の位置を誤りやすくなります。
層ごとに切り分ける
原因を探すときは、コード全体を一気に見ない方がよいです。
層ごとに分けて確認します。
Webアプリであれば、ざっくり次のように分けられます。
- ブラウザ
- フロントエンド
- BFFやAPI
- アプリケーションロジック
- DB
- 外部サービス
- 実行環境
例えば、画面で一覧が更新されない場合でも、原因はいろいろあります。
- ボタンのイベントが発火していない
- リクエストが送られていない
- APIは成功しているがレスポンス形式が違う
- DB更新は成功しているが検索条件に引っかからない
- フロント側の状態更新だけが漏れている
画面で起きている問題だからといって、原因が画面にあるとは限りません。
DBで起きている問題だからといって、原因がSQLにあるとも限りません。
層ごとに切ると、見るべき範囲を小さくできます。
複数言語や複数システムをまたぐ場合は、境界地点ごとに値を確認します。
- 送信元画面の入力値
- 送信リクエストの値
- 受信側で受け取った値
- 業務ロジックで使った値
- 永続化時に設定された値
- DBへ反映された値
どの地点から値がおかしくなるかを確認すると、調査範囲を一気に絞れます。
目視だけに頼らずエディタ機能を使う
既存システムでは、目視だけで追うより、早い段階からエディタ機能を使う方が速くて正確なことが多いです。
使う機能は次のとおりです。
- 定義元への移動
- 参照元の検索
- 呼び出し階層の確認
- シンボル検索
- 差分比較
特に更新処理を探すときは、保存処理の呼び出しだけでなく、更新対象フィールドのsetterや代入箇所から逆引きすると見つけやすくなります。
JPAのように変更検知で更新される実装では、persist や merge が見つからなくてもDB更新が起きます。
そのため、次の流れで追うと外しにくくなります。
- 更新されたテーブルとカラムを特定する
- 対応するエンティティやDTOのフィールドを特定する
- setterや代入箇所の参照元を追う
- どの入力値が渡っているかを確認する
ログがないことを事実として扱わない
ログは便利ですが、ログがないことをそのまま事実として扱うのは危険です。
例えば、SQLログが出ていないからといって、SQLが実行されていないとは限りません。
ログレベルやログ設定の問題で出ていないだけかもしれません。
同じように、エラーログがないからといって、問題が起きていないとも限りません。
例外を握りつぶしている場合もあります。
正常終了扱いで、内部的には一部の処理だけ失敗している場合もあります。
ログを見るときは、次を確認します。
- そのログは必ず出る設計か
- 出力レベルは環境ごとに同じか
- エラー時に握りつぶしていないか
- リクエストIDなどで処理を追えるか
- 失敗した処理と成功した処理を区別できるか
ログは事実を取るための道具です。
ただし、ログが出ない理由も調査対象になります。
デバッガーで実行時の値を見る
コードを読んで分からない場合は、デバッガーで実行時の値を見ます。
デバッガーで見るべきなのは、止まった行だけではありません。
- 引数
- 戻り値
- 分岐条件
- ループ中の値
- 例外が発生した場所
- 呼び出し元
- コールスタック
特にコールスタックは重要です。
問題の行が分かっても、なぜそこに到達したのかが分からなければ、修正を誤ります。
例えばnull参照なら、その場でnullチェックを足す前に、なぜnullが渡ってきたかを確認します。
前段処理の失敗、必須データ欠損、呼び出し順序ミスなど、原因の位置を特定してから修正します。
また、ステップ実行は常に細かく入る必要はありません。
次の機能を使い分けると、調査速度が上がります。
- 条件付きブレークポイント
- ヒットカウント
- カーソル行の前まで実行
- ウォッチ式
重要なのは、調べたいことを決めてから止めることです。
何となく止めて眺めるだけでは、事実が増えません。
調査中に迷子になりやすい場合は、次をメモに残します。
- 今どこを見ているか
- 何を否定できたか
- 何が未確認か
- 次に何を確認するか
記録を残すと、同じ検証を繰り返しにくくなります。
仮説は立てるが、すぐ修正しない
デバッグでは仮説が必要です。
ただし、仮説を立てた瞬間に修正へ進むと危険です。
まずは、仮説が正しいか確認します。
例えば、キャッシュが原因だと思ったら、コード修正の前にキャッシュ無効化時の再現有無やキャッシュキーを確認します。
DBデータが原因だと思ったら、問題データと正常データの差分を確認します。
仮説は調査を進めるための道具です。
結論ではありません。
修正はできるだけ小さくする
原因が見えたら、修正はできるだけ小さくします。
デバッグ中は、関連して気になる箇所がたくさん見つかります。
しかし、原因調査と大きなリファクタリングを同時にやると、何で直ったのか分からなくなります。
修正時は次を意識します。
- 原因に対応した変更だけ入れる
- ついでの整形を混ぜない
- 仕様変更を混ぜない
- 修正前後で確認条件を変えない
- 再発防止のテストを足す
もちろん、コードが読みにくすぎて修正できない場合は、先に小さく整理することもあります。
その場合でも、調査用の整理と仕様変更は分けた方がよいです。
直ったあとに原因を説明できるか確認する
修正してテストが通ったあとも、最後に確認したいことがあります。
それは、原因を説明できるかどうかです。
次の形で説明できると、かなり良い状態です。
- 何が起きていたか
- なぜ起きていたか
- どの条件で再現したか
- どこを直したか
- なぜその修正で直るか
- 再発防止として何を確認したか
逆に、次のような状態は危険です。
- なんとなく直った
- 複数箇所を直したので、どれが効いたか分からない
- 手元では直ったが、再現条件を説明できない
- テストはないが、たぶん大丈夫
実務では、直すこと自体も大事ですが、説明できる状態にすることも大事です。
説明できない修正は、レビューもしにくく、再発時の調査も難しくなります。
デバッグでよくある失敗
デバッグでよくある失敗は、次のとおりです。
- 最初に思いついた原因に寄せて調査する
- 再現条件を固定しないまま修正する
- コードを目視だけで追い、実行時の値を確認しない
- ログ未出力を処理未実行と決めつける
- 本番とローカルの設定差分やデータ差分を見落とす
- 一時対応を恒久対応として残す
- 修正後に再発防止テストを足さない
どれも特別な失敗ではありません。
焦っていると普通に起きます。
だからこそ、デバッグの手順を自分の中に持っておくことが大事です。
短い事例
以前、画面の画像表示不具合を調査したとき、原因は画像パスを設定する2行の実装漏れでした。
最初は画面側の描画不具合を疑っていましたが、正常に動く共通部品と比較し、
入力値からレスポンスまでの値を境界ごとに確認したことで、実装漏れに絞り込めました。
別の案件では、JavaとVB.NETをまたぐ在庫更新不具合で、Java側の保存処理を直接探しても見つかりませんでした。
更新カラムに対応するsetterの参照元を逆引きし、Java側の値が正しいことを確認したうえで、境界より前のVB.NET側へ調査範囲を絞れました。
どちらの事例も、勘で当てたのではなく、確認地点を分けて事実を積み上げた結果です。
実務で使いやすいデバッグ手順
実務では、次の順番で進めると大きく外しにくいです。
- 現象を一文で書く
- 期待値との差分を書く
- 再現条件を固定する
- 正常系の比較対象を決める
- 層とシステム境界で確認地点を決める
- エディタ機能、ログ、DB、通信、デバッガーで事実を取る
- 原因候補を消していく
- 原因に対応した小さな修正を入れる
- 再現手順で直ったことを確認する
- 再発防止のテストやログを足す
- 原因と対応を説明できる形で残す
この手順は地味ですが、実務では強いです。
原因を素早く当てられる人が優秀なのではありません。
外れたときにすぐ戻れて、事実に基づいて調査を進められる人が安定します。
最後まで分からないときは二分探索を使う
過去には正常に動いていたのに、今は壊れている。
この条件がはっきりしているなら、コミット履歴の二分探索が有効です。
進め方は単純です。
- 不具合が入りそうな期間を決める
- 中間のコミットで再現有無を確認する
- 正常側と異常側に範囲を分ける
- 収束するまで繰り返す
ただし、これは最初の手段ではありません。
再現条件の固定、正常系比較、境界での値確認を先にやったうえで使う方が効率的です。
まとめ
デバッグは、原因を当てる作業ではありません。
事実を集めて、原因候補を狭める作業です。
現象を言葉にし、差分を取り、再現条件を固定する。
そのうえで、層と境界で切り分け、実行経路と値を確認する。
知らない技術や複雑なシステムに当たったときほど、勘より手順が効きます。
原因を当てに行くより、確実に違うものを消していく。