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 경계

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. **예외 타입과 메시지 (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 디버깅을 제대로

디버거를 못 쓰는 상황(운영, 분산, 타이밍 민감)에서는 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