Skip to content
주제
Python
Python Assert: 더 똑똑하게 디버깅하기

Python Assert: 더 똑똑하게 디버깅하기

업데이트

데이터 처리 함수를 디버깅하고 있다고 해보겠습니다. CSV를 읽고 출력을 쓰는 사이 어딘가에서 정수 컬럼이 float로 바뀌었고, 절대 비어 있으면 안 되는 리스트가 비어 버렸으며, 양수여야 할 사용자 ID가 이제 -1이 되었습니다. 여기저기 print()를 흩뿌리고, 스크립트를 다시 실행하고, 출력 결과를 눈을 가늘게 뜨고 확인한 뒤, 이 과정을 반복합니다. 한 시간 후에야 버그를 찾습니다 -- 단 하나의 함수가 조용히 잘못된 입력을 받아들이고, 손상된 데이터를 아래 단계로 넘기고 있었습니다. 실제 실패는 충돌보다 50줄 앞에서 일어났지만, 그 누구도 알려주지 않았습니다.

이 문제는 Python 개발에서 아주 흔합니다. 버그는 조용히 전파됩니다. None 값이 세 번의 함수 호출을 지나 AttributeError를 일으킬 때쯤이면, 이미 원래 문제의 맥락은 사라져 있습니다. 음수 배열 인덱스는 원치 않는 요소를 가리키고, 다섯 개 키가 있어야 하는 딕셔너리가 네 개만 가진 채 미묘한 로직 오류를 만들며, 이 문제는 운영 환경에서야 비로소 드러납니다. 에러가 눈에 보이는 시점에는, 실제로 어디서 잘못됐는지에 대한 맥락을 전혀 잃어버린 뒤입니다.

Python의 assert 문은 실패 지점에서 바로 버그를 잡아내고, 무엇이 잘못되었는지 명확한 메시지를 제공함으로써 이를 해결합니다. 잘못된 데이터가 언젠가 눈에 띄는 충돌을 일으키길 바라기보다, 당신의 가정을 명시적으로 선언하면 Python이 즉시 검증해 줍니다.

Assert 문이란 무엇인가?

assert 문은 조건을 검사합니다. 조건이 True이면 아무 일도 일어나지 않습니다. 조건이 False이면 Python은 즉시 AssertionError를 발생시킵니다.

assert 2 + 2 == 4      # Passes silently
assert 2 + 2 == 5      # Raises AssertionError

기본 문법은 다음과 같습니다:

assert condition
assert condition, "Error message explaining what went wrong"

내부적으로 Python은 assertif 문으로 변환합니다. 정확히 같은 의미는 다음과 같습니다:

# assert condition, message
# is equivalent to:
if __debug__:
    if not condition:
        raise AssertionError(message)

__debug__ 변수는 기본적으로 True입니다. Python을 -O(optimize) 플래그와 함께 실행할 때만 False가 됩니다. 즉, assert는 프로덕션에서 완전히 비활성화될 수 있습니다 -- 이 점은 나중에 중요한 의미를 가지므로 뒤에서 다루겠습니다.

assertion이 실패하면 다음과 같이 됩니다:

x = -1
assert x >= 0, f"Expected non-negative value, got {x}"

출력:

Traceback (most recent call last):
  File "example.py", line 2, in <module>
    assert x >= 0, f"Expected non-negative value, got {x}"
AssertionError: Expected non-negative value, got -1

트레이스백은 가정이 위반된 정확한 줄을 가리키고, 메시지는 무엇이 잘못되었는지 정확히 알려줍니다. 음수 값이 검증 없이 전파되어 20줄 뒤에 IndexError가 나는 상황과 비교해 보세요.

기본 Assert 사용법

단순한 Assertion

가장 단순한 assert는 하나의 조건을 검사합니다:

# Check that a variable is not None
config = load_config()
assert config is not None
 
# Check that a list is not empty
items = get_items()
assert len(items) > 0
 
# Check a mathematical property
result = calculate_discount(price=100, percent=20)
assert result == 80

사용자 지정 메시지가 있는 Assertion

항상 메시지를 포함하세요. 메시지가 없으면 실패한 assertion이 거의 맥락을 주지 못합니다:

# Bad: no message
assert len(users) > 0
 
# Good: descriptive message
assert len(users) > 0, "User list is empty -- database query may have failed"
 
# Good: include the actual value
assert temperature >= -273.15, f"Temperature {temperature}C is below absolute zero"

메시지는 assert의 두 번째 인자로, 쉼표 뒤에 작성합니다. f-string처럼 런타임 값을 포함하는 문자열 표현식이면 무엇이든 사용할 수 있습니다:

def process_batch(items, batch_size):
    assert batch_size > 0, f"batch_size must be positive, got {batch_size}"
    assert len(items) >= batch_size, (
        f"Not enough items: need {batch_size}, have {len(items)}"
    )
    # Process the batch...

괄호를 쓸 때의 함정 -- 흔한 실수

많은 Python 개발자가 빠지는 미묘한 버그가 있습니다:

# WARNING: This assertion NEVER fails!
assert(condition, "error message")

이 코드는 (condition, "error message")라는 튜플을 만듭니다. 비어 있지 않은 튜플은 항상 truthy이므로 assertion은 항상 통과합니다. Python은 심지어 경고까지 띄웁니다:

SyntaxWarning: assertion is always true, perhaps remove parentheses?

올바른 형태는 다음과 같습니다:

# Correct: no parentheses
assert condition, "error message"
 
# Also correct: parentheses only around the condition
assert (condition), "error message"
 
# Also correct: multi-line with implicit line continuation
assert (
    very_long_condition_that_needs_wrapping
), "error message"

복잡한 조건에서의 Assert

여러 조건 결합하기

and, or, not을 사용해 조건을 조합할 수 있습니다:

def create_user(name, age, email):
    assert name and isinstance(name, str), f"Invalid name: {name!r}"
    assert 0 < age < 150, f"Invalid age: {age}"
    assert "@" in email and "." in email, f"Invalid email format: {email}"
 
    # Proceed with user creation...

isinstance를 활용한 타입 검사

개발 중 데이터 타입을 검증할 때는 isinstance assert를 사용하세요:

def calculate_mean(values):
    assert isinstance(values, (list, tuple)), (
        f"Expected list or tuple, got {type(values).__name__}"
    )
    assert all(isinstance(v, (int, float)) for v in values), (
        "All values must be numeric"
    )
    assert len(values) > 0, "Cannot calculate mean of empty sequence"
 
    return sum(values) / len(values)

프로덕션에서 타입 검사가 필요하다면, Python type hints와 mypy 같은 정적 타입 검사기를 고려하세요. assertion은 개발 중 버그를 잡기 위한 것이지, 런타임 타입 강제를 위한 도구가 아닙니다.

컨테이너와 컬렉션 검사

# Check dictionary has required keys
required_keys = {"name", "email", "role"}
assert required_keys.issubset(user_data.keys()), (
    f"Missing keys: {required_keys - user_data.keys()}"
)
 
# Check list contains no duplicates
ids = [item.id for item in items]
assert len(ids) == len(set(ids)), (
    f"Duplicate IDs found: {[x for x in ids if ids.count(x) > 1]}"
)
 
# Check that all elements satisfy a condition
scores = [85, 92, 78, 95, 88]
assert all(0 <= s <= 100 for s in scores), (
    f"Scores out of range: {[s for s in scores if not 0 <= s <= 100]}"
)

Assert를 활용한 일반적인 디버깅 패턴

함수의 전제 조건

전제 조건(precondition)은 함수가 어떤 작업을 하기 전에 유효한 입력을 받았는지 확인합니다. 함수의 맨 위에 배치하세요:

def transfer_money(from_account, to_account, amount):
    # Preconditions
    assert from_account != to_account, "Cannot transfer to the same account"
    assert amount > 0, f"Transfer amount must be positive, got {amount}"
    assert from_account.balance >= amount, (
        f"Insufficient funds: balance={from_account.balance}, transfer={amount}"
    )
 
    from_account.balance -= amount
    to_account.balance += amount

함수의 사후 조건

사후 조건(postcondition)은 함수가 반환하기 전에 올바른 결과를 만들었는지 확인합니다. return 직전에 배치하세요:

def sort_descending(items):
    result = sorted(items, reverse=True)
 
    # Postconditions
    assert len(result) == len(items), "Sort changed the number of elements"
    assert all(result[i] >= result[i+1] for i in range(len(result)-1)), (
        "Result is not sorted in descending order"
    )
 
    return result

루프 불변식

루프 불변식(loop invariant)은 루프의 각 반복에서 조건이 항상 참인지 확인합니다. 이는 off-by-one 오류, 무한 루프, 로직 버그를 잡아냅니다:

def binary_search(sorted_list, target):
    low = 0
    high = len(sorted_list) - 1
 
    while low <= high:
        # Loop invariant: target must be in sorted_list[low:high+1] if it exists
        assert low >= 0 and high < len(sorted_list), (
            f"Bounds out of range: low={low}, high={high}, len={len(sorted_list)}"
        )
 
        mid = (low + high) // 2
        if sorted_list[mid] == target:
            return mid
        elif sorted_list[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
 
    return -1

클래스 불변식

클래스 불변식(class invariant)은 객체의 내부 상태가 각 작업 이후에도 일관성을 유지하는지 검증합니다:

class BoundedQueue:
    """A queue with a maximum capacity."""
 
    def __init__(self, capacity):
        assert capacity > 0, f"Capacity must be positive, got {capacity}"
        self._items = []
        self._capacity = capacity
        self._check_invariant()
 
    def _check_invariant(self):
        assert 0 <= len(self._items) <= self._capacity, (
            f"Queue size {len(self._items)} violates capacity {self._capacity}"
        )
 
    def enqueue(self, item):
        assert len(self._items) < self._capacity, "Queue is full"
        self._items.append(item)
        self._check_invariant()
 
    def dequeue(self):
        assert len(self._items) > 0, "Queue is empty"
        item = self._items.pop(0)
        self._check_invariant()
        return item
 
    def __len__(self):
        return len(self._items)

Assert vs Raise: 언제 무엇을 사용해야 할까

이것은 Python 에러 처리에서 가장 중요한 구분 중 하나입니다. assertraise는 근본적으로 다른 목적을 가집니다.

Featureassertraise
PurposeCatch programmer errors (bugs)Handle runtime conditions (expected failures)
Can be disabledYes, with -O flagNo, always active
Use for input validationNeverYes
Use for external dataNeverYes
Typical exceptionAssertionErrorValueError, TypeError, RuntimeError, etc.
When it firesSomething is wrong with the codeSomething is wrong with the input/environment
AudienceThe developerThe user or calling code
Presence in productionShould not be relied uponRequired

assert를 사용할 곳: 내부 불변식과 개발자 가정

def _calculate_tax(income, brackets):
    # Developer assumption: brackets are sorted
    assert all(
        brackets[i][0] <= brackets[i+1][0]
        for i in range(len(brackets) - 1)
    ), "Tax brackets must be sorted by threshold"
 
    # This is a bug in the code if brackets aren't sorted,
    # not a user input problem
    ...

raise를 사용할 곳: 입력 검증과 예상 가능한 오류 조건

def create_account(username, password):
    if not username or len(username) < 3:
        raise ValueError("Username must be at least 3 characters")
    if len(password) < 8:
        raise ValueError("Password must be at least 8 characters")
 
    # These are user input problems, not programmer bugs.
    # They must ALWAYS be checked, even in production.
    ...

중요한 차이점은 이렇습니다: 누군가 python -O your_script.py를 실행하면 모든 assert 문이 완전히 제거됩니다. 입력 검증에 assert를 사용하면, 최적화 모드에서 검증 자체가 사라집니다. 이는 이론적인 위험이 아닙니다 -- 많은 배포 도구와 프로덕션 환경이 -O 플래그를 사용합니다. 예외 처리 패턴에 대해 더 깊게 보려면 Python try/except guide를 참고하세요.

경험 법칙

스스로에게 이렇게 물어보세요: "이 검사가 완전히 제거된다면, 사용자가 보안 문제나 데이터 손상을 일으킬 수 있는가?" 그렇다면 raise를 사용하세요. 이 검사가 코드 자체의 개발자 실수만 잡아낸다면 assert를 사용하세요.

테스트에서의 Assert

Assertion은 Python 테스트의 핵심입니다. unittestpytest 모두 기대 동작을 검증하기 위해 assertion을 사용합니다.

pytest Assertions

pytest는 특수한 assertion 메서드 대신 일반 assert 문을 사용합니다. 이것은 가장 큰 장점 중 하나입니다 -- 메서드 이름을 외우는 대신 자연스러운 Python 코드를 작성할 수 있습니다:

# test_math.py
def test_addition():
    assert 2 + 2 == 4
 
def test_string_methods():
    greeting = "hello world"
    assert greeting.upper() == "HELLO WORLD"
    assert greeting.split() == ["hello", "world"]
 
def test_list_operations():
    items = [1, 2, 3]
    items.append(4)
    assert len(items) == 4
    assert items[-1] == 4

pytest Assert Rewriting

pytest를 특별하게 만드는 것은 assert rewriting입니다. 일반 assert가 실패하면 Python은 단지 AssertionError만 보여줍니다. pytest는 import 시점에 assert 문을 다시 작성하여 풍부한 실패 메시지를 제공합니다:

def test_comparison():
    result = {"name": "Alice", "age": 30}
    expected = {"name": "Alice", "age": 31}
    assert result == expected

pytest 출력:

FAILED test_example.py::test_comparison - AssertionError: assert {'age': 30, 'name': 'Alice'} == {'age': 31, 'name': 'Alice'}
  Differing items:
  {'age': 30} != {'age': 31}

pytest의 rewriting이 없으면, 세부 정보 없이 그저 AssertionError만 보게 됩니다. 이 마법은 pytest가 import hook을 사용해 assert 문을 더 자세한 검사로 변환하고, 중간 값을 캡처하기 때문에 가능합니다.

흔한 pytest Assertion 패턴

# Check that an exception is raised
import pytest
 
def test_division_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0
 
def test_invalid_input():
    with pytest.raises(ValueError, match="must be positive"):
        create_user(age=-5)
 
# Check approximate equality (for floats)
def test_float_calculation():
    result = 0.1 + 0.2
    assert result == pytest.approx(0.3)
 
# Check that a value is in a collection
def test_membership():
    valid_statuses = {"active", "inactive", "pending"}
    user_status = get_user_status(user_id=42)
    assert user_status in valid_statuses
 
# Check with custom message
def test_data_integrity():
    records = load_records()
    assert len(records) > 0, "No records loaded -- check database connection"

unittest에서의 Assertions

unittest 모듈은 plain assert 대신 메서드 기반 assertion을 제공합니다. pytest의 rewriting 없이도 더 좋은 에러 메시지를 제공합니다:

import unittest
 
class TestStringMethods(unittest.TestCase):
    def test_upper(self):
        self.assertEqual("hello".upper(), "HELLO")
 
    def test_contains(self):
        self.assertIn("world", "hello world")
 
    def test_raises(self):
        with self.assertRaises(TypeError):
            "hello" + 5

둘 다 유효합니다. pytest의 plain assert는 더 읽기 쉽고 Pythonic합니다. unittest의 메서드 기반 assertion은 import-time rewriting 없이도 상세한 메시지를 제공합니다.

인터랙티브하게 Testing Assertions 하기

개발 및 디버깅 중 테스트 assertion을 인터랙티브하게 다룰 때는 RunCell (opens in a new tab) 같은 도구를 사용해 Jupyter notebook에서 개별 test cell을 즉시 실행할 수 있습니다. 복잡한 assertion 조건을 단계적으로 구성할 때 특히 유용합니다 -- 전체 테스트 스위트로 합치기 전에 각 assertion을 개별적으로 확인할 수 있습니다.

Assert를 사용하면 안 되는 경우

이 섹션은 매우 중요합니다. assert를 잘못 사용하면 운영 환경에서만 나타나는 미묘하고 위험한 버그가 생깁니다.

입력 검증에 Assert를 절대 사용하지 마세요

# WRONG: This check disappears with python -O
def withdraw(amount):
    assert amount > 0, "Amount must be positive"
    self.balance -= amount
 
# RIGHT: This check always runs
def withdraw(amount):
    if amount <= 0:
        raise ValueError("Amount must be positive")
    self.balance -= amount

외부 소스의 데이터에 Assert를 절대 사용하지 마세요

사용자, 파일, 네트워크, 데이터베이스, API의 데이터는 언제든 잘못될 수 있습니다. 이런 검사는 항상 실행되어야 합니다:

# WRONG: Network data validation with assert
def handle_api_response(response):
    assert response.status_code == 200
    data = response.json()
    assert "results" in data
 
# RIGHT: Proper error handling for external data
def handle_api_response(response):
    if response.status_code != 200:
        raise RuntimeError(f"API returned status {response.status_code}")
    data = response.json()
    if "results" not in data:
        raise ValueError("API response missing 'results' field")

보안 검사에 Assert를 절대 사용하지 마세요

# CATASTROPHICALLY WRONG: Security check with assert
def delete_user(requesting_user, target_user_id):
    assert requesting_user.is_admin, "Only admins can delete users"
    database.delete(target_user_id)
 
# RIGHT: Security check that cannot be disabled
def delete_user(requesting_user, target_user_id):
    if not requesting_user.is_admin:
        raise PermissionError("Only admins can delete users")
    database.delete(target_user_id)

python -O가 적용되면, assert 버전은 어떤 사용자든 다른 사용자를 삭제할 수 있게 만듭니다. 이는 실제 보안 취약점입니다.

부작용이 있는 Assert는 절대 사용하지 마세요

assert가 비활성화될 수 있으므로, assert 안의 표현식은 절대 부작용을 가져서는 안 됩니다:

# WRONG: The pop() is a side effect that disappears with -O
assert items.pop() == expected_value
 
# RIGHT: Separate the side effect from the assertion
value = items.pop()
assert value == expected_value

-O 플래그: Assert가 사라지는 방식

Python에는 assertion에 영향을 주는 두 가지 최적화 수준이 있습니다:

python script.py        # Normal: __debug__ is True, assertions active
python -O script.py     # Optimize: __debug__ is False, assertions removed
python -OO script.py    # Extra optimize: assertions removed + docstrings removed

Python이 -O로 실행되면, 인터프리터는 __debug__False로 설정하고 모든 assert 문을 바이트코드에서 완전히 제거합니다. 단순히 건너뛰는 것이 아니라, 아예 존재하지 않게 됩니다. 조건은 평가되지 않고, 에러 메시지도 생성되지 않습니다.

이를 직접 확인할 수 있습니다:

# check_debug.py
print(f"__debug__ = {__debug__}")
 
if __debug__:
    print("Assertions are ACTIVE")
else:
    print("Assertions are DISABLED")
 
assert False, "This should raise an error"
$ python check_debug.py
__debug__ = True
Assertions are ACTIVE
Traceback (most recent call last):
  File "check_debug.py", line 8
AssertionError: This should raise an error
 
$ python -O check_debug.py
__debug__ = False
Assertions are DISABLED
# No error! The assert was completely removed.

실제로 -O가 사용되는 곳

  • Docker images: 많은 프로덕션 Dockerfile이 PYTHONOPTIMIZE=1 또는 python -O를 사용합니다
  • Deployment tools: 일부 WSGI 서버는 Python을 최적화 모드로 실행합니다
  • Performance-sensitive applications: assertion 제거는 타이트한 루프의 속도를 높일 수 있습니다
  • Library code: 라이브러리는 사용자가 최적화 수준을 제어할 수 있으므로 assertion이 활성화되어 있다고 가정하면 안 됩니다

코드에 대한 시사점

assertion은 마치 건축 중의 비계와 같습니다. 구조를 세우는 동안은 지지하지만, 건물이 완성되면 제거됩니다. 따라서 코드가 assertion이 있든 없든 항상 올바르게 동작해야 합니다.

# This code works correctly with or without assertions:
def safe_divide(a, b):
    assert isinstance(a, (int, float)), f"Expected number, got {type(a)}"
    assert isinstance(b, (int, float)), f"Expected number, got {type(b)}"
 
    if b == 0:
        raise ValueError("Division by zero")
    return a / b

assertion은 개발 중 버그를 잡아줍니다. raise는 프로덕션에서 예상 가능한 오류 조건을 처리합니다. 두 계층은 서로 다른 목적을 가집니다.

사용자 정의 Assertion 헬퍼

같은 assertion 패턴을 반복해서 쓰게 된다면, 재사용 가능한 헬퍼 함수로 추출하세요.

단순한 Assertion 함수

def assert_positive(value, name="value"):
    """Assert that a value is a positive number."""
    assert isinstance(value, (int, float)), (
        f"{name} must be a number, got {type(value).__name__}"
    )
    assert value > 0, f"{name} must be positive, got {value}"
 
 
def assert_valid_probability(p, name="probability"):
    """Assert that a value is a valid probability (0 to 1)."""
    assert isinstance(p, (int, float)), (
        f"{name} must be a number, got {type(p).__name__}"
    )
    assert 0 <= p <= 1, f"{name} must be between 0 and 1, got {p}"
 
 
def assert_same_length(*sequences, names=None):
    """Assert that all sequences have the same length."""
    lengths = [len(s) for s in sequences]
    if names:
        details = ", ".join(f"{n}={l}" for n, l in zip(names, lengths))
    else:
        details = ", ".join(str(l) for l in lengths)
    assert len(set(lengths)) == 1, (
        f"Length mismatch: {details}"
    )
 
 
# Usage
def calculate_weighted_average(values, weights):
    assert_same_length(values, weights, names=["values", "weights"])
    assert_valid_probability(sum(weights) / len(weights), "average weight")
 
    return sum(v * w for v, w in zip(values, weights)) / sum(weights)

데코레이터 기반 Assertions

decorators를 사용하면 함수 본문을 어지럽히지 않고 pre/post-condition 검사를 추가할 수 있습니다:

import functools
 
def preconditions(**checks):
    """Decorator that asserts preconditions on function arguments."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            import inspect
            sig = inspect.signature(func)
            bound = sig.bind(*args, **kwargs)
            bound.apply_defaults()
 
            for param_name, check_func in checks.items():
                value = bound.arguments[param_name]
                assert check_func(value), (
                    f"Precondition failed for '{param_name}': "
                    f"got {value!r}"
                )
            return func(*args, **kwargs)
        return wrapper
    return decorator
 
 
@preconditions(
    x=lambda v: isinstance(v, (int, float)) and v >= 0,
    n=lambda v: isinstance(v, int) and v > 0
)
def nth_root(x, n):
    """Calculate the nth root of x."""
    return x ** (1 / n)
 
 
# This passes
print(nth_root(27, 3))  # 3.0
 
# This fails with a clear message
print(nth_root(-1, 2))  # AssertionError: Precondition failed for 'x': got -1

Assertion 그룹을 위한 Context Manager

여러 관련 조건을 한 번에 검사하고, 실패한 항목들을 모두 보고해야 할 때:

class AssertionGroup:
    """Collect multiple assertion failures and report them together."""
 
    def __init__(self, description=""):
        self.description = description
        self.failures = []
 
    def check(self, condition, message):
        if not condition:
            self.failures.append(message)
 
    def verify(self):
        if self.failures:
            header = f"{self.description}: " if self.description else ""
            details = "\n  - ".join(self.failures)
            assert False, f"{header}{len(self.failures)} checks failed:\n  - {details}"
 
 
# Usage
def validate_user_record(record):
    checks = AssertionGroup("User record validation")
    checks.check("name" in record, "Missing 'name' field")
    checks.check("email" in record, "Missing 'email' field")
    checks.check(
        record.get("age", 0) > 0,
        f"Invalid age: {record.get('age')}"
    )
    checks.check(
        "@" in record.get("email", ""),
        f"Invalid email: {record.get('email')}"
    )
    checks.verify()  # Raises with all failures at once

AssertionError를 try/except로 처리하기

AssertionError도 다른 예외처럼 잡을 수 있지만, 애플리케이션 코드에서는 거의 권장되지 않습니다:

try:
    assert len(data) > 0, "Data is empty"
    process(data)
except AssertionError as e:
    print(f"Assertion failed: {e}")
    # Handle the failure...

AssertionError를 잡는 것이 의미 있는 경우

몇 가지 정당한 사용 사례가 있습니다:

1. 테스트 프레임워크: pytest와 unittest는 테스트 실패를 보고하기 위해 AssertionError를 잡습니다.

2. 장시간 실행 프로세스에서 assertion 실패를 로깅할 때:

import logging
 
logger = logging.getLogger(__name__)
 
def process_records(records):
    failed = []
    for record in records:
        try:
            assert_valid_record(record)
            process(record)
        except AssertionError as e:
            logger.error(f"Skipping invalid record: {e}")
            failed.append(record)
 
    if failed:
        logger.warning(f"{len(failed)} records failed validation")
    return failed

프로덕션 logging 패턴에서는, 전체 프로세스가 중단되지 않으면서도 실패가 보이도록 AssertionError를 적절한 예외 처리와 함께 사용하는 것이 좋습니다.

3. 중요하지 않은 경로에서의 우아한 축소 운영:

def generate_report(data):
    report = {"data": data, "charts": []}
 
    try:
        assert len(data) >= 10, "Not enough data for chart"
        chart = create_chart(data)
        report["charts"].append(chart)
    except AssertionError:
        report["charts_note"] = "Insufficient data for visualization"
 
    return report

AssertionError를 잡으면 안 되는 경우

버그를 조용히 묻어버리기 위해 AssertionError를 잡지 마세요. assertion의 목적은 버그를 크고 분명하게 드러내는 것입니다:

# WRONG: Silencing assertions defeats their purpose
try:
    assert user.is_valid()
except AssertionError:
    pass  # Who cares?

실제 사례

데이터 파이프라인 검증

Assertion은 데이터 변환이 특정 속성을 보존해야 하는 데이터 처리 파이프라인에서 매우 유용합니다:

import pandas as pd
 
def clean_sales_data(df):
    """Clean and validate sales data."""
    assert isinstance(df, pd.DataFrame), f"Expected DataFrame, got {type(df)}"
    assert len(df) > 0, "DataFrame is empty"
 
    initial_rows = len(df)
 
    # Remove duplicates
    df = df.drop_duplicates(subset=["order_id"])
    assert len(df) > 0, "All rows were duplicates"
 
    # Validate required columns
    required = {"order_id", "product", "quantity", "price"}
    assert required.issubset(df.columns), (
        f"Missing columns: {required - set(df.columns)}"
    )
 
    # Clean numeric columns
    df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
    df["price"] = pd.to_numeric(df["price"], errors="coerce")
 
    # Drop rows with invalid numbers
    df = df.dropna(subset=["quantity", "price"])
 
    # Postcondition: all prices and quantities are positive
    assert (df["price"] > 0).all(), (
        f"Found {(df['price'] <= 0).sum()} non-positive prices"
    )
    assert (df["quantity"] > 0).all(), (
        f"Found {(df['quantity'] <= 0).sum()} non-positive quantities"
    )
 
    # Calculate total
    df["total"] = df["quantity"] * df["price"]
    assert (df["total"] > 0).all(), "Totals must be positive"
 
    print(f"Cleaned {initial_rows} -> {len(df)} rows")
    return df

Data Science 워크플로우에서 DataFrame을 다룰 때, PyGWalker (opens in a new tab)는 검증된 DataFrame을 인터랙티브 시각화로 바꿔 추가 탐색을 할 수 있게 해줍니다 -- 파이프라인 assertion이 데이터가 깨끗하고 분석 준비가 되었음을 확인한 뒤 자연스럽게 이어지는 다음 단계입니다.

API 응답 검사

import requests
 
def fetch_user_profile(user_id):
    """Fetch user profile from API with defensive assertions."""
    response = requests.get(f"https://api.example.com/users/{user_id}")
 
    # Use raise for external data validation (not assert!)
    if response.status_code != 200:
        raise RuntimeError(f"API error: {response.status_code}")
 
    data = response.json()
    if "user" not in data:
        raise ValueError("API response missing 'user' field")
 
    user = data["user"]
 
    # Use assert for internal invariants -- things that should
    # always be true if the API contract is correct
    assert "id" in user, "API contract violation: user missing 'id'"
    assert user["id"] == user_id, (
        f"API returned wrong user: requested {user_id}, got {user['id']}"
    )
 
    return user

차이를 보세요: raise는 예상 가능한 오류 조건(네트워크 문제, 잘못된 상태 코드)을 처리합니다. assert는 API의 버그 또는 API에 대한 코드의 가정이 잘못되었음을 나타내는 문제를 잡아냅니다.

머신 러닝 모델 안전성 검사

import numpy as np
 
def train_model(X_train, y_train, X_test, y_test):
    """Train a model with sanity checks at each stage."""
 
    # Data shape assertions
    assert X_train.ndim == 2, f"X_train must be 2D, got {X_train.ndim}D"
    assert y_train.ndim == 1, f"y_train must be 1D, got {y_train.ndim}D"
    assert X_train.shape[0] == y_train.shape[0], (
        f"Sample count mismatch: X={X_train.shape[0]}, y={y_train.shape[0]}"
    )
    assert X_train.shape[1] == X_test.shape[1], (
        f"Feature count mismatch: train={X_train.shape[1]}, test={X_test.shape[1]}"
    )
 
    # No NaN or Inf in data
    assert not np.isnan(X_train).any(), "X_train contains NaN values"
    assert not np.isinf(X_train).any(), "X_train contains Inf values"
 
    # Labels are valid
    unique_labels = np.unique(y_train)
    assert len(unique_labels) >= 2, (
        f"Need at least 2 classes, got {len(unique_labels)}"
    )
 
    # Train the model
    model = fit(X_train, y_train)
 
    # Predictions sanity check
    predictions = model.predict(X_test)
    assert predictions.shape == y_test.shape, (
        f"Prediction shape {predictions.shape} != target shape {y_test.shape}"
    )
    assert set(predictions).issubset(set(unique_labels)), (
        f"Model predicted unknown labels: {set(predictions) - set(unique_labels)}"
    )
 
    # Accuracy sanity check (should be better than random)
    accuracy = np.mean(predictions == y_test)
    random_baseline = 1 / len(unique_labels)
    assert accuracy > random_baseline * 0.8, (
        f"Accuracy {accuracy:.2%} is worse than random ({random_baseline:.2%})"
    )
 
    return model

상태 머신 전이

class OrderStateMachine:
    VALID_TRANSITIONS = {
        "created": {"confirmed", "cancelled"},
        "confirmed": {"shipped", "cancelled"},
        "shipped": {"delivered", "returned"},
        "delivered": {"returned"},
        "cancelled": set(),
        "returned": set(),
    }
 
    def __init__(self):
        self.state = "created"
        self.history = ["created"]
 
    def transition(self, new_state):
        assert new_state in self.VALID_TRANSITIONS.get(self.state, set()), (
            f"Invalid transition: {self.state} -> {new_state}. "
            f"Valid transitions: {self.VALID_TRANSITIONS[self.state]}"
        )
 
        self.state = new_state
        self.history.append(new_state)
 
        # Invariant: history should always start with "created"
        assert self.history[0] == "created", "History corrupted"
        # Invariant: current state should match last history entry
        assert self.state == self.history[-1], "State/history mismatch"

성능 고려사항

Assertion은 얼마나 비용이 들까?

Assertion에는 작지만 측정 가능한 비용이 있습니다. 조건 표현식은 assert가 실행될 때마다 평가됩니다. assert x > 0 같은 간단한 검사는 비용이 거의 없습니다. 하지만 비용이 큰 검사는 누적될 수 있습니다:

import time
 
data = list(range(1_000_000))
 
# Fast assertion: O(1)
start = time.perf_counter()
for _ in range(10_000):
    assert len(data) > 0
fast_time = time.perf_counter() - start
print(f"Simple assertion: {fast_time:.4f}s")
 
# Slow assertion: O(n) -- checks every element
start = time.perf_counter()
for _ in range(100):
    assert all(isinstance(x, int) for x in data)
slow_time = time.perf_counter() - start
print(f"Expensive assertion: {slow_time:.4f}s")

비싼 Assertion을 다루는 전략

assertion이 너무 느려서 타이트한 루프에 넣기 어렵다면, 몇 가지 방법이 있습니다:

1. 전체 데이터셋 대신 샘플만 검사하기:

import random
 
def process_large_dataset(records):
    # Check a random sample instead of all records
    sample = random.sample(records, min(100, len(records)))
    assert all(is_valid(r) for r in sample), "Invalid records found in sample"
    # Process all records...

2. __debug__ 플래그로 비싼 검사를 조건부 실행하기:

def matrix_multiply(a, b):
    if __debug__:
        # This entire block is removed with python -O
        assert a.shape[1] == b.shape[0], (
            f"Incompatible shapes: {a.shape} x {b.shape}"
        )
        # Expensive but helpful during development
        assert not np.isnan(a).any(), "Matrix a contains NaN"
        assert not np.isnan(b).any(), "Matrix b contains NaN"
 
    return a @ b

3. inner loop 안이 아니라 경계에서만 assert하기:

def process_batch(items):
    # Assert once at the boundary
    assert all(item.is_valid() for item in items), "Invalid items in batch"
 
    # Inner loop without assertions for performance
    results = []
    for item in items:
        # No assertions here -- we validated above
        result = transform(item)
        results.append(result)
 
    # Assert once at the output boundary
    assert len(results) == len(items), "Result count mismatch"
    return results

모범 사례 요약

효과적인 assertion 사용을 위한 핵심 원칙은 다음과 같습니다:

1. 항상 메시지를 포함하세요. assert x > 0은 실패해도 아무 정보도 주지 않습니다. assert x > 0, f"Expected positive value, got {x}"는 필요한 정보를 모두 줍니다.

2. 입력 검증에 assert를 사용하지 마세요. 사용자 입력, 파일 내용, API 응답, 데이터베이스 조회 결과는 언제든 잘못될 수 있습니다. if/raise로 검증하세요.

3. 내부 불변식에는 assert를 사용하세요. 코드가 올바르면 항상 참이어야 하는 것들: 함수 전제 조건, 사후 조건, 루프 불변식, 클래스 불변식.

4. assert 안에 부작용을 넣지 마세요. assert items.pop() == expected는 assertion이 활성화될 때만 리스트에서 항목을 제거합니다.

5. 개발 중에는 assert를 적극적으로 사용하세요. -O로 비활성화하면 비용이 거의 없고, 활성화되어 있을 때는 디버깅 시간을 크게 줄여 줍니다.

6. assertion 메시지는 실행 가능한 정보가 되게 하세요. 실제 값, 기대값, 그리고 무엇이 잘못되었는지 이해할 수 있는 충분한 맥락을 포함하세요.

7. assertion도 테스트하세요. 의도한 버그를 assertion이 실제로 잡아내는지 검증하는 테스트를 작성하세요.

import pytest
 
def test_transfer_rejects_negative_amount():
    with pytest.raises(AssertionError, match="positive"):
        transfer_money(account_a, account_b, amount=-100)

FAQ

결론

Python assert 문은 가벼우면서도 강력한 방어적 프로그래밍 도구입니다. 암묵적인 가정을 명시적이고 강제되는 검사로 바꿔, 잘못된 데이터가 코드 전체로 전파되기 전에 실패 지점에서 버그를 잡아냅니다. 올바르게 사용하면 assertion은 디버깅을 빠르게 만들고, 코드를 더 읽기 쉽게 하며, 불변식을 자기 문서화(self-documenting)하게 만듭니다.

핵심 규칙은 간단합니다: 내부 불변식과 개발자 가정에는 assert를 사용하고, 입력 검증과 예상 가능한 오류 조건에는 raise를 사용하세요. 항상 설명적인 메시지를 포함하고, assertion 표현식 안에 부작용을 넣지 마세요. 테스트에서는 pytest와 unittest 모두 기대 동작을 검증하기 위해 assertion에 크게 의존합니다.

데이터 과학 및 분석 워크플로우에서는 assertion이 PyGWalker (opens in a new tab) 같은 도구와 자연스럽게 맞물려 시각화 전에 DataFrame을 검증할 수 있고, RunCell (opens in a new tab) 같은 인터랙티브 환경에서는 Jupyter notebook에서 assertion으로 보호되는 데이터 파이프라인을 반복적으로 만들고 테스트할 수 있습니다.

이 패턴들을 익히면 디버깅 세션은 짧아지고, 코드는 더 견고해지며, 테스트는 더 표현력이 좋아질 것입니다.

관련 가이드

📚