디버깅이란? 에러 메시지와 실행 기록으로 버그 찾는 방법

디버깅이란? 에러 메시지와 실행 기록으로 버그 찾는 방법#

디버깅은 프로그램이 예상과 다르게 동작할 때, 실제 실행 과정을 조사해 원인을 찾아 수정하고 결과를 확인하는 과정입니다.

에러 메시지가 나타나며 프로그램이 중단되는 경우도 있고, 아무 오류 없이 끝났는데 계산 결과가 틀린 경우도 있습니다. 두 상황 모두 디버깅이 필요합니다. 중요한 것은 코드를 무작정 바꿔보는 것이 아니라 어떤 입력에서, 어느 지점부터, 예상과 실제 값이 달라졌는지 확인하는 것입니다.

예를 들어 할인율 10%가 적용되어야 하는 주문에서 최종 금액이 예상과 다르다고 해보겠습니다. 화면에 표시한 값만 보면 원인을 알 수 없습니다. 상품 합계가 틀렸는지, 할인율 계산이 틀렸는지, 최종 금액을 표시할 때 잘못 반올림했는지 순서대로 살펴봐야 합니다.

이 글에서는 Python 예제로 스택 트레이스 읽기, print()를 통한 값 추적, 로그와 디버거 활용, 수정 후 검증까지 살펴봅니다. 설명하는 조사 방법은 다른 프로그래밍 언어에도 적용할 수 있습니다.

버그와 예외는 어떤 관계일까?#

상황 예시 눈에 보이는 현상
실행 중 예외 발생 0으로 나눔 실행이 중단되고 오류 메시지가 표시됨
잘못된 결과 발생 짝수 합을 구해야 하는데 홀수 합을 계산 실행은 끝나지만 결과가 틀림
잘못된 상태 발생 주문 저장은 실패했는데 완료 화면을 표시 일부 작업은 끝났지만 상태가 서로 맞지 않음

예외와 버그를 완전히 별개로 나눌 수는 없습니다. 코드의 버그가 예외를 일으키기도 하고, 외부 입력이나 환경의 변화가 예상한 예외를 발생시키기도 합니다. 또 잘못된 계산처럼 예외를 일으키지 않는 버그도 있습니다.

따라서 “에러 메시지가 없으니 버그도 없다”라고 판단해서는 안 됩니다.

디버깅의 출발점: 문제를 재현하기#

원인을 찾으려면 먼저 문제가 발생하는 상황을 분명히 해야 합니다. 다음 네 가지를 적어 보세요.

  1. 어떤 입력을 사용했는가?
  2. 원래 어떤 결과를 예상했는가?
  3. 실제로 어떤 결과가 나왔는가?
  4. 매번 발생하는가, 특정 조건에서만 발생하는가?

예를 들면 이렇게 기록할 수 있습니다.

입력: [1, 2, 3, 4]
기대 결과: 짝수 2 + 4의 합인 6
실제 결과: 4
발생 조건: 목록에 홀수가 포함될 때

이처럼 문제를 작게 재현할 수 있으면 조사 범위가 좁아집니다. 실제 서비스에서 발견한 문제라도 가능한 한 적은 데이터와 짧은 코드로 같은 현상을 만들어 보는 것이 도움이 됩니다.

기대값도 다시 확인해야 한다#

디버깅 중에는 코드가 틀린 줄 알았는데, 사람이 예상한 결과가 잘못된 경우도 있습니다. 가격에 세금을 포함하는지, 할인 전에 배송비를 더하는지처럼 업무 규칙을 오해하면 코드를 고쳐도 문제를 해결하지 못합니다.

실제 결과와 비교할 기준이 올바른지 먼저 확인해야 합니다.

에러 메시지와 스택 트레이스 읽기#

프로그램이 예외로 중단되면 Python은 보통 스택 트레이스를 표시합니다. 스택 트레이스는 어떤 코드가 어떤 함수를 호출했고, 마지막에 어디서 예외가 발생했는지 보여줍니다.

다음 코드를 보겠습니다.

def calculate_ratio(total, part):
    return (part / total) * 100


def analyze_data(data):
    total_users = len(data)
    active_users = data.count("active")

    ratio = calculate_ratio(total_users, active_users)
    print(f"활성 사용자 비율: {ratio:.1f}%")


user_data = []
analyze_data(user_data)

빈 목록을 전달하면 다음과 같은 스택 트레이스가 나타납니다. 줄 번호는 파일에 코드를 배치한 방식에 따라 달라질 수 있습니다.

Traceback (most recent call last):
  File "main.py", line ..., in <module>
    analyze_data(user_data)
  File "main.py", line ..., in analyze_data
    ratio = calculate_ratio(total_users, active_users)
  File "main.py", line ..., in calculate_ratio
    return (part / total) * 100
ZeroDivisionError: division by zero

첫 번째로 볼 것: 마지막 줄의 예외 종류#

ZeroDivisionError: division by zero

0으로 나누기를 시도했다는 뜻입니다. 오류가 발생한 함수는 calculate_ratio()입니다.

에러 메시지는 무슨 종류의 실패가 일어났는지 알려줍니다. 다만 왜 분모가 0이 되었는지는 호출 흐름을 따라가야 알 수 있습니다.

두 번째로 볼 것: 실제로 실패한 코드#

return (part / total) * 100

여기서 분모는 total입니다. 따라서 조사할 첫 번째 값은 total입니다.

part가 0인 것은 문제가 아닙니다. 0 / 10은 계산할 수 있지만 0 / 0이나 10 / 0은 계산할 수 없습니다. 이렇게 오류 메시지와 연산의 구조를 연결하면 조사 대상을 바로 좁힐 수 있습니다.

세 번째로 볼 것: 그 값은 어디서 왔는가?#

ratio = calculate_ratio(total_users, active_users)

total에는 호출할 때 전달한 total_users가 들어갑니다.

total_users = len(data)

data는 빈 목록 []이므로 len(data)는 0입니다. 이 0이 total에 전달되어 0으로 나누게 된 것입니다.

흐름을 정리하면 다음과 같습니다.

  1. user_data에 []를 저장했습니다.
  2. analyze_data([])를 호출했습니다.
  3. len([])의 결과인 0을 total_users에 저장했습니다.
  4. calculate_ratio(0, 0)을 호출했습니다.
  5. part / total에서 0으로 나누기가 발생했습니다.

스택 트레이스의 가장 아래쪽 호출 지점에서 예외가 발생했지만, 문제의 입력값은 더 바깥쪽 호출에서 만들어졌습니다. 따라서 실패한 줄만 보고 임의로 수정하기보다 값이 들어온 경로를 추적해야 합니다.

오류를 어떻게 수정할까?#

이 예제에서는 데이터가 없을 때 어떤 비율을 반환할지 먼저 결정해야 합니다. “대상이 0명이면 비율을 0%로 표시한다”라는 규칙을 선택했다면 다음처럼 작성할 수 있습니다.

def calculate_ratio(total, part):
    if total == 0:
        return 0.0

    return (part / total) * 100

반면 데이터가 없어 비율을 정의할 수 없다고 판단한다면, None을 반환하거나 명시적인 예외를 발생시키는 편이 맞을 수 있습니다. 어떤 값이 올바른지는 프로그램의 목적과 업무 규칙에 달려 있습니다. 단지 오류를 없애기 위해 무조건 0을 반환해서는 안 됩니다.

에러가 없는데 결과가 틀릴 때: 값을 추적하기#

다음 함수는 숫자 목록에서 짝수만 더하려는 코드입니다.

def sum_even_numbers(numbers):
    total = 0

    for number in numbers:
        if number % 2:
            total += number

    return total


numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print(sum_even_numbers(numbers))  # 25

기대 결과는 2 + 4 + 6 + 8 + 10 = 30입니다. 하지만 실제 결과는 25입니다. Python은 오류를 표시하지 않습니다. 코드 자체는 실행 가능한데 조건식이 의도와 다르게 작성되었기 때문입니다.

number % 2는 2로 나눈 나머지입니다.

  • 짝수는 나머지가 0입니다. 조건에서는 거짓으로 취급됩니다.
  • 홀수는 나머지가 1입니다. 조건에서는 참으로 취급됩니다.

따라서 이 코드는 짝수가 아니라 홀수를 더하고 있습니다.

print()로 예상한 값과 실제 값을 비교하기#

원인을 아직 모른다고 가정하고 반복문 안을 관찰해 보겠습니다.

def sum_even_numbers_debug(numbers):
    total = 0

    for number in numbers:
        remainder = number % 2
        expected_is_even = remainder == 0

        print(
            f"현재 값={number}, "
            f"나머지={remainder}, "
            f"짝수 여부={expected_is_even}, "
            f"현재 합계={total}"
        )

        if number % 2:
            total += number
            print(f"  → 합산 후={total}")

    return total


print(sum_even_numbers_debug([1, 2, 3, 4]))

출력의 일부는 다음과 같습니다.

현재 값=1, 나머지=1, 짝수 여부=False, 현재 합계=0
  → 합산 후=1
현재 값=2, 나머지=0, 짝수 여부=True, 현재 합계=1
현재 값=3, 나머지=1, 짝수 여부=False, 현재 합계=1
  → 합산 후=4
현재 값=4, 나머지=0, 짝수 여부=True, 현재 합계=4
4

1이 합산되고 2가 건너뛰어진다는 사실을 확인했습니다. “짝수인지 확인하는 조건이 거꾸로 동작한다”는 가설을 뒷받침하는 증거입니다.

수정한 코드는 다음과 같습니다.

def sum_even_numbers(numbers):
    total = 0

    for number in numbers:
        if number % 2 == 0:
            total += number

    return total


print(sum_even_numbers([1, 2, 3, 4]))              # 6
print(sum_even_numbers([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))  # 30

수정이 끝났다면 처음 문제가 발생한 입력으로 다시 확인해야 합니다. 다른 예제에서 우연히 맞는 결과가 나왔다는 이유만으로 해결됐다고 판단하면 안 됩니다.

효과적인 print() 디버깅 방법#

print()는 단순하지만 현재 프로그램이 지나간 위치와 그 시점의 값을 확인하는 데 유용합니다. 다만 무작정 많은 값을 출력하면 필요한 정보가 묻힙니다.

위치를 알 수 있는 이름을 함께 출력하기#

print(total)

여러 곳에서 total을 출력한다면 어느 값인지 구분하기 어렵습니다.

print(f"[할인 계산 전] 상품 합계={total}")
print(f"[할인 계산 후] 결제 금액={final_price}")

어느 단계에서 나온 값인지 함께 적으면 실행 흐름을 읽기 쉽습니다.

의심하는 함수의 입력과 출력을 함께 확인하기#

def calculate_discount(price, discount_rate):
    print(f"[입력] price={price}, discount_rate={discount_rate}")

    discount = price * discount_rate
    final_price = price - discount

    print(f"[결과] discount={discount}, final_price={final_price}")
    return final_price

함수의 입력이 이미 잘못되었는지, 함수 내부에서 값이 틀어졌는지를 구분할 수 있습니다.

예를 들어 할인율을 0.1로 예상했는데 실제 입력이 0.01이라면, 이 함수의 계산식을 바꾸기 전에 할인율을 전달하는 코드를 조사해야 합니다.

한 번에 모든 반복을 출력할 필요는 없다#

수백만 건을 처리하는 반복문에서 매번 print()를 호출하면 출력이 지나치게 많아지고 실행 속도에도 영향을 줍니다. 오류가 발생하는 항목의 식별자나 특정 조건에 한정해 관찰하는 편이 낫습니다.

for order in orders:
    if order["id"] == "A-101":
        print(f"[조사 대상] order={order}")

    process_order(order)

실제 운영 데이터라면 주문 전체를 출력하기 전에 개인정보나 결제 정보가 포함되어 있지 않은지도 확인해야 합니다.

조사가 끝난 출력은 정리하기#

조사용 print()가 계속 남아 있으면 정상 실행에서도 불필요한 정보가 출력됩니다. 운영 환경에서 계속 관찰해야 하는 값이라면 적절한 수준의 로깅으로 바꾸고, 일회성 조사 출력은 제거합니다.

로깅과 디버거는 언제 사용할까?#

로그: 나중에 실행 기록을 확인해야 할 때#

print()는 실행 중 화면에서 값을 보는 데 편하지만, 프로그램을 운영할 때는 보통 로깅 도구를 사용합니다.

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def process_order(order_id, price):
    logger.info("주문 처리 시작: order_id=%s", order_id)

    if price < 0:
        logger.error(
            "주문 가격 오류: order_id=%s, price=%s",
            order_id,
            price,
        )
        raise ValueError("가격은 음수일 수 없습니다.")

    logger.info("주문 처리 완료: order_id=%s", order_id)

로그에는 작업을 구별할 수 있는 정보, 현재 단계, 실패 이유를 남기는 것이 좋습니다. 반면 비밀번호, 인증 토큰, 결제 정보, 불필요한 개인정보를 그대로 기록해서는 안 됩니다.

예외가 발생한 경로까지 기록해야 할 때는 예외 처리 블록 안에서 logger.exception()을 사용할 수 있습니다.

try:
    process_order("A-101", -500)
except ValueError:
    logger.exception("주문 처리 실패")

로그는 실행이 끝난 뒤 현상을 조사할 때 특히 중요합니다.

디버거: 실행을 멈추고 상태를 살펴볼 때#

에디터의 디버거를 사용하면 특정 줄에 브레이크포인트를 놓고 실행을 일시 중지할 수 있습니다. 멈춘 자리에서 변수 값을 보고 코드를 한 줄씩 진행할 수 있습니다.

일반적으로 다음 순서로 사용합니다.

  1. 값이 틀어졌다고 의심되는 줄에 브레이크포인트를 둡니다.
  2. 문제가 발생하는 입력으로 실행합니다.
  3. 멈춘 시점의 변수 값을 확인합니다.
  4. 한 줄씩 실행하며 어느 연산 뒤에 값이 달라지는지 봅니다.
  5. 필요하면 함수를 호출하는 쪽으로 올라가 입력이 만들어진 경로를 확인합니다.

복잡한 조건과 여러 함수 호출이 얽힌 문제는 print()를 계속 추가하는 것보다 디버거로 멈춰서 살펴보는 편이 빠를 수 있습니다.

도구 특히 유용한 상황
print() 짧은 코드에서 몇 개의 값을 빠르게 확인할 때
로그 실행이 끝난 후 기록을 조사하거나 운영 환경의 흐름을 추적할 때
디버거 실행을 멈추고 변수와 호출 흐름을 단계별로 살펴볼 때

한 가지 도구만 고집할 필요는 없습니다. 문제의 발생 위치와 실행 환경에 맞게 선택하면 됩니다.

디버깅은 가설을 세우고 검증하는 과정이다#

버그를 발견했을 때 처음 떠오르는 설명이 항상 정답은 아닙니다. 따라서 추측을 바로 코드 변경으로 옮기기보다 확인할 수 있는 질문으로 바꿉니다.

짝수 합계 예제로 보면 다음과 같습니다.

1. 현상을 적는다#

[1, 2, 3, 4]를 입력하면 6을 기대하지만 4가 반환된다.

2. 가능한 원인을 세운다#

마지막 항목인 4를 처리하지 못하는 것일까?
조건문이 홀수를 선택하고 있는 것일까?
합계가 중간에 초기화되는 것일까?

3. 원인마다 확인할 값을 정한다#

  • 마지막 항목 처리 여부 → 반복 중 number 출력
  • 조건의 참·거짓 → number % 2 출력
  • 합계 초기화 여부 → 반복 전후 total 출력

4. 증거를 보고 하나씩 제외한다#

반복문은 4까지 처리합니다. 합계도 초기화되지 않습니다. 그런데 나머지가 1일 때만 합산합니다. 따라서 조건식이 원인입니다.

5. 수정하고 다시 검증한다#

if number % 2 == 0:으로 고친 뒤, 문제가 발생한 입력과 경계 사례를 확인합니다.

assert sum_even_numbers([1, 2, 3, 4]) == 6
assert sum_even_numbers([2, 4, 6]) == 12
assert sum_even_numbers([1, 3, 5]) == 0
assert sum_even_numbers([]) == 0

이 검사는 복잡한 테스트 체계를 소개하려는 것이 아닙니다. 한 번 고친 버그가 같은 입력에서 다시 나타나지 않는지 짧고 분명하게 확인하는 방법입니다.

원인을 찾았다고 생각했을 때 주의할 점#

오류가 보이는 위치와 오류가 만들어진 위치는 다를 수 있다#

100 / total에서 예외가 발생했더라도 total이 0이 된 이유는 더 앞의 데이터 수집이나 필터링에 있을 수 있습니다. 실패한 줄에서 거꾸로 값을 추적해야 합니다.

증상을 가리는 수정은 문제를 남길 수 있다#

try:
    result = calculate_ratio(total, part)
except ZeroDivisionError:
    result = 0

이 코드는 예외를 없애지만 “데이터가 없는 상태에서 0%로 표시하는 것이 맞는가?”라는 질문에는 답하지 않습니다. 값이 0이 되어서는 안 되는 작업이라면 오히려 데이터 누락을 숨길 수 있습니다.

한꺼번에 여러 곳을 바꾸면 원인을 확인하기 어렵다#

조건문, 반복 범위, 입력 형식을 한 번에 바꿔 결과가 맞아졌다면 어떤 수정이 효과가 있었는지 알기 어렵습니다. 가능한 한 하나의 가설을 검증하는 변경부터 적용하고 결과를 확인하세요.

직접 따라 해 보기: 할인 금액 오류 추적#

다음 코드는 장바구니 금액에 10% 할인을 적용하려는 프로그램입니다.

prices = [10_000, 20_000]
discount_percent = 10

subtotal = sum(prices)
discount_amount = subtotal * (discount_percent / 100)
final_price = subtotal - discount_amount

print(f"상품 합계: {subtotal}원")
print(f"할인 금액: {discount_amount}원")
print(f"최종 금액: {final_price}원")

실행 결과는 상품 합계 30,000원, 할인 금액 3,000원, 최종 금액 27,000원입니다.

이제 아래처럼 코드를 바꾸면 어떤 문제가 생길지 먼저 예상해 보세요.

discount_amount = subtotal * (discount_percent // 100)

//는 몫을 구하는 연산자입니다. 10 // 100은 0이므로 할인 금액이 0원이 됩니다. 프로그램은 중단되지 않지만 결과가 틀립니다.

다음처럼 중간값을 출력하면 원인을 바로 좁힐 수 있습니다.

discount_rate = discount_percent // 100
print(f"[확인] 할인율 계산 결과={discount_rate}")

실제로 0이 나온다는 증거를 얻은 뒤 /로 수정할 수 있습니다. 금액을 처리하는 실제 서비스라면 소수점 계산과 반올림 정책도 별도로 정해야 하지만, 여기서는 잘못된 연산자가 결과에 어떤 영향을 주는지 추적하는 과정에 집중합니다.

정리#

디버깅은 버그가 있을 것 같은 줄을 감으로 고치는 작업이 아닙니다. 현상을 재현하고, 실행 흐름과 값을 관찰하고, 원인에 대한 가설을 검증한 뒤, 수정 결과를 다시 확인하는 과정입니다.

  • 예외가 발생했다면 에러 종류와 스택 트레이스를 읽고 값의 출처를 추적합니다.
  • 실행은 되지만 결과가 틀리다면 주요 단계의 입력값, 조건 판단, 중간 결과를 비교합니다.
  • print(), 로그, 디버거는 상황에 맞게 선택합니다.
  • 수정 후에는 처음 문제가 발생한 입력으로 결과를 다시 확인합니다.

처음에는 “어디가 잘못됐는지 모르겠다”는 막막함이 자연스럽습니다. 그때 가장 유용한 질문은 하나입니다. “내가 예상한 값과 실제 값이 처음으로 달라진 곳은 어디인가?” 그 지점을 찾으면 조사할 코드의 범위가 크게 줄어듭니다.

이 페이지의 목차