Split View: 체계적 디버깅 — 추측이 아니라 추론으로 버그를 잡는 법
체계적 디버깅 — 추측이 아니라 추론으로 버그를 잡는 법
프롤로그 — 디버깅은 대부분 추측이다
버그 리포트가 올라온다. 개발자는 코드를 연다. 그리고 "여기가 의심스러운데" 하면서 console.log를 한 줄 박는다. 다시 빌드하고, 재현하고, 로그가 안 찍히면 다른 곳에 또 한 줄 박는다. 30분 뒤, 코드 곳곳에 로그가 흩어져 있고 버그는 여전히 거기 있다.
이게 현장에서 벌어지는 디버깅의 90%다. 구조가 없는 추측. "이거 아닐까?" "저거 아닐까?" 운이 좋으면 빨리 찾고, 운이 나쁘면 하루를 태운다. 그리고 찾고 나서도 왜 그게 버그였는지 정확히 설명하지 못한다. 설명하지 못하면 같은 버그가 다시 온다.
디버깅은 운이 아니다. 방법론이다. 좋은 디버거(사람)는 머리가 좋은 게 아니라, 탐색 공간을 체계적으로 좁힌다. 100개의 가능성 중에서 무작위로 찍는 대신, 한 번의 실험으로 50개를 지운다. 그다음 25개, 그다음 12개. 이건 재능이 아니라 훈련 가능한 기술이다.
이 글은 그 기술을 정리한다. 핵심 루프, 재현 가능성을 먼저 확보하는 법, 과학적 방법을 버그에 적용하는 법, 이진 탐색, 스택 트레이스를 제대로 읽는 법, 관측성 도구, 어려운 버그 클래스, 막혔을 때 빠져나오는 법, 그리고 AI 에이전트와 디버깅하는 법까지. 언어와 스택을 가리지 않는다 — 버그가 있는 모든 곳에 적용된다.
핵심 한 줄: 디버깅은 가설을 세우고, 그 가설을 가장 효율적으로 반증하는 실험을 설계하는 일이다. 코드를 노려보는 게 아니다.
1장 · 핵심 루프 — 재현 → 격리 → 가설 → 검증 → 수정 → 확인
모든 체계적 디버깅은 같은 루프를 돈다. 순서가 중요하다. 순서를 건너뛰면 추측으로 떨어진다.
┌─────────────────────────────────────────────────┐
│ 1. 재현 (Reproduce) │
│ 버그를 명령 하나로 다시 일으킬 수 있는가? │
│ ↓ │
│ 2. 격리 (Isolate) │
│ 버그가 사는 영역을 절반으로 줄인다 │
│ ↓ │
│ 3. 가설 (Hypothesize) │
│ "X 때문이다" — 검증 가능한 한 문장 │
│ ↓ │
│ 4. 검증 (Test) │
│ 가설을 반증하는 실험을 설계하고 돌린다 │
│ ↓ │
│ 5. 수정 (Fix) │
│ 근본 원인을 고친다 (증상이 아니라) │
│ ↓ │
│ 6. 확인 (Verify) │
│ 재현 케이스가 이제 통과하는가? 회귀 테스트? │
└─────────────────────────────────────────────────┘
↑________________________________│
가설이 틀렸으면 3번으로 돌아간다
각 단계의 핵심:
| 단계 | 던지는 질문 | 흔한 실수 |
|---|---|---|
| 재현 | "명령 하나로 다시 일으킬 수 있나?" | 재현 안 되는 채로 추측 시작 |
| 격리 | "범위를 절반으로 줄였나?" | 전체 코드를 한 번에 노려봄 |
| 가설 | "검증 가능한 한 문장인가?" | "뭔가 이상하다" 같은 모호한 가설 |
| 검증 | "이 실험이 가설을 반증할 수 있나?" | 가설을 확인만 하는 실험 |
| 수정 | "증상이 아니라 원인을 고쳤나?" | try/catch로 증상만 덮음 |
| 확인 | "재현 케이스가 통과하나?" | 눈으로만 보고 "된 것 같다" |
이 루프의 힘은 각 단계가 다음 단계를 작게 만든다는 데 있다. 재현이 확실하면 격리가 빨라진다. 격리가 잘되면 가설이 좁아진다. 가설이 좁으면 검증이 한 번에 끝난다. 반대로 재현을 건너뛰면 나머지 전부가 추측 위에 쌓인다.
2장 · 재현 가능성을 먼저 — 결정론적 재현과 케이스 최소화
재현할 수 없는 버그는 디버깅할 수 없다. 고쳤는지 확인할 방법이 없기 때문이다. 그래서 첫 단계는 항상 **"명령 하나로 버그를 다시 일으키기"**다.
결정론적 재현 만들기
"가끔 발생한다"는 디버깅 불가능 상태다. "가끔"을 "항상"으로 바꿔야 한다. 비결정성의 원인을 하나씩 고정한다.
| 비결정성 원인 | 고정하는 법 |
|---|---|
| 시간 / 날짜 | 시계를 주입하고 고정값 사용 (clock.now() 모킹) |
| 난수 | 시드를 고정 (seed=42) |
| 동시성 / 스케줄링 | 스레드 수를 1로, 또는 스케줄 강제 |
| 네트워크 응답 | 응답을 녹화/재생 (VCR, 픽스처) |
| 입력 데이터 | 실패한 정확한 입력을 파일로 저장 |
| 환경 변수 / 설정 | .env를 통째로 캡처 |
| 빌드 / 의존성 버전 | 락파일 고정, 컨테이너 이미지 핀 |
목표는 이런 한 줄이다:
# 이 명령은 항상 같은 실패를 낸다
SEED=42 TZ=UTC ./repro.sh fixtures/bug-1234.json
이 한 줄을 만드는 데 30분이 걸려도, 그 30분은 거의 항상 본전을 뽑는다. 재현 명령이 있으면 나머지 모든 단계가 측정 가능해진다.
케이스 최소화 (Minimize)
재현이 되면, 그다음은 재현 케이스를 최소로 줄이는 것이다. 1000줄짜리 입력으로 버그가 난다면, 990줄을 지워도 버그가 나는지 본다. 난다면 990줄은 무관하다.
이것이 "delta debugging"의 핵심 아이디어다. 입력을 절반으로 줄이고, 여전히 실패하면 그 절반을 다시 절반으로 줄인다. 실패가 사라지면 한 단계 되돌린다.
입력 1000줄 → 실패 ✗
입력 앞 500줄 → 실패 ✗ (뒤 500줄 버림)
입력 앞 250줄 → 통과 ✓ (한 단계 되돌림)
입력 앞 375줄 → 실패 ✗
입력 앞 312줄 → 실패 ✗
...
입력 3줄 → 실패 ✗ ← 최소 재현 케이스
3줄짜리 재현 케이스는 1000줄짜리보다 디버깅이 압도적으로 쉽다. 노이즈가 제거됐기 때문에, 남은 3줄이 곧 단서다. C/C++ 컴파일러 버그에는 creduce, 일반 텍스트에는 손으로 하는 이진 삭제, 프로퍼티 기반 테스트 도구에는 자동 shrink 기능이 있다.
최소 재현 케이스를 만드는 과정 자체가 디버깅이다. 990줄을 지우는 동안, 어느 줄을 지웠을 때 버그가 사라지는지 보게 되고, 그게 바로 원인의 위치다.
3장 · 과학적 방법을 버그에 — 한 번에 한 변수만
체계적 디버깅의 본질은 과학적 방법이다. 관찰 → 가설 → 실험 → 결론. 그리고 과학적 방법의 가장 중요한 규칙: 한 번에 한 변수만 바꾼다.
나쁜 디버깅 vs 좋은 디버깅
나쁜 디버깅:
"음, 캐시 끄고, 로그 레벨 올리고, 타임아웃 늘리고,
이 라이브러리 버전도 바꿔보자."
→ 버그가 사라짐. 그런데 무엇 때문에 사라졌는지 모름.
→ 4개 중 무엇이 원인? 알 수 없음. 다시 켜면 또 날 수 있음.
좋은 디버깅:
가설: "캐시 때문이다."
실험: 캐시만 끈다. 다른 건 전부 그대로.
결과 A: 버그 사라짐 → 캐시가 원인 (또는 캐시와 상호작용)
결과 B: 버그 그대로 → 캐시는 무관. 가설 기각, 다음 가설로.
한 번에 여러 변수를 바꾸면, 결과가 나와도 어느 변수 때문인지 모른다. 정보를 얻지 못한 실험이다. 한 변수만 바꾸면, 어떤 결과가 나오든 정보를 얻는다 — 그 변수가 원인이거나, 원인이 아니거나.
반증을 노려라
확인 편향(confirmation bias)은 디버깅의 적이다. "X가 원인이야"라고 믿으면, X를 확인하는 증거만 찾게 된다. 좋은 실험은 가설을 반증하려고 설계한다.
| 가설 | 확인하는 실험 (약함) | 반증하는 실험 (강함) |
|---|---|---|
| "널 포인터가 원인" | 그 지점에 로그 찍어보기 | 그 변수가 절대 널이 아니도록 막고 — 버그가 사라지나? |
| "레이스 컨디션이다" | 동시성 코드 노려보기 | 락을 거칠게 추가하고 — 사라지나? 그럼 레이스 맞음 |
| "이 PR이 깨뜨렸다" | PR diff 읽기 | 그 PR을 revert하고 — 버그가 사라지나? |
가설이 살아남으면(반증 실패) 신뢰도가 올라간다. 가설이 죽으면 다음으로 넘어간다. 어느 쪽이든 탐색 공간이 줄어든다.
무엇이 바뀌었는가?
작동하던 게 멈췄다면, 가장 강력한 질문은 **"무엇이 바뀌었는가?"**다. 코드, 데이터, 설정, 의존성, 인프라, 트래픽 — 멀쩡하던 시스템이 멈췄다면 무언가는 바뀌었다.
"어제는 됐는데 오늘 안 돼" 체크리스트:
□ 코드 — git log, 최근 배포
□ 설정 — 환경 변수, 피처 플래그, 시크릿
□ 데이터 — 새로 들어온 엣지 케이스 입력
□ 의존성 — 락파일 변경, 자동 업데이트
□ 인프라 — 인스턴스 교체, OS 패치, 인증서 만료
□ 트래픽 — 양/패턴 변화, 새 클라이언트
□ 시간 — 월말, 자정, DST, 윤년, epoch 경계
4장 · 이진 탐색 — git bisect와 모든 곳의 binary search
이진 탐색은 디버깅에서 가장 강력한 단일 기법이다. N개의 후보를 log2(N) 단계로 줄인다. 후보가 1000개여도 10단계면 끝난다. 이진 탐색은 세 곳에 적용된다: 커밋 히스토리, 입력, 코드 경로.
git bisect — 커밋 히스토리 이진 탐색
"이전에는 됐는데 지금은 안 된다"면 어딘가의 커밋이 깨뜨렸다. git bisect는 그 커밋을 log2(커밋 수) 단계로 찾는다. 500개 커밋 사이여도 9번만 테스트하면 된다.
# 세션 시작
git bisect start
git bisect bad # 현재 커밋은 깨졌다
git bisect good v2.3.0 # 이 태그에서는 됐었다
# git이 중간 커밋으로 체크아웃한다. 테스트하고 판정:
# $ ./repro.sh && git bisect good (또는)
# $ ./repro.sh || git bisect bad
# 이걸 ~9번 반복하면 git이 범인을 출력한다:
#
# a1b2c3d is the first bad commit
# Author: ...
# Date: ...
# refactor: switch session store to redis
git bisect reset # 원래 브랜치로 복귀
핵심은 자동화다. 재현 명령이 종료 코드를 정확히 내면(성공 0, 실패 비0), 사람이 손으로 반복할 필요가 없다:
git bisect start HEAD v2.3.0
git bisect run ./repro.sh
# git이 알아서 전 구간을 이진 탐색하고 범인 커밋을 뱉는다
git bisect run은 2장의 결정론적 재현 명령이 왜 그렇게 중요한지 보여주는 지점이다. 재현 명령 하나가 있으면, 500개 커밋 디버깅이 명령 한 줄로 끝난다.
입력 이진 탐색
3장의 케이스 최소화가 곧 입력 이진 탐색이다. 1000줄 입력에서 절반을 지워 본다. 1만 개 레코드 중 어느 것이 파이프라인을 깨는지 — 절반씩 나눠서 넣는다. 14번이면 찾는다.
코드 경로 이진 탐색
버그가 어느 함수에서 나는지 모를 때, 코드 경로의 중간 지점에 검증을 박는다. 처리 흐름이 A → B → C → D → E일 때, C 지점에서 상태를 검사한다.
A → B → [C: 상태 정상?] → D → E
├─ 정상 → 버그는 C와 E 사이 (D를 검사)
└─ 깨짐 → 버그는 A와 C 사이 (B를 검사)
"중간 지점에서 데이터가 아직 멀쩡한가?"를 묻는 것이다. 멀쩡하면 버그는 뒤쪽, 깨졌으면 앞쪽. 매 검사가 코드 경로를 절반으로 줄인다. 이게 무작정 console.log를 뿌리는 것과 체계적 print 디버깅의 차이다.
5장 · 스택 트레이스와 에러 메시지 제대로 읽기
스택 트레이스는 공짜로 주어지는 가장 정확한 단서다. 그런데 많은 개발자가 맨 윗줄만 보고 닫는다. 스택 트레이스는 읽는 법이 있다.
스택 트레이스 해부
TypeError: Cannot read properties of undefined (reading 'id') ← (1) 예외 타입 + 메시지
at getUserName (src/user.js:42:18) ← (2) 던진 지점 (가장 깊은 프레임)
at formatProfile (src/profile.js:17:9) ← (3) 호출자
at renderPage (src/page.js:88:14) ← (4) 그 호출자
at processRequest (src/server.js:120:5) ← (5) 진입점 쪽
읽는 순서:
- 예외 타입과 메시지 (1) —
TypeError,Cannot read properties of undefined. 무엇이undefined였는지(reading 'id')까지 알려준다. 메시지를 끝까지 읽어라. - 가장 깊은 프레임 (2) —
src/user.js:42. 예외가 실제로 던져진 줄. 90%는 여기 근처가 원인이다. 하지만 "근처"지 "정확히 거기"는 아니다 — 42번 줄에서 터졌어도,undefined가 들어온 건 호출자 때문일 수 있다. - 호출 스택 따라 내려가기 (3, 4, 5) —
undefined값이 어디서 시작됐는지 추적한다.getUserName에 들어온 인자가 이미undefined였다면,formatProfile을 보고, 거기서도undefined였다면renderPage를 본다. 이게 4장의 코드 경로 이진 탐색과 같다. - 내 코드와 라이브러리 코드 구분 —
node_modules/프레임은 보통 건너뛰어도 된다. 내 코드와 라이브러리의 경계 프레임이 핵심이다. 거기서 라이브러리에 잘못된 값을 넘긴 것이다.
에러 메시지를 끝까지 읽어라
가장 흔한 실수: 에러 메시지 첫 줄만 읽고 추측을 시작한다. 메시지는 보통 정답에 가까운 정보를 담고 있다.
| 메시지 단서 | 흔히 놓치는 것 |
|---|---|
ECONNREFUSED 127.0.0.1:5432 | 포트 번호 — 어느 서비스인지 알려줌 |
expected 3 arguments but got 2 | 정확한 개수 — 어느 호출인지 좁혀줌 |
... at line 42 column 18 | 컬럼 번호 — 같은 줄에서 정확한 위치 |
Caused by: ... (중첩 예외) | 진짜 원인은 맨 아래 Caused by |
경고 로그 (WARN) | 에러보다 먼저 찍힌 경고가 단서 |
비동기 코드에서는 스택 트레이스가 끊긴다. async/await은 비교적 잘 이어주지만, 콜백·이벤트 기반 코드는 "어디서 호출됐는지"가 트레이스에 안 남는다. 이럴 때는 진입점에서 에러를 잡아 컨텍스트(요청 ID, 입력)를 함께 로깅해야 한다.
6장 · 디버깅을 위한 관측성 — 로그, 트레이스, 메트릭, 디버거, 그리고 제대로 된 print
도구는 탐색 공간을 들여다보는 창이다. 도구마다 보여주는 게 다르다.
| 도구 | 보여주는 것 | 강점 | 약점 |
|---|---|---|---|
| 디버거 (브레이크포인트) | 한 순간의 전체 상태 | 변수·콜스택을 자유롭게 탐색 | 타이밍을 바꿈, 분산·운영 환경엔 부적합 |
| print / 로그 | 시간에 따른 특정 값의 변화 | 어디서든 동작, 타이밍 영향 적음 | 미리 무엇을 찍을지 정해야 함 |
| 구조적 로그 | 쿼리 가능한 이벤트 스트림 | 사후 분석, 운영 환경 | 설계가 필요함 |
| 분산 트레이스 | 서비스 경계를 넘는 요청 흐름 | "어느 서비스가 느린가/깨졌나" | 계측이 필요함 |
| 메트릭 | 시간에 따른 집계 (지연·에러율) | 추세·이상 탐지 | 개별 케이스는 못 봄 |
| 코어 덤프 / 프로파일러 | 크래시 순간, 또는 자원 사용 | 메모리·성능 버그 | 분석에 전문성 필요 |
디버거를 먼저 시도하라
print 디버깅으로 빠지기 전에, 디버거를 붙일 수 있는가를 먼저 물어라. 디버거는 한 번의 브레이크포인트로 그 순간의 모든 변수를 보여준다. print는 미리 정한 변수만 보여준다. 로컬에서 재현되는 버그라면 디버거가 거의 항상 빠르다. 조건부 브레이크포인트(i == 9999일 때만 멈춤), 워치 표현식, 호출 스택 탐색 — 이걸 안 쓰는 건 손해다.
print 디버깅을 제대로
디버거를 못 쓰는 상황(운영, 분산, 타이밍 민감)에서는 print가 답이다. 단, 4장에서 본 대로 체계적으로:
나쁜 print 디버깅:
의심 가는 곳마다 log("here1"), log("here2") 난사
→ 로그 폭탄. 무엇을 찾는지 불명확.
좋은 print 디버깅:
- 가설을 먼저 세운다: "C 지점에서 user.id가 이미 null이다"
- 그 가설을 검증하는 값만 찍는다: log("at C, user.id =", user?.id)
- 코드 경로의 중간 지점부터 (이진 탐색)
- 식별 가능한 태그를 붙인다: log("[bug-1234] at C:", ...)
- 끝나면 전부 지운다 (또는 로그 레벨로 가둔다)
print 디버깅이 부끄러운 게 아니다. 무계획한 print가 부끄러운 것이다. 가설을 검증하는 print는 정당한 실험이다.
운영 환경: 로그·트레이스·메트릭의 협업
운영 버그는 보통 세 가지를 함께 쓴다. 메트릭으로 "언제부터 에러율이 올랐나"를 찾고(시간 범위 좁히기), 트레이스로 "어느 서비스에서 깨지나"를 찾고(공간 좁히기), 로그로 "그 서비스 안에서 정확히 무슨 값 때문에"를 찾는다(원인 짚기). 넓게 시작해서 좁힌다 — 1장의 격리 단계와 같은 구조다.
7장 · 어려운 버그 클래스 — 하이젠버그, 레이스, 메모리, "내 컴퓨터에선 되는데"
어떤 버그는 핵심 루프를 곧이곧대로 적용하기 어렵다. 재현이 안 되거나, 관찰하면 사라지기 때문이다. 클래스별로 전략이 다르다.
하이젠버그 — 관찰하면 사라지는 버그
브레이크포인트를 걸거나 로그를 추가하면 사라지는 버그. 보통 타이밍이나 초기화되지 않은 메모리가 원인이다. print 한 줄이 타이밍을 바꾸거나, 디버그 빌드가 메모리를 0으로 초기화한다.
전략: 관찰 도구가 타이밍을 바꾸지 않게 한다. 비동기·버퍼링 로깅, 또는 사후 코어 덤프. 디버그 빌드가 아니라 릴리스 빌드에서 재현한다. 그리고 "관찰하면 사라진다"는 사실 자체가 단서다 — 거의 항상 타이밍/메모리 초기화 문제다.
레이스 컨디션 — 순서에 의존하는 버그
두 스레드/프로세스의 실행 순서에 따라 결과가 갈리는 버그. 1000번에 1번 난다.
전략:
- 재현률을 높인다. 의심 구간에
sleep을 넣어 레이스 윈도를 인위적으로 벌린다. 1000번에 1번이 10번에 1번이 되면 디버깅 가능해진다. - 반증 실험. 의심 구간에 거친 락을 걸어 본다. 버그가 사라지면 레이스가 맞다(3장).
- 도구. ThreadSanitizer, Go의
-race, Java의 동시성 검증 도구는 데이터 레이스를 정적·동적으로 잡아준다.
메모리 버그 — 누수, 손상, use-after-free
전략: AddressSanitizer/Valgrind로 손상과 use-after-free를 잡는다. 누수는 힙 스냅샷을 두 시점에 찍어 차이(diff)를 본다 — 무엇이 계속 쌓이는지가 곧 단서다. 메모리 버그는 증상이 원인에서 멀리 떨어져 나타나는 게 특징이다. 손상된 메모리를 읽는 곳이 아니라 쓴 곳이 원인이다.
"내 컴퓨터에선 되는데"
환경 차이 버그. 내 환경과 깨지는 환경의 차이 목록을 만든다.
환경 diff 체크리스트:
□ OS / 아키텍처 (arm64 vs x86_64, 줄바꿈 CRLF/LF)
□ 언어 / 런타임 버전
□ 의존성 버전 (락파일을 정말 커밋했나?)
□ 환경 변수 / 설정 파일
□ 로케일 / 타임존 / 인코딩
□ 파일 시스템 (대소문자 구분 여부)
□ 데이터 (로컬 DB vs 운영 DB의 실제 데이터)
□ 권한 / 네트워크 / 방화벽
근본 해법은 환경을 코드화하는 것이다 — 컨테이너, 락파일, IaC. "차이"를 0에 가깝게 만들면 이 버그 클래스 자체가 사라진다.
분산 · 타이밍 버그
여러 서비스에 걸친 버그. 단일 스택 트레이스로는 안 보인다. 핵심은 상관관계 ID(correlation ID) — 요청이 시작될 때 ID를 발급하고, 모든 서비스 로그에 그 ID를 함께 남긴다. 그러면 분산 트레이스로 한 요청의 전체 여정을 재구성할 수 있다. 시계 차이(clock skew)에도 주의 — 서비스 간 타임스탬프를 비교할 때 몇 초 어긋날 수 있다.
8장 · 정말 막혔을 때 — 러버덕, 휴식, 가정 의심하기
방법론을 따라도 막힐 때가 있다. 막혔다는 건 보통 틀린 가정 위에서 탐색하고 있다는 신호다.
러버덕 디버깅
문제를 처음부터 끝까지, 한 줄씩, 소리 내어 설명한다. 고무 오리에게든, 동료에게든, 빈 채팅창에든. 설명하다 보면 "어... 잠깐, 이게 왜 이렇지?" 하는 지점이 나온다. 머릿속에서 건너뛰던 가정을 입 밖으로 내면 그게 드러난다.
휴식을 취하라
2시간 동안 막혔다면, 더 노려본다고 풀리지 않는다. 산책, 잠, 다른 작업. 무의식이 배경에서 탐색 공간을 재정리한다. "샤워하다 답이 떠올랐다"는 클리셰가 아니라 인지과학이다. 막혔을 때 휴식은 게으름이 아니라 전략이다.
가정을 의심하라
막혔을 때 가장 강력한 행동: 지금까지 사실이라고 믿은 것들을 하나씩 의심한다.
"확실하다고 생각한 것들"을 검증하라:
- "이 함수는 분명히 호출된다" → 정말? 로그로 확인했나?
- "이 값은 절대 null이 아니다" → 정말? assert를 걸어봤나?
- "내가 고친 코드가 배포됐다" → 정말? 빌드 해시를 확인했나?
- "DB에는 올바른 데이터가 있다" → 정말? 직접 쿼리했나?
- "이 설정 파일이 읽힌다" → 정말? 어느 경로를 읽는지 봤나?
- "라이브러리는 문서대로 동작한다" → 정말? 소스를 봤나?
검증하지 않은 가정은 가정이 아니라 추측이다. "분명히 ~일 것이다"라고 말하는 순간, 그게 바로 검증해야 할 대상이다.
버그는 네가 생각하는 곳에 없다
오래 막혔다면, 탐색 범위 자체가 틀렸을 가능성이 높다. 일주일 동안 애플리케이션 코드를 봤는데 사실 원인은 설정 파일이었거나, 라이브러리 버전이었거나, 데이터였거나, 인프라였다. 한 영역에서 충분히 찾았는데 없으면 — 그 영역이 아니다. 범위를 넓혀라. 1장의 핵심 루프로 돌아가서, "재현"부터 다시 한다. 종종 재현 단계를 대충 했던 게 드러난다.
9장 · AI 에이전트와 디버깅 — 에이전트에게도 추측을 시키지 마라
AI 코딩 에이전트는 강력한 디버깅 파트너지만, 잘못 쓰면 사람보다 더 빠르게 추측한다. 핵심 원칙은 같다: 에이전트에게 추론할 재료를 주고, 추측하지 못하게 막아라.
에이전트에게 줘야 할 것
에이전트는 컨텍스트가 좋을수록 정확하다. 디버깅을 요청할 때 다음을 함께 준다:
| 줄 것 | 왜 |
|---|---|
| 재현 명령 (2장) | 에이전트가 자기 가설을 직접 검증할 수 있게 |
| 정확한 에러 / 스택 트레이스 (5장) | "에러가 난다" 말고 전체 텍스트 |
| 무엇이 바뀌었는지 (3장) | 최근 diff, 배포 — 탐색 범위를 좁힘 |
| 이미 시도하고 배제한 것 | 같은 가설을 반복하지 않게 |
| 기대 동작 vs 실제 동작 | 버그의 정의를 명확히 |
재현 명령을 주는 게 가장 중요하다. 그러면 에이전트는 "이게 원인 같다"가 아니라 "가설을 세우고 → repro 명령으로 검증하고 → 통과/실패를 보고" 하는 핵심 루프를 스스로 돌 수 있다.
에이전트가 추측하게 두지 마라
에이전트가 빠지는 안티패턴은 사람과 똑같다 — 한 번에 여러 곳을 바꾸고, 가설 없이 코드를 수정하고, 증상을 try/catch로 덮는다. 막아야 한다:
에이전트 디버깅을 관리하는 법:
- "추측하지 말고, 먼저 가설을 말한 다음 검증해" 라고 지시
- 핵심 루프를 명시적으로 요구: 재현 → 격리 → 가설 → 검증
- 한 번에 한 변수만 — "여러 개 동시에 바꾸지 마"
- 수정 전에 "근본 원인이 무엇인지" 설명하게 시킴
- 수정 후 재현 명령으로 확인하고 결과를 보여달라고 함
- 에이전트의 가설을 사람이 검토 — 그럴듯해 보여도 검증 전엔 추측
에이전트의 장점은 지치지 않고 이진 탐색을 돌리고, 방대한 로그를 빠르게 훑고, git bisect를 자동으로 돌릴 수 있다는 것이다. 단점은 그럴듯한 가설을 자신 있게 말한다는 것이다. 그래서 사람의 역할은 방법론을 강제하는 것 — 에이전트가 핵심 루프를 따르게 하고, 검증되지 않은 가설을 사실로 받아들이지 않는 것이다.
사람이든 에이전트든 규칙은 같다: 검증되지 않은 가설은 추측이다. 추측으로 코드를 고치지 않는다.
에필로그 — 체크리스트와 안티패턴
체계적 디버깅은 재능이 아니라 훈련 가능한 절차다. 핵심 루프를 따르고, 한 번에 한 변수만 바꾸고, 이진 탐색으로 탐색 공간을 좁히고, 검증되지 않은 가정을 의심한다. 추측이 아니라 추론이다.
디버깅 체크리스트
- 재현했는가? — 명령 하나로 버그를 항상 일으킬 수 있는가? (안 되면 여기서 멈춤)
- 최소화했는가? — 재현 케이스에서 무관한 부분을 다 제거했는가?
- 무엇이 바뀌었는가? — 코드/설정/데이터/의존성/인프라 중 무엇이?
- 가설이 한 문장인가? — "X 때문이다" — 검증 가능한 형태인가?
- 한 번에 한 변수인가? — 이번 실험에서 바꾸는 게 하나뿐인가?
- 반증을 노리는가? — 이 실험이 가설을 죽일 수 있는가?
- 이진 탐색을 쓸 수 있는가? — git bisect / 입력 / 코드 경로 중 어디에?
- 에러 메시지를 끝까지 읽었는가? — 스택 트레이스 전체를?
- 올바른 도구인가? — 디버거를 먼저 시도했는가?
- 근본 원인을 고쳤는가? — 증상이 아니라 원인을? 왜 버그였는지 설명할 수 있는가?
- 확인했는가? — 재현 케이스가 이제 통과하는가? 회귀 테스트를 추가했는가?
- 막혔다면 — 러버덕, 휴식, 가정 의심, 범위 확대.
안티패턴
| 안티패턴 | 왜 나쁜가 | 대신 |
|---|---|---|
| 재현 없이 추측 시작 | 고쳤는지 확인 불가 | 재현 명령부터 만든다 |
| 한 번에 여러 변수 변경 | 무엇이 원인인지 모름 | 한 번에 하나만 |
| 스택 트레이스 첫 줄만 읽기 | 진짜 단서를 놓침 | 끝까지, Caused by까지 |
console.log 난사 | 로그 폭탄, 무계획 | 가설을 검증하는 값만 |
증상을 try/catch로 덮기 | 버그는 그대로, 더 깊이 숨음 | 근본 원인을 고친다 |
| 확인 편향 (가설 확인만) | 틀린 가설을 못 버림 | 반증하는 실험을 설계 |
| 검증 안 된 가정 위에서 탐색 | 틀린 곳을 영원히 판다 | "분명히 ~"를 전부 검증 |
| 눈으로 보고 "된 것 같다" | 회귀가 다시 옴 | 재현 케이스로 확인 + 회귀 테스트 |
| 막혔는데 계속 노려보기 | 같은 추측 반복 | 휴식 / 러버덕 / 범위 확대 |
| 에이전트에게 추측 위임 | 빠른 추측은 여전히 추측 | 재현·로그를 주고 루프를 강제 |
다음 글 예고
다음 글은 **"회귀 테스트 설계 — 버그를 두 번 만나지 않는 법"**이다. 이 글이 버그를 찾는 법이라면, 다음 글은 같은 버그를 다시 만나지 않는 법이다. 재현 케이스를 영구 테스트로 굳히는 법, 어떤 버그가 회귀 테스트를 받아야 하는지, 테스트가 진짜로 그 버그를 잡는지 검증하는 법, 그리고 깨지기 쉬운(flaky) 회귀 테스트를 만들지 않는 법을 다룬다. 버그를 잘 잡는 것만큼 중요한 게, 잡은 버그를 가두는 것이다.
Systematic Debugging — Finding Bugs by Reasoning, Not Guessing
Prologue — Most Debugging Is Guessing
A bug report comes in. The developer opens the code. "This part looks suspicious," they think, and drop in a console.log. Rebuild, reproduce, no log fires — so they drop another one somewhere else. Thirty minutes later there are log statements scattered all over the code and the bug is still right there.
This is 90% of debugging as it actually happens in the field. Guessing with no structure. "Maybe it's this?" "Maybe it's that?" If you're lucky, you find it fast. If you're not, you burn a day. And even after you find it, you can't explain exactly why it was a bug. If you can't explain it, the same bug comes back.
Debugging is not luck. It's a methodology. A good debugger (the person) isn't smarter — they narrow the search space systematically. Instead of randomly poking at 100 possibilities, they eliminate 50 with a single experiment. Then 25. Then 12. This is not talent; it's a trainable skill.
This post lays out that skill: the core loop, securing reproducibility first, applying the scientific method to bugs, binary search, reading stack traces properly, observability tooling, the hard bug classes, getting unstuck, and debugging with AI agents. It's language- and stack-agnostic — it applies anywhere there's a bug.
The one-liner: debugging is forming a hypothesis and designing the experiment that disproves it most efficiently. It is not staring at code.
Chapter 1 · The Core Loop — Reproduce → Isolate → Hypothesize → Test → Fix → Verify
Every systematic debugging session runs the same loop. The order matters. Skip a step and you fall back into guessing.
┌─────────────────────────────────────────────────┐
│ 1. Reproduce │
│ Can you trigger the bug with one command? │
│ ↓ │
│ 2. Isolate │
│ Halve the region the bug lives in │
│ ↓ │
│ 3. Hypothesize │
│ "It's caused by X" — one testable sentence │
│ ↓ │
│ 4. Test │
│ Design and run an experiment that disproves │
│ ↓ │
│ 5. Fix │
│ Fix the root cause (not the symptom) │
│ ↓ │
│ 6. Verify │
│ Does the repro case pass now? Regression? │
└─────────────────────────────────────────────────┘
↑________________________________│
If the hypothesis was wrong, go back to 3
The key to each step:
| Step | Question to ask | Common mistake |
|---|---|---|
| Reproduce | "Can I trigger it again with one command?" | Starting to guess with no repro |
| Isolate | "Did I halve the scope?" | Staring at the whole codebase at once |
| Hypothesize | "Is it one testable sentence?" | A vague hypothesis like "something's off" |
| Test | "Can this experiment disprove the hypothesis?" | An experiment that only confirms |
| Fix | "Did I fix the cause, not the symptom?" | Covering the symptom with try/catch |
| Verify | "Does the repro case pass?" | Eyeballing it and saying "looks fixed" |
The power of this loop is that each step shrinks the next one. A solid repro makes isolation faster. Good isolation narrows the hypothesis. A narrow hypothesis means the test ends in one shot. Conversely, if you skip reproduction, everything else is built on a guess.
Chapter 2 · Reproducibility First — Deterministic Repro and Minimizing the Case
You cannot debug a bug you cannot reproduce, because you have no way to confirm you fixed it. So the first step is always triggering the bug again with a single command.
Building a Deterministic Repro
"It happens sometimes" is an un-debuggable state. You have to turn "sometimes" into "always." Pin down the sources of non-determinism one by one.
| Source of non-determinism | How to pin it |
|---|---|
| Time / date | Inject the clock and use a fixed value (mock clock.now()) |
| Randomness | Fix the seed (seed=42) |
| Concurrency / scheduling | Set thread count to 1, or force the schedule |
| Network responses | Record/replay responses (VCR, fixtures) |
| Input data | Save the exact failing input to a file |
| Environment variables / config | Capture the whole .env |
| Build / dependency versions | Pin the lockfile, pin the container image |
The goal is a line like this:
# This command always produces the same failure
SEED=42 TZ=UTC ./repro.sh fixtures/bug-1234.json
Even if it takes 30 minutes to build that one line, those 30 minutes almost always pay for themselves. Once you have a repro command, every remaining step becomes measurable.
Minimizing the Case
Once it reproduces, the next move is to shrink the repro case to the minimum. If a 1000-line input triggers the bug, see whether deleting 990 of those lines still triggers it. If it does, those 990 lines are irrelevant.
This is the core idea of "delta debugging." Halve the input; if it still fails, halve that half again. When the failure disappears, back up one step.
input 1000 lines → fails ✗
input first 500 → fails ✗ (drop the back 500)
input first 250 → passes ✓ (back up one step)
input first 375 → fails ✗
input first 312 → fails ✗
...
input 3 lines → fails ✗ ← minimal repro case
A 3-line repro case is overwhelmingly easier to debug than a 1000-line one. With the noise stripped out, the remaining 3 lines are the clue. For C/C++ compiler bugs there's creduce; for plain text there's manual binary deletion; property-based testing tools have automatic shrinking built in.
The process of building a minimal repro case is debugging. While deleting those 990 lines, you see which line, when removed, makes the bug vanish — and that's the location of the cause.
Chapter 3 · The Scientific Method, Applied to Bugs — One Variable at a Time
The essence of systematic debugging is the scientific method: observe → hypothesize → experiment → conclude. And the most important rule of the scientific method: change one variable at a time.
Bad Debugging vs Good Debugging
Bad debugging:
"Hmm, let me turn off the cache, raise the log level,
bump the timeout, and try a different version of this library."
→ The bug disappears. But you don't know what made it disappear.
→ Which of the 4 was it? No idea. It may come back if you re-enable.
Good debugging:
Hypothesis: "It's the cache."
Experiment: Turn off only the cache. Leave everything else as-is.
Outcome A: bug gone → cache is the cause (or interacts with it)
Outcome B: bug remains → cache is irrelevant. Reject, next hypothesis.
When you change several variables at once, even a result tells you nothing about which variable caused it. It's an experiment that yielded no information. Change one variable and any outcome is informative — that variable is the cause, or it isn't.
Aim to Disprove
Confirmation bias is debugging's enemy. Once you believe "X is the cause," you only look for evidence that confirms X. A good experiment is designed to disprove the hypothesis.
| Hypothesis | Confirming experiment (weak) | Disproving experiment (strong) |
|---|---|---|
| "A null pointer is the cause" | Log at that spot | Guarantee that variable is never null — does the bug vanish? |
| "It's a race condition" | Stare at the concurrent code | Add a coarse lock — does it vanish? Then it's a race |
| "This PR broke it" | Read the PR diff | Revert that PR — does the bug vanish? |
If the hypothesis survives (disproof fails), your confidence rises. If it dies, you move on. Either way the search space shrinks.
What Changed?
If something that used to work stopped working, the most powerful question is "What changed?" Code, data, config, dependencies, infrastructure, traffic — if a healthy system stopped, something changed.
"It worked yesterday, not today" checklist:
□ Code — git log, recent deploys
□ Config — env vars, feature flags, secrets
□ Data — a new edge-case input arrived
□ Dependencies — lockfile change, auto-updates
□ Infrastructure — instance swap, OS patch, cert expiry
□ Traffic — volume/pattern shift, a new client
□ Time — month-end, midnight, DST, leap year, epoch boundary
Chapter 4 · Binary Search — git bisect and Binary Search Everywhere
Binary search is the single most powerful technique in debugging. It cuts N candidates down in log2(N) steps. Even with 1000 candidates, you're done in 10 steps. Binary search applies in three places: commit history, input, and the code path.
git bisect — Binary Search the Commit History
If "it worked before and doesn't now," some commit broke it. git bisect finds that commit in log2(commit count) steps. Even across 500 commits, you only test 9 times.
# Start the session
git bisect start
git bisect bad # the current commit is broken
git bisect good v2.3.0 # it worked at this tag
# git checks out a middle commit. Test and judge:
# $ ./repro.sh && git bisect good (or)
# $ ./repro.sh || git bisect bad
# Repeat ~9 times and git prints the culprit:
#
# a1b2c3d is the first bad commit
# Author: ...
# Date: ...
# refactor: switch session store to redis
git bisect reset # return to the original branch
The key is automation. If the repro command returns an exit code precisely (0 on success, non-zero on failure), no human needs to repeat the loop:
git bisect start HEAD v2.3.0
git bisect run ./repro.sh
# git binary-searches the whole range itself and spits out the culprit commit
git bisect run is exactly where the deterministic repro command from Chapter 2 shows its worth. With one repro command, debugging across 500 commits collapses into a single line.
Binary Search the Input
The case minimization from Chapter 3 is binary search the input. Delete half of a 1000-line input. Which of 10,000 records breaks the pipeline — feed them in by halves. You find it in 14 steps.
Binary Search the Code Path
When you don't know which function the bug originates in, plant a check at the midpoint of the code path. If the processing flow is A → B → C → D → E, inspect the state at point C.
A → B → [C: state OK?] → D → E
├─ OK → bug is between C and E (inspect D)
└─ broken → bug is between A and C (inspect B)
You're asking "is the data still intact at the midpoint?" If it's intact, the bug is downstream; if broken, upstream. Each check halves the code path. This is the difference between blindly scattering console.log and systematic print debugging.
Chapter 5 · Reading Stack Traces and Error Messages Properly
A stack trace is the most precise clue you get for free. Yet many developers read only the top line and close it. A stack trace has a way it should be read.
Anatomy of a Stack Trace
TypeError: Cannot read properties of undefined (reading 'id') ← (1) exception type + message
at getUserName (src/user.js:42:18) ← (2) where it was thrown (deepest frame)
at formatProfile (src/profile.js:17:9) ← (3) the caller
at renderPage (src/page.js:88:14) ← (4) its caller
at processRequest (src/server.js:120:5) ← (5) toward the entry point
Reading order:
- Exception type and message (1) —
TypeError,Cannot read properties of undefined. It even tells you what wasundefined(reading 'id'). Read the message to the end. - The deepest frame (2) —
src/user.js:42. The line where the exception was actually thrown. 90% of the time the cause is near here. But "near" is not "exactly there" — line 42 blew up, but theundefinedmay have entered because of a caller. - Walking down the call stack (3, 4, 5) — trace where the
undefinedvalue originated. If the argument that enteredgetUserNamewas alreadyundefined, look atformatProfile; if it wasundefinedthere too, look atrenderPage. This is the same as binary search the code path from Chapter 4. - Distinguish your code from library code —
node_modules/frames can usually be skipped. The boundary frame between your code and the library is the key. That's where you passed a bad value into the library.
Read the Error Message to the End
The most common mistake: reading only the first line of an error message and starting to guess. The message usually carries information that's close to the answer.
| Message clue | What people often miss |
|---|---|
ECONNREFUSED 127.0.0.1:5432 | The port number — tells you which service |
expected 3 arguments but got 2 | The exact count — narrows which call |
... at line 42 column 18 | The column number — exact spot on the same line |
Caused by: ... (nested exception) | The real cause is the bottom-most Caused by |
Warning logs (WARN) | A warning logged before the error is a clue |
In async code the stack trace breaks. async/await stitches it reasonably well, but callback- and event-based code leaves "where it was called from" out of the trace. In that case you have to catch the error at the entry point and log the context (request ID, input) alongside it.
Chapter 6 · Observability for Debugging — Logs, Traces, Metrics, Debuggers, and Print Done Right
Tools are windows into the search space. Each tool shows you something different.
| Tool | What it shows | Strength | Weakness |
|---|---|---|---|
| Debugger (breakpoints) | The whole state at one instant | Freely explore variables and call stack | Changes timing; unfit for distributed/prod |
| print / logs | How a specific value changes over time | Works anywhere, low timing impact | You must decide what to print up front |
| Structured logs | A queryable stream of events | Post-mortem analysis, production | Requires design |
| Distributed traces | A request flow crossing service boundaries | "Which service is slow/broken" | Requires instrumentation |
| Metrics | Aggregates over time (latency, error rate) | Trend and anomaly detection | Can't see individual cases |
| Core dumps / profilers | The moment of a crash, or resource usage | Memory and performance bugs | Analysis needs expertise |
Try the Debugger First
Before falling into print debugging, first ask can I attach a debugger. A debugger shows you every variable at that instant with a single breakpoint. Print shows only the variables you picked in advance. For a bug that reproduces locally, the debugger is almost always faster. Conditional breakpoints (stop only when i == 9999), watch expressions, call-stack navigation — not using these is leaving value on the table.
Print Debugging Done Right
In situations where you can't use a debugger (production, distributed, timing-sensitive), print is the answer. But systematically, as in Chapter 4:
Bad print debugging:
Spray log("here1"), log("here2") at every suspicious spot
→ Log bomb. Unclear what you're looking for.
Good print debugging:
- Form a hypothesis first: "at point C, user.id is already null"
- Print only the value that tests that hypothesis: log("at C, user.id =", user?.id)
- Start at the midpoint of the code path (binary search)
- Attach an identifiable tag: log("[bug-1234] at C:", ...)
- Remove them all when done (or gate them behind a log level)
Print debugging is not shameful. Unplanned print is. A print that tests a hypothesis is a legitimate experiment.
Production: Logs, Traces, and Metrics Working Together
A production bug usually uses all three together. Metrics tell you "when did the error rate climb" (narrowing the time window), traces tell you "which service breaks" (narrowing the space), and logs tell you "exactly what value inside that service caused it" (pinning the cause). Start wide, narrow down — the same structure as the isolation step in Chapter 1.
Chapter 7 · The Hard Bug Classes — Heisenbugs, Races, Memory, "Works on My Machine"
Some bugs resist a straight application of the core loop, because they won't reproduce or they vanish when observed. Each class needs a different strategy.
Heisenbugs — Bugs That Vanish When Observed
A bug that disappears when you set a breakpoint or add a log. Usually the cause is timing or uninitialized memory. One print line changes the timing, or a debug build zeroes out the memory.
Strategy: keep your observation tools from changing the timing. Async or buffered logging, or a post-mortem core dump. Reproduce on the release build, not the debug build. And the fact that it "vanishes when observed" is itself a clue — it's almost always a timing or memory-initialization problem.
Race Conditions — Bugs That Depend on Ordering
A bug whose outcome depends on the execution order of two threads/processes. It happens 1 in 1000 times.
Strategy:
- Raise the reproduction rate. Insert a
sleepin the suspect region to artificially widen the race window. When 1-in-1000 becomes 1-in-10, it becomes debuggable. - Disproving experiment. Put a coarse lock around the suspect region. If the bug vanishes, it's a race (Chapter 3).
- Tools. ThreadSanitizer, Go's
-race, and Java concurrency checkers catch data races statically and dynamically.
Memory Bugs — Leaks, Corruption, Use-After-Free
Strategy: use AddressSanitizer/Valgrind to catch corruption and use-after-free. For leaks, take a heap snapshot at two points in time and diff them — what keeps piling up is the clue. A defining trait of memory bugs is that the symptom shows up far from the cause. The cause is where the corrupted memory was written, not where it was read.
"Works on My Machine"
An environment-difference bug. Build a diff list between your environment and the one that breaks.
Environment diff checklist:
□ OS / architecture (arm64 vs x86_64, CRLF/LF line endings)
□ Language / runtime version
□ Dependency versions (did you actually commit the lockfile?)
□ Environment variables / config files
□ Locale / timezone / encoding
□ File system (case-sensitive or not)
□ Data (local DB vs the real data in the production DB)
□ Permissions / network / firewall
The fundamental fix is to codify the environment — containers, lockfiles, IaC. Drive "the difference" close to zero and this bug class disappears entirely.
Distributed and Timing Bugs
A bug spanning multiple services. A single stack trace can't see it. The key is a correlation ID — issue an ID when a request starts and stamp every service's logs with that ID. Then distributed tracing can reconstruct the full journey of one request. Watch out for clock skew too — timestamps across services can be off by several seconds.
Chapter 8 · When You're Truly Stuck — Rubber Duck, Take a Break, Question Assumptions
Even following the methodology, you get stuck. Being stuck is usually a sign that you're searching on top of a wrong assumption.
Rubber Duck Debugging
Explain the problem from start to finish, line by line, out loud — to a rubber duck, a colleague, or an empty chat window. While explaining, you hit a point where you go "wait... why is this like this?" Saying out loud an assumption you'd been skipping in your head exposes it.
Take a Break
If you've been stuck for 2 hours, staring harder won't solve it. Walk, sleep, switch tasks. Your subconscious reorganizes the search space in the background. "The answer came to me in the shower" isn't a cliché — it's cognitive science. When stuck, a break is a strategy, not laziness.
Question Assumptions
The most powerful move when stuck: question, one by one, the things you've believed to be true.
Verify the things "you thought were certain":
- "This function definitely gets called" → Really? Did you confirm with a log?
- "This value is never null" → Really? Did you add an assert?
- "The code I fixed got deployed" → Really? Did you check the build hash?
- "The DB has the correct data" → Really? Did you query it directly?
- "This config file gets read" → Really? Did you see which path it reads?
- "The library behaves as documented" → Really? Did you read the source?
An unverified assumption is not an assumption — it's a guess. The moment you say "it's definitely X," that's exactly what you need to verify.
The Bug Is Not Where You Think
If you've been stuck a long time, the search scope itself is probably wrong. You spent a week looking at application code, but the cause was actually a config file, a library version, the data, or the infrastructure. If you've searched one area thoroughly and it's not there — it's not that area. Widen the scope. Go back to the core loop in Chapter 1 and start over from "reproduce." Often it turns out you did the reproduction step sloppily.
Chapter 9 · Debugging With AI Agents — Don't Let Agents Guess Either
AI coding agents are powerful debugging partners, but used wrong they guess faster than humans. The core principle is the same: give the agent material to reason with, and stop it from guessing.
What to Give the Agent
An agent is more accurate the better its context. When you ask it to debug, hand over the following:
| What to give | Why |
|---|---|
| The repro command (Ch. 2) | So the agent can verify its own hypotheses |
| The exact error / stack trace (Ch. 5) | The full text, not "it errors" |
| What changed (Ch. 3) | Recent diffs, deploys — narrows the search scope |
| What you already tried and ruled out | So it doesn't repeat the same hypothesis |
| Expected behavior vs actual behavior | Make the definition of the bug explicit |
Giving the repro command matters most. Then the agent can run the core loop itself — not "this looks like the cause" but "form a hypothesis → verify with the repro command → report pass/fail."
Don't Let the Agent Guess
The anti-patterns an agent falls into are exactly the human ones — changing several places at once, modifying code with no hypothesis, covering the symptom with try/catch. You have to block them:
How to manage agent debugging:
- Instruct: "Don't guess. State a hypothesis first, then verify it."
- Explicitly require the core loop: reproduce → isolate → hypothesize → test
- One variable at a time — "don't change several things at once"
- Before any fix, make it explain "what the root cause is"
- After the fix, have it verify with the repro command and show the result
- Review the agent's hypotheses yourself — plausible is still a guess until verified
The agent's advantage is that it runs binary search tirelessly, skims vast logs fast, and can run git bisect automatically. Its weakness is that it states plausible hypotheses with confidence. So the human's role is to enforce the methodology — make the agent follow the core loop and never accept an unverified hypothesis as fact.
Human or agent, the rule is the same: an unverified hypothesis is a guess. You don't fix code on a guess.
Epilogue — Checklist and Anti-Patterns
Systematic debugging is not talent; it's a trainable procedure. Follow the core loop, change one variable at a time, narrow the search space with binary search, and question unverified assumptions. Reasoning, not guessing.
Debugging Checklist
- Did you reproduce it? — Can you trigger the bug reliably with one command? (If not, stop here.)
- Did you minimize it? — Have you removed every irrelevant part from the repro case?
- What changed? — Of code/config/data/dependencies/infrastructure, which?
- Is the hypothesis one sentence? — "It's caused by X" — in a testable form?
- One variable at a time? — Is there only one thing you're changing in this experiment?
- Are you aiming to disprove? — Can this experiment kill the hypothesis?
- Can you use binary search? — On git bisect / the input / the code path?
- Did you read the error message to the end? — The whole stack trace?
- Is it the right tool? — Did you try the debugger first?
- Did you fix the root cause? — The cause, not the symptom? Can you explain why it was a bug?
- Did you verify? — Does the repro case pass now? Did you add a regression test?
- If you're stuck — Rubber duck, take a break, question assumptions, widen the scope.
Anti-Patterns
| Anti-pattern | Why it's bad | Instead |
|---|---|---|
| Start guessing with no repro | Can't confirm you fixed it | Build the repro command first |
| Change several variables at once | You don't know what the cause is | One at a time |
| Read only the first line of a stack trace | You miss the real clue | Read to the end, including Caused by |
Spray console.log | Log bomb, no plan | Only the value that tests a hypothesis |
Cover the symptom with try/catch | The bug remains, hidden deeper | Fix the root cause |
| Confirmation bias (only confirm) | You can't drop a wrong hypothesis | Design a disproving experiment |
| Search on top of an unverified assumption | You dig forever in the wrong place | Verify every "it's definitely..." |
| Eyeball it and say "looks fixed" | The regression comes back | Verify with the repro case + regression test |
| Keep staring while stuck | You repeat the same guess | Break / rubber duck / widen the scope |
| Delegate guessing to the agent | A fast guess is still a guess | Give it the repro and logs, enforce the loop |
Next Post Teaser
The next post is "Designing Regression Tests — How Not to Meet a Bug Twice." If this post is how to find a bug, the next is how not to meet the same bug again. It covers how to harden a repro case into a permanent test, which bugs deserve a regression test, how to verify the test actually catches the bug, and how not to write flaky regression tests. As important as catching bugs well is caging the bugs you've caught.