デバッグの技術
この部の 3 / 9 章 ・ 全体で 15 / 76 章 ・ 読了目安 50 分
- 症状から原因までを手順で詰められる
- 再現手順を作ってから直すという順序を守れる
- 「なぜか動かない」で止まらない
前章で、既存コードを読めるようになりました。次は読んだコードが期待通りに動かない時の話です。
実務でコードを書いている時間より、動かない理由を探している時間の方が長い日はよくあります。 それなのに、デバッグのやり方を体系的に教わる機会はほとんどありません。
この章は精神論ではなく、手順と道具の話をします。
デバッグは推理ではなく手順
「なんとなく直った」が最悪の結果
デバッグの結果は、良い順に3つあります。
| 結果 | 状態 |
|---|---|
| ✅ 最良 | 原因を特定して直した。なぜ壊れ、なぜ直ったかを説明できる |
| ⚠️ 次善 | 原因は特定したが、根本対処はできず暫定回避した。それを記録してある |
| ❌ 最悪 | なんとなく直った。なぜ直ったか分からない |
3番目が最悪なのは、直っていない可能性が高いからです。
- たまたま条件が揃わなくなっただけで、本番で再発します
- 別の場所に同じ原因のバグが残っていても気づけません
- 「触ったら直った」コードは、次の人が触ると再び壊れます
なぜ直ったか説明できないなら、それは直っていません。
これがこの章で一番大事な一文です。 「とりあえず色々変えたら通った」で終わらせないために、以下の手順があります。
手順の全体像
① 再現手順を作る 確実に再現する最小の手順を書き出す
↓
② エラーを読む メッセージとスタックトレースを最後まで読む
↓
③ 範囲を半分にする どこまでは正しいかを確かめ、二分探索する
↓
④ 仮説を1つ検証する 1回に1つだけ変える。違ったら戻す
↓
⑤ 直す 原因を説明できる状態で直す
↓
⑥ テストを1本書く 同じバグが二度と通らないようにする
この順番を飛ばすほど時間がかかります。 新人がやりがちなのは ① と ② を飛ばして、いきなり④の「なんとなく直してみる」に入ることです。
先輩が一瞬で原因を当てるのは、勘が鋭いからではありません。 過去に同じ壊れ方を見たことがあるか、この手順を高速で回しているかのどちらかです。 前者は経験なので今日は真似できませんが、後者は今日から真似できます。 そして手順を持っている人は、初めて見るバグでも必ず原因に到達します。
ステップ1: まず再現手順を作る
再現できないバグは直せない
デバッグで最初にやるのは、コードを読むことでも直すことでもありません。 手元で確実に再現させることです。
再現できないと、こうなります。
- 原因を調べる手段がない(実際に動かして確かめられない)
- 直したつもりでも、直ったことを確認できない
- 「最近見ないので直ったと思います」という報告になる
逆に言うと、再現手順ができた時点で半分終わっています。 残りは範囲を絞る作業で、これは機械的にできます。
再現手順に必要な情報
「ログインするとエラーになる」では再現できません。次を埋めます。
| 項目 | 例 |
|---|---|
| 前提データ | 注文が0件のユーザー、クーポン適用済みの注文 |
| 操作の順番 | 一覧 → 詳細 → 「戻る」→ 再度詳細 |
| 入力値 | 数量に 0 を入れる、名前に絵文字を含める |
| 環境 | ローカル / staging、ブランチ、Node のバージョン |
| 頻度 | 毎回か、5回に1回か(ここは必ず書く) |
| 期待と実際 | 期待: 一覧が表示される / 実際: 500 が返る |
「5回に1回」は、それ自体が強いヒントです。 毎回でないなら、時刻・順序・並行実行・キャッシュ・乱数・外部依存のどれかが絡んでいます。
再現条件を削っていく(最小再現)
再現できたら、次は条件を1つずつ削って、なくても再現するかを確かめます。
最初: ログイン → 商品検索 → カートに3つ入れる → クーポン適用 → 決済 → 落ちる
↓ ログインを消す → 落ちない → ログインは必要
↓ 検索をやめて直接URLへ → 落ちる → 検索は不要(削除)
↓ カートを1つにする → 落ちる → 3つは不要(削除)
↓ クーポンを外す → 落ちない → クーポンは必要
最小: ログイン → 商品1つ → クーポン適用 → 決済 → 落ちる
削ったあとに残った条件が、そのまま原因の候補です。 上の例なら「クーポン適用時の金額計算」に一気に絞れました。
最小再現ができたら、それをそのままテストコードに書き写せます。
it('クーポン適用済みの注文で合計が計算できる', () => {
const order = makeOrder({ items: [item(1000)], coupon: coupon(500) });
expect(calcTotal(order)).toBe(500);
});このテストは今は落ちます。落ちるテストがあるということは、 直った瞬間に緑になるということで、「直った」の判定が自動化されます。 この章の最後(再発防止)とここは繋がっています。
新人が最も信頼を失うのは、これです。
- 「再現しなかったので、たぶんこれが原因だと思って直しました」
- 「エラーが出なくなったので直ったと思います」
どちらも直っていない可能性が半分以上あります。 再現できないなら、正直にそう言ってください。
「手元で再現できていません。再現条件を教えてもらえますか。 発生時刻とユーザーIDが分かればログから追えます」
これは無能な発言ではなく、正しい進め方です。 再現できないバグを推測で直すと、原因が残ったまま「対応済み」になり、 数週間後にもっと悪い形で再発します。
ステップ2: エラーメッセージを読む
読まずに検索しない
エラーが出た瞬間に、メッセージをコピーして検索していませんか。 その前に、まず日本語に訳して読んでください。答えが書いてあることが本当に多いです。
Error: connect ECONNREFUSED 127.0.0.1:5432これは「127.0.0.1 の 5432 番ポートに繋ごうとしたが、接続を拒否された」です。
5432 は PostgreSQL の標準ポートなので、「DB が起動していない」か「別のポートで動いている」。
検索する前に docker ps を見れば10秒で終わります。
頻出メッセージの意味
現場で毎週見るものだけ並べます。意味を覚えると、検索する回数が激減します。
| メッセージ | 意味 | まず疑うこと |
|---|---|---|
undefined is not a function / x is not a function | 関数だと思って呼んだものが関数ではない | import 名の綴り、default / named の取り違え、this の消失 |
Cannot read properties of undefined (reading 'name') | undefined.name を読もうとした | 前の行の戻り値が undefined。API のレスポンス形が想定と違う |
nil pointer dereference(Go) | nil のポインタを参照した | エラーを無視して戻り値を使った、初期化していないフィールド |
index out of range [3] with length 3(Go) | 配列の範囲外アクセス | ループ条件の <=、空スライス |
connection refused | 相手はいるが誰も待ち受けていない | プロセス未起動、ポート違い、コンテナ内から localhost を見ている |
connection timeout | 応答が返ってこない | 相手に届いていない。ホスト名違い、ファイアウォール、Security Group |
no such host / ENOTFOUND | 名前解決に失敗 | ホスト名の綴り、DNS、サービス名の間違い |
EADDRINUSE | そのポートは既に使われている | 前のプロセスが残っている(後述のチェックリスト) |
401 Unauthorized | 認証されていない(あなたが誰か分からない) | トークン未送信・期限切れ |
403 Forbidden | 認証はできたが権限がない | ロール、スコープ、他人のリソース |
404 Not Found | パスが違う | URL の綴り、ルーティング、/api の付け忘れ |
500 Internal Server Error | サーバー側で例外が出た | クライアントを見ても無駄。サーバーのログを見る |
ENOENT: no such file or directory | ファイルが無い | 相対パスの基準ディレクトリ違い |
Permission denied | 権限不足 | 実行権限、所有者、ボリュームのマウント権限 |
この2つを取り違えると、調査の方向が丸ごと変わります。
- 401: 「あなたが誰か分からない」。トークンが無い・壊れている・期限切れ
- 403: 「あなたが誰かは分かった。でもそれをする権限が無い」
401 なのに権限設定を1時間見直す、というのは実際によくある時間の溶かし方です。 まず認証まで通っているのかを切り分けてください。
最初と最後を両方読む
エラーが数十行あると、つい一番上だけ見て閉じてしまいます。 実際には、本当の原因は末尾に書かれていることが多いです。
Error: Failed to compile
...
Caused by: Cannot find module '@/domain/order'Caused by、原因:、the above exception was the direct cause of といった語の後ろが本体です。
長いログは「最初の10行」と「最後の10行」を必ず読んでください。
ステップ3: スタックトレースを読む
スタックトレースは、エラーが起きるまでに通った関数の呼び出し履歴です。 読み方さえ知っていれば、原因の場所がほぼ書いてあります。
どこから読むか
原則は3つだけです。
- 一番上が、実際に落ちた場所(最も内側の呼び出し)
- 下に向かうほど、呼び出し元をさかのぼる
node_modulesや標準ライブラリ・フレームワークの行は飛ばす
そして実務で一番使うのはこれです。
上から読んで、最初に出てくる「自分のコードの行」を見る。
フレームワークの中で落ちていても、原因の9割はあなたが渡した値にあります。
TypeScript のスタックトレース
TypeError: Cannot read properties of undefined (reading 'displayName')
at formatUser (/app/src/user/format.ts:12:22)
at /app/src/user/list.ts:31:26
at Array.map (<anonymous>)
at buildUserList (/app/src/user/list.ts:30:20)
at getUsersHandler (/app/src/routes/users.ts:18:18)
at Layer.handle (/app/node_modules/express/lib/router/layer.js:95:5)
at next (/app/node_modules/express/lib/router/route.js:144:13)
at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)読む順番はこうです。
| 行 | 見方 |
|---|---|
| 1行目 | 何が起きたか。undefined の displayName を読もうとした |
format.ts:12:22 | 落ちた場所。ここを開く。12 行目の 22 文字目 |
list.ts:31 / list.ts:30 | 呼び出し元。map の中で formatUser を呼んでいる |
routes/users.ts:18 | さらに呼び出し元。ここがハンドラの入口 |
node_modules/express/... | 読まなくてよい。express の内部 |
ここから分かるのは「formatUser に渡された値のどれかが undefined だった」ことです。
なので次にやるのは list.ts:31 で何を渡しているかを実際に出して確かめることであって、
format.ts を睨むことではありません。
await を挟むと、スタックトレースが呼び出し元まで繋がらないことがあります。
Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)自分のコードが1行も出てきません。こうなったら、
Error.captureStackTraceを持つエラーを投げ直す(throw new Error('...', { cause: err }))- 呼び出し側で
try / catchして、どの処理中だったかをログに足す - Node なら
--async-stack-traces(既定で有効)や、フレームワークのエラーハンドラを確認する
「どこで落ちたか分からないエラー」は、まず場所が分かる形に加工してから追います。
Go のスタックトレース
Go の panic は、より多くの情報が出ます。
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x104a2c8]
goroutine 34 [running]:
main.(*OrderService).Total(0x0, {0x14000112000, 0x2, 0x2})
/app/order/service.go:48 +0x28
main.(*Handler).CreateOrder(0x14000104018, {0x1050e40, 0x14000138000}, 0x1400013a000)
/app/handler/order.go:73 +0x114
net/http.HandlerFunc.ServeHTTP(0x14000106020, {0x1050e40, 0x14000138000}, 0x1400013a000)
/usr/local/go/src/net/http/server.go:2220 +0x38
net/http.(*ServeMux).ServeHTTP(0x14000110000, {0x1050e40, 0x14000138000}, 0x1400013a000)
/usr/local/go/src/net/http/server.go:2747 +0x1a4見る場所は3つです。
| 見る場所 | 分かること |
|---|---|
nil pointer dereference | nil を参照した。多くは戻り値の err を無視した結果 |
main.(*OrderService).Total(0x0, ...) | 落ちた関数。第1引数の 0x0 はレシーバが nil という決定的な証拠 |
/app/order/service.go:48 | 関数名の次の行にファイルと行番号が出る |
/usr/local/go/src/net/http/... | 標準ライブラリ。飛ばす |
(0x0, ...) に気づけるかどうかで、調査時間が10分と2時間に分かれます。
Total の中を読む前に、OrderService が nil のまま呼ばれていることが確定しているからです。
つまり見るべきは handler/order.go:73 の側、サービスの初期化漏れです。
なお、Go の panic は実行中の全 goroutine を出力することがあり、数百行になります。
読むのは goroutine ... [running]: と書かれたブロックだけです。
[chan receive] や [select] の goroutine は待機中であって犯人ではありません。
ただしデッドロック(all goroutines are asleep - deadlock!)の時だけは逆で、
待っている goroutine 全部が手がかりになります。
ステップ4: 二分探索で範囲を半分にする
再現でき、落ちた場所も分かった。それでも原因が分からない時に効く、最も機械的な武器です。
考え方は「どこが悪いか」ではなく「どこまでは正しいか」です。
コード上の二分探索
処理の流れの真ん中で、値を確かめます。
[入力] → A → B → C → D → E → [出力がおかしい]
↑
まずここで値を見る
- C の時点で値が正しい → 原因は D か E
- C の時点で既におかしい → 原因は A か B
これを繰り返せば、10段の処理でも3〜4回で場所が確定します。 「入力から順に全部追う」より圧倒的に速く、しかも当てずっぽうが入りません。
確かめるポイントは、境界を選ぶと効率が良いです。
| 境界 | 確かめること |
|---|---|
| 関数の入口 | 渡ってきた引数は想定通りか |
| API を呼ぶ直前 | 送っているリクエストの中身 |
| API のレスポンス直後 | 返ってきた生のボディ(パース前) |
| DB クエリの直前 | 実際に発行される SQL とパラメータ |
| 画面に出す直前 | 描画用に整形したあとの値 |
「送っているもの」と「返ってきたもの」を実際に見るだけで、 自分のバグか相手のバグかが切り分けられます。これが一番大きい分岐です。
git bisect で履歴を二分探索する
「先週は動いていたのに、今日は動かない」——このタイプはコードではなく履歴を二分探索します。
git bisect は、good なコミットと bad なコミットを指定すると、
その間を二分探索して壊したコミットを特定してくれる機能です。
# 1. 開始
git bisect start
# 2. 今(HEAD)は壊れている
git bisect bad
# 3. 動いていたことが分かっている地点を good と伝える
git bisect good v1.4.0
# → git が中間のコミットを checkout する
# Bisecting: 87 revisions left to test after this (roughly 7 steps)
# 4. 毎回、再現手順を実行して判定する
git bisect good # 再現しなかった
git bisect bad # 再現した
# ... これを7回ほど繰り返すと
# a1b2c3d is the first bad commit
# 5. 必ず後片付けする(元のブランチに戻る)
git bisect reset1000 コミットあっても10回で終わります(毎回半分になるため)。 手で1つずつ戻すのとは比較になりません。
判定コマンドがある場合は、全自動にできます。
# テストで判定できるなら run に渡すだけ
git bisect start HEAD v1.4.0
git bisect run npm test -- tests/order.test.ts
# 任意のスクリプトでもよい(終了コードで判定する)
git bisect run ./scripts/repro.shgit bisect run の判定は終了コードで行われます。
| 終了コード | 意味 |
|---|---|
0 | good(再現しない) |
1〜124, 126, 127 | bad(再現する) |
125 | 判定できない(ビルドが通らない等)→ そのコミットをスキップ |
git bisect は「そのコミットで再現するか」を毎回判定できることが前提です。
つまり、ステップ1の再現手順が無いと使えません。
ここでも「再現手順ができた時点で半分終わっている」が効いてきます。 逆に、bisect のためだけに簡単な再現スクリプトを1本書く価値は十分にあります。
「先週のリリースまでは動いていた機能が、今日は動きません」。差分は 200 コミットあります。最も速い進め方はどれですか。
ステップ5: 仮説を1つずつ検証する
同時に複数変えない
範囲が絞れたら、いよいよ原因の仮説を立てて検証します。ここでのルールはひとつです。
1回の試行で変えるのは1つだけ。
3箇所同時に変えて動いたとき、あなたは何も学んでいません。
- どれが効いたのか分からない(=なぜ直ったか説明できない)
- 残りの2つは、無関係な変更として本番に混入します
- そのうち1つが、別のバグを新しく作っているかもしれません
変えたら戻す
効かなかった変更は必ず戻してください。 これを怠ると、 デバッグの終わりに「何をどう変えたのか分からないコード」が残ります。
# 今の作業を一時退避してから実験する
git stash
# 実験して外れたら、まとめて捨てる
git checkout -- src/order/service.ts
# 何を変えたか分からなくなったら、まず全部見る
git diffデバッグ中は git diff を頻繁に見る癖をつけてください。
「デバッグ用の print が本番に混入する」事故の大半はこれで防げます。
検証した内容を書き留める
30分以上かかりそうなら、メモを取りながら進めます。手元のテキストファイルで十分です。
症状: クーポン適用時だけ合計が 0 になる
再現: ログイン → 商品1つ → クーポン500円 → 決済 (毎回)
仮説1: クーポン額が商品額を超えている → 商品1000円なので違う ❌
仮説2: calcTotal が nil を受け取っている → ログ出力: coupon は入っている ❌
仮説3: 割引後に再度割引が適用されている → discount が2回呼ばれていた ✅これを書くと、3つの効果があります。
- 同じ仮説を2回検証してしまう無駄が消える
- 人に相談する時、そのまま貼れる(30分ルールで聞く時に強い)
- 直したあと、PR の説明文とテストの根拠になる
道具: デバッガと print の使い分け
デバッガでできること
print を仕込んで再実行する代わりに、動いているプログラムを止めて中を見る道具です。
| 機能 | できること |
|---|---|
| ブレークポイント | 指定した行で実行を止める |
| ステップ実行 | 1行ずつ進める |
| 変数の観察 | その時点の全変数の値を見る。式を評価もできる |
| コールスタック | どこから呼ばれてここに来たかを遡る |
| 条件付きブレークポイント | 条件を満たす時だけ止める |
print と決定的に違うのは、止めた時点で「全部」見られることです。 print は「出すと決めた変数しか見えません」。 「あ、あの変数も見たかった」で再実行する回数がゼロになります。
ステップ実行の3種類
ここだけは用語を覚えてください。IDE のボタンの意味が分かります。
| 操作 | 動き | 使う場面 |
|---|---|---|
| ステップオーバー(over) | 次の行へ進む。関数呼び出しは中に入らず実行する | 既に正しいと分かっている関数を飛ばす。基本はこれ |
| ステップイン(into) | 呼び出している関数の中に入る | その関数が怪しい時 |
| ステップアウト(out) | 今の関数を最後まで実行して、呼び出し元に戻る | 間違って into して深く入りすぎた時 |
初心者がやりがちなのは、全部 into で入ってフレームワークの奥深くに迷い込むことです。
基本は over、怪しい関数だけ into、深入りしたら out と覚えてください。
条件付きブレークポイント
デバッガが print に圧勝するのがここです。 1000件のループの中で、特定の1件だけ止められます。
VS Code なら、ブレークポイントを右クリックして条件を書きます。
orderId === 'o_1042'
items.length === 0
i > 500Go の delve なら、コマンドで同じことができます。
dlv debug ./cmd/api
(dlv) break order/service.go:48 # ブレークポイント1 を設定
(dlv) condition 1 o.ID == "o_1042" # 条件を付ける
(dlv) continue # 条件に合うまで走らせる
(dlv) locals # ローカル変数を全部見る
(dlv) print o.Items # 式を評価する
(dlv) next # ステップオーバー
(dlv) step # ステップイン
(dlv) stepout # ステップアウトNode(TypeScript)は、VS Code の実行ボタンが最も簡単ですが、CLI からも繋げます。
# 1行目で止めた状態で起動し、デバッガの接続を待つ
node --inspect-brk dist/index.js
# tsx / ts-node なら
node --inspect-brk -r tsx/cjs src/index.ts接続先は Chrome の chrome://inspect か、VS Code の Attach 構成です。
print / ログの方が有効な場面
デバッガが常に最善ではありません。むしろ print の方が速い場面があります。
| 場面 | 向いている道具 | 理由 |
|---|---|---|
| 1箇所の値を見たい | 起動より速い | |
| 複雑なオブジェクトの中身を追いたい | デバッガ | 全部見える |
| ループ1000回のうち1回を見たい | 条件付きブレークポイント | 手で1000回進めない |
| 時間に依存する不具合(並行処理、タイムアウト) | ログ | 止めると再現しなくなる |
| 本番・staging で起きている | ログ | デバッガは繋げない |
| テストが落ちる理由を知りたい | デバッガ | テストにブレークポイントを置ける |
並行処理・レースコンディション・タイムアウト絡みの不具合は、 ブレークポイントで止めた瞬間にタイミングが変わり、再現しなくなります。
「デバッガを繋ぐと直る」バグは、ほぼ確実にタイミングの問題です。 この場合はログ(時刻・goroutine ID・スレッド ID 付き)で追ってください。 監視とオンコールの構造化ログが、ここでも同じように効きます。
console.log('ここ通った') や fmt.Println("aaa") を消し忘れて
レビューに出す事故は、新人が最もよくやるものの1つです。
- コミット前に 必ず
git diffを全部読む - 目印を付けておく(
// DEBUG:と書いて、後で grep する) - lint で
no-consoleを有効にしているリポジトリなら CI が止めてくれる
そして、本番に残す価値のあるものはログとして構造化して残します。 「消すか、正式なログにするか」の二択で、中間はありません。
思い込みを外す
「ここは絶対正しい」が一番怪しい
3時間溶かすバグは、ほぼ例外なくここに潜んでいます。
- 「この設定ファイルは読まれているはず」
- 「この関数は前から動いているから正しい」
- 「保存したから最新のコードが動いているはず」
「はず」は検証されていない仮説です。 そして、あなたが確認を飛ばした場所こそが、探しても見つからない理由です。
対処はひとつだけです。
実際に値を出して確かめる。
// 「読めているはず」を確かめる。マスクして出す
console.log('DB_HOST =', process.env.DB_HOST);
console.log('API_KEY set?', Boolean(process.env.API_KEY));// この関数に本当に来ているか、引数は何か
log.Printf("CreateOrder: userID=%q items=%d", userID, len(items))そもそも自分のコードが動いていないことがある
もう1つの大物がこれです。直したコードが実行されていないパターン。
// 一番上に置いて、まず「このファイルが読まれているか」を確かめる
console.log('>>> service.ts loaded at', new Date().toISOString());これが出なければ、コードのロジックをいくら読んでも無駄です。疑うのは次です。
- ビルドし直していない(
dist/が古い) - ホットリロードが効いていない
- 別のディレクトリ・別のブランチを編集している
- Docker のイメージが古い(
--buildしていない) - ブラウザがキャッシュした JS を使っている
環境・キャッシュ・ビルド成果物を疑う
「コードは正しいのに動かない」時の定番です。効く順に並べます。
# 1. 本当に最新が動いているか(ビルド成果物を消す)
rm -rf dist .next node_modules/.cache
npm run build
# 2. Go のビルドキャッシュ
go clean -cache && go build ./...
# 3. Docker が古いイメージを使っていないか
docker compose up --build
# 4. ブラウザのキャッシュ(DevTools を開いてハードリロード)これも「なんとなく直った」の一種です。なぜ古い成果物が使われていたのかを確認してください。
- ビルドの出力先と、実行時に読むパスがずれている
.dockerignoreの設定で、変更したファイルがイメージに入っていない- watch の対象ディレクトリから外れている
原因を放置すると、チーム全員が毎回クリーンビルドする文化が生まれます。 1人あたり1日10分の損失が、そのままチームの損失になります。
よくある原因のチェックリスト
原因が思いつかなくなったら、上から順に実際に確認してください。 「たぶん大丈夫」で飛ばさないこと。ここに挙げたものは全部、実際に起きます。
| # | 疑うこと | 確認方法 |
|---|---|---|
| 1 | 環境変数が読めていない | 実際に出力する。.env の場所、読み込みの順序、NEXT_PUBLIC_ などの接頭辞 |
| 2 | キャッシュが古い | ビルド成果物・ブラウザ・CDN・ORM のクエリキャッシュを消す |
| 3 | 別のプロセスが動いている | lsof -i :3000 / ps aux | grep node。古いサーバーが応答している |
| 4 | ポートが違う | 起動ログのポートと、叩いている URL を突き合わせる |
| 5 | 保存していない | 編集中のタブに未保存マークが無いか。本当にあります |
| 6 | 見ているログが別のインスタンス | Pod / コンテナが複数ある。ログにホスト名を入れる |
| 7 | 型は合っているが値が空 | [] / "" / {} は型検査を通る。空かどうかを実際に見る |
| 8 | 別のブランチ・別のディレクトリを見ている | git status と pwd |
| 9 | 依存が入っていない・古い | npm ci / go mod tidy してからもう一度 |
| 10 | 相手のサービスが落ちている | 自分のコードを疑う前に、依存先に直接 curl する |
| 11 | DB のデータが想定と違う | コードではなく実データを SQL で見る |
| 12 | タイムゾーン・文字コード | UTC と JST、絵文字を含む文字列 |
特に効くのは7番です。TypeScript や Go の型検査を通っていると、 「値も正しいはず」と錯覚します。しかし型が保証するのは形であって、中身ではありません。
const users = await fetchUsers(); // 型は User[] で正しい
// 実際は [] が返っていて、画面に何も出ないこのとき壊れているのは表示側ではなく、取得側のクエリ条件です。 型エラーが出ないバグほど、値を出して確かめる価値があります。
詰まった時の脱出
時間を決める
この教科書の使い方の30分ルールは、デバッグでもそのまま使います。 30分進まなければ手を止めてください。 判断基準はシンプルです。
| 状態 | 続けてよいか |
|---|---|
| 範囲が狭まっている(さっきより候補が減った) | 続けてよい |
| 同じ場所を2回目に見ている | 危険信号 |
| 何を試したか思い出せない | 止める。人に聞く |
デバッグは、進捗が見えにくいので無限に時間を溶かせます。 「もう少しで分かりそう」という感覚は、たいてい当たりません。
聞く時は、この教科書の使い方の3点セットに、この章のメモを足すだけです。
「注文の合計が0になる件、手元で毎回再現しています。
calcTotalに渡る時点では正しくて、割引適用後に0になっているところまでは絞りました。 割引が2回かかっている可能性を見ていますが、なぜ2回呼ばれるかが分かりません」
ここまで書けると、相手は5分で答えられます。
人に説明すると自分で気づく
説明の途中で「あ、分かりました」となる現象は、冗談ではなく本当に頻繁に起きます。 ラバーダック・デバッグ(アヒルのおもちゃに説明する)と呼ばれる有名な手法です。
理由は単純で、説明するには前提を言語化しないといけないからです。 「ここは正しいはずで……いや、確かめてないな」と、自分の思い込みが露出します。
相手は人でなくても構いません。Slack の下書き、ノート、AI への説明でも同じ効果があります。 書き出す時点で半分解決しているので、送る前に自己解決したら、 そのまま「自己解決しました、原因は〜でした」と投げると、チームの知識になります。
寝る
真面目な話です。疲れているとデバッグの効率は劇的に落ちます。
- 同じ場所を何度も読む(見ているのに見えていない)
- 思い込みから抜けられなくなる
- 焦って複数箇所を同時に変え始める
3時間詰まったものが、翌朝10分で解けることは珍しくありません。 深夜に本番のコードを触るのは、最も事故が起きやすい状態でもあります。
そして中断する時は、必ずメモに1行残してください。
次: discount が2回呼ばれる理由を調べる。applyCoupon の呼び出し元を grep する翌朝の再開が5分早くなるだけでなく、別の人が引き継げる状態になります。 これは監視とオンコールの障害対応の記録と同じ考え方です。
直したら再発を防ぐ
必ずテストを1本書く
デバッグは、直した時点では終わっていません。最後に必ずテストを1本追加します。
手順はこうです。
1. バグを再現するテストを書く → 落ちることを確認する(赤)
2. コードを直す → 通ることを確認する(緑)
3. わざとコードを元に戻してみる → 再び落ちることを確認する
3番目が重要です。落ちないテストは、何も守っていません。 「テストを書いたのに、実は壊しても緑のまま」という事故はよくあります。
書くテストは、ステップ1で作った最小再現がそのまま使えます。 だから最小化しておく価値があるわけです。詳しい書き方はテストを書くを参照してください。
// バグ: クーポン適用時に割引が2回かかっていた
it('クーポンの割引は1回だけ適用される', () => {
const order = makeOrder({ items: [item(1000)], coupon: coupon(300) });
expect(calcTotal(order)).toBe(700); // 400 ではない
});記録を残す
もう1つ、なぜ壊れていたかを文章で残します。置き場所は次のどれかです。
| 場所 | 書くこと |
|---|---|
| コミットメッセージ・PR の説明 | 症状・原因・直し方。未来の git blame で読まれる |
| コード中のコメント | なぜこうしたかが自明でない時だけ1行 |
| チケット | 再現手順と、影響範囲 |
fix: クーポンの割引が2回適用される不具合を修正
applyDiscount がハンドラと calcTotal の両方から呼ばれていたため、
クーポン適用済みの注文で割引が二重にかかっていた。
calcTotal 側に集約し、ハンドラからの呼び出しを削除する。同じ形のバグが他に無いか探す
これができると、一気に「新人ではない仕事」になります。 1箇所で起きたバグは、同じパターンが他にもあることが多いためです。
# 同じ関数の呼び出し箇所を全部見る
grep -rn "applyDiscount(" src/
# エラーを無視している箇所を探す(Go)
grep -rn "_ = .*err" .監視とオンコールの再発防止と同じで、「その1箇所を直した」で止めないことが価値になります。
実務の落とし穴まとめ
- 再現手順を作らずに直し始める — 直ったかどうかを確認できない
- 「なんとなく直った」で完了にする — ほぼ再発する。説明できないなら直っていない
- エラーメッセージを読まずに検索する — 答えが本文に書いてある
- スタックトレースの1行目だけ見る — 自分のコードの最初の行と、末尾の
Caused byを読む node_modulesの中を読み込む — 原因の9割は自分が渡した値- 一度に複数箇所を変える — どれが効いたか分からなくなる
- 効かなかった変更を戻さない — デバッグ用コードが本番に混入する
- 「ここは絶対正しい」を確認しない — そこが原因
- クリーンビルドで直って終わりにする — なぜ古い成果物が使われたかを潰す
printの消し忘れ — コミット前にgit diffを全部読む- 3時間ひとりで粘る — 30分で止める。説明する過程で解けることも多い
- 直してテストを書かない — 同じバグが半年後に戻ってくる
まとめ
- デバッグは推理ではなく手順。なぜ直ったか説明できないなら直っていない
- 最初にやるのは再現手順を作ること。条件を削って最小化する。できた時点で半分終わり
- エラーメッセージは読んでから検索する。401 と 403、
connection refusedとtimeoutの違いは覚える - スタックトレースは上から読んで、最初の自分のコードの行を見る。フレームワークの奥は飛ばす
- Go の
(*T).Method(0x0, ...)はレシーバが nil という決定的なヒント - 「どこが悪いか」ではなく 「どこまでは正しいか」 で範囲を半分にする
- 履歴の二分探索は
git bisect。1000コミットでも10回。git bisect runで全自動にできる - 仮説は1回に1つ。効かなかった変更は必ず戻す
- デバッガは条件付きブレークポイントが本領。時間に依存する不具合とリモートはログ
- 「絶対正しい」が一番怪しい。値を実際に出す。環境変数・キャッシュ・古いビルド成果物・別プロセスを疑う
- 30分で止める。人に説明すると自分で気づく。寝ると解ける
- 直したら必ずテストを1本。赤→緑を確認し、原因をコミットメッセージに残す
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| Chrome DevTools(日本語) | https://developer.chrome.com/docs/devtools?hl=ja |
| Delve(Go のデバッガ) | https://github.com/go-delve/delve/tree/master/Documentation |
| Node.js デバッグガイド | https://nodejs.org/ja/learn/getting-started/debugging |
| git bisect | https://git-scm.com/docs/git-bisect |
章末問題
API のレスポンスが空になる不具合を調べています。3箇所を同時に修正したところ、正常に動くようになりました。次にどうすべきですか。
デバッグができるようになると、知らない技術のコードでも触れるようになります。 仕組みを全部理解していなくても、再現して、範囲を絞って、値を確かめれば原因に辿り着けるからです。
次の章では、コードではなく文章を書きます。設計ドキュメント、手順書、相談の文面—— 新人が最初に評価されるのは、実はここです。