파이썬 타입 힌트 - typing mypy 와 런타임 미강제 (파이썬 중급 16단원)
이 단원에서 배우는 것
09단원부터 예제 코드에 def total(self) -> int:나 lines: list[OrderLine] 같은 표기를 계속 써 왔다. "편집기에 주는 힌트"라고만 하고 넘어갔던 그 표기를 이번 단원에서 제대로 다룬다. 중급 과정의 마지막 단원이고, 앞의 일곱 단원에서 만든 주문 로그 분석 도구에 타입을 입혀 마무리한다. 고급 과정에서는 for와 with가 어떤 약속 위에서 돌아가는지를 열어 보게 되는데, 그때 필요한 시그니처 읽는 눈이 여기서 만들어진다. 기준은 파이썬 3.11이며, 3.12에서 바뀐 문법도 그때그때 표시한다.
- 타입 힌트가 런타임에 아무것도 검사하지 않는다는 사실과, 그럼에도 쓰는 이유를 설명할 수 있다
list[str],X | None,TypedDict,Protocol을 상황에 맞게 고를 수 있다mypy를 붙여 실제로 버그를 잡고, 잡히지 않는 종류의 버그를 구분할 수 있다
왜 필요한가
10단원에서 만든 함수를 여섯 달 뒤에 다시 본다고 하자.
def run_batch(raws):
...
raws가 무엇인지 알아내려면 함수 본문을 읽고, 그것으로 부족하면 호출하는 곳을 찾아야 한다. 딕셔너리 리스트인가, 제너레이터인가, 파일 경로 문자열인가. 11단원에서 배운 대로 제너레이터라면 두 번 순회할 수 없는데, 시그니처만 봐서는 알 수 없다.
def run_batch(raws: Iterable[dict[str, Any]]) -> BatchResult:
...
한 줄로 세 가지가 정해진다. 순회 가능한 것을 받고, 인덱싱이나 len()은 쓰지 않겠다는 약속이고, 결과는 BatchResult다. 타입 힌트의 첫 번째 효용은 검사가 아니라 이 약속을 코드에 적는 것이다. 주석과 달리 도구가 읽을 수 있고, 편집기가 자동완성과 이름 바꾸기에 쓴다.
두 번째 효용은 정적 검사다. mypy가 실행 전에 타입이 안 맞는 자리를 찾아 준다. 다만 여기에 큰 오해가 하나 있어서 먼저 정리하고 시작한다.
런타임에는 아무 일도 일어나지 않는다
def add_line(qty: int) -> int:
return qty * 2
print(add_line("삼")) # 삼삼 <- 에러가 나지 않는다
print(add_line.__annotations__) # {'qty': <class 'int'>, 'return': <class 'int'>}
파이썬은 힌트를 __annotations__에 보관만 하고 검사하지 않는다. 자바나 타입스크립트를 하다 온 사람이 가장 자주 하는 착각이 이것이다. 외부에서 들어오는 데이터의 형식을 보장하려면 힌트가 아니라 검증 코드가 필요하다. 09단원에서 __init__에 검증을 넣은 것이 그 역할이고, 규모가 커지면 pydantic 같은 라이브러리를 쓴다. 힌트는 내 코드 안에서 앞뒤가 맞는지 보는 도구다.
문법과 예제
기본 표기
from collections.abc import Iterable, Sequence, Callable
# 3.9 부터 내장 타입에 바로 대괄호를 쓴다. typing.List 는 이제 필요 없다
skus: list[str] = ["BOOK-01", "PEN-07"]
price_of: dict[str, int] = {"BOOK-01": 12000}
pair: tuple[str, int] = ("BOOK-01", 12000)
row: tuple[str, ...] = ("a", "b", "c") # 길이가 정해지지 않은 튜플
# 3.10 부터 Union 대신 | 를 쓴다. Optional[str] 은 str | None 과 같다
def find_sku(order_id: str) -> str | None:
return None
# 함수를 받는 함수
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
Callable[[int, int], int]은 "정수 두 개를 받아 정수를 돌려주는 것"이다. 대괄호 안의 첫 리스트가 인자, 뒤가 반환형이다.
인자에는 넓게, 반환에는 좁게
실무에서 가장 값을 하는 규칙이다.
from collections.abc import Iterable
# 나쁨: 리스트만 받는다. 제너레이터나 튜플을 넘기면 mypy 가 거부한다
def total_amount(lines: list[OrderLine]) -> int:
return sum(line.amount for line in lines)
# 좋음: 순회만 하므로 순회 가능한 것이면 무엇이든 받는다
def total_amount(lines: Iterable[OrderLine]) -> int:
return sum(line.amount for line in lines)
11단원의 제너레이터 파이프라인을 그대로 넘기려면 Iterable이어야 한다. 인덱싱과 len()이 필요하면 Sequence, 정말 수정해야 하면 그때 list를 쓴다. 반대로 반환형은 구체적으로 적는다. Iterable[int]를 반환한다고 적으면 호출하는 쪽이 인덱싱을 못 하고, 실제로는 리스트인데도 list()로 한 번 더 감싸게 된다.
클래스에 붙이기
from dataclasses import dataclass, field
from datetime import datetime
from typing import Self
@dataclass(frozen=True, slots=True)
class OrderLine:
sku: str
qty: int
unit_price: int
@property
def amount(self) -> int:
return self.qty * self.unit_price
@dataclass
class Order:
order_id: str
ordered_at: datetime
lines: list[OrderLine] = field(default_factory=list)
@property
def total(self) -> int:
return sum(line.amount for line in self.lines)
@classmethod
def from_dict(cls, raw: dict) -> Self: # 파이썬 3.11+
lines = [OrderLine(i["sku"], i["qty"], i["unit_price"]) for i in raw["lines"]]
return cls(raw["id"], datetime.fromisoformat(raw["ordered_at"]), lines)
dataclass는 클래스 본문의 힌트를 실제로 읽어서 __init__을 만든다. 여기서만은 힌트가 런타임에 의미가 있다. Self는 3.11에 들어온 것으로, 09단원에서 -> "Order"라고 문자열로 적던 자리를 대체한다. 자식 클래스에서 from_dict를 부르면 그 자식 타입으로 좁혀지므로 문자열 표기보다 정확하다.
TypedDict: 외부에서 들어오는 딕셔너리의 모양
from typing import TypedDict, NotRequired
class RawLine(TypedDict):
sku: str
qty: int
unit_price: int
class RawOrder(TypedDict):
id: str
ordered_at: str
lines: list[RawLine]
memo: NotRequired[str] # 있을 수도 없을 수도 (3.11+)
def parse(raw: RawOrder) -> str:
return raw["id"]
raw: RawOrder = {"id": "ORD-1", "ordered_at": "2026-08-25T09:00:00+09:00", "lines": []}
print(parse(raw)) # ORD-1
JSON을 받아 그대로 다루는 경계 코드에 잘 맞는다. raw["oid"]처럼 오타를 내면 mypy가 잡는다. 다만 이것도 런타임 검증은 아니다. 실제 JSON에 qty가 문자열로 들어와도 아무 일도 일어나지 않는다. 경계에서 한 번 검증해 Order 객체로 바꾸고, 안쪽 코드는 그 객체만 다루는 구조가 안전하다.
Protocol: 상속 없이 모양만 맞추기
09단원에서 상속을 남용하지 말라고 했다. 그런데 "이 함수는 save 메서드가 있는 무언가를 받는다"를 타입으로 적으려면 공통 부모가 필요해 보인다. Protocol이 그 문제를 없앤다.
from typing import Protocol
class OrderRepository(Protocol):
def save(self, order: Order) -> None: ...
def find(self, order_id: str) -> Order | None: ...
class MemoryRepository: # 상속하지 않는다
def __init__(self) -> None:
self._data: dict[str, Order] = {}
def save(self, order: Order) -> None:
self._data[order.order_id] = order
def find(self, order_id: str) -> Order | None:
return self._data.get(order_id)
def store_all(repo: OrderRepository, orders: Iterable[Order]) -> int:
count = 0
for order in orders:
repo.save(order)
count += 1
return count
MemoryRepository는 OrderRepository를 상속하지 않았지만 메서드 모양이 맞으므로 store_all에 넘길 수 있고 mypy도 통과시킨다. 테스트용 가짜 저장소를 만들 때 특히 편하다. 파이썬이 원래 하던 방식(모양이 맞으면 쓴다)을 타입으로 적을 수 있게 한 것이다.
Literal 과 좁히기
from typing import Literal
Status = Literal["pending", "paid", "cancelled"]
def label(status: Status) -> str:
if status == "pending":
return "결제 대기"
elif status == "paid":
return "결제 완료"
return "취소됨"
print(label("paid")) # 결제 완료
# label("PAID")
# mypy: Argument 1 to "label" has incompatible type "Literal['PAID']";
# expected "Literal['pending', 'paid', 'cancelled']"
상태값 오타를 실행 전에 잡는다. None이 섞인 값은 if로 좁히면 mypy가 그 사실을 따라온다.
def describe(order: Order | None) -> str:
if order is None:
return "없음"
return f"{order.order_id} {order.total}원" # 이 아래에서는 Order 로 확정된다
mypy 붙이기
python -m pip install mypy
python -m mypy analyze_orders/
14단원에서 만든 pyproject.toml에 설정을 넣는다.
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_ignores = true
check_untyped_defs = true # 힌트 없는 함수 안도 검사한다
# 기존 코드에 도입할 때: 새 모듈부터 엄격하게
[[tool.mypy.overrides]]
module = "analyze_orders.parsing.*"
disallow_untyped_defs = true
이미 돌아가는 코드에 도입할 때 strict = true부터 켜면 오류가 수천 개 나와서 대개 포기하게 된다. check_untyped_defs 정도로 시작해 새로 쓰는 모듈만 엄격하게 하는 편이 실제로 굴러간다. 어쩔 수 없는 자리는 # type: ignore[에러코드]로 넘기되, 코드까지 적어야 나중에 warn_unused_ignores가 불필요해진 것을 알려 준다.
실무에서 자주 틀리는 것
1. 힌트가 검증이라고 믿는다
import json
def load_qty(payload: str) -> int:
data: dict[str, int] = json.loads(payload)
return data["qty"] * 2
print(load_qty('{"qty": "3"}')) # 33 <- int 라고 적었지만 문자열이다
json.loads의 반환은 Any다. Any가 들어오는 자리에서는 mypy도 검사를 포기한다. 그래서 가장 위험한 경계에서 정적 검사가 가장 약하다. 외부 입력은 힌트가 아니라 코드로 검증한다. int(data["qty"])처럼 변환하거나, pydantic으로 스키마 검증을 건다.
2. Any 를 한 번 쓰고 전염시킨다
from typing import Any
def parse(raw: Any) -> Any: # 여기서 검사가 끊긴다
return raw["lines"]
lines = parse(raw)
print(lines.tital()) # 오타인데 mypy 가 아무 말도 안 한다
Any가 반환되는 순간 그 값을 받은 변수와 그 뒤 코드 전체가 검사 대상에서 빠진다. 무엇이 올지 정말 모를 때는 Any 대신 object를 쓴다. object는 아무 속성도 못 부르게 막으므로, 쓰기 전에 isinstance로 좁히도록 강제한다. 그게 원래 의도한 안전성이다.
3. list 를 인자 타입으로 써 놓고 다른 것을 넘긴다
from collections.abc import Iterator
def parse_log_lines(raw_lines: list[str]) -> Iterator[OrderLine]:
...
def total(lines: list[OrderLine]) -> int:
return sum(l.amount for l in lines)
total(parse_log_lines(raw_lines))
# mypy: Argument 1 to "total" has incompatible type "Iterator[OrderLine]";
# expected "list[OrderLine]"
15단원의 parse_log_lines는 제너레이터를 돌려준다. 여기서 mypy 오류를 없애려고 list(...)로 감싸면, 11단원에서 애써 만든 메모리 이점이 사라진다. 타입을 Iterable[OrderLine]로 고치는 것이 맞다. 타입 오류가 났을 때 호출부를 고칠지 시그니처를 고칠지 판단하는 것이 실제 실력 차이가 나는 지점이다.
한 가지 덧붙이면, parse_log_lines에 반환형을 안 적었다면 이 오류는 나오지 않는다. 힌트 없는 함수의 반환은 Any로 취급돼 검사가 그냥 통과한다. 타입을 적다 만 코드가 가장 위험한 이유가 이것이다.
list[Any] 대신 list[object]를 쓰면 안 되는 경우도 있다. list는 읽고 쓰는 컨테이너라 하위 타입 관계가 성립하지 않아서, list[int]를 list[object] 자리에 넘길 수 없다. 읽기만 한다면 Sequence[object]를 쓴다.
4. 힌트를 늘려 놓고 mypy 를 안 돌린다
가장 흔하고 가장 손해가 큰 경우다. 검사기를 돌리지 않으면 힌트는 틀려도 아무도 모르는 주석이 된다. 실제로 리팩터링 뒤 반환형만 옛날 그대로 남은 함수가 쌓이고, 그 힌트를 믿고 쓴 사람이 엉뚱한 곳에서 데인다. 힌트를 쓰기로 했다면 CI에 mypy 실행을 넣는다. 로컬에서 가끔 돌리는 것으로는 유지되지 않는다. 어떤 표기가 어느 버전부터 되는지는 typing 문서에 항목마다 적혀 있으니, 3.11 환경이라면 그것을 확인하고 쓴다.
스스로 확인하기
- 아래 함수는 mypy를 통과하지만 실행하면
TypeError가 난다. 왜 정적 검사가 못 잡는가?import json def total(payload: str) -> int: data: dict[str, int] = json.loads(payload) return data["qty"] + 1 def save_all(orders: list[Order]) -> None:에 제너레이터를 넘기려니 mypy가 거부한다. 함수 본문은for문으로 순회만 한다. 어떻게 고치는 것이 맞고,list()로 감싸는 방법은 왜 나쁜가?- 무엇이 들어올지 모르는 인자에
Any와object중 무엇을 쓰는 것이 안전하며 그 이유는?
정답
json.loads의 반환형이Any라서, 그것을dict[str, int]라고 적은 것은 검사 없이 그대로 받아들여진다. 실제 JSON에"qty": "3"이 들어오면 문자열과 정수를 더하다 실행 시점에 터진다. 타입 힌트는 런타임 검증이 아니므로 경계에서 직접 변환하거나 스키마 검증 라이브러리를 쓴다.- 시그니처를
Iterable[Order]로 바꾼다. 함수가 실제로 요구하는 것은 순회 가능성뿐이므로 그것만 적는 것이 정확하다.list()로 감싸면 제너레이터가 주는 메모리 이점이 사라지고, 100만 건짜리 로그에서는 그 한 줄 때문에 메모리가 터진다. object가 안전하다.Any는 그 값에 대한 모든 연산을 허용해 검사를 사실상 끄고, 그 값을 받은 뒤의 코드까지 검사 대상에서 빠진다.object는 속성 접근을 막으므로isinstance로 타입을 좁힌 뒤에야 쓸 수 있고, 그 과정에서 실제로 필요한 검증 코드가 만들어진다.