Python의 Type Hints와 mypy로 검증하기

1. 개요

Python은 동적 타입 언어입니다. 즉, 미리 데이터 타입을 선언할 필요가 없습니다. 이는 유연하지만, 프로젝트가 커질수록 런타임 에러를 유발하기 쉽습니다. Type hints(PEP 484)는 런타임 동작을 변경하지 않으면서 데이터 타입에 주석을 달 수 있게 해주며, mypy는 이 주석을 읽고 코드를 작성할 때 타입 오류를 감지합니다.

이 글을 읽고 나면, 변수, 함수, 클래스에 대한 type hints를 작성하는 방법, Optional, Union, TypedDict, generic을 사용하는 방법, 그리고 mypy를 코드 검증 프로세스에 통합하는 방법을 알게 될 것입니다.

2. 기본 Type Hints

2.1. 변수와 함수의 타입 선언

def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

customer_name: str = "Nguyễn Văn A"
total: float = calculate_total(price=15.5, quantity=3)
print(total)
  • price: float, quantity: int는 입력 매개변수의 타입입니다.
  • -> float은 반환값의 타입입니다.
  • customer_name: str은 변수에 대한 주석이며, IDE 자동완성과 mypy 검증을 돕습니다.
46.5

주의: type hints는 런타임에 타입을 강제하지 않습니다. calculate_total("10", 2)를 전달하더라도 Python은 인터프리터가 주석을 무시하므로 정상적으로 실행됩니다. 오직 mypy 같은 도구만 오류를 보고합니다.

2.2. mypy 설치

pip install mypy

파일 검증:

mypy order_service.py

오류가 없으면 mypy는 다음을 출력합니다:

Success: no issues found in 1 source file

3. None이 될 수 있는 값을 위한 Optional과 Union

많은 함수들은 데이터를 찾지 못할 때 None을 반환합니다. Optional을 사용하여 이를 명시적으로 나타낼 수 있습니다.

from typing import Optional

class Customer:
    def __init__(self, customer_id: int, email: str) -> None:
        self.customer_id = customer_id
        self.email = email

def find_customer(customer_id: int) -> Optional[Customer]:
    database = {1: Customer(1, "a@example.com")}
    return database.get(customer_id)

customer = find_customer(2)
if customer is not None:
    print(customer.email)
  • Optional[Customer]는 Union[Customer, None]과 동일합니다.
  • 먼저 None을 확인하지 않고 customer.email을 호출하면, mypy는 Item "None" of "Optional[Customer]" has no attribute "email" 오류를 보고합니다.
from typing import Union

def format_id(value: Union[int, str]) -> str:
    return str(value).zfill(6)

Union[int, str]는 매개변수가 int 또는 str이 될 수 있음을 나타냅니다. Python 3.10부터는 Union[int, str] 대신 int | str으로, Optional[Customer] 대신 Customer | None으로 더 간결하게 작성할 수 있습니다.

4. Collection 타입: list, dict, tuple

from typing import Dict, List, Tuple

def summarize_orders(order_totals: List[float]) -> Tuple[float, int]:
    return sum(order_totals), len(order_totals)

def build_price_table(items: Dict[str, float]) -> Dict[str, float]:
    return {name: price * 1.1 for name, price in items.items()}
  • List[float]는 실수들의 리스트를 의미합니다.
  • Tuple[float, int]는 정확히 2개의 요소를 가진 tuple을 의미합니다: float 다음 int.
  • Dict[str, float]는 str 타입 키와 float 타입 값을 가진 dict입니다.

주의: Python 3.9부터는 typing에서 import하지 않고 직접 list[float], dict[str, float]을 사용할 수 있습니다. typing.List/typing.Dict는 주로 Python 3.8 이하와의 호환성을 위해 사용됩니다.

5. 구조화된 dict 데이터를 위한 TypedDict

JSON이나 고정 스키마를 가진 dict로 작업할 때, TypedDict는 mypy가 각 키를 검증하도록 돕습니다.

from typing import TypedDict

class OrderPayload(TypedDict):
    order_id: int
    customer_email: str
    total: float

def create_order(payload: OrderPayload) -> str:
    return f"Order #{payload['order_id']} - {payload['customer_email']} - {payload['total']}"

payload: OrderPayload = {
    "order_id": 101,
    "customer_email": "b@example.com",
    "total": 250.0,
}
print(create_order(payload))

키 total을 빠뜨리거나 "totl"로 잘못 입력하면, mypy는 다음과 같이 보고합니다:

error: Missing key "total" for TypedDict "OrderPayload"

이는 Dict[str, Any]를 사용하는 것보다 큰 이점입니다. 순수 Python dict를 런타임에 유지하면서도 데이터 구조를 제어할 수 있습니다.

6. TypeVar를 사용한 Generic

여러 데이터 타입에서 공통으로 사용할 함수나 클래스를 작성할 때, TypeVar를 사용하여 입력 타입과 출력 타입 간의 관계를 유지할 수 있습니다.

from typing import TypeVar, List

T = TypeVar("T")

def first_item(items: List[T]) -> T:
    return items[0]

first_name: str = first_item(["Hùng", "Lan"])
first_number: int = first_item([10, 20, 30])
  • TypeVar("T")는 타입 placeholder를 생성합니다.
  • first_item(["Hùng", "Lan"])을 호출할 때, mypy는 T = str을 추론하므로 반환값은 str입니다.
  • List[int]로 호출할 때 T = int입니다. 이렇게 mypy는 각 호출에 따른 정확한 반환 타입을 알 수 있으며, 단순히 Any로 처리하지 않습니다.

7. Interface 대신 Class와 Protocol

Python에는 Java 같은 interface가 없지만, Protocol(PEP 544)은 상속이 필요 없이 “올바른 메서드가 있는지 확인”하는 구조적 타이핑을 정의할 수 있게 해줍니다.

from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, amount: float) -> bool:
        ...

class VnPayGateway:
    def charge(self, amount: float) -> bool:
        print(f"Charging {amount} via VNPay")
        return True

def process_payment(gateway: PaymentGateway, amount: float) -> bool:
    return gateway.charge(amount)

process_payment(VnPayGateway(), 199000.0)

VnPayGateway는 PaymentGateway를 상속할 필요가 없습니다. charge(self, amount: float) -> bool 메서드만 있으면 mypy는 Protocol을 만족한다고 인정합니다. 이것이 매우 “Pythonic”한 방식으로, duck typing을 유지하면서도 정적 검증을 제공합니다.

8. 프로젝트의 mypy 설정

requirements-dev.txt에 의존성을 추가하세요:

mypy==1.11.2

프로젝트 루트 디렉터리에 mypy.ini 설정 파일을 만드세요:

[mypy]
python_version = 3.12
disallow_untyped_defs = True
warn_return_any = True
warn_unused_ignores = True
ignore_missing_imports = True
  • disallow_untyped_defs = True는 모든 함수가 type hints를 가져야 하므로 빠뜨리는 것을 방지합니다.
  • warn_return_any = True는 함수가 암시적으로 Any를 반환할 때 경고합니다.
  • ignore_missing_imports = True는 아직 타입 stub이 없는 타사 라이브러리를 사용할 때 유용합니다.

전체 프로젝트 검증을 실행하세요:

mypy src/

9. Type Hints가 있는 동작에 대한 테스트 작성

Type hints는 테스트를 대체하지 않지만, 입출력 데이터 타입에 대해 테스트를 더 명확하게 만듭니다. pytest를 사용합니다:

<!-- requirements-dev.txt 동등한 형식, 의존성 구조를 설명하기 위한 것입니다 -->
pytest==8.3.2
from order_service import calculate_total

def test_calculate_total_returns_correct_sum() -> None:
    result = calculate_total(price=20.0, quantity=5)

    assert result == 100.0

def test_calculate_total_with_zero_quantity() -> None:
    result = calculate_total(price=50.0, quantity=0)

    assert result == 0.0

테스트를 실행하세요:

pytest -v
test_order_service.py::test_calculate_total_returns_correct_sum PASSED
test_order_service.py::test_calculate_total_with_zero_quantity PASSED

CI에서 두 단계를 결합할 수 있습니다: 타입 오류를 잡기 위해 먼저 mypy를 실행한 다음, 비즈니스 로직을 검증하기 위해 pytest를 실행합니다. 두 도구는 서로 보완합니다. mypy는 “잘못된 데이터 타입” 오류를 잡고, pytest는 “잘못된 로직” 오류를 잡습니다.

10. 일반적인 오류 및 mypy가 필요 없는 경우

  • 로직을 변경할 때 type hint를 업데이트하지 않는 것: 예를 들어, 함수가 Optional[Customer]를 반환하는 것에서 항상 Customer를 반환하는 것으로 변경되었지만 annotation을 업데이트하지 않은 경우, 코드가 더 넓은 이전 타입과 일치하면 mypy는 오류를 보고하지 않으므로 annotation 정보가 더 이상 정확하지 않게 됩니다.
  • Any를 남용하는 것: def process(data: Any) -> Any를 선언하면 mypy는 검증 능력을 잃고 그 부분에서 정적 타이핑이 비활성화됩니다.
  • type stub이 없는 타사 라이브러리와의 주석 불일치: 많은 구식 패키지는 아직 type stub을 공개하지 않았으므로, mypy는 error: Cannot find implementation or library stub을 보고합니다. ignore_missing_imports를 사용하거나 사용 가능한 경우 types-<package>을 설치하여 처리할 수 있습니다.
  • mypy를 엄격하게 사용하면 안 되는 경우: 작은 스크립트, 일회용 스크립트(throwaway script), 또는 아이디어를 빠르게 프로토타입하는 경우, 완전한 type hints를 강제하면 상응하는 가치 없이 작성 속도를 늦출 수 있습니다.

주의: mypy는 소스 코드에 대한 정적 분석(static analysis)만 수행하며 프로그램을 실행하지 않습니다. 설정 파일에서 읽은 값이나 외부 API의 응답이 선언된 대로 올바른 타입인지 여부와 같이 런타임 데이터에 따라 달라지는 오류는 포착할 수 없습니다.

11. 결론

Type hints는 Python 코드를 읽기 쉽고 유지하기 쉽게 하며 데이터 타입 오류를 줄이는 데 도움이 되며, mypy는 이러한 주석을 단순한 문서가 아닌 실제 검증 도구로 변환합니다. public 함수와 중요한 모듈부터 점진적으로 적용을 시작한 다음, disallow_untyped_defs 설정을 사용하여 점차 전체 프로젝트로 확장해야 합니다. CI에서 mypy와 pytest 테스트를 결합하는 것이 Python의 유연성을 유지하면서도 정적 타입 언어에 가까운 안전성을 갖는 가장 현실적인 방법입니다.

pythontype-hintsmypystatic-typing