[에러 해결] Python 할인 함수 음수 값 반환: 비정상 할인율 버그 원인과 체계적인 해결 방법

안녕하세요, 개발자 여러분! 전자상거래 시스템에서 가격 할인을 적용하는 기능은 매우 흔합니다. 하지만 때로는 가장 단순해 보이는 로직에서도 예상치 못한 치명적인 버그가 발생할 수 있습니다. 오늘은 Python에서 할인 함수가 음수 값을 반환하는 미묘한 버그의 원인을 파헤치고, 정적 분석 도구들이 이를 왜 놓치는지, 그리고 대규모 코드베이스에서 이 문제를 체계적으로 감지하고 예방하는 방법에 대해 알아보겠습니다.

1. 에러 발생 상황

문제의 발단은 다음과 같은 Python 할인 함수에서 시작되었습니다.

def apply_discount(price, discount):
    return price * (1 - discount)

이 함수는 일반적인 입력에 대해서는 완벽하게 작동합니다. 예를 들어:

  • apply_discount(100, 0.20)80.0을 반환합니다. (20% 할인)
  • apply_discount(50, 0.10)45.0을 반환합니다. (10% 할인)

그러나 예상치 못한 입력이 주어졌을 때 문제가 발생했습니다.

  • apply_discount(100, 1.5)-50.0을 반환합니다.
  • apply_discount(200, 2.0)-200.0을 반환합니다.

결제 시스템에서 음수 가격이 발생한다는 것은 심각한 문제입니다. 더욱 놀라운 것은 이 코드가 pylint, mypy, flake8, bandit과 같은 모든 표준 정적 분석 도구를 통과했으며, 작성된 단위 테스트(apply_discount(100, 0.20) == 80.0, apply_discount(50, 0.10) == 45.0) 또한 pytest에서 녹색 불을 받으며 성공했다는 점입니다. 이 버그는 모든 표준 도구에 완전히 보이지 않았습니다.

2. 명확한 발생 원인

2.1. 문제의 핵심: 비정상적인 할인율(Discount Rate)

이 버그의 근본적인 원인은 discount 매개변수가 예상 범위를 벗어났기 때문입니다. 일반적으로 할인율은 0%에서 100% 사이, 즉 0과 1 사이의 값으로 표현됩니다. 예를 들어, 20% 할인은 0.20으로, 100% 할인은 1.0으로 표현됩니다.

함수의 로직을 다시 살펴보면:

return price * (1 - discount)
  • discount0이면 price * (1 - 0) = price (할인 없음)
  • discount0.2이면 price * (1 - 0.2) = price * 0.8 (20% 할인)
  • discount1.0이면 price * (1 - 1.0) = price * 0 = 0 (100% 할인)

하지만 discount1보다 커지면 어떻게 될까요?

  • discount1.5이면 price * (1 - 1.5) = price * -0.5. 만약 price100이라면 -50.0이 됩니다.
  • discount2.0이면 price * (1 - 2.0) = price * -1.0. 만약 price200이라면 -200.0이 됩니다.

즉, discount 값이 1을 초과하면 (1 - discount) 부분이 음수가 되어, 최종 가격이 음수로 계산되는 것입니다. 이는 가격이나 할인율에 대한 비즈니스 도메인(Business Domain)의 제약 조건을 코드가 반영하지 못했기 때문에 발생합니다.

2.2. 정적 분석 도구와 단위 테스트가 놓치는 이유

  • 정적 분석 도구 (Linters, Type Checkers): Pylint, Mypy, Flake8, Bandit과 같은 도구들은 코드의 문법적 오류, 스타일 가이드라인 위반, 잠재적 보안 취약점, 타입 힌트 불일치 등을 검사합니다. 이들은 코드가 파이썬 문법 규칙을 따르고 있는지, 일반적으로 좋은 코딩 관행을 지키고 있는지 등을 확인합니다. 하지만 이 도구들은 코드에 담긴 “비즈니스 로직”의 정확성, 즉 특정 값이 도메인(예: 가격, 할인율)의 제약 조건을 만족하는지 여부를 판단할 수는 없습니다. price * (1 - discount)라는 코드는 문법적으로나 타입적으로 전혀 문제가 없습니다.

  • 단위 테스트 (Unit Tests): 제공된 단위 테스트는 apply_discount(100, 0.20)과 같은 ‘정상적인’ 시나리오, 즉 ‘Happy Path’만 검증하고 있습니다. 할인율이 1을 초과하거나 0 미만인 ‘경계값(Edge Case)’이나 ‘유효하지 않은 입력(Invalid Input)’ 시나리오에 대한 테스트 케이스가 없기 때문에 버그를 발견할 수 없었습니다.

2.3. 버그의 공식 분류 (Formal Classification)

이러한 유형의 버그는 일반적으로 다음과 같이 분류할 수 있습니다.

  • 도메인 제약 위반 (Domain Constraint Violation): 시스템이 작동하는 특정 ‘도메인'(여기서는 전자상거래의 가격 및 할인 로직)에 존재하는 암묵적이거나 명시적인 규칙(할인율은 0%~100%여야 함)을 코드가 위반했을 때 발생합니다.

  • 비즈니스 로직 오류 (Business Logic Error): 애플리케이션의 핵심 비즈니스 규칙(제품 가격은 음수가 될 수 없음)이 코드에 잘못 구현되었거나, 필요한 검증 로직이 누락되어 발생합니다.

이는 문법 오류(Syntax Error)나 런타임 오류(Runtime Error), 타입 오류(Type Error)와는 다른, “의미론적(Semantic)” 또는 “논리적(Logical)” 오류에 해당합니다.

3. 해결 방법 및 코드 예시

이러한 유형의 버그를 해결하고 예방하기 위한 가장 효과적인 방법은 “입력 값 유효성 검사(Input Validation)”를 추가하고, “포괄적인 단위 테스트”를 작성하는 것입니다.

3.1. 입력 값 유효성 검사(Input Validation) 추가

함수가 처리하는 입력 값이 올바른 비즈니스 도메인 내에 있는지 확인하는 것이 중요합니다. 할인율은 0에서 1 사이여야 하고, 가격은 음수일 수 없습니다.

코드 예시 1: 간단한 조건문으로 유효성 검사

def apply_discount(price: float, discount: float) -> float:
    # 가격은 음수일 수 없습니다.
    if not (price >= 0):
        raise ValueError("가격은 음수일 수 없습니다.")

    # 할인율은 0과 1 (0%와 100%) 사이여야 합니다.
    if not (0 <= discount <= 1):
        raise ValueError("할인율은 0과 1 사이여야 합니다.")

    return price * (1 - discount)

# 예시
print(apply_discount(100, 0.20)) # 80.0
print(apply_discount(50,  0.10)) # 45.0

# 유효하지 않은 입력 시도
try:
    apply_discount(100, 1.5)
except ValueError as e:
    print(f"Error: {e}") # Error: 할인율은 0과 1 사이여야 합니다.

try:
    apply_discount(-100, 0.1)
except ValueError as e:
    print(f"Error: {e}") # Error: 가격은 음수일 수 없습니다.

3.2. 단위 테스트 보강

이제 유효성 검사 로직이 추가되었으므로, 이를 검증하는 단위 테스트도 추가해야 합니다. 단순히 정상적인 케이스뿐만 아니라, 예상되는 오류 케이스(경계값, 유효하지 않은 입력)에 대해서도 테스트를 작성해야 합니다.

코드 예시 2: 유효성 검사를 포함한 단위 테스트 (pytest 사용)

import pytest

# 위에 정의된 apply_discount 함수를 사용합니다.

def test_apply_discount_valid():
    assert apply_discount(100, 0.20) == 80.0
    assert apply_discount(50,  0.10) == 45.0
    assert apply_discount(200, 0.0) == 200.0 # 0% 할인
    assert apply_discount(100, 1.0) == 0.0   # 100% 할인

def test_apply_discount_invalid_high_discount():
    with pytest.raises(ValueError, match="할인율은 0과 1 사이여야 합니다."):
        apply_discount(100, 1.5)

def test_apply_discount_invalid_low_discount():
    with pytest.raises(ValueError, match="할인율은 0과 1 사이여야 합니다."):
        apply_discount(100, -0.1)

def test_apply_discount_negative_price_input():
    with pytest.raises(ValueError, match="가격은 음수일 수 없습니다."):
        apply_discount(-100, 0.1)

def test_apply_discount_zero_price_valid_discount():
    assert apply_discount(0, 0.5) == 0.0

pytest.raises를 사용하면 특정 예외가 발생하는지를 테스트할 수 있어, 유효성 검사 로직이 제대로 작동하는지 확인할 수 있습니다.

3.3. Pydantic을 활용한 입력 모델 강제 (선택 사항)

대규모 애플리케이션이나 API 개발에서는 Pydantic과 같은 데이터 유효성 검사 라이브러리를 활용하여 입력 모델 자체에 제약 조건을 강제할 수 있습니다. 이는 특히 웹 프레임워크(예: FastAPI)와 함께 사용될 때 강력한 이점을 제공합니다.

from pydantic import BaseModel, Field, ValidationError

class DiscountParams(BaseModel):
    price: float = Field(..., ge=0, description="상품 가격은 0 이상이어야 합니다.")
    discount: float = Field(..., ge=0, le=1, description="할인율은 0과 1 사이여야 합니다.")

def apply_discount_with_pydantic(params: DiscountParams) -> float:
    return params.price * (1 - params.discount)

try:
    # 유효한 입력
    params = DiscountParams(price=100, discount=0.2)
    print(apply_discount_with_pydantic(params)) # 80.0

    # 유효하지 않은 입력
    params_invalid = DiscountParams(price=100, discount=1.5)
except ValidationError as e:
    print(f"Validation Error: {e}")
# Validation Error: 1 validation error for DiscountParams
# discount
#   ensure this value is less than or equal to 1 (type=value_error.number.not_le;limit_value=1)

4. 향후 예방을 위한 팁

4.1. 도메인 지식에 기반한 유효성 검사의 중요성 강조

모든 코드는 특정 도메인(비즈니스 영역) 내에서 작동합니다. 개발자는 이 도메인에 대한 깊은 이해를 바탕으로 입력 값에 대한 암묵적인 제약 조건을 명시적인 유효성 검사 로직으로 구현해야 합니다. "Garbage in, garbage out"(쓰레기가 들어가면 쓰레기가 나온다) 원칙을 항상 기억하고, 신뢰할 수 없는 외부 입력은 반드시 검증해야 합니다.

4.2. 포괄적인 단위 테스트 작성 (Edge Case 및 Invalid Case)

단위 테스트는 단순히 코드가 '작동하는지'를 넘어 '예상대로 작동하는지'를 검증해야 합니다. 이를 위해서는 다음과 같은 유형의 테스트 케이스를 포함해야 합니다.

  • Happy Path (정상 경로): 예상되는 올바른 입력과 출력.
  • Edge Cases (경계값): 입력 값의 최소/최대, 0, 1 등 경계 지점에서의 동작.
  • Invalid Inputs (유효하지 않은 입력): 비즈니스 규칙을 위반하는 입력에 대해 적절한 오류 처리(예외 발생)가 이루어지는지.

4.3. 계약에 의한 설계(Design by Contract) 개념 적용

함수나 메서드를 설계할 때, "계약에 의한 설계" 개념을 적용하는 것이 좋습니다. 이는 다음과 같습니다.

  • 선행 조건(Preconditions): 함수가 호출되기 전에 반드시 참이어야 하는 조건. (예: 할인율은 0과 1 사이여야 한다.)
  • 후행 조건(Postconditions): 함수가 실행된 후 반드시 참이어야 하는 조건. (예: 반환된 가격은 음수가 아니어야 한다.)
  • 불변 조건(Invariants): 클래스의 생명주기 동안 항상 참이어야 하는 조건.

이러한 조건을 명시적으로 정의하고 코드에 반영하면, 개발자가 코드의 제약 사항을 명확히 이해하고 오류를 예방하는 데 도움이 됩니다.

4.4. 타입 힌트와 Mypy의 한계 인식

Python의 타입 힌트와 Mypy는 코드의 가독성과 유지보수성을 높이고 특정 유형의 오류를 방지하는 데 매우 유용합니다. 그러나 타입 검사기는 변수의 '타입'을 확인하지, '값의 논리적 유효성'을 검사하지 않습니다. float 타입이 예상된다고 해서 그 float 값이 비즈니스 로직에 맞게 유효한 값(예: 0~1 사이)인지는 알려주지 않습니다. 따라서 타입 검사와 함께 도메인 기반의 유효성 검사가 필수적입니다.

이러한 접근 방식을 통해 개발자는 단순히 코드가 실행되는 것을 넘어, 코드가 "올바르게" 작동하고 비즈니스 요구사항을 충족하는 견고한 애플리케이션을 구축할 수 있습니다. 복잡한 시스템일수록 이러한 기본적인 원칙을 철저히 지키는 것이 중요합니다.

댓글 남기기