Skip to content

Split View: 과학자처럼 디버깅하기: 가설 기반 디버깅

|

과학자처럼 디버깅하기: 가설 기반 디버깅

들어가며 — 디버깅은 추측이 아니다

버그가 떴습니다. 프로덕션에서 결제가 가끔 실패합니다. 재현은 안 되고, 로그는 애매하고, 마감은 오늘입니다. 이럴 때 많은 개발자가 하는 일은 이렇습니다. 코드를 노려보다가 "여기가 수상한데" 싶은 줄을 바꾸고, 다시 돌려 보고, 안 되면 또 다른 줄을 바꾸고... 이걸 저는 **샷건 디버깅(shotgun debugging)**이라 부릅니다. 총알을 사방에 뿌리다 보면 언젠가 맞겠지 하는 방식이죠.

문제는 이게 가끔 통한다는 겁니다. 그래서 습관이 됩니다. 하지만 운으로 고친 버그는 왜 고쳐졌는지 모르고, 왜 고쳐졌는지 모르면 다음에 또 만납니다. 더 나쁜 건, 고치는 과정에서 멀쩡한 코드를 세 군데 더 건드려 놓는다는 겁니다.

이 글의 주장은 단순합니다. 디버깅은 과학이다. 과학자가 자연을 이해하는 방법—가설을 세우고, 그 가설이 참이라면 무슨 일이 일어날지 예측하고, 실험으로 검증하고, 관찰 결과로 가설을 수정하는—을 그대로 코드에 적용하면 됩니다. 운이 아니라 방법으로 버그를 잡는 겁니다.

과학적 방법 — 가설, 예측, 실험, 관찰

버그를 만났을 때의 루프는 정확히 과학적 방법입니다.

  1. 관찰(observe): 무슨 일이 일어나는가? 정확히 무엇이 잘못되었나?
  2. 가설(hypothesize): 왜 그런지에 대한 검증 가능한 설명을 하나 세운다.
  3. 예측(predict): 그 가설이 참이라면, 내가 X를 하면 Y가 관찰될 것이다.
  4. 실험(experiment): X를 한다.
  5. 관찰: 실제로 Y가 나왔는가? 나왔으면 가설이 강해지고, 안 나왔으면 가설을 버리거나 수정한다.

핵심은 3번, 예측입니다. 좋은 디버깅과 나쁜 디버깅을 가르는 지점이 여기입니다. 코드를 바꾸기 전에 "이걸 바꾸면 무슨 일이 일어날 것"이라고 소리 내어 말할 수 있어야 합니다. 예측 없이 코드를 바꾸는 건 실험이 아니라 그냥 도박입니다.

예를 들어 봅시다. "결제가 가끔 실패한다"는 관찰에서 출발합니다.

  • 나쁜 접근: "타임아웃일지도 몰라. 타임아웃 값을 늘려 보자." (예측 없음, 왜 늘리는지 모름)
  • 좋은 접근: "가설: 카드사 API가 3초 넘게 걸릴 때 우리 클라이언트가 2초에서 끊는다. 예측: 이게 맞다면, 실패한 요청의 로그에는 반드시 타임아웃 에러가 찍혀 있어야 하고, 성공한 요청은 전부 2초 이내여야 한다. 실험: 실패/성공 요청의 응답 시간 분포를 뽑아 본다."

두 번째 접근은 실험이 실패하더라도 배우는 게 있습니다. 실패한 요청이 타임아웃이 아니라 500 에러였다면, 가설은 틀렸지만 이제 원인이 카드사가 아니라 우리 쪽 서버라는 걸 압니다. 틀린 가설도 정보를 준다는 게 이 방법의 힘입니다.

가설은 반드시 **반증 가능(falsifiable)**해야 합니다. "네트워크가 이상해서 그래" 같은 건 검증할 수 없으니 가설이 아닙니다. "특정 리전의 요청만 실패한다"는 검증할 수 있으니 가설입니다.

문제 공간을 이분 탐색하라

버그를 잡는 가장 강력한 단일 기법은 **이분 탐색(binary search)**입니다. 정렬된 배열에서 값을 찾을 때만 쓰는 게 아닙니다. 문제의 원인을 찾는 데 그대로 씁니다.

원리는 이렇습니다. 버그가 일어나는 지점(증상)과 코드가 정상인 지점(입력) 사이 어딘가에 원인이 있습니다. 그 사이의 중간 지점을 하나 잡아서 "여기까지는 정상인가?"를 물으면, 한 번의 실험으로 탐색 공간이 절반으로 줄어듭니다. 이걸 반복하면 수천 줄의 코드도 열 번 남짓의 실험으로 좁혀집니다. 2의 10제곱이 1024니까요.

구체적으로 이분 탐색을 걸 수 있는 축은 여러 개입니다.

  • 코드 경로: 요청이 A → B → C → D를 거친다면, C 시점에 값을 찍어 본다. 값이 정상이면 원인은 CD 사이, 이상하면 AC 사이.
  • 시간(커밋 히스토리): 어제는 됐는데 오늘 안 된다면, 그 사이 커밋들을 이분 탐색한다. (이게 바로 뒤에 나올 git bisect입니다.)
  • 입력 데이터: 10만 줄짜리 입력에서 크래시가 난다면, 5만 줄로 잘라서 재현되는지 본다. 재현되면 앞쪽 절반, 안 되면 뒤쪽 절반에 문제 데이터가 있다.
  • 설정/의존성: 기능 플래그를 절반씩 꺼 보거나, 의존성을 절반씩 제거해 본다.

이분 탐색이 강력한 이유는 각 실험이 최대한의 정보를 주도록 설계되기 때문입니다. 정보 이론적으로 말하면, 절반을 갈라내는 질문이 한 번에 1비트의 정보를 뽑아냅니다. 아무 데나 찍어 보는 실험은 이보다 훨씬 적은 정보를 줍니다.

최소 재현 예제 — 버그를 우리에 가두기

"재현이 안 돼요"는 디버깅에서 가장 흔한 벽입니다. 여기서 결정적인 도구가 **최소 재현 예제(minimal reproducible example, MRE)**입니다.

MRE는 버그를 일으키는 가장 작은 코드 조각입니다. 만드는 과정 자체가 디버깅입니다. 전체 애플리케이션에서 버그와 무관한 부분을 하나씩 제거해 나가면서, 여전히 버그가 재현되는 최소 상태까지 깎아 냅니다. 이 과정에서 두 가지 중 하나가 일어납니다.

  1. 무언가를 제거했더니 버그가 사라진다 → 방금 제거한 게 원인과 관련 있다. 범인을 찾은 겁니다.
  2. 다 제거하고 나니 20줄짜리 코드만 남았는데도 재현된다 → 이제 이 20줄만 노려보면 됩니다. 무한히 다루기 쉬워졌습니다.

MRE를 만들 때의 규칙은 이렇습니다.

  • 외부 의존성을 제거하라: DB, 네트워크, 파일 시스템을 하드코딩된 값으로 대체한다. 버그가 여전히 나면 외부가 원인이 아니다.
  • 데이터를 최소화하라: 100개 레코드 중 1개로 재현되면 그 1개만 남긴다.
  • 자기충족적으로 만들라: 남에게 복붙해서 그대로 돌릴 수 있어야 한다. 이게 되면 동료에게 물어보기도, 이슈로 올리기도 쉽다.

MRE를 만들다가 버그가 저절로 사라지는 경험을 자주 하게 됩니다. 그건 실패가 아닙니다. 제거 과정에서 원인을 지나쳤다는 뜻이고, 마지막으로 제거한 걸 되돌리면 범인이 드러납니다.

git bisect — 어느 커밋이 범인인가

"지난주엔 됐는데 지금 안 된다." 이 상황을 위한 도구가 git bisect입니다. 커밋 히스토리에 대고 이분 탐색을 자동으로 돌려 주는, git의 숨은 보석입니다.

원리는 앞에서 본 이분 탐색 그대로입니다. 정상이었던 옛날 커밋(good)과 버그가 있는 지금 커밋(bad)을 알려 주면, git이 그 중간 커밋으로 체크아웃해 줍니다. 거기서 버그가 있는지 테스트하고 결과를 알려 주면, git이 남은 구간의 중간으로 또 데려갑니다. 커밋이 1000개여도 열 번 남짓이면 범인 커밋 하나로 좁혀집니다.

# 이분 탐색 시작
git bisect start

# 지금 커밋은 버그가 있다
git bisect bad

# 3주 전 이 커밋은 정상이었다
git bisect good v1.4.0

# 이제 git이 중간 커밋으로 체크아웃해 준다.
# 여기서 버그를 테스트하고, 결과에 따라:
git bisect good   # 이 커밋은 정상
# 또는
git bisect bad    # 이 커밋에도 버그가 있음

# 반복하면 git이 범인 커밋을 지목한다:
# "abc1234 is the first bad commit"

# 다 끝나면 원래 위치로 복귀
git bisect reset

진짜 마법은 자동화입니다. 버그를 판정하는 스크립트(예: 실패하면 0이 아닌 종료 코드를 반환하는 테스트)만 있으면, git bisect run이 사람 개입 없이 전 과정을 돌립니다.

git bisect start
git bisect bad
git bisect good v1.4.0
# 스크립트가 exit 0이면 good, 아니면 bad로 자동 판정
git bisect run ./test-for-bug.sh

몇 초 뒤 범인 커밋이 나옵니다. 그 커밋의 diff를 보면 대개 원인이 바로 보입니다. git bisect가 잘 돌아가려면 커밋이 작고 각각 빌드 가능해야 한다는 점이, 커밋을 잘게 나눠야 하는 또 하나의 이유입니다.

git bisect 같은 git 워크플로를 손에 익히고 싶다면 Git 실습장에서 안전하게 커밋·브랜치·이분 탐색을 연습해 볼 수 있습니다.

가설을 검증하려면 시스템 내부를 관찰해야 합니다. 관찰의 도구는 크게 셋입니다. 하나가 정답인 게 아니라 상황에 맞게 골라 씁니다.

가장 원시적이고 가장 저평가된 도구입니다. print(또는 console.log)를 찍어 값을 눈으로 봅니다. 무시당하기 쉽지만, 실제로는 강력합니다.

  • 장점: 어디서나 된다. 설정이 필요 없다. 시간에 따른 값의 흐름을 한눈에 본다(디버거로 스텝을 밟는 것보다 흐름 파악이 빠를 때가 많다). 비동기·멀티스레드·분산 환경처럼 디버거로 멈추기 어려운 곳에서 특히 유용하다.
  • 단점: 코드를 건드려야 한다. 지우는 걸 깜빡하면 로그가 지저분해진다. 재컴파일·재배포가 필요할 수 있다.

print를 찍을 때의 요령: 무엇을 찍는지 라벨을 붙여라. print(x)보다 print("after validation, x =", x)가 훨씬 낫습니다. 값이 여럿이면 함께 찍어 상관관계를 봅니다.

디버거

브레이크포인트를 걸고 실행을 멈춘 뒤, 그 순간의 전체 상태(모든 변수, 콜 스택)를 들여다보고 한 줄씩 스텝을 밟는 도구입니다.

  • 장점: 코드를 안 바꿔도 된다. 멈춘 지점에서 모든 것을 볼 수 있다. 조건부 브레이크포인트(i == 4821일 때만 멈춤), 스텝 인/오버, 콜 스택 확인, 심지어 실행 중 변수 수정까지 된다. 복잡한 상태나 깊은 콜 스택을 이해할 때 압도적이다.
  • 단점: 설정이 필요하다. 비동기·타이밍 의존 버그는 멈추는 순간 조건이 바뀌어(하이젠버그) 재현이 안 되기도 한다. 프로덕션에서는 대개 못 쓴다.

로깅

print의 어른 버전입니다. 구조화된 로그를 레벨(DEBUG/INFO/WARN/ERROR)과 함께 영구적으로 남깁니다.

  • 장점: 프로덕션에서 상시 돌아간다. 지나간 사건을 사후에 조사할 수 있다(디버거는 지금 일어나는 일만 본다). 레벨로 소음을 조절하고, 구조화하면 검색·집계가 된다.
  • 단점: 미리 심어 둬야 한다. 정작 필요한 그 지점에 로그가 없으면 소용없다. 양이 많으면 비용과 소음이 된다.

정리하면: 재현되는 로컬 버그의 복잡한 상태를 파고들 땐 디버거, 흐름을 빠르게 훑거나 비동기·분산 환경이면 print, 프로덕션에서 지나간 사건을 조사하려면 로깅. 셋을 상황에 맞게 오가는 사람이 진짜 잘 잡습니다.

에러를 실제로 읽어라

이건 너무 당연해 보이는데 놀랄 만큼 안 지켜집니다. 에러 메시지를 처음부터 끝까지 실제로 읽으세요. 훑지 말고, 무시하지 말고, "아 또 그거네" 하고 넘기지 말고, 진짜로 읽으세요.

에러 메시지와 스택트레이스는 버그가 자기가 어디서 왜 죽었는지 알려 주는 자백서입니다. 그런데 많은 개발자가 빨간 텍스트를 보는 순간 눈을 감고 추측 모드로 들어갑니다. 거기 답이 적혀 있는데도요.

읽을 때 뽑아낼 것:

  • 정확한 예외 타입과 메시지: NullPointerException인가 IndexOutOfBoundsException인가는 완전히 다른 이야기다. 메시지의 단어 하나하나가 단서다.
  • 파일과 줄 번호: 어디서 터졌는지 정확히 알려 준다.
  • 스택트레이스: 맨 위가 터진 지점, 아래로 갈수록 그걸 부른 호출 사슬이다. "내 코드"가 처음 등장하는 줄을 찾아라(대개 라이브러리 내부보다 거기가 진짜 시작점이다).
  • "Caused by" 사슬: 진짜 근본 원인은 맨 아래 "caused by" 뒤에 숨어 있는 경우가 많다.

에러 메시지의 정확한 문구를 그대로 복사해 검색하는 것만으로도 절반은 해결됩니다. 다만 메시지에 파일 경로나 ID 같은 내 환경 고유값이 섞여 있으면 그 부분은 빼고 검색하세요.

"컴파일러 탓이 아니다"

디버깅 세계의 오래된 격언이 있습니다. "It's never the compiler." 컴파일러(또는 인터프리터, 런타임, 표준 라이브러리, 유명 프레임워크) 탓인 경우는 거의 없습니다.

버그를 못 잡다가 좌절하면 이런 생각이 스멀스멀 올라옵니다. "이거 언어 버그 아니야?" "컴파일러가 최적화를 잘못한 거 아니야?" "이 라이브러리가 이상한 거 아니야?" 그럴 수 있습니다. 하지만 확률적으로, 성숙한 도구는 수백만 명이 매일 두들기며 검증한 물건입니다. 당신의 100줄짜리 신규 코드10년간 수억 번 실행된 컴파일러 중 누가 버그일 확률이 높을까요.

이게 실용적으로 중요한 이유는, "도구 탓"이라고 결론 내리는 순간 탐색을 멈추기 때문입니다. 진짜 원인은 십중팔구 내 가정 어딘가에 있는데, 남 탓을 하면 거길 안 봅니다. 그러니 기본 자세는 "범인은 내 코드, 내 가정, 내 이해에 있다"로 두세요. 정말 드물게 도구 버그일 때도 있지만, 그 결론은 내 쪽을 남김없이 검증한 맨 마지막에 내리는 겁니다.

같은 맥락에서 흔한 함정 하나: 정규식이 "왜 매칭이 안 되지?" 할 때도 대부분 정규식 엔진이 아니라 내 패턴이 틀린 겁니다. 이럴 땐 정규식 테스터에 패턴과 입력을 넣고 실제로 무엇에 매칭되는지 관찰하는 게, 머릿속으로 추측하는 것보다 백배 빠릅니다.

러버덕 — 소리 내어 설명하기

마지막 기법은 우습게 들리지만 진짜로 통합니다. **러버덕 디버깅(rubber duck debugging)**입니다. 책상에 고무 오리를 놓고, 문제를 그 오리에게 한 줄 한 줄 소리 내어 설명하는 겁니다.

왜 통할까요. 코드를 눈으로 읽을 때 뇌는 "여긴 당연히 맞겠지" 하고 자동으로 건너뜁니다. 그 건너뛴 가정 속에 버그가 숨어 있습니다. 그런데 남에게(오리에게) 설명하려면 그 가정을 말로 풀어야 하고, 말로 푸는 순간 "어? 이게 정말 그런가?" 하고 걸립니다. 설명을 강제당하면 암묵적 가정이 명시적으로 드러납니다.

이게 동료에게 도움을 요청하다가 "아, 됐다 방금 알았어" 하고 끊는 그 현상의 정체입니다. 동료는 아무 말도 안 했습니다. 설명을 준비하는 과정에서 스스로 찾은 겁니다. 오리는 공짜고 인내심이 무한하니, 동료를 부르기 전에 오리에게 먼저 설명해 보세요.

효과를 높이려면 아주 구체적으로, 아주 기초부터 설명하세요. "이 함수는 유저를 가져와서..."가 아니라 "이 함수는 인자로 user_id 정수를 받아서, DB에 이 쿼리를 던지고, 결과의 첫 행을 이 객체로 매핑해서..." 수준으로요. 구체적일수록 숨은 가정이 더 잘 튀어나옵니다.

종합 — 버그 하나를 끝까지

지금까지의 조각을 하나의 흐름으로 엮어 봅시다. "결제가 가끔 실패한다"는 처음의 버그로 돌아갑니다.

  1. 읽는다: 실패한 요청의 에러 로그를 실제로 끝까지 읽는다. PaymentGatewayTimeoutException이 스택트레이스 맨 아래 "caused by"에 있다.
  2. 가설: 카드사 API가 느릴 때 우리가 먼저 끊는다.
  3. 예측: 맞다면 실패 요청은 전부 우리 타임아웃 값 근처에서 죽었을 것이다.
  4. 관찰: 실패 요청의 응답 시간을 뽑는다. 전부 딱 2000ms 근처. 가설이 강해진다.
  5. 이분 탐색(시간): "언제부터?"를 묻는다. git bisect를 돌린다. 타임아웃을 5초에서 2초로 줄인 커밋이 범인으로 나온다.
  6. 최소 재현: 카드사를 2.5초 지연시키는 목(mock)으로 로컬에서 재현. 실패가 재현된다.
  7. 수정과 검증: 타임아웃을 올리거나 재시도를 넣는다. 예측: 이러면 목 지연을 넣어도 성공해야 한다. 실험. 성공한다.

매 단계에 가설과 예측이 있었고, 각 실험이 탐색 공간을 좁혔습니다. 운은 한 방울도 없었습니다. 그리고 무엇보다, 왜 고쳐졌는지 정확히 압니다. 이게 과학자처럼 디버깅한다는 것의 의미입니다.

마치며

디버깅을 잘하는 사람과 못하는 사람의 차이는 지식의 양이 아니라 방법입니다. 잘하는 사람은 코드를 노려보며 추측하지 않습니다. 관찰하고, 검증 가능한 가설을 세우고, 예측을 하고, 문제 공간을 반씩 갈라 좁히고, 에러가 하는 말을 실제로 듣습니다.

다음에 버그를 만나면, 코드를 바꾸기 전에 스스로에게 물어보세요. "내 가설이 뭐지? 이걸 바꾸면 무슨 일이 일어날 거라고 예측하지?" 이 두 질문에 답할 수 없다면, 아직 실험할 준비가 안 된 겁니다. 답할 수 있게 되는 순간, 당신은 도박꾼이 아니라 과학자가 됩니다.

참고 자료

Debugging Like a Scientist: Hypothesis-Driven Debugging

Introduction — Debugging Is Not Guessing

A bug shows up. Payments occasionally fail in production. You can't reproduce it, the logs are vague, and the deadline is today. Here's what a lot of developers do next: stare at the code, change a line that "looks suspicious," run it again, and if that doesn't work, change another line, and another. I call this shotgun debugging — spray bullets everywhere and hope one hits.

The problem is that it occasionally works. So it becomes a habit. But a bug fixed by luck is a bug you don't understand, and a bug you don't understand will come back. Worse, in the process of "fixing" it, you've touched three other lines of perfectly good code.

The claim of this post is simple: debugging is a science. The way a scientist understands nature — form a hypothesis, predict what would happen if it were true, test it, and revise the hypothesis based on what you observe — maps directly onto code. You catch bugs by method, not by luck.

The Scientific Method — Hypothesis, Prediction, Experiment, Observation

The loop you run when you hit a bug is precisely the scientific method.

  1. Observe: What is happening? What exactly is wrong?
  2. Hypothesize: Propose one testable explanation for why.
  3. Predict: If that hypothesis is true, then if I do X, I should observe Y.
  4. Experiment: Do X.
  5. Observe: Did Y actually happen? If yes, the hypothesis gets stronger; if no, discard or revise it.

The crucial step is step 3, prediction. This is where good debugging and bad debugging part ways. Before you change any code, you should be able to say out loud, "if I change this, here is what I expect to happen." Changing code without a prediction isn't an experiment — it's just gambling.

Let's make it concrete. Start from the observation "payments occasionally fail."

  • Bad approach: "Maybe it's a timeout. Let's bump the timeout." (No prediction, no idea why.)
  • Good approach: "Hypothesis: the card processor's API sometimes takes more than 3 seconds, and our client cuts it off at 2. Prediction: if that's true, failed requests must show a timeout error in the logs, and successful requests should all be under 2 seconds. Experiment: pull the response-time distribution for failed vs successful requests."

The second approach teaches you something even when the experiment fails. If the failed requests turn out to be 500 errors, not timeouts, your hypothesis was wrong — but now you know the cause is your own server, not the processor. A wrong hypothesis still gives you information. That's the power of the method.

A hypothesis must be falsifiable. "The network is being weird" can't be tested, so it isn't a hypothesis. "Only requests from a specific region fail" can be tested, so it is.

Binary-Search the Problem Space

The single most powerful technique for catching bugs is binary search. It isn't just for finding a value in a sorted array — you use it to find the cause of a problem.

Here's the principle. Somewhere between where the bug appears (the symptom) and where the code is fine (the input), the cause is hiding. Pick a midpoint between them and ask, "is everything still fine here?" One experiment cuts the search space in half. Repeat, and even thousands of lines of code collapse to the culprit in about ten experiments — 2 to the 10th is 1024, after all.

Concretely, there are several axes you can binary-search along.

  • Code path: if a request goes A → B → C → D, print the value at C. If it's fine, the cause is between C and D; if it's wrong, it's between A and C.
  • Time (commit history): it worked yesterday but not today? Binary-search the commits in between. (This is exactly git bisect, coming up next.)
  • Input data: crashing on a 100k-line input? Cut it to 50k and see if it still reproduces. If it does, the bad data is in the first half; if not, the second.
  • Config / dependencies: turn off half your feature flags, or remove half your dependencies, and see which half breaks.

Binary search is powerful because each experiment is designed to yield maximum information. In information-theoretic terms, a question that splits the space in half extracts one full bit per experiment. Poking at a random spot yields far less.

Minimal Reproducible Example — Caging the Bug

"I can't reproduce it" is the most common wall in debugging. The decisive tool here is the minimal reproducible example (MRE).

An MRE is the smallest chunk of code that triggers the bug. The act of building one is debugging. You strip away everything in your application that's unrelated to the bug, one piece at a time, until you reach the smallest state that still reproduces it. During this process, one of two things happens.

  1. You remove something and the bug disappears → what you just removed is related to the cause. You've found your suspect.
  2. You remove everything and are left with 20 lines that still reproduce it → now you only have to stare at those 20 lines. It just became infinitely more tractable.

Rules for building an MRE:

  • Remove external dependencies: replace the DB, network, and file system with hardcoded values. If the bug persists, the outside world isn't the cause.
  • Minimize the data: if 1 of 100 records reproduces it, keep only that one.
  • Make it self-contained: someone should be able to copy-paste it and run it as-is. When that works, asking a colleague — or filing an issue — becomes easy.

You'll often find the bug vanishes while you're building the MRE. That's not a failure. It means you passed over the cause while removing things; undo the last removal and the culprit is revealed.

git bisect — Which Commit Is the Culprit?

"It worked last week, and now it doesn't." The tool for this situation is git bisect. It's a hidden gem of git that runs a binary search over your commit history automatically.

The principle is the binary search from above, applied to time. You tell it an old commit that was fine (good) and the current commit that's broken (bad), and git checks out the commit halfway between them. You test whether the bug is present and report the result; git takes you to the midpoint of the remaining range. Even with 1000 commits, about ten steps narrow it to a single culprit.

# Start the binary search
git bisect start

# The current commit has the bug
git bisect bad

# This commit from 3 weeks ago was fine
git bisect good v1.4.0

# git now checks out a commit in the middle.
# Test the bug here, and depending on the result:
git bisect good   # this commit is fine
# or
git bisect bad    # this commit has the bug too

# Repeat, and git points to the culprit:
# "abc1234 is the first bad commit"

# When done, return to where you started
git bisect reset

The real magic is automation. If you have a script that judges the bug (for example, a test that returns a non-zero exit code on failure), git bisect run drives the whole process with no human in the loop.

git bisect start
git bisect bad
git bisect good v1.4.0
# exit 0 from the script means good, anything else means bad
git bisect run ./test-for-bug.sh

A few seconds later, the culprit commit appears. Look at that commit's diff and the cause is usually obvious. The fact that git bisect works best when commits are small and each one builds is yet another reason to keep commits granular.

If you want to get git bisect and other git workflows into your fingers, you can safely practice commits, branches, and bisecting in the Git Playground.

To test a hypothesis, you have to observe the system's internals. There are three main tools of observation. None is the one right answer — you pick based on the situation.

The most primitive and most underrated tool. You drop a print (or console.log) and look at the value with your own eyes. It's easy to dismiss, but it's genuinely powerful.

  • Pros: works everywhere, needs no setup. You see how a value flows over time at a glance (often faster for grasping flow than stepping through a debugger). Especially useful in async, multithreaded, or distributed environments where pausing with a debugger is hard.
  • Cons: you have to touch the code. Forget to remove them and your logs get messy. May require a recompile/redeploy.

A tip for printing: label what you print. print("after validation, x =", x) beats print(x) by a mile. If several values matter, print them together so you can see the correlation.

Debugger

Set a breakpoint, pause execution, inspect the entire state at that instant (every variable, the call stack), and step through one line at a time.

  • Pros: no code changes. You can see everything at the paused point. Conditional breakpoints ("only stop when i == 4821"), step in/over, call-stack inspection, even modifying variables mid-run. Unbeatable for understanding complex state or deep call stacks.
  • Cons: needs setup. Async or timing-dependent bugs can vanish the moment you pause, because pausing changes the conditions (a "heisenbug"). Usually unavailable in production.

Logging

The grown-up version of print. You record structured logs permanently, with levels (DEBUG/INFO/WARN/ERROR).

  • Pros: runs continuously in production. You can investigate past events after the fact (a debugger only shows what's happening right now). Levels control the noise, and structure makes logs searchable and aggregatable.
  • Cons: you have to instrument ahead of time. If there's no log at the exact spot you need, it's useless. High volume becomes cost and noise.

In short: to dig into complex state in a reproducible local bug, use a debugger; to skim the flow quickly or in async/distributed settings, use print; to investigate past events in production, use logging. The people who move fluidly between all three are the ones who really catch bugs.

Actually Read the Error

This seems too obvious, yet it's astonishingly often ignored: read the error message from top to bottom, for real. Don't skim it, don't dismiss it, don't go "oh, that thing again" and move on — actually read it.

The error message and stack trace are the bug's confession of where and why it died. Yet many developers close their eyes the instant they see red text and switch into guessing mode — even though the answer is written right there.

What to extract when you read:

  • The exact exception type and message: a NullPointerException and an IndexOutOfBoundsException are completely different stories. Every word of the message is a clue.
  • File and line number: tells you exactly where it blew up.
  • The stack trace: the top is where it blew up; going down is the chain of calls that led there. Find the first line that's "your code" (that's usually the real starting point, rather than deep inside a library).
  • The "Caused by" chain: the true root cause is often hidden behind the last "caused by" at the bottom.

Simply copying the exact wording of the message and searching for it solves half of these. Just strip out anything environment-specific — a file path or an ID — before you search.

"It's Never the Compiler"

There's an old saying in the debugging world: "It's never the compiler." It's almost never the compiler's fault (nor the interpreter's, the runtime's, the standard library's, or a well-known framework's).

When you're stuck and frustrated, a thought starts to creep in. "Is this a language bug?" "Did the compiler optimize this wrong?" "Is this library broken?" It could be. But probabilistically, a mature tool is something millions of people hammer on and validate every day. Between your 100 lines of brand-new code and a compiler that has run hundreds of millions of times over ten years, which is more likely to be the one with the bug?

This matters practically because the moment you conclude "it's the tool's fault," you stop searching. The real cause is almost certainly somewhere in your assumptions, and blaming the tool means you never look there. So make your default posture "the culprit is in my code, my assumptions, my understanding." On the very rare occasion it really is a tool bug, that conclusion comes last — only after you've exhausted your own side.

A common trap in the same spirit: when a regular expression "just won't match," it's usually not the regex engine — it's your pattern. Rather than guessing in your head, drop the pattern and the input into a Regex Tester and observe what it actually matches. It's a hundred times faster.

Rubber Duck — Explaining It Out Loud

The last technique sounds silly but genuinely works: rubber duck debugging. Put a rubber duck on your desk and explain the problem to it, out loud, line by line.

Why does it work? When you read code with your eyes, your brain automatically skips over "well, this part is obviously correct." The bug is hiding inside that skipped assumption. But to explain it to someone (the duck), you have to put the assumption into words — and the moment you do, you catch yourself: "wait, is that actually true?" Being forced to explain turns implicit assumptions explicit.

This is the real mechanism behind asking a colleague for help and then going "oh, never mind, I just got it" and hanging up. The colleague said nothing. You found it yourself while preparing the explanation. The duck is free and infinitely patient, so explain it to the duck before you summon a human.

To boost the effect, explain very specifically, from the very basics. Not "this function gets the user and..." but "this function takes a user_id integer as an argument, fires this query at the DB, maps the first row of the result into this object..." The more specific you are, the more the hidden assumptions pop out.

Putting It Together — One Bug, All the Way Through

Let's weave the pieces into a single flow. Back to the original bug: "payments occasionally fail."

  1. Read: actually read the failed request's error log to the end. PaymentGatewayTimeoutException is at the bottom of the stack trace, behind "caused by."
  2. Hypothesis: when the card processor's API is slow, we cut it off first.
  3. Prediction: if true, failed requests should all have died near our timeout value.
  4. Observe: pull the response times of failed requests. All right around 2000ms. The hypothesis gets stronger.
  5. Binary-search (time): ask "since when?" Run git bisect. It points to the commit that reduced the timeout from 5 seconds to 2.
  6. Minimal reproduction: reproduce locally with a mock that delays the processor by 2.5 seconds. The failure reproduces.
  7. Fix and verify: raise the timeout or add a retry. Prediction: with this, it should succeed even with the mock delay in place. Experiment. It succeeds.

Every step had a hypothesis and a prediction, and each experiment narrowed the search space. Not a single drop of luck. And above all, you know exactly why it's fixed. That's what it means to debug like a scientist.

Wrapping Up

The difference between people who are good at debugging and people who aren't is not how much they know — it's method. The good ones don't stare at code and guess. They observe, form a falsifiable hypothesis, make a prediction, cut the problem space in half, and actually listen to what the error is telling them.

The next time you hit a bug, ask yourself before you change any code: "What's my hypothesis? If I change this, what do I predict will happen?" If you can't answer those two questions, you're not ready to experiment yet. The moment you can, you stop being a gambler and become a scientist.

References