- 들어가며 — 디버깅은 추측이 아니다
- 과학적 방법 — 가설, 예측, 실험, 관찰
- 문제 공간을 이분 탐색하라
- 최소 재현 예제 — 버그를 우리에 가두기
- git bisect — 어느 커밋이 범인인가
- print vs 디버거 vs 로깅 — 관찰의 도구들
- 에러를 실제로 읽어라
- "컴파일러 탓이 아니다"
- 러버덕 — 소리 내어 설명하기
- 종합 — 버그 하나를 끝까지
- 마치며
- 참고 자료
들어가며 — 디버깅은 추측이 아니다
버그가 떴습니다. 프로덕션에서 결제가 가끔 실패합니다. 재현은 안 되고, 로그는 애매하고, 마감은 오늘입니다. 이럴 때 많은 개발자가 하는 일은 이렇습니다. 코드를 노려보다가 "여기가 수상한데" 싶은 줄을 바꾸고, 다시 돌려 보고, 안 되면 또 다른 줄을 바꾸고... 이걸 저는 **샷건 디버깅(shotgun debugging)**이라 부릅니다. 총알을 사방에 뿌리다 보면 언젠가 맞겠지 하는 방식이죠.
문제는 이게 가끔 통한다는 겁니다. 그래서 습관이 됩니다. 하지만 운으로 고친 버그는 왜 고쳐졌는지 모르고, 왜 고쳐졌는지 모르면 다음에 또 만납니다. 더 나쁜 건, 고치는 과정에서 멀쩡한 코드를 세 군데 더 건드려 놓는다는 겁니다.
이 글의 주장은 단순합니다. 디버깅은 과학이다. 과학자가 자연을 이해하는 방법—가설을 세우고, 그 가설이 참이라면 무슨 일이 일어날지 예측하고, 실험으로 검증하고, 관찰 결과로 가설을 수정하는—을 그대로 코드에 적용하면 됩니다. 운이 아니라 방법으로 버그를 잡는 겁니다.
과학적 방법 — 가설, 예측, 실험, 관찰
버그를 만났을 때의 루프는 정확히 과학적 방법입니다.
- 관찰(observe): 무슨 일이 일어나는가? 정확히 무엇이 잘못되었나?
- 가설(hypothesize): 왜 그런지에 대한 검증 가능한 설명을 하나 세운다.
- 예측(predict): 그 가설이 참이라면, 내가 X를 하면 Y가 관찰될 것이다.
- 실험(experiment): X를 한다.
- 관찰: 실제로 Y가 나왔는가? 나왔으면 가설이 강해지고, 안 나왔으면 가설을 버리거나 수정한다.
핵심은 3번, 예측입니다. 좋은 디버깅과 나쁜 디버깅을 가르는 지점이 여기입니다. 코드를 바꾸기 전에 "이걸 바꾸면 무슨 일이 일어날 것"이라고 소리 내어 말할 수 있어야 합니다. 예측 없이 코드를 바꾸는 건 실험이 아니라 그냥 도박입니다.
예를 들어 봅시다. "결제가 가끔 실패한다"는 관찰에서 출발합니다.
- 나쁜 접근: "타임아웃일지도 몰라. 타임아웃 값을 늘려 보자." (예측 없음, 왜 늘리는지 모름)
- 좋은 접근: "가설: 카드사 API가 3초 넘게 걸릴 때 우리 클라이언트가 2초에서 끊는다. 예측: 이게 맞다면, 실패한 요청의 로그에는 반드시 타임아웃 에러가 찍혀 있어야 하고, 성공한 요청은 전부 2초 이내여야 한다. 실험: 실패/성공 요청의 응답 시간 분포를 뽑아 본다."
두 번째 접근은 실험이 실패하더라도 배우는 게 있습니다. 실패한 요청이 타임아웃이 아니라 500 에러였다면, 가설은 틀렸지만 이제 원인이 카드사가 아니라 우리 쪽 서버라는 걸 압니다. 틀린 가설도 정보를 준다는 게 이 방법의 힘입니다.
가설은 반드시 **반증 가능(falsifiable)**해야 합니다. "네트워크가 이상해서 그래" 같은 건 검증할 수 없으니 가설이 아닙니다. "특정 리전의 요청만 실패한다"는 검증할 수 있으니 가설입니다.
문제 공간을 이분 탐색하라
버그를 잡는 가장 강력한 단일 기법은 **이분 탐색(binary search)**입니다. 정렬된 배열에서 값을 찾을 때만 쓰는 게 아닙니다. 문제의 원인을 찾는 데 그대로 씁니다.
원리는 이렇습니다. 버그가 일어나는 지점(증상)과 코드가 정상인 지점(입력) 사이 어딘가에 원인이 있습니다. 그 사이의 중간 지점을 하나 잡아서 "여기까지는 정상인가?"를 물으면, 한 번의 실험으로 탐색 공간이 절반으로 줄어듭니다. 이걸 반복하면 수천 줄의 코드도 열 번 남짓의 실험으로 좁혀집니다. 2의 10제곱이 1024니까요.
구체적으로 이분 탐색을 걸 수 있는 축은 여러 개입니다.
- 코드 경로: 요청이 A → B → C → D를 거친다면, C 시점에 값을 찍어 본다. 값이 정상이면 원인은 C
D 사이, 이상하면 AC 사이. - 시간(커밋 히스토리): 어제는 됐는데 오늘 안 된다면, 그 사이 커밋들을 이분 탐색한다. (이게 바로 뒤에 나올
git bisect입니다.) - 입력 데이터: 10만 줄짜리 입력에서 크래시가 난다면, 5만 줄로 잘라서 재현되는지 본다. 재현되면 앞쪽 절반, 안 되면 뒤쪽 절반에 문제 데이터가 있다.
- 설정/의존성: 기능 플래그를 절반씩 꺼 보거나, 의존성을 절반씩 제거해 본다.
이분 탐색이 강력한 이유는 각 실험이 최대한의 정보를 주도록 설계되기 때문입니다. 정보 이론적으로 말하면, 절반을 갈라내는 질문이 한 번에 1비트의 정보를 뽑아냅니다. 아무 데나 찍어 보는 실험은 이보다 훨씬 적은 정보를 줍니다.
최소 재현 예제 — 버그를 우리에 가두기
"재현이 안 돼요"는 디버깅에서 가장 흔한 벽입니다. 여기서 결정적인 도구가 **최소 재현 예제(minimal reproducible example, MRE)**입니다.
MRE는 버그를 일으키는 가장 작은 코드 조각입니다. 만드는 과정 자체가 디버깅입니다. 전체 애플리케이션에서 버그와 무관한 부분을 하나씩 제거해 나가면서, 여전히 버그가 재현되는 최소 상태까지 깎아 냅니다. 이 과정에서 두 가지 중 하나가 일어납니다.
- 무언가를 제거했더니 버그가 사라진다 → 방금 제거한 게 원인과 관련 있다. 범인을 찾은 겁니다.
- 다 제거하고 나니 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 vs 디버거 vs 로깅 — 관찰의 도구들
가설을 검증하려면 시스템 내부를 관찰해야 합니다. 관찰의 도구는 크게 셋입니다. 하나가 정답인 게 아니라 상황에 맞게 골라 씁니다.
print 디버깅
가장 원시적이고 가장 저평가된 도구입니다. 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에 이 쿼리를 던지고, 결과의 첫 행을 이 객체로 매핑해서..." 수준으로요. 구체적일수록 숨은 가정이 더 잘 튀어나옵니다.
종합 — 버그 하나를 끝까지
지금까지의 조각을 하나의 흐름으로 엮어 봅시다. "결제가 가끔 실패한다"는 처음의 버그로 돌아갑니다.
- 읽는다: 실패한 요청의 에러 로그를 실제로 끝까지 읽는다.
PaymentGatewayTimeoutException이 스택트레이스 맨 아래 "caused by"에 있다. - 가설: 카드사 API가 느릴 때 우리가 먼저 끊는다.
- 예측: 맞다면 실패 요청은 전부 우리 타임아웃 값 근처에서 죽었을 것이다.
- 관찰: 실패 요청의 응답 시간을 뽑는다. 전부 딱 2000ms 근처. 가설이 강해진다.
- 이분 탐색(시간): "언제부터?"를 묻는다.
git bisect를 돌린다. 타임아웃을 5초에서 2초로 줄인 커밋이 범인으로 나온다. - 최소 재현: 카드사를 2.5초 지연시키는 목(mock)으로 로컬에서 재현. 실패가 재현된다.
- 수정과 검증: 타임아웃을 올리거나 재시도를 넣는다. 예측: 이러면 목 지연을 넣어도 성공해야 한다. 실험. 성공한다.
매 단계에 가설과 예측이 있었고, 각 실험이 탐색 공간을 좁혔습니다. 운은 한 방울도 없었습니다. 그리고 무엇보다, 왜 고쳐졌는지 정확히 압니다. 이게 과학자처럼 디버깅한다는 것의 의미입니다.
마치며
디버깅을 잘하는 사람과 못하는 사람의 차이는 지식의 양이 아니라 방법입니다. 잘하는 사람은 코드를 노려보며 추측하지 않습니다. 관찰하고, 검증 가능한 가설을 세우고, 예측을 하고, 문제 공간을 반씩 갈라 좁히고, 에러가 하는 말을 실제로 듣습니다.
다음에 버그를 만나면, 코드를 바꾸기 전에 스스로에게 물어보세요. "내 가설이 뭐지? 이걸 바꾸면 무슨 일이 일어날 거라고 예측하지?" 이 두 질문에 답할 수 없다면, 아직 실험할 준비가 안 된 겁니다. 답할 수 있게 되는 순간, 당신은 도박꾼이 아니라 과학자가 됩니다.
참고 자료
- 앤드루 헌트 & 데이비드 토머스, 『실용주의 프로그래머』(디버깅 장): https://pragprog.com/titles/tpp20/the-pragmatic-programmer-20th-anniversary-edition/
- 데이비드 아그넬로, 『Debug It!』: https://pragprog.com/titles/dparef/debug-it/
- git bisect 공식 문서: https://git-scm.com/docs/git-bisect
- "How to create a Minimal, Reproducible Example" (Stack Overflow): https://stackoverflow.com/help/minimal-reproducible-example
- 러버덕 디버깅 소개: https://rubberduckdebugging.com/
현재 단락 (1/91)
버그가 떴습니다. 프로덕션에서 결제가 가끔 실패합니다. 재현은 안 되고, 로그는 애매하고, 마감은 오늘입니다. 이럴 때 많은 개발자가 하는 일은 이렇습니다. 코드를 노려보다가 "여...