초보자는 디버깅을 잘하는 사람을 보면 왠지 직감이 뛰어난 것처럼 느낀다. 하지만 실제로는 많은 경우, 순서를 잘 지키기 때문에 빨리 해결하는 것이다.
무작정 코드를 여기저기 바꾸는 대신, 문제를 재현하고 범위를 좁히고 가설을 하나씩 검증하는 흐름이 필요하다.
직감처럼 보이는 것의 정체는 대개 경험의 압축이다. 같은 종류의 실수를 여러 번 본 사람은 후보 목록이 짧을 뿐, 순서를 건너뛰는 것이 아니다.
디버깅의 기본 순서
먼저 문제를 다시 재현한다. 그다음 어디까지는 정상인지 경계를 찾는다. 그리고 가능한 원인을 하나씩 세우고, 가장 작은 수정으로 확인한다.
이 순서를 지키면 감정적으로 급해지는 시간을 줄일 수 있다. 특히 초보자에게는 모든 것이 이상해 보이는 상태를 피하는 데 큰 도움이 된다.
순서의 첫 항목이 재현인 데는 이유가 있다. 재현 방법을 정확히 적어 두지 않으면, 고친 뒤에도 정말 고쳐졌는지 확인할 수 없다. 무엇을 실행하고 어떤 값을 넣었을 때 어떤 결과가 나오는지를 세 줄로 적어 두는 것이 디버깅의 실제 시작점이다. 이 세 줄이 나중에 그대로 테스트 코드가 되기도 한다.
이분법으로 범위를 반씩 줄인다
경계를 찾는다는 말이 추상적으로 들린다면, 실제로 하는 일은 훨씬 단순하다. 코드의 중간쯤에서 값을 한 번 찍어 보고, 그 값이 맞으면 문제는 뒤쪽에, 틀리면 앞쪽에 있다는 것을 확인하는 것이다. 이것을 두세 번 반복하면 백 줄짜리 코드에서도 후보 구간이 금방 몇 줄로 줄어든다.
평균이 이상하게 나오는 코드
def average(rows):
total = 0
for row in rows:
total += row["score"]
return total / len(rows)
rows = load("scores.csv")
print(average(rows)) # 기대: 82.5, 실제: 8250.0
결과가 정확히 100배다. 이런 규칙성은 우연이 아니라 단서다.
여기서 코드를 고치기 전에 값을 본다. 중간 지점 한 곳이면 충분하다.
경계 찾기
rows = load("scores.csv")
print(len(rows), rows[0])
# 100 {'name': 'mina', 'score': 82.5}
rows는 정상이다. 그러면 문제는 load가 아니라 average 안쪽에 있다.
이 한 줄로 후보의 절반이 사라진다. 남은 구간에서 다시 total을 찍어 보면, 합계는 맞는데 나누는 수가 1이라는 사실이 드러난다. 원인은 len(rows)가 아니라 그 앞에서 rows를 다른 이름으로 덮어썼다든가 하는 지점에 있다. 중요한 것은 이 결론에 추측이 아니라 관찰로 도달했다는 점이다.
결과가 정확히 100배, 정확히 1 차이, 정확히 절반이면 원인은 논리가 아니라 단위나 인덱스일 가능성이 높습니다. 어긋난 값의 모양이 후보를 줄여 줍니다.
print와 디버거의 역할은 다르다
값을 찍어 보는 방식을 촌스럽게 여기는 분위기가 있는데, 입문 구간에서는 오히려 이쪽이 더 잘 맞는 경우가 많다. print는 흐름 전체를 한눈에 늘어놓는 데 강하다. 여러 번 도는 반복문에서 값이 어떻게 변해 가는지 보려면, 매 단계에서 멈추는 디버거보다 열 줄이 한꺼번에 찍히는 쪽이 파악이 빠르다.
대신 찍을 때 규칙이 하나 필요하다. 값만 찍으면 화면에 숫자만 쏟아져서 어느 줄에서 나온 것인지 알 수 없다. 이름표를 같이 붙이는 것만으로 쓸모가 크게 달라진다.
이름표를 붙인다
print("[loop] i=", i, "total=", total)
대괄호 안의 표시가 있으면 나중에 지울 때도 검색으로 한 번에 찾을 수 있다.
찍은 뒤에는 반드시 지운다는 것도 규칙에 넣어야 한다. 디버깅용 출력이 남은 채로 커밋되면, 나중에 실제 출력과 섞여서 다른 사람이 읽을 때 혼란을 준다. 대괄호 표시를 붙여 두는 이유가 여기에도 있다.
디버거가 확실히 나은 순간은 따로 있다. 어디를 찍어야 할지조차 모를 때, 그리고 값이 언제 바뀌는지 추적해야 할 때다. 이때는 중단점을 걸고 한 줄씩 진행하며 변수 목록을 보는 편이 빠르다. 두 도구는 대체 관계가 아니라 상황이 다른 도구다.
어느 쪽을 쓰든 원칙은 같다. 코드를 읽어서 추측하는 대신, 실제 값을 보고 판단한다는 것이다. 머릿속에서 코드를 실행해 보는 일은 사람이 가장 자주 틀리는 작업이다.
막히면 이 순서로 진행하기
- 문제가 다시 재현되는지 확인한다.
- 정상 동작하는 지점과 깨지는 지점을 나눈다.
- 중간 값을 찍어 후보 구간을 반씩 줄인다.
- 원인 가설을 한 번에 하나만 세운다.
- 수정도 한 번에 하나만 한다.
- 해결 뒤에는 원인을 한 문장으로 기록한다.
재현되지 않는 문제는 다른 종목이다
이 순서 전체가 첫 단계에 걸려 있다는 점은 짚고 넘어갈 만하다. 재현이 안 되면 나머지가 성립하지 않는다. 열 번에 한 번 나는 문제, 특정 사용자에게만 나는 문제, 어제는 났는데 오늘은 안 나는 문제가 그렇다. 이런 경우 같은 순서를 고집하며 코드를 들여다보는 것은 시간만 쓴다.
이때 해야 할 일은 순서를 바꾸는 것이 아니라 재현 조건을 찾는 일로 목표를 옮기는 것이다. 언제 났는지, 어떤 입력이었는지, 어떤 환경이었는지를 기록으로 모은다. 로그를 남기는 이유가 여기에 있다. 재현되는 순간 문제는 다시 이 글의 순서로 돌아온다.
또 하나, 순서를 너무 엄격하게 적용하는 것도 손해다. 방금 저장한 파일에서 오타 하나 때문에 난 에러까지 가설을 세우고 검증할 필요는 없다. 삼십 초 안에 눈으로 찾을 수 있는 문제는 그냥 찾으면 된다. 절차는 눈으로 안 보일 때 꺼내는 도구다.
안 되니까 여러 곳을 한꺼번에 고치는 것입니다. 그러면 고쳐졌을 때 무엇이 원인이었는지 알 수 없고, 같은 문제가 다음 주에 다시 옵니다.
좋은 디버깅은 많이 아는 기술이 아니라, 문제를 좁혀 가는 기술에 더 가깝다.
더 볼 것
경계를 찾는 작업을 매번 손으로 하기 지겨워질 때쯤이 테스트를 배울 시점입니다. 파이썬 테스트 가이드가 그 다리 역할을 하고, 에러 메시지 자체를 읽는 순서는 에러 메시지 독해에 따로 정리해 두었습니다.
이 글의 포인트
- 디버깅은 재현, 경계 찾기, 가설 검증의 순서로 간다.
- 중간 값을 한 번 찍는 것이 코드를 열 번 읽는 것보다 빠르다.
- 수정은 한 번에 하나씩 해야 원인을 잃지 않는다.
- 재현되지 않는 문제는 재현 조건을 찾는 일로 목표를 바꾼다.
- 해결 후 한 줄 기록을 남기면 다음 디버깅이 빨라진다.