41. 코드 디버깅과 테스트
코드를 작성하는 것은 전투의 절반에 불과합니다. 나머지 절반은 코드가 올바르게 동작하는지 확인하고, 동작하지 않을 때 문제를 찾아내는 것입니다. 초보자부터 전문가까지 모든 프로그래머는 버그가 있는 코드를 작성합니다. 차이는 숙련된 프로그래머들이 그 버그를 찾아 고치는 체계적인 접근법을 발전시켜 왔다는 점입니다.
이 장에서는 코드가 실제로 무엇을 하고 있는지 이해하고, 문제를 빠르게 찾아내며, 코드가 의도대로 동작하는지 검증하는 데 도움이 되는 실용적인 디버깅 기법을 배웁니다. 이 기술들은 여러분을 더 자신감 있고 효과적인 프로그래머로 만들어 줄 것입니다.
41.1) 트레이스백(traceback)을 읽어 오류 위치 찾기(빠른 복습)
24장에서 배웠듯이, Python은 문제가 발생했을 때 트레이스백(traceback) 이라고 하는 자세한 오류 메시지를 제공합니다. 디버깅할 때 가장 먼저 의지할 수 있는 수단이므로, 이를 효과적으로 읽는 방법을 복습해 보겠습니다.
41.1.1) 트레이스백의 구조
Python이 오류를 만나면, 문제가 발생한 정확한 위치와 오류 타입이 무엇인지 보여 줍니다. 아래는 전형적인 트레이스백입니다:
def calculate_average(numbers):
total = sum(numbers)
count = len(numbers)
return total / count
def process_student_grades(grades):
average = calculate_average(grades)
return f"Average: {average:.1f}"
# 이것은 오류를 발생시킵니다
student_grades = []
result = process_student_grades(student_grades)
print(result)Output:
Traceback (most recent call last):
File "grades.py", line 12, in <module>
result = process_student_grades(student_grades)
File "grades.py", line 7, in process_student_grades
average = calculate_average(grades)
File "grades.py", line 4, in calculate_average
return total / count
~~~~~~^~~~~~~
ZeroDivisionError: division by zero이 트레이스백이 우리에게 무엇을 말해 주는지 자세히 살펴봅시다:
아래에서 위로 읽기:
- 오류 타입과 메시지(맨 아래):
ZeroDivisionError: division by zero는 무엇이 잘못됐는지 정확히 알려 줍니다 - 오류가 발생한 정확한 줄: 4행의
return total / count - 그곳에 도달한 과정을 보여주는 호출 체인(call chain): 12행에서 시작해 7행을 거쳐 4행에서 끝납니다
41.1.2) 트레이스백으로 근본 원인 찾기
트레이스백은 증상(오류가 발생한 위치)을 보여 주지만, 우리는 원인(왜 발생했는지)을 찾아야 합니다. 문제를 따라가 봅시다:
# 오류는 여기에서 발생합니다
return total / count # count는 0입니다
# 하지만 진짜 문제는 여기입니다
student_grades = [] # 빈 리스트가 함수에 전달되었습니다0으로 나누기가 발생한 이유는 빈 리스트를 전달했기 때문입니다. 트레이스백은 4행을 가리키지만, 수정은 더 앞에서 이루어져야 합니다. 입력을 검증하거나 빈 리스트 케이스를 처리해야 합니다:
def calculate_average(numbers):
"""숫자들의 평균을 반환하거나, 리스트가 비어있으면 None을 반환합니다."""
if not numbers:
return None
return sum(numbers) / len(numbers)
def process_student_grades(grades):
"""학생 성적을 처리하고 포맷된 문자열을 반환합니다."""
average = calculate_average(grades)
if average is None:
return "No grades to process"
return f"Average: {average:.1f}"
# 이제 이것은 안전하게 동작합니다
student_grades = []
result = process_student_grades(student_grades)
print(result) # Output: No grades to process
# 그리고 이것도 동작합니다
student_grades = [85, 92, 78, 90]
result = process_student_grades(student_grades)
print(result) # Output: Average: 86.2핵심 정리:
- 트레이스백은 아래에서 위로 읽습니다
- 에러 발생 위치(증상)가 항상 근본 원인은 아닙니다
- 에러를 방지하기 위해 입력값을 조기에 검증합니다
- 더 안전한 코드를 위해 방어적 프로그래밍(
.get(), 길이 체크 등)을 사용합니다
다양한 유형의 에러가 서로 다른 트레이스백을 생성하지만, 읽는 과정은 항상 동일합니다: 맨 아래에서 시작해 무엇이 잘못되었는지 확인한 다음, 위로 추적하여 어떻게 그 지점에 도달했는지 이해합니다. 특정 예외 타입에 대한 복습이 필요하다면 Chapter 24를 참고하세요.
이제 트레이스백을 효과적으로 읽을 수 있게 되었으니, 다음 섹션에서는 코드를 머릿속으로 추적하여 단계별로 무엇을 하는지 이해하는 방법을 배우겠습니다.
41.2) 머릿속으로 코드 실행 추적하기
때로는 버그를 발견했지만 즉시 코드를 실행할 수 없는 상황이 있습니다. 종이에 인쇄된 코드를 검토하거나, 다른 사람의 풀 리퀘스트를 읽거나, 함수가 왜 예상치 못한 동작을 하는지 이해하려고 할 때가 그렇습니다. 이런 상황에서 머릿속 실행(mental execution)—코드를 한 줄씩 머릿속으로 따라가며 각 변수에 무슨 일이 일어나는지 추적하는 것—이 매우 유용합니다.
숙련된 프로그래머도 이 기법을 정기적으로 사용합니다. print 문을 추가하거나 디버거를 실행하기 전에, 몇 번의 반복을 머릿속으로 추적하여 문제가 어디에 있을지 가설을 세웁니다. 이는 시행착오보다 빠르며 코드를 더 깊이 이해하는 데 도움이 됩니다.
머릿속 실행은 특히 다음과 같은 경우에 유용합니다:
- 익숙하지 않은 코드를 읽을 때 무엇을 하는지 이해하기 위해
- 작은 함수(5-15줄)를 검토할 때 실행하기 전에
- 논리 오류를 디버깅할 때 코드는 실행되지만 잘못된 결과를 생성하는 경우
- 루프 동작을 이해할 때 패턴이 즉시 명확하지 않은 경우
- 코드 리뷰 시 코드를 직접 실행하기 어려운 경우
더 크거나 복잡한 코드의 경우, 이 장의 뒷부분에서 다룰 다른 기법들과 머릿속 추적을 결합하여 사용합니다. 하지만 이 기술을 마스터하면 훨씬 더 효과적인 디버거가 될 수 있습니다.
41.2.1) 머릿속 실행 과정
코드를 머릿속으로 실행할 때는 Python 인터프리터처럼 행동하며 Python이 따르는 것과 같은 규칙을 따릅니다. 간단한 예제로 연습해 봅시다:
def find_maximum(numbers):
max_value = numbers[0]
for num in numbers:
if num > max_value:
max_value = num
return max_value
result = find_maximum([3, 7, 2, 9, 5])
print(result) # Output: 9이 코드를 추적하는 방법은 다음과 같습니다:
단계별 추적:
초기 상태:
numbers = [3, 7, 2, 9, 5]
max_value = 3 (numbers[0])
반복 1: num = 3
검사: 3 > 3? → False
max_value는 3으로 유지
반복 2: num = 7
검사: 7 > 3? → True
max_value = 7 ✓
반복 3: num = 2
검사: 2 > 7? → False
max_value는 7로 유지
반복 4: num = 9
검사: 9 > 7? → True
max_value = 9 ✓
반복 5: num = 5
검사: 5 > 9? → False
max_value는 9로 유지
반환값: 941.2.2) 추적 테이블 만들기
더 복잡한 코드에서는 시간이 지나면서 변수가 어떻게 바뀌는지 보여 주는 추적 테이블(trace table) 을 만들어 보세요. 이는 특히 반복문(loop)과 중첩 구조에서 유용합니다:
def calculate_running_totals(numbers):
totals = []
running_sum = 0
for num in numbers:
running_sum += num
totals.append(running_sum)
return totals
result = calculate_running_totals([10, 20, 30, 40])
print(result) # Output: [10, 30, 60, 100]추적 테이블:
이 표는 각 단계에서 변수의 상태를 보여줍니다. running_sum이 각 덧셈마다 "이전"에서 "이후"로 어떻게 변하는지 주목하세요:
| 반복 | num | running_sum (이전) | running_sum (이후) | totals |
|---|---|---|---|---|
| 시작 | - | 0 | 0 | [] |
| 1 | 10 | 0 | 10 | [10] |
| 2 | 20 | 10 | 30 | [10, 30] |
| 3 | 30 | 30 | 60 | [10, 30, 60] |
| 4 | 40 | 60 | 100 | [10, 30, 60, 100] |
이 표를 만들면 데이터가 코드 안에서 정확히 어떻게 흐르는지 볼 수 있습니다. 출력이 예상과 다르다면, 어디에서 문제가 발생하는지 정확히 집어낼 수 있습니다.
41.2.3) 조건문 로직 추적하기
조건문(conditional) 문장은 어떤 분기가 실행되는지에 세심한 주의가 필요합니다. 조금 더 복잡한 예제를 따라가 봅시다:
def categorize_grade(score):
if score >= 90:
category = "Excellent"
bonus = 10
elif score >= 80:
category = "Good"
bonus = 5
elif score >= 70:
category = "Satisfactory"
bonus = 0
else:
category = "Needs Improvement"
bonus = 0
final_score = score + bonus
return category, final_score
result = categorize_grade(85)
print(result) # Output: ('Good', 90)score = 85에 대한 머릿속 추적:
85 >= 90확인 → False, 첫 번째 블록 건너뜀85 >= 80확인 → True, 두 번째 블록 진입category = "Good"및bonus = 5설정- 나머지 elif와 else 블록 건너뜀(이미 일치하는 조건을 찾음)
final_score = 85 + 5 = 90계산("Good", 90)반환
41.2.4) 함수 호출과 반환 추적하기
함수가 다른 함수를 호출하면 호출 스택(call stack)—함수 호출의 순서와 각각의 로컬 변수—를 추적해야 합니다:
def calculate_tax(amount, rate):
tax = amount * rate
return tax
def calculate_total(price, quantity, tax_rate):
subtotal = price * quantity
tax = calculate_tax(subtotal, tax_rate)
total = subtotal + tax
return total
result = calculate_total(50, 3, 0.08)
print(f"Total: ${result:.2f}") # Output: Total: $162.00호출 스택으로 추적:
┌─ calculate_total(50, 3, 0.08)
│ price = 50, quantity = 3, tax_rate = 0.08
│ subtotal = 150
│
│ ┌─ calculate_tax(150, 0.08)
│ │ amount = 150, rate = 0.08
│ │ tax = 12.0
│ │ return 12.0
│ └─
│
│ tax = 12.0 (from calculate_tax)
│ total = 162.0
│ return 162.0
└─
result = 162.0이 단계별 추적은 데이터가 함수 사이에서 정확히 어떻게 흐르는지 보여 줍니다. 디버깅할 때 최종 결과가 틀렸다면, 어떤 함수가 잘못된 중간 값을 만들어 냈는지 보기 위해 거꾸로 추적할 수 있습니다.
머릿속으로 코드를 추적하는 것은 강력한 기법이지만, 복잡한 코드에서는 번거로울 수 있습니다. 다음 섹션에서는 print 문을 전략적으로 사용하여 코드가 실행될 때 실제로 무슨 일이 일어나는지 확인하는 방법을 배우겠습니다. 이는 종종 머릿속 실행보다 더 빠르고 신뢰할 수 있습니다.
41.3) print로 디버깅하기: f"{var=}"와 repr()
머릿속 실행은 작은 함수에서는 잘 작동하지만, 더 크거나 복잡한 코드에서는 비실용적입니다. 루프 내부에서 무슨 일이 일어나는지 확실하지 않거나, 계산이 예상치 못한 결과를 생성할 때, 조사하는 가장 빠른 방법은 전략적으로 print() 문을 추가하는 것입니다.
Print 디버깅은 다른 기법에 비해 몇 가지 장점이 있습니다:
- 특별한 도구가 필요 없음: 모든 Python 환경에서 작동합니다
- 빠른 구현: 몇 초 만에 print 문을 추가할 수 있습니다
- 명확한 출력: 요청한 것을 정확히 볼 수 있습니다
- 쉬운 제거: 완료되면 print 문을 삭제하면 됩니다
전문 개발자들도 print 디버깅을 항상 사용합니다—이것은 "초보자" 기법이 아닙니다. 효과적으로 사용하는 방법을 배워봅시다.
41.3.1) 기본 print 디버깅
가장 단순한 디버깅 접근은 코드의 핵심 지점에서 변수 값을 출력하는 것입니다:
def process_order(items, discount_rate):
print(f"Starting process_order")
print(f"Items: {items}")
print(f"Discount rate: {discount_rate}")
subtotal = sum(item['price'] * item['quantity'] for item in items)
print(f"Subtotal: {subtotal}")
discount = subtotal * discount_rate
print(f"Discount amount: {discount}")
total = subtotal - discount
print(f"Final total: {total}")
return total
order_items = [
{'name': 'Book', 'price': 25.99, 'quantity': 2},
{'name': 'Pen', 'price': 3.50, 'quantity': 5}
]
result = process_order(order_items, 0.10)Output:
Starting process_order
Items: [{'name': 'Book', 'price': 25.99, 'quantity': 2}, {'name': 'Pen', 'price': 3.5, 'quantity': 5}]
Discount rate: 0.1
Subtotal: 69.47999999999999
Discount amount: 6.9479999999999995
Final total: 62.53199999999999이 print 문들은 실행 흐름과 각 단계에서의 값을 보여 줍니다. 최종 결과가 틀리면, 계산이 어디에서 어긋났는지 정확히 볼 수 있습니다.
41.3.2) 빠른 확인을 위한 f"{var=}" 사용하기
Python 3.8에는 편리한 디버깅 문법인 f"{var=}"가 도입되었습니다. 이는 변수 이름과 값을 함께 출력합니다:
def calculate_compound_interest(principal, rate, years):
# 전통적인 방식
print(f"principal: {principal}")
print(f"rate: {rate}")
print(f"years: {years}")
# f"{var=}"를 사용하는 더 깔끔한 방식
print(f"{principal=}")
print(f"{rate=}")
print(f"{years=}")
# 변수 뿐만 아니라 표현식도 가능
print(f"{principal * rate=}")
print(f"{(1 + rate) ** years=}")
amount = principal * (1 + rate) ** years
print(f"{amount=}")
return amount
result = calculate_compound_interest(1000, 0.05, 10)Output:
principal: 1000
rate: 0.05
years: 10
principal=1000
rate=0.05
years=10
principal * rate=50.0
(1 + rate) ** years=1.628894626777442
amount=1628.89462677744241.3.3) repr()로 데이터의 진짜 형태 보기
때로는 출력된 것만 보고는 그것이 실제로 무엇인지 착각할 수 있습니다. repr() 함수는 숨겨진 문자를 포함해 객체의 정확한 표현(representation) 을 보여 줍니다:
# 출력하면 동일해 보이는 문자열들
text1 = "Hello"
text2 = "Hello\n" # 끝에 개행이 있습니다
print("Using print():")
print(f"text1: {text1}")
print(f"text2: {text2}")
print("\nUsing repr():")
print(f"text1: {repr(text1)}")
print(f"text2: {repr(text2)}")Output:
Using print():
text1: Hello
text2: Hello
Using repr():
text1: 'Hello'
text2: 'Hello\n'repr() 출력은 text2에 숨겨진 개행 문자가 있음을 보여 줍니다. 이는 문자열 처리를 디버깅할 때 매우 중요합니다:
def clean_user_input():
# 사용자 입력에는 숨겨진 공백이 있는 경우가 많습니다
username = input("Enter username: ") # 사용자가 "Alice "을 입력합니다
print(f"Username with print(): {username}")
print(f"Username with repr(): {repr(username)}")
# 입력 정리
cleaned = username.strip()
print(f"Cleaned with repr(): {repr(cleaned)}")
return cleaned사용자가 "Alice"를 입력한 뒤 공백을 넣고 Enter를 누르면, 다음과 같이 보일 수 있습니다:
Output:
Enter username: Alice
Username with print(): Alice
Username with repr(): 'Alice '
Cleaned with repr(): 'Alice'repr() 출력은 print()로는 명확히 보이지 않는 뒤쪽 공백을 드러냅니다.
repr()와 str() 중 언제 무엇을 사용할까:
repr()은 개발자를 위해 설계되었습니다—객체를 재생성할 수 있는 "공식적인" 문자열 표현을 보여줍니다. str()(print()가 기본적으로 사용)은 최종 사용자를 위해 설계되었습니다—읽기 쉽고 친근한 버전을 보여줍니다.
디버깅할 때는 데이터의 실제 구조를 드러내기 때문에 repr()이 보통 더 유용합니다.
41.3.4) print를 전략적으로 배치하기
print 문을 여기저기 무작정 흩뿌리지 마세요. 전략적으로 배치해야 합니다:
def calculate_shipping_cost(weight, distance, express=False):
print(f"=== calculate_shipping_cost called ===")
print(f"Input: {weight=}, {distance=}, {express=}")
# 기본 비용 계산
base_rate = 0.50
base_cost = weight * distance * base_rate
print(f"Calculated: {base_cost=}")
# 익스프레스 추가요금 적용
if express:
surcharge = base_cost * 0.50
print(f"Express surcharge: {surcharge=}")
total = base_cost + surcharge
else:
print("No express surcharge")
total = base_cost
print(f"Final: {total=}")
print(f"=== calculate_shipping_cost returning ===\n")
return total
# 서로 다른 시나리오 테스트
cost1 = calculate_shipping_cost(10, 500, express=True)
cost2 = calculate_shipping_cost(5, 200, express=False)Output:
=== calculate_shipping_cost called ===
Input: weight=10, distance=500, express=True
Calculated: base_cost=2500.0
Express surcharge: surcharge=1250.0
Final: total=3750.0
=== calculate_shipping_cost returning ===
=== calculate_shipping_cost called ===
Input: weight=5, distance=200, express=False
Calculated: base_cost=500.0
No express surcharge
Final: total=500.0
=== calculate_shipping_cost returning ===명확한 마커(===)와 정리된 출력은 실행 흐름을 따라가기가 쉽습니다.
41.3.5) 디버그 Print 문 제거하기
버그를 찾아서 수정한 후에는 디버그용 print 문을 제거하는 것을 잊지 마세요. 다음은 몇 가지 전략입니다:
전략 1: 구별되는 접두사 사용
# 검색/바꾸기로 쉽게 찾아서 제거할 수 있습니다
print(f"DEBUG: {total=}")
print(f"DEBUG: {items=}")전략 2: 디버그 플래그 사용
DEBUG = True
def calculate_total(items):
if DEBUG:
print(f"{len(items)}개의 항목 처리 중")
total = sum(item['price'] for item in items)
if DEBUG:
print(f"{total=}")
return total
# 모든 디버그 출력을 한 번에 끄기
DEBUG = False전략 3: 주석 처리하되 보관하기
def process_data(data):
# print(f"DEBUG: {data=}") # 나중에 디버깅할 때 유용
result = transform(data)
# print(f"DEBUG: {result=}")
return result프로덕션 코드에 남겨둘 수 있는 더 정교한 로깅을 위해서는 Python의 logging 모듈이 있지만, 개발 중 빠른 디버깅에는 간단한 print 문이 완벽합니다.
Print 디버깅은 변수의 값을 보여주지만, 때로는 객체의 구조—어떤 메서드가 있는지, 어떤 타입인지, 무엇을 할 수 있는지—를 이해해야 할 필요가 있습니다. 다음 섹션에서는 type()과 dir()을 사용하여 객체를 검사하는 방법을 배우겠습니다.
41.4) 객체 검사하기: type()과 dir()
Print 디버깅은 변수의 값을 보여주지만, 때로는 문제가 값이 아니라 다루고 있는 객체의 타입일 수 있습니다. 리스트를 기대했지만 문자열을 받거나, 익숙하지 않은 객체로 작업하면서 어떤 메서드를 지원하는지 모를 수 있습니다.
Python은 객체를 검사하는 내장 도구를 제공합니다: type()은 어떤 종류의 객체인지 알려주고, dir()은 어떤 작업을 지원하는지 보여줍니다. 이 함수들은 다음과 같은 경우에 필수적입니다:
- 타입 관련 에러 디버깅 (TypeError, AttributeError)
- 익숙하지 않은 라이브러리나 API 작업
- 서드파티 코드가 반환하는 객체 이해
- 코드가 예상한 타입을 받는지 확인
이 검사 도구들을 효과적으로 사용하는 방법을 배워봅시다.
41.4.1) type()으로 객체 타입 식별하기
type() 함수는 객체의 종류를 정확히 알려 줍니다. 이는 타입 관련 오류를 디버깅할 때 매우 중요합니다:
def process_data(data):
print(f"Received data: {data}")
print(f"Data type: {type(data)}")
if isinstance(data, list):
print("Processing as list")
return sum(data)
elif isinstance(data, dict):
print("Processing as dictionary")
return sum(data.values())
else:
print("Unexpected type!")
return None
# 서로 다른 타입으로 테스트
result1 = process_data([10, 20, 30])
print(f"Result: {result1}\n")
result2 = process_data({'a': 10, 'b': 20, 'c': 30})
print(f"Result: {result2}\n")
result3 = process_data("123")
print(f"Result: {result3}")Output:
Received data: [10, 20, 30]
Data type: <class 'list'>
Processing as list
Result: 60
Received data: {'a': 10, 'b': 20, 'c': 30}
Data type: <class 'dict'>
Processing as dictionary
Result: 60
Received data: 123
Data type: <class 'str'>
Unexpected type!
Result: None41.4.2) 타입 혼동(type confusion) 디버깅하기
타입 혼동은 특히 여러 소스(사용자 입력, 파일 읽기, API 응답, 또는 다른 함수)에서 데이터를 받을 수 있는 함수로 작업할 때 흔한 버그의 원인입니다. 숫자 리스트를 예상했지만 실수로 문자열을 받거나, 딕셔너리를 예상했지만 리스트를 받을 수 있습니다.
type()을 사용하면 잘못된 타입을 가지고 있는 시점을 파악하는 데 도움이 됩니다. 함수 초반에 타입을 출력하면, 코드 깊숙한 곳에서 혼란스러운 에러 메시지가 발생하기 전에 타입 불일치를 즉시 발견할 수 있습니다:
def calculate_average(numbers):
print(f"{type(numbers)=}")
print(f"{numbers=}") # 실제로 받은 값을 보여줍니다
# numbers가 숫자 리스트가 아니면 실패합니다
total = sum(numbers)
count = len(numbers)
return total / count
# 흔한 실수: 문자열을 리스트로 변환하는 것을 잊음
scores = "85" # [85] 또는 그냥 85여야 합니다
try:
avg = calculate_average(scores)
print(f"Average: {avg}")
except TypeError as e:
print(f"TypeError: {e}")
print(f"Expected list of numbers, got {type(scores)}")
print(f"The string contains: {repr(scores)}")Output:
type(numbers)=<class 'str'>
numbers='85'
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Expected list of numbers, got <class 'str'>
The string contains: '85'type() 확인은 문제를 즉시 드러냅니다: 리스트가 필요한데 문자열을 전달했습니다. 이 디버그 출력이 없었다면, sum()이 왜 실패했는지 이해하려고 시간을 낭비했을 것입니다. 실제 문제는 잘못된 타입의 데이터가 애초에 함수로 들어왔다는 것입니다.
41.4.3) dir()로 사용 가능한 메서드 찾기
익숙하지 않은 객체로 작업할 때—배우고 있는 라이브러리, API 응답, 또는 Python의 내장 타입이든—자주 필요한 질문이 있습니다: "이 객체로 무엇을 할 수 있을까?" dir() 함수는 객체에서 사용 가능한 모든 속성과 메서드를 나열하여 이 질문에 답합니다.
이는 특히 다음과 같은 경우에 유용합니다:
- 새로운 라이브러리를 탐색하면서 객체가 어떤 메서드를 제공하는지 보고 싶을 때
- 서드파티 코드에서 객체를 받아 그 기능을 이해해야 할 때
- 사용하려는 메서드의 정확한 이름을 잊어버렸을 때
- 디버깅하면서 객체가 예상한 메서드를 가지고 있는지 확인하고 싶을 때
문자열이 어떤 메서드를 가지고 있는지 탐색해봅시다:
# 문자열에 어떤 메서드가 있는지 탐색
text = "Python Programming"
print(f"Type: {type(text)}")
print(f"\nAvailable string methods (showing first 10):")
methods = [m for m in dir(text) if not m.startswith('_')]
for method in methods[:10]: # Show first 10
print(f" {method}")
print(f" ... and {len(methods) - 10} more")Output:
Type: <class 'str'>
Available string methods (showing first 10):
capitalize
casefold
center
count
encode
endswith
expandtabs
find
format
format_map
... and 37 more이제 문자열에서 사용 가능한 모든 작업을 볼 수 있습니다. 문자열에 count 메서드나 endswith 메서드가 있는지 확실하지 않았다면, dir()이 그것들이 존재한다는 것을 보여줍니다. 그런 다음 Python의 help() 함수를 사용하여 특정 메서드에 대해 더 알아볼 수 있습니다:
# 특정 메서드에 대해 더 알아보기
help(text.count)이렇게 하면 count 메서드의 문서가 표시됩니다:
Help on built-in function count:
count(sub[, start[, end]], /) method of builtins.str instance
Return the number of non-overlapping occurrences of substring sub in string S[start:end].
Optional arguments start and end are interpreted as in slice notation.dir() 함수는 Python에 내장된 문서를 가지고 있는 것과 같습니다—작업하고 있는 모든 객체로 무엇이 가능한지 보여줍니다.
41.4.4) 커스텀 객체 검사하기
사용자 정의 클래스로 작업할 때, type()과 dir()은 다루고 있는 것을 이해하는 데 도움이 됩니다. 또한 Python은 hasattr()을 제공하여 속성에 접근하기 전에 객체가 특정 속성을 가지고 있는지 확인할 수 있습니다—이는 AttributeError 예외를 방지합니다.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def get_status(self):
return "Passing" if self.grade >= 60 else "Failing"
student = Student("Alice", 85)
print(f"Object type: {type(student)}")
print(f"\nAvailable attributes and methods:")
for attr in dir(student):
if not attr.startswith('_'):
print(f" {attr}")
# 특정 속성이 존재하는지 확인
print(f"\nHas 'name' attribute: {hasattr(student, 'name')}")
print(f"Has 'age' attribute: {hasattr(student, 'age')}")
print(f"Has 'get_status' method: {hasattr(student, 'get_status')}")
# 이제 존재한다고 확인한 속성에 안전하게 접근 가능
if hasattr(student, 'name'):
print(f"\nStudent name: {student.name}")
else:
print("\nNo name attribute found")
if hasattr(student, 'get_status'):
print(f"Status: {student.get_status()}")
else:
print("No get_status method found")
# 이렇게 하면 다음과 같은 에러를 방지합니다:
# print(student.age) # AttributeError를 발생시킵니다!Output:
Object type: <class '__main__.Student'>
Available attributes and methods:
get_status
grade
name
Has 'name' attribute: True
Has 'age' attribute: False
Has 'get_status' method: True
Student name: Alice
Status: Passinghasattr() 함수는 방어적 코드(defensive code)를 작성하는 데 필수적입니다—작업을 수행하기 전에 안전한지 확인하는 코드입니다. 이 함수는 속성이 존재하면 True를, 존재하지 않으면 False를 반환합니다—이를 통해 속성에 접근하기 전에 결정을 내릴 수 있습니다. 이는 특히 외부 라이브러리의 객체나 사용자 입력으로 작업할 때 중요합니다. 이런 경우 어떤 속성이 존재할지 보장할 수 없기 때문입니다.
41.4.5) getattr()로 안전하게 속성 접근하기
속성이 존재하는지 확신할 수 없을 때는 기본값과 함께 getattr()을 사용하세요:
def display_student_info(student):
"""일부 속성이 없어도 안전하게 학생 정보 표시."""
print(f"Type: {type(student)}")
# 기본값을 사용한 안전한 속성 접근
name = getattr(student, 'name', 'Unknown')
grade = getattr(student, 'grade', 0)
age = getattr(student, 'age', 'Not specified')
print(f"Name: {name}")
print(f"Grade: {grade}")
print(f"Age: {age}")
# 메서드 호출 전에 존재 여부 확인
if hasattr(student, 'get_status'):
status = student.get_status()
print(f"Status: {status}")
# 위에서 정의한 Student 클래스 사용
student = Student("Bob", 72)
display_student_info(student)Output:
Type: <class '__main__.Student'>
Name: Bob
Grade: 72
Age: Not specified
Status: Passing이 접근 방식은 모든 예상 속성을 가지지 않을 수 있는 객체로 작업할 때 AttributeError 예외를 방지합니다. getattr() 함수는 특히 다음과 같은 경우에 유용합니다:
- 버전이 다를 수 있는 외부 API의 객체 작업
- 자체 클래스에서 선택적 속성 처리
- 누락된 데이터를 우아하게 처리하는 방어적 코드 작성
어떤 타입의 객체를 가지고 있고 어떤 메서드를 지원하는지 이해하는 것은 디버깅에 매우 중요합니다. 하지만 때로는 코드가 실행되는지뿐만 아니라 올바른 결과를 생성하는지 검증해야 합니다. 다음 섹션에서는 assert 문을 사용하여 가정을 테스트하고 버그를 조기에 발견하는 방법을 배우겠습니다.
41.5) assert 문으로 테스트하기
우리는 문제가 발생했을 때 코드를 디버깅하는 방법을 배웠습니다—트레이스백 읽기, 머릿속으로 실행 추적하기, print 문 사용하기, 그리고 객체 검사하기. 하지만 버그가 나타난 후에 수정하는 것보다 더 나은 접근 방식이 있습니다: 테스트를 통해 애초에 버그를 방지하는 것입니다.
assert 문은 Python의 가장 간단한 테스트 도구입니다. 중요한 지점에서 가정을 확인하여 코드가 올바르게 동작하는지 검증할 수 있게 해줍니다. assertion이 실패하면, Python은 무엇이 잘못되었는지, 어디서 잘못되었는지 즉시 알려주어 버그를 조기에 발견하기 훨씬 쉽게 만듭니다—종종 메인 프로그램을 실행하기도 전에 말입니다.
Assertion은 특히 다음과 같은 경우에 유용합니다:
- 함수가 예상한 결과를 생성하는지 검증
- 입력이 요구사항을 충족하는지 확인
- 코드를 망가뜨릴 수 있는 엣지 케이스 테스트
- 코드가 의존하는 가정을 문서화
Assertion을 자동화된 검사로 생각하세요. 코드가 의도한 대로 작동하는지 지속적으로 검증합니다. 효과적으로 사용하는 방법을 배워봅시다.
41.5.1) assert가 하는 일
assert 문은 조건이 참인지 확인합니다. 조건이 참이면 아무 일도 일어나지 않고 코드가 정상적으로 계속됩니다. 거짓이면 Python은 AssertionError를 발생시키고 실행을 중단합니다.
문법:
assert 조건, "선택적 에러 메시지"조건: True 또는 False로 평가되는 모든 표현식"선택적 에러 메시지": assertion이 실패할 때 표시되는 유용한 텍스트
실제로 어떻게 작동하는지 봅시다:
# 간단한 assertion들
x = 10
assert x > 0 # 조용히 통과 (x는 실제로 > 0)
assert x < 5 # 실패! AssertionError 발생
# 에러 메시지와 함께 (훨씬 더 유용합니다!)
assert x > 0, f"x must be positive, got {x}"
assert x < 5, f"x must be less than 5, got {x}" # 명확한 메시지와 함께 실패이제 실제 함수에서 assertion을 봅시다:
def calculate_discount(price, discount_percent):
# 입력이 유효한지 검증
assert price >= 0, "Price cannot be negative"
assert 0 <= discount_percent <= 100, "Discount must be between 0 and 100"
discount_amount = price * (discount_percent / 100)
final_price = price - discount_amount
# 출력이 타당한지 검증
assert final_price >= 0, "Final price cannot be negative"
return final_price
# 유효한 입력은 문제없이 동작합니다
result = calculate_discount(100, 20)
print(f"Price after 20% discount: ${result}") # Output: Price after 20% discount: $80.0
# 유효하지 않은 입력은 assertion을 트리거합니다
try:
result = calculate_discount(-50, 20)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Price cannot be negative
try:
result = calculate_discount(100, 150)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Discount must be between 0 and 10041.5.2) assertion으로 함수 동작 검증하기
assertion은 함수가 기대한 결과를 생성하는지 테스트하는 데 아주 좋습니다:
def calculate_average(numbers):
if not numbers:
return 0.0
return sum(numbers) / len(numbers)
# 다양한 입력으로 테스트
result = calculate_average([10, 20, 30])
assert result == 20.0, f"Expected 20.0, got {result}"
print(f"Test 1 passed: average of [10, 20, 30] = {result}")
result = calculate_average([5, 5, 5, 5])
assert result == 5.0, f"Expected 5.0, got {result}"
print(f"Test 2 passed: average of [5, 5, 5, 5] = {result}")
result = calculate_average([])
assert result == 0.0, f"Expected 0.0 for empty list, got {result}"
print(f"Test 3 passed: average of [] = {result}")
result = calculate_average([100])
assert result == 100.0, f"Expected 100.0, got {result}"
print(f"Test 4 passed: average of [100] = {result}")Output:
Test 1 passed: average of [10, 20, 30] = 20.0
Test 2 passed: average of [5, 5, 5, 5] = 5.0
Test 3 passed: average of [] = 0.0
Test 4 passed: average of [100] = 100.0어떤 assertion이라도 실패하면, 어떤 테스트 케이스가 문제를 드러냈는지 즉시 알 수 있습니다.
41.5.3) 엣지 케이스(edge case) 테스트하기
엣지 케이스(edge case)는 함수가 처리해야 하는 범위의 경계에 있는 입력입니다. 이를 테스트하면 일반적인 입력에서는 드러나지 않는 버그를 발견할 수 있습니다:
def get_first_and_last(items):
"""Return the first and last items from a sequence."""
assert len(items) > 0, "Cannot get first and last from empty sequence"
return items[0], items[-1]
# 일반 케이스 테스트
result = get_first_and_last([1, 2, 3, 4, 5])
assert result == (1, 5), f"Expected (1, 5), got {result}"
print(f"Normal case: {result}")
# 엣지 케이스: 단일 항목
result = get_first_and_last([42])
assert result == (42, 42), f"Expected (42, 42), got {result}"
print(f"Single item: {result}")
# 엣지 케이스: 두 항목
result = get_first_and_last([10, 20])
assert result == (10, 20), f"Expected (10, 20), got {result}"
print(f"Two items: {result}")
# 엣지 케이스: 빈 시퀀스(실패해야 함)
try:
result = get_first_and_last([])
print("ERROR: Should have raised AssertionError for empty list")
except AssertionError as e:
print(f"Empty list correctly rejected: {e}")Output:
Normal case: (1, 5)
Single item: (42, 42)
Two items: (10, 20)
Empty list correctly rejected: Cannot get first and last from empty sequence41.5.4) 데이터 변환 테스트하기
함수가 데이터를 변환(transform)할 때는, 변환이 올바른지 assert로 확인하세요:
def remove_duplicates(items):
"""Remove duplicates while preserving order."""
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
# 기본 중복 제거 테스트
input_data = [1, 2, 2, 3, 1, 4, 3, 5]
result = remove_duplicates(input_data)
expected = [1, 2, 3, 4, 5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 1 passed: {input_data} -> {result}")
# 순서가 유지되는지 테스트
input_data = [3, 1, 2, 1, 3, 2]
result = remove_duplicates(input_data)
expected = [3, 1, 2]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 2 passed: {input_data} -> {result}")
# 중복이 없는 경우 테스트
input_data = [1, 2, 3, 4, 5]
result = remove_duplicates(input_data)
assert result == input_data, f"Expected {input_data}, got {result}"
print(f"Test 3 passed: {input_data} -> {result}")
# 모두 중복인 경우 테스트
input_data = [5, 5, 5, 5]
result = remove_duplicates(input_data)
expected = [5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 4 passed: {input_data} -> {result}")Output:
Test 1 passed: [1, 2, 2, 3, 1, 4, 3, 5] -> [1, 2, 3, 4, 5]
Test 2 passed: [3, 1, 2, 1, 3, 2] -> [3, 1, 2]
Test 3 passed: [1, 2, 3, 4, 5] -> [1, 2, 3, 4, 5]
Test 4 passed: [5, 5, 5, 5] -> [5]41.5.5) 간단한 테스트 함수 만들기
코드가 커질수록, 메인 코드 전체에 assert 문을 흩어놓는 것은 지저분하고 관리하기 어려워집니다. 더 나은 접근 방식은 테스트를 전용 테스트 함수로 조직화하는 것입니다. 이렇게 하면 테스트 코드와 프로덕션 코드를 분리하고 모든 테스트를 한 번에 쉽게 실행할 수 있습니다.
전용 테스트 함수를 사용하는 이유는?
- 조직화: 함수에 대한 모든 테스트가 한 곳에 있습니다
- 재사용성: 코드를 변경할 때마다 테스트를 실행할 수 있습니다
- 문서화: 테스트는 함수가 어떻게 동작해야 하는지 보여줍니다
- 디버깅: 테스트가 실패하면 어떤 시나리오가 망가졌는지 즉시 알 수 있습니다
- 개발 워크플로우: 먼저 테스트하고, 그 다음 코드를 구현하거나 수정합니다
실제로 봅시다:
def calculate_grade(score):
"""숫자 점수를 문자 등급으로 변환."""
if score >= 90:
return 'A'
elif score >= 80:
return 'B'
elif score >= 70:
return 'C'
elif score >= 60:
return 'D'
else:
return 'F'
def test_calculate_grade():
"""calculate_grade 함수 테스트.
이 함수는 모든 예상 동작을 테스트합니다:
- 각 등급 범위 (A, B, C, D, F)
- 경계값 (90, 80, 70, 60)
- 엣지 케이스 (각 경계 바로 아래)
"""
print("Testing calculate_grade...")
# A 등급 테스트
assert calculate_grade(95) == 'A', "95 should be A"
assert calculate_grade(90) == 'A', "90 should be A (boundary)"
print(" ✓ A grades: passed")
# B 등급 테스트
assert calculate_grade(85) == 'B', "85 should be B"
assert calculate_grade(80) == 'B', "80 should be B (boundary)"
print(" ✓ B grades: passed")
# C 등급 테스트
assert calculate_grade(75) == 'C', "75 should be C"
assert calculate_grade(70) == 'C', "70 should be C (boundary)"
print(" ✓ C grades: passed")
# D 등급 테스트
assert calculate_grade(65) == 'D', "65 should be D"
assert calculate_grade(60) == 'D', "60 should be D (boundary)"
print(" ✓ D grades: passed")
# F 등급 테스트
assert calculate_grade(55) == 'F', "55 should be F"
assert calculate_grade(0) == 'F', "0 should be F"
print(" ✓ F grades: passed")
# 경계 엣지 케이스 테스트 (각 임계값 바로 아래)
assert calculate_grade(89) == 'B', "89 should be B (just below A)"
assert calculate_grade(79) == 'C', "79 should be C (just below B)"
assert calculate_grade(69) == 'D', "69 should be D (just below C)"
assert calculate_grade(59) == 'F', "59 should be F (just below D)"
print(" ✓ Boundary cases: passed")
print("All tests passed! ✓\n")
# 테스트 실행
test_calculate_grade()
# 이제 자신 있게 함수를 사용할 수 있습니다
student_score = 87
grade = calculate_grade(student_score)
print(f"Student score {student_score} = Grade {grade}")Output:
Testing calculate_grade...
✓ A grades: passed
✓ B grades: passed
✓ C grades: passed
✓ D grades: passed
✓ F grades: passed
✓ Boundary cases: passed
All tests passed! ✓
Student score 87 = Grade B이 접근 방식의 이점:
- 명확한 테스트 조직화: 모든 테스트 케이스를 한눈에 볼 수 있습니다
- 쉬운 실행: 함수를 수정할 때마다
test_calculate_grade()만 호출하면 됩니다 - 점진적 피드백: 함수가 실행되면서 어떤 테스트 그룹이 통과하는지 봅니다
- 자체 문서화: 테스트 함수는
calculate_grade()가 정확히 어떻게 작동해야 하는지 보여줍니다
테스트를 실행해야 할 때:
- 변경하기 전: 현재 코드로 테스트가 통과하는지 확인
- 변경한 후: 아무것도 망가뜨리지 않았는지 검증
- 기능 추가 시: 먼저 새 기능에 대한 테스트를 작성 (테스트 주도 개발)
- 버그 수정 시: 버그를 재현하는 테스트를 추가하고, 그 다음 수정
이 간단한 패턴—assertion이 있는 테스트 함수 작성—은 전문적인 소프트웨어 테스팅의 기초입니다. 나중에 pytest와 unittest 같은 테스팅 프레임워크에 대해 배우게 되지만, 핵심 아이디어는 동일합니다: 코드가 올바르게 작동하는지 검증하는 함수를 작성하는 것입니다.
41.5.6) Assertion과 Exception을 언제 사용할까
Assertion과 exception을 언제 사용해야 하는지 이해하는 것은 매우 중요합니다. 이들은 근본적으로 다른 목적을 제공합니다:
Assertion은 개발 중 버그를 찾기 위한 것입니다:
- 코드가 올바르게 작성되었다면 절대 거짓이 되어서는 안 되는 것들을 확인합니다
- 자신의 코드 내부 가정과 로직을 검증합니다
- 코드를 작성하고 테스트하는 동안 프로그래밍 실수를 잡는 데 도움이 됩니다
- 예: "내 함수의 이 지점에서, 이 리스트는 절대 비어있지 않아야 함"
- 예: "이 리스트의 모든 항목은 정수여야 함, 방금 필터링했으니까"
Exception은 정상 작동 중에 발생할 수 있는 에러를 처리하기 위한 것입니다:
- 제어할 수 없는 외부 조건을 다룹니다
- 코드가 완벽해도 발생할 수 있는 상황을 처리합니다
- 프로그램이 우아하게 복구하거나 유익하게 실패할 수 있게 합니다
- 예: 사용자가 숫자를 예상했는데 텍스트를 입력함
- 예: 코드가 열려고 하는 파일이 존재하지 않음
- 예: 네트워크 요청 시간 초과
핵심 차이점: Assertion은 "이것은 불가능해야 한다"고 말하고, exception은 "이것이 발생할 수 있고, 이렇게 처리할 것이다"라고 말합니다.
실제로 봅시다:
# 예제 1: 사용자 입력과 함께 사용되는 함수
# 사용자는 0을 포함한 무엇이든 입력할 수 있습니다
def calculate_user_ratio(numerator, denominator):
"""사용자가 제공한 숫자로 비율 계산."""
# 사용자가 0을 입력할 수 있으므로 예외 처리를 사용
if denominator == 0:
raise ValueError("Denominator cannot be zero")
return numerator / denominator
# 예제 2: 0이 불가능해야 하는 내부 계산
def calculate_percentage(part, total):
"""'part'가 'total'의 몇 퍼센트인지 계산."""
# 이것은 total > 0을 확인한 후 내부적으로 호출됩니다
# total이 0이면, 우리 코드의 프로그래밍 버그입니다
assert total > 0, "total must be positive - check calling code"
return (part / total) * 100각각이 처리해야 하는 것의 더 많은 예:
| 상황 | Assertion 사용 | Exception 사용 |
|---|---|---|
| 사용자가 잘못된 입력을 함 | ❌ 아니오 | ✅ 예 |
| 파일이 존재하지 않음 | ❌ 아니오 | ✅ 예 |
| 네트워크 요청 실패 | ❌ 아니오 | ✅ 예 |
| 함수가 자신의 코드에서 잘못된 파라미터 타입을 받음 | ✅ 예 | ❌ 아니오 |
| 로직 에러로 인해 리스트에 항목이 있어야 하는데 비어있음 | ✅ 예 | ❌ 아니오 |
| 버그로 인해 데이터 구조가 예상치 못한 상태임 | ✅ 예 | ❌ 아니오 |
| 데이터베이스 연결 실패 | ❌ 아니오 | ✅ 예 |
| API가 예상치 못한 형식을 반환 | ❌ 아니오 | ✅ 예 |
| 알고리즘이 수학적으로 불가능한 결과를 생성 | ✅ 예 | ❌ 아니오 |
Assertion의 중요한 제한사항:
Assertion은 Python이 최적화 모드로 실행될 때 완전히 비활성화될 수 있습니다:
python -O script.py # 모든 assert 문이 무시됩니다!Assertion이 비활성화되면 단순히 사라집니다—Python이 전혀 확인하지 않습니다. 이는 다음을 의미합니다:
- ❌ 사용자 입력 검증에 assertion을 절대 사용하지 마세요
- ❌ 보안 검사에 assertion을 절대 사용하지 마세요
- ❌ 프로덕션에서 반드시 작동해야 하는 것에 assertion을 절대 사용하지 마세요
# 위험함 - 이렇게 하지 마세요:
def process_payment(amount):
assert amount > 0, "Amount must be positive" # 잘못됨! -O로 비활성화됩니다
# 결제 처리...
# 올바름 - 이렇게 하세요:
def process_payment(amount):
if amount <= 0:
raise ValueError("Amount must be positive") # 항상 확인됩니다!
# 결제 처리...요약:
-
Assertion = "개발 중 내 코드의 버그를 확인하고 있다"
- 생각: "내가 올바르게 코딩했다면 이것은 불가능해야 한다"
- 로직의 실수를 찾는 데 도움이 됩니다
-
Exception = "실제로 발생할 수 있는 현실 세계 조건을 처리하고 있다"
- 생각: "이것은 정상 사용 중에 발생할 수 있고, 처리해야 한다"
- 프로그램이 예측할 수 없는 상황을 처리하는 데 도움이 됩니다
Assertion은 올바른 코드를 작성하는 데 도움이 되는 개발 및 디버깅 도구입니다. Exception은 사용자 입력, 파일 시스템, 네트워크 및 제어할 수 없는 기타 외부 요인의 지저분한 현실을 프로그램이 처리하는 데 도움이 되는 프로덕션 도구입니다.
이제 프로그래밍 여정 내내 도움이 될 필수 디버깅 및 테스팅 기법을 배웠습니다:
- 트레이스백 읽기로 에러가 발생한 위치를 빠르게 찾기
- 코드를 머릿속으로 추적하여 코드가 단계별로 무엇을 하는지 이해하기
- Print 문을 전략적으로 사용하여 런타임 값과 흐름 확인하기
- type()과 dir()로 객체 검사하여 무엇을 다루고 있는지 이해하기
- Assertion으로 테스트하여 코드가 작동하는지 검증하고 버그를 조기에 발견하기
이 기술들은 완전한 디버깅 도구 상자로서 함께 작동합니다. 문제에 직면했을 때:
- 트레이스백을 읽어 어디서 실패했는지 찾습니다
- Print 디버깅이나 머릿속 추적으로 왜 실패했는지 이해합니다
- 객체가 무엇을 할 수 있는지 확실하지 않을 때 type/dir 검사를 사용합니다
- 버그가 다시 발생하지 않도록 assertion을 작성합니다
연습을 통해 각 상황에서 어떤 기법을 사용해야 할지에 대한 직관을 개발하게 될 것입니다. 기억하세요: 모든 프로그래머가 코드를 디버깅합니다—차이점은 숙련된 프로그래머는 체계적이고 효율적으로 한다는 것입니다. 이 기법들은 당신을 그들 중 한 명으로 만들어줄 것입니다.