Skip to content

✍️ 필사 모드: 체계적 디버깅 — 추측이 아니라 추론으로 버그를 잡는 법

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

프롤로그 — 디버깅은 대부분 추측이다

버그 리포트가 올라온다. 개발자는 코드를 연다. 그리고 "여기가 의심스러운데" 하면서 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 경계

이진 탐색은 디버깅에서 가장 강력한 단일 기법이다. 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. 예외 타입과 메시지 (1)TypeError, Cannot read properties of undefined. 무엇이 undefined였는지(reading 'id')까지 알려준다. 메시지를 끝까지 읽어라.
  2. 가장 깊은 프레임 (2)src/user.js:42. 예외가 실제로 던져진 줄. 90%는 여기 근처가 원인이다. 하지만 "근처"지 "정확히 거기"는 아니다 — 42번 줄에서 터졌어도, undefined가 들어온 건 호출자 때문일 수 있다.
  3. 호출 스택 따라 내려가기 (3, 4, 5)undefined 값이 어디서 시작됐는지 추적한다. getUserName에 들어온 인자가 이미 undefined였다면, formatProfile을 보고, 거기서도 undefined였다면 renderPage를 본다. 이게 4장의 코드 경로 이진 탐색과 같다.
  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가 답이다. 단, 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를 자동으로 돌릴 수 있다는 것이다. 단점은 그럴듯한 가설을 자신 있게 말한다는 것이다. 그래서 사람의 역할은 방법론을 강제하는 것 — 에이전트가 핵심 루프를 따르게 하고, 검증되지 않은 가설을 사실로 받아들이지 않는 것이다.

사람이든 에이전트든 규칙은 같다: 검증되지 않은 가설은 추측이다. 추측으로 코드를 고치지 않는다.


에필로그 — 체크리스트와 안티패턴

체계적 디버깅은 재능이 아니라 훈련 가능한 절차다. 핵심 루프를 따르고, 한 번에 한 변수만 바꾸고, 이진 탐색으로 탐색 공간을 좁히고, 검증되지 않은 가정을 의심한다. 추측이 아니라 추론이다.

디버깅 체크리스트

  1. 재현했는가? — 명령 하나로 버그를 항상 일으킬 수 있는가? (안 되면 여기서 멈춤)
  2. 최소화했는가? — 재현 케이스에서 무관한 부분을 다 제거했는가?
  3. 무엇이 바뀌었는가? — 코드/설정/데이터/의존성/인프라 중 무엇이?
  4. 가설이 한 문장인가? — "X 때문이다" — 검증 가능한 형태인가?
  5. 한 번에 한 변수인가? — 이번 실험에서 바꾸는 게 하나뿐인가?
  6. 반증을 노리는가? — 이 실험이 가설을 죽일 수 있는가?
  7. 이진 탐색을 쓸 수 있는가? — git bisect / 입력 / 코드 경로 중 어디에?
  8. 에러 메시지를 끝까지 읽었는가? — 스택 트레이스 전체를?
  9. 올바른 도구인가? — 디버거를 먼저 시도했는가?
  10. 근본 원인을 고쳤는가? — 증상이 아니라 원인을? 왜 버그였는지 설명할 수 있는가?
  11. 확인했는가? — 재현 케이스가 이제 통과하는가? 회귀 테스트를 추가했는가?
  12. 막혔다면 — 러버덕, 휴식, 가정 의심, 범위 확대.

안티패턴

안티패턴왜 나쁜가대신
재현 없이 추측 시작고쳤는지 확인 불가재현 명령부터 만든다
한 번에 여러 변수 변경무엇이 원인인지 모름한 번에 하나만
스택 트레이스 첫 줄만 읽기진짜 단서를 놓침끝까지, Caused by까지
console.log 난사로그 폭탄, 무계획가설을 검증하는 값만
증상을 try/catch로 덮기버그는 그대로, 더 깊이 숨음근본 원인을 고친다
확인 편향 (가설 확인만)틀린 가설을 못 버림반증하는 실험을 설계
검증 안 된 가정 위에서 탐색틀린 곳을 영원히 판다"분명히 ~"를 전부 검증
눈으로 보고 "된 것 같다"회귀가 다시 옴재현 케이스로 확인 + 회귀 테스트
막혔는데 계속 노려보기같은 추측 반복휴식 / 러버덕 / 범위 확대
에이전트에게 추측 위임빠른 추측은 여전히 추측재현·로그를 주고 루프를 강제

다음 글 예고

다음 글은 **"회귀 테스트 설계 — 버그를 두 번 만나지 않는 법"**이다. 이 글이 버그를 찾는 법이라면, 다음 글은 같은 버그를 다시 만나지 않는 법이다. 재현 케이스를 영구 테스트로 굳히는 법, 어떤 버그가 회귀 테스트를 받아야 하는지, 테스트가 진짜로 그 버그를 잡는지 검증하는 법, 그리고 깨지기 쉬운(flaky) 회귀 테스트를 만들지 않는 법을 다룬다. 버그를 잘 잡는 것만큼 중요한 게, 잡은 버그를 가두는 것이다.

현재 단락 (1/225)

버그 리포트가 올라온다. 개발자는 코드를 연다. 그리고 "여기가 의심스러운데" 하면서 `console.log`를 한 줄 박는다. 다시 빌드하고, 재현하고, 로그가 안 찍히면 다른 ...

작성 글자: 0원문 글자: 10,421작성 단락: 0/225