26. 예외와 검증을 활용한 방어적 프로그래밍 기법
방어적 프로그래밍(defensive programming)은 문제가 발생하기 전에 미리 대비하는 코드를 작성하는 것을 의미합니다. 모든 것이 완벽하게 동작할 것이라고 가정하는 대신, 방어적 코드는 입력을 검증하고, 오류를 우아하게 처리하며, 가정을 확인합니다. 이 접근 방식은 더 신뢰할 수 있고, 디버깅하기 쉬우며, 예기치 않게 크래시가 날 가능성이 낮은 프로그램을 만들어 줍니다.
이전 장들에서는 예외가 발생했을 때 이를 처리하는 방법을 배웠습니다. 이제는 애초에 많은 오류가 발생하지 않도록 예방하는 방법과, 오류가 발생하더라도 초기에 문제를 잡아내는 방법을 배울 것입니다.
26.1) 함수 인자 검증하기
함수(function)는 프로그램의 다른 부분이나 사용자로부터 데이터를 받는 경우가 많습니다. 함수가 유효하지 않은 데이터를 받으면 잘못된 결과를 내거나, 이해하기 어려운 오류로 크래시가 나거나, 프로그램의 다른 곳에서 문제를 일으킬 수 있습니다. 인자 검증(argument validation)은 함수 인자를 사용하기 전에 요구사항을 만족하는지 확인하는 것을 의미합니다.
26.1.1) 왜 인자를 검증해야 할까요?
학생의 성적 백분율을 계산하는 다음 함수를 생각해 봅시다:
def calculate_percentage(points_earned, total_points):
return (points_earned / total_points) * 100
# 함수 사용
percentage = calculate_percentage(85, 100)
print(f"Grade: {percentage}%") # Output: Grade: 85.0%유효한 입력에서는 잘 동작합니다. 하지만 문제가 있는 데이터가 들어오면 어떻게 될까요?
# 문제 1: 0으로 나누기
percentage = calculate_percentage(85, 0) # ZeroDivisionError!
# 문제 2: 음수 값(말이 안 됨)
percentage = calculate_percentage(-10, 100) # -10.0%
# 문제 3: 획득 점수가 총점을 초과(불가능)
percentage = calculate_percentage(120, 100) # 120.0%검증이 없으면 함수는 크래시가 나거나, 말이 되지 않는 결과를 만들어 냅니다.
26.1.2) 조건문으로 하는 기본 인자 검증
가장 단순한 검증 접근은 if 문으로 인자를 확인하고 유효하지 않으면 예외를 발생시키는 것입니다:
def calculate_percentage(points_earned, total_points):
# total_points 검증
if total_points <= 0:
raise ValueError("total_points must be positive")
# points_earned 검증
if points_earned < 0:
raise ValueError("points_earned cannot be negative")
if points_earned > total_points:
raise ValueError("points_earned cannot exceed total_points")
# 모든 검증 통과 - 안전하게 계산 가능
return (points_earned / total_points) * 100
# 유효한 사용
percentage = calculate_percentage(85, 100)
print(f"Grade: {percentage}%") # Output: Grade: 85.0%
# 유효하지 않은 사용 - 명확한 오류 메시지
try:
percentage = calculate_percentage(85, 0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: total_points must be positive
try:
percentage = calculate_percentage(-10, 100)
except ValueError as e:
print(f"Error: {e}") # Output: Error: points_earned cannot be negative
try:
percentage = calculate_percentage(120, 100)
except ValueError as e:
print(f"Error: {e}") # Output: Error: points_earned cannot exceed total_points이제 문제가 발생하면 오류 메시지가 무엇이 문제인지와 어떻게 고칠지 명확히 설명합니다.
26.1.3) 인자 타입 검증하기
때로는 인자가 올바른 타입인지 확인해야 합니다:
def calculate_discount(price, discount_percent):
# 타입 검증
if not isinstance(price, (int, float)):
raise TypeError("price must be a number")
if not isinstance(discount_percent, (int, float)):
raise TypeError("discount_percent must be a number")
# 값 검증
if price < 0:
raise ValueError("price cannot be negative")
if not (0 <= discount_percent <= 100):
raise ValueError("discount_percent must be between 0 and 100")
# 할인 계산
discount_amount = price * (discount_percent / 100)
return price - discount_amount
# 유효한 사용
final_price = calculate_discount(50.00, 20)
print(f"Final price: ${final_price:.2f}") # Output: Final price: $40.00
# 타입 오류
try:
final_price = calculate_discount("50", 20)
except TypeError as e:
print(f"Error: {e}") # Output: Error: price must be a number
# 값 오류
try:
final_price = calculate_discount(50.00, 150)
except ValueError as e:
print(f"Error: {e}") # Output: Error: discount_percent must be between 0 and 100isinstance() 함수는 객체가 지정된 타입(또는 타입들)의 인스턴스인지 확인합니다. 정수 또는 실수를 모두 허용하기 위해 (int, float) 튜플을 전달하는데, 둘 다 가격에 대해 유효한 숫자 타입이기 때문입니다.
언제 타입을 검증할까요: Python의 철학은 "덕 타이핑(duck typing)"입니다. 즉, 객체가 필요한 방식으로 동작한다면 사용합니다. 타입 검증은 다음과 같은 경우에 가장 유용합니다:
- 다른 사람이 사용할 함수를 작성할 때
- 타입 오류가 나중에 혼란스러운 실패를 일으킬 수 있을 때
- 함수가 공개 API 또는 라이브러리의 일부일 때
26.1.4) 컬렉션 인자 검증하기
함수가 리스트(list), 딕셔너리(dictionary) 또는 다른 컬렉션을 받을 때는 컬렉션 자체와 그 내용물을 모두 검증하세요:
def calculate_average_grade(grades):
# 컬렉션 자체 검증
if not isinstance(grades, list):
raise TypeError("grades must be a list")
if len(grades) == 0:
raise ValueError("grades list cannot be empty")
# 컬렉션의 각 점수 검증
for i, grade in enumerate(grades):
if not isinstance(grade, (int, float)):
raise TypeError(f"grade at index {i} must be a number, got {type(grade).__name__}")
if not (0 <= grade <= 100):
raise ValueError(f"grade at index {i} must be between 0 and 100, got {grade}")
# 모든 검증 통과
return sum(grades) / len(grades)
# 유효한 사용
grades = [85, 92, 78, 95]
average = calculate_average_grade(grades)
print(f"Average: {average:.1f}") # Output: Average: 87.5
# 빈 리스트 오류
try:
average = calculate_average_grade([])
except ValueError as e:
print(f"Error: {e}") # Output: Error: grades list cannot be empty
# 유효하지 않은 점수 타입
try:
average = calculate_average_grade([85, "92", 78])
except TypeError as e:
print(f"Error: {e}") # Output: Error: grade at index 1 must be a number, got str
# 유효하지 않은 점수 값
try:
average = calculate_average_grade([85, 92, 150])
except ValueError as e:
print(f"Error: {e}") # Output: Error: grade at index 2 must be between 0 and 100, got 150컬렉션 요소를 검증할 때 오류 메시지에 인덱스를 포함하는 것을 확인할 수 있습니다. 이는 특히 큰 컬렉션에서 어떤 항목이 문제인지 정확히 식별하는 데 도움이 됩니다.
26.2) 사용자 입력의 유효성 확인하기
사용자 입력은 본질적으로 신뢰할 수 없습니다. 사용자는 오타를 내거나, 지시를 오해하거나, 예상치 못한 형식으로 데이터를 입력할 수 있습니다. 사용자 입력을 검증하면 이러한 실수가 프로그램 크래시나 잘못된 결과로 이어지는 것을 방지할 수 있습니다.
26.2.1) 기본 입력 검증 패턴
입력 검증의 기본 패턴은 input()과 검증 체크를 결합합니다:
# 사용자 입력 받기
age_str = input("Enter your age: ")
# 입력 검증하기
try:
age = int(age_str)
if age < 0:
print("Error: Age cannot be negative")
elif age > 150:
print("Error: Age seems unrealistic")
else:
print(f"You are {age} years old")
except ValueError:
print("Error: Please enter a valid number")이 패턴은 세 부분으로 구성됩니다:
- 입력을 문자열로 받기
- 필요한 타입으로 변환 시도하기
- 변환된 값이 유효한지 확인하기
다양한 입력으로 실제 동작을 봅시다:
# 유효한 입력
# User enters: 25
# Output: You are 25 years old
# 유효하지 않은 타입
# User enters: twenty-five
# Output: Error: Please enter a valid number
# 유효하지 않은 값(음수)
# User enters: -5
# Output: Error: Age cannot be negative
# 유효하지 않은 값(비현실적)
# User enters: 200
# Output: Error: Age seems unrealistic26.2.2) 입력 범위와 형식 검증하기
어떤 입력은 특정 범위 안에 있어야 하거나 특정 형식과 일치해야 합니다:
# 월(1-12) 검증하기
month_str = input("Enter month (1-12): ")
try:
month = int(month_str)
if not (1 <= month <= 12):
print("Error: Month must be between 1 and 12")
else:
print(f"Month: {month}")
except ValueError:
print("Error: Please enter a whole number")
# 이메일 형식 검증하기(단순 검사)
email = input("Enter email: ")
if '@' not in email or '.' not in email:
print("Error: Email must contain @ and .")
else:
print(f"Email: {email}")
# yes/no 입력 검증하기
response = input("Continue? (yes/no): ").lower().strip()
if response not in ['yes', 'no', 'y', 'n']:
print("Error: Please answer yes or no")
else:
if response in ['yes', 'y']:
print("Continuing...")
else:
print("Stopping...")여기서 이메일 검증은 의도적으로 단순하게 만들었습니다. 기본 구조만 확인합니다. 실제 이메일 검증은 훨씬 더 복잡하며 보통 정규 표현식(regular expressions)을 사용합니다(39장에서 배울 것입니다).
26.2.3) 도움이 되는 오류 메시지 제공하기
좋은 오류 메시지는 무엇이 잘못됐는지와 어떻게 고칠지 사용자에게 정확히 알려 줍니다:
# 나쁜 오류 메시지
password = input("Enter password: ")
if len(password) < 8:
print("Error: Invalid password") # 도움이 안 됨!
# 더 나은 오류 메시지
password = input("Enter password: ")
if len(password) < 8:
print("Error: Password must be at least 8 characters long")
print(f"Your password is only {len(password)} characters")
# 더 좋게 - 모든 요구사항을 처음부터 설명하기
print("Password requirements:")
print("- At least 8 characters")
print("- Must contain at least one number")
password = input("Enter password: ")
# 길이 확인
if len(password) < 8:
print(f"Error: Password too short ({len(password)} characters)")
print("Password must be at least 8 characters")
# 숫자 포함 여부 확인
elif not any(char.isdigit() for char in password):
print("Error: Password must contain at least one number")
else:
print("Password accepted")any() 함수는 이터러블(iterable)의 어떤 요소라도 참이면 True를 반환합니다. 여기서는 char.isdigit()가 각 문자가 숫자인지 확인하고, any()가 적어도 하나의 문자가 테스트를 통과했는지 알려 줍니다.
26.3) input(), 반복문, try/except를 결합해 견고한 입력 처리하기
단일 검증 체크는 유용하지만, 사용자가 계속해서 잘못 입력하는 상황은 처리하지 못합니다. 사용자가 유효하지 않은 데이터를 입력하면 프로그램은 다시 기회를 줘야 합니다. 반복문과 검증을 결합하면 유효한 데이터를 받을 때까지 계속 묻는 견고한 입력 처리를 만들 수 있습니다.
26.3.1) 기본 입력 루프 패턴
기본 패턴은 유효한 입력을 받을 때까지 계속되는 while 반복문(loop)을 사용합니다:
# 유효한 나이를 입력받을 때까지 계속 묻기
while True:
age_str = input("Enter your age: ")
try:
age = int(age_str)
if age < 0:
print("Error: Age cannot be negative. Please try again.")
elif age > 150:
print("Error: Age seems unrealistic. Please try again.")
else:
# 유효한 입력 - 루프 탈출
break
except ValueError:
print("Error: Please enter a valid number.")
print(f"You are {age} years old")이 패턴에는 몇 가지 핵심 요소가 있습니다:
while True:는 무한 루프를 만듭니다- 검증은 루프 안에서 이루어집니다
- 입력이 유효하면
break로 루프를 종료합니다 - 오류 메시지가 사용자가 다시 시도하도록 유도합니다
다양한 입력을 어떻게 처리하는지 봅시다:
# Example interaction:
# Enter your age: twenty
# Error: Please enter a valid number.
# Enter your age: -5
# Error: Age cannot be negative. Please try again.
# Enter your age: 25
# You are 25 years old26.3.2) 재사용 가능한 입력 함수 만들기
여러 곳에서 같은 종류의 검증된 입력이 필요하다면 함수를 만드세요:
def get_positive_integer(prompt):
"""사용자가 양의 정수를 입력할 때까지 계속 묻습니다."""
while True:
try:
value = int(input(prompt))
if value <= 0:
print("Error: Please enter a positive number.")
else:
return value
except ValueError:
print("Error: Please enter a valid whole number.")
def get_number_in_range(prompt, min_value, max_value):
"""사용자가 지정된 범위의 숫자를 입력할 때까지 계속 묻습니다."""
while True:
try:
value = float(input(prompt))
if value < min_value or value > max_value:
print(f"Error: Please enter a number between {min_value} and {max_value}.")
else:
return value
except ValueError:
print("Error: Please enter a valid number.")
# 함수 사용하기
quantity = get_positive_integer("Enter quantity: ")
print(f"Quantity: {quantity}")
grade = get_number_in_range("Enter grade (0-100): ", 0, 100)
print(f"Grade: {grade}")
temperature = get_number_in_range("Enter temperature (-50 to 50): ", -50, 50)
print(f"Temperature: {temperature}°C")이 함수들은 검증 로직을 캡슐화하여 메인 코드를 더 깔끔하고 읽기 쉽게 만듭니다. 또한 프로그램 전체에서 일관된 검증 동작을 보장합니다.
26.4) 개발 시점 불변 조건(invariant) 검사를 위한 assert 사용하기
assertion(assert)은 개발 중 코드의 가정이 올바른지 확인하기 위해 사용하는 특별한 종류의 검사입니다. 검증(validation)이 사용자나 외부 데이터에서 오는 예상 가능한 오류를 처리하는 것과 달리, assert는 프로그래밍 실수—코드가 올바르다면 절대 발생해서는 안 되는 상황—를 잡아냅니다.
26.4.1) assert란 무엇이며 언제 사용해야 할까요?
assertion(assert)은 코드의 특정 지점에서 항상 참이어야 하는 문입니다. 만약 거짓이라면 프로그램 로직에 근본적인 문제가 있다는 뜻입니다:
def calculate_average(numbers):
# 함수가 올바르게 호출되었다면 절대 일어나면 안 되는 상황
assert len(numbers) > 0, "numbers list cannot be empty"
return sum(numbers) / len(numbers)
# 올바른 사용
grades = [85, 90, 78]
average = calculate_average(grades)
print(f"Average: {average:.1f}") # Output: Average: 84.3
# 잘못된 사용 - assert 트리거
empty_list = []
average = calculate_average(empty_list) # AssertionError: numbers list cannot be emptyassert가 실패하면 Python은 메시지와 함께 AssertionError를 발생시킵니다. 이는 즉시 프로그램을 멈추고 어떤 가정이 깨졌는지 정확히 보여 줍니다.
핵심 차이점:
- 검증(validation) (
if와raise사용): 사용자 또는 외부 데이터에서 오는 예상 가능한 문제를 처리할 때 - assert: 개발 중 프로그래밍 버그를 잡아낼 때
# 검증 - 예상 가능한 사용자 오류를 처리
def get_positive_number(prompt):
while True:
try:
value = float(input(prompt))
if value <= 0:
print("Error: Please enter a positive number.")
else:
return value
except ValueError:
print("Error: Please enter a valid number.")
# assert - 프로그래밍 실수를 잡아냄
def calculate_discount(price, discount_rate):
# 프로그램이 올바르게 작성되었다면 절대 깨지면 안 되는 조건들
assert price >= 0, "price should be non-negative"
assert 0 <= discount_rate <= 1, "discount_rate should be between 0 and 1"
return price * (1 - discount_rate)26.4.2) 함수 사전조건(preconditions) 확인하기
assert는 함수의 사전조건(preconditions)(함수가 실행되기 전에 반드시 참이어야 하는 요구사항)이 충족되었는지 검증하는 데 매우 좋습니다:
def get_list_element(items, index):
"""지정된 인덱스의 리스트 요소를 가져옵니다."""
# 사전조건
assert isinstance(items, list), "items must be a list"
assert isinstance(index, int), "index must be an integer"
assert 0 <= index < len(items), f"index {index} out of range for list of length {len(items)}"
return items[index]
# 올바른 사용
numbers = [10, 20, 30, 40]
value = get_list_element(numbers, 2)
print(f"Value: {value}") # Output: Value: 30
# 프로그래밍 오류 - 잘못된 타입
value = get_list_element("not a list", 0) # AssertionError: items must be a list
# 프로그래밍 오류 - 유효하지 않은 인덱스
value = get_list_element(numbers, 10) # AssertionError: index 10 out of range for list of length 4이러한 assert는 개발 중 버그를 잡아내는 데 도움이 됩니다. 실수로 잘못된 타입이나 유효하지 않은 인덱스를 전달하면 assert가 즉시 무엇이 잘못됐는지 알려 줍니다.
26.4.3) 함수 사후조건(postconditions) 확인하기
사후조건(postconditions)은 함수가 실행된 뒤 반드시 참이어야 하는 조건입니다. assert는 함수가 유효한 결과를 만들어냈는지 확인할 수 있습니다:
def calculate_percentage(part, whole):
"""'part'가 'whole'의 몇 퍼센트인지 계산합니다."""
# 사전조건
assert whole > 0, "whole must be positive"
assert part >= 0, "part must be non-negative"
# 백분율 계산
percentage = (part / whole) * 100
# 사후조건 - 결과는 유효한 백분율이어야 함
assert 0 <= percentage <= 100, f"percentage {percentage} is outside valid range"
return percentage
# 정상 동작
percentage = calculate_percentage(25, 100)
print(f"Percentage: {percentage}%") # Output: Percentage: 25.0%
# 함수의 로직 오류를 드러냄
# (part <= whole을 검사하지 않았음)
percentage = calculate_percentage(150, 100) # AssertionError: percentage 150.0 is outside valid range사후조건 assert가 함수의 버그를 잡아냈습니다. part가 whole을 초과하지 않는다는 검증을 빠뜨렸던 것입니다. 이것이 바로 assert의 목적입니다. 프로그래밍 실수를 잡아내는 것 말입니다.
26.4.4) assert는 비활성화될 수 있습니다
assert의 중요한 특징은 Python을 -O(최적화) 플래그로 실행하면 비활성화될 수 있다는 점입니다:
# 이 파일 이름은 test_assertions.py 입니다
def divide(a, b):
assert b != 0, "divisor cannot be zero"
return a / b
result = divide(10, 2)
print(f"Result: {result}")
result = divide(10, 0) # AssertionError when assertions are enabled일반 실행:
python test_assertions.py
# Output: Result: 5.0
# Then: AssertionError: divisor cannot be zero최적화 실행:
python -O test_assertions.py
# Output: Result: 5.0
# Then: ZeroDivisionError: division by zero그래서 외부 데이터 검증에 assert를 절대로 사용하면 안 됩니다. 누군가 -O로 프로그램을 실행하면 모든 assert가 건너뛰어집니다. assert는 개발과 테스트 중에 프로그래밍 버그를 잡는 용도로만 사용하세요.