모던 파이썬 중급 #2 typing 본격 — Generic, Protocol, TypedDict, Literal
#1 dataclass와 __slots__에서 데이터 모양을 짧게 적는 도구를 봤다면, 이번 글은 그 모양을 더 정확하고 표현력 있게 적는 도구들입니다. typing 모듈의 본격 무기 네 개 — Generic, Protocol, TypedDict, Literal을 다룹니다.
출발점 — 어디까지가 기초였나 #
기초 #2에서:
int,str,bool,Nonelist[int],dict[str, int]int | None(union 단축)type Alias = int(타입 별칭)Callable[[int, int], int]
여기까지 잡혔습니다. 그 다음 단계 — 사용자가 매개변수를 채울 수 있는 타입부터 시작합니다.
Generic — 타입 매개변수 #
같은 모양인데 안에 들어가는 타입만 다른 함수/클래스를 적고 싶을 때가 있습니다.
함수 — 입력 타입 그대로 반환 #
def first(items: list) -> object:
return items[0]
x = first([1, 2, 3]) # x: object ← int 인 정보를 잃어버림first([1,2,3])은 분명히 int를 반환하지만, 시그니처가 object라 에디터가 그 정보를 잃습니다. 매개변수화가 필요합니다.
def first[T](items: list[T]) -> T:
return items[0]
x = first([1, 2, 3]) # x: int
y = first(["a", "b"]) # y: strdef first[T] 형태가 Python 3.12의 새 문법입니다. PEP 695. 이전에는 다음과 같이 적었습니다.
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]새 코드는 메소드/함수 옆 [T] 위치에 직접 쓰는 방식으로 통일하면 됩니다.
여러 타입 매개변수 #
def pair[K, V](key: K, value: V) -> tuple[K, V]:
return (key, value)
p = pair("name", 42) # tuple[str, int]타입 제약 — bound / 명시적 제한
#
class HasLength:
def __len__(self) -> int: ...
def shortest[T: HasLength](items: list[T]) -> T:
return min(items, key=len)[T: HasLength]는 “T는 HasLength의 서브타입이어야 한다"는 제약입니다. 사실 위 경우는 Protocol이 더 어울립니다. (아래에서 다룹니다)
def add[T: (int, float)](a: T, b: T) -> T:
return a + b
add(1, 2) # int
add(1.0, 2.0) # float
add(1, 2.0) # ✗ T가 한 타입으로 결정 안 됨[T: (int, float)] 형태는 “T는 int 또는 float“를 의미합니다. 제네릭 제약 (constraint)이라고 부릅니다.
클래스 — Generic class #
class Stack[T]:
def __init__(self):
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
s.push(2)
x = s.pop() # x: int | NoneStack[int]처럼 사용 시점에 T를 채워서 쓰는 것이 제네릭 클래스의 사용 패턴입니다.
Protocol — 덕 타이핑을 타입으로 #
파이썬 관용구는 “오리처럼 걷고 꽥꽥거리면 오리” 입니다. 객체가 어떤 클래스 인스턴스인지가 아니라, 어떤 메소드/속성을 가지는지가 중요합니다. 이걸 정적 타입으로 표현하는 게 Protocol 입니다.
자바/C# 의 인터페이스가 아님 #
자바 인터페이스는 명시적으로 implements를 적어야 그 인터페이스가 됩니다. 파이썬 Protocol은 그게 필요 없습니다. 모양만 맞으면 그 Protocol을 충족합니다 (structural typing).
from typing import Protocol
class Closable(Protocol):
def close(self) -> None: ...
# 무엇이 Closable 인가?
def safe_close(resource: Closable) -> None:
resource.close()
# 위 함수는 .close()를 가진 모든 객체를 받음.
# 명시적인 상속 관계가 필요 없음.
class File:
def close(self) -> None:
print("file closed")
class Connection:
def close(self) -> None:
print("connection closed")
safe_close(File()) # OK — File은 Closable을 충족
safe_close(Connection()) # OK — Connection도 충족runtime_checkable — 런타임에 isinstance가능
#
기본 Protocol은 정적 검사용입니다. isinstance로 검사하려면 데코레이터를 붙입니다.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
print(isinstance(File(), Closable)) # True다만 isinstance는 메소드 이름만 검사 하고 시그니처는 안 봅니다. 정확한 검사는 정적 타입 체커가 더 잘합니다.
Protocol의 진가 — “원하는 만큼만 약속” #
from typing import Protocol
class Sized(Protocol):
def __len__(self) -> int: ...
def first_n[T](items: Sized, n: int) -> ...:
if len(items) < n:
raise ValueError("부족함")
...함수가 필요한 건 __len__ 메소드뿐입니다. 그래서 인자 타입을 “리스트"가 아니라 “len가능한 무언가"로 표현하는 게 더 정확하고 유연합니다. 호출 측이 list / tuple / dict / 사용자 정의 클래스 무엇이든 통과합니다.
collections.abc가 이미 가진 Protocol
#
표준 라이브러리에 자주 쓰는 Protocol 들이 이미 있습니다.
from collections.abc import (
Iterable, Iterator, Sized, Container,
Mapping, Sequence, Callable, Hashable
)
def process(items: Iterable[int]) -> None:
for x in items:
...
def lookup(m: Mapping[str, int], key: str) -> int | None:
return m.get(key)collections.abc는 abstract base class지만 Protocol처럼 동작하기도 해서 (@runtime_checkable 됨), 표준 형태는 여기서 가져다 쓰면 됩니다. 직접 Protocol을 정의하는 건 표준에 없는 모양일 때입니다.
TypedDict — dict의 모양을 타입으로 #
JSON 같은 데이터를 dict로 다룰 때, 어떤 키와 어떤 값 타입이 들어가는지 적고 싶을 때 쓰는 도구입니다.
from typing import TypedDict
class User(TypedDict):
id: int
name: str
age: int
u: User = {"id": 1, "name": "커티스", "age": 30}
print(u["name"]) # name: str — 타입이 좁혀짐TypedDict는 런타임에는 그냥 dict 입니다. 인스턴스 검사를 해도 dict로 보입니다. 타입 체커만 알아봅니다.
dataclass와 차이 #
| dataclass | TypedDict | |
|---|---|---|
| 런타임 형태 | 사용자 정의 클래스 인스턴스 | 일반 dict |
| 메소드 | __init__, __repr__, __eq__ 자동 | 없음 (dict 메소드만) |
| 접근 | u.name | u["name"] |
| 어울리는 용도 | 내부 도메인 모델 | JSON, 외부 API 응답 |
API 응답을 받아서 dict로 다루는 경우에 TypedDict가 어울립니다. 변환 비용이 없습니다 — 그냥 들어온 dict를 그 모양이라고 선언만 하면 됩니다.
옵션 키 — total=False / NotRequired
#
from typing import TypedDict, NotRequired
class User(TypedDict):
id: int # 필수
name: str # 필수
nickname: NotRequired[str] # 선택
u1: User = {"id": 1, "name": "커티스"} # OK
u2: User = {"id": 1, "name": "커티스", "nickname": "C"} # OK옛 방식은 클래스 단위 total=False인데, 모든 키를 옵션으로 만들어버려서 거의 안 씁니다. 새 코드는 **NotRequired**가 표준입니다.
반대로, 모두 옵션인 dict에서 일부만 필수로 만들고 싶으면 Required:
from typing import TypedDict, Required
class Config(TypedDict, total=False):
timeout: int
retries: int
base_url: Required[str] # 이것만 필수상속도 됨 #
class BaseUser(TypedDict):
id: int
name: str
class AdminUser(BaseUser):
permissions: list[str]AdminUser는 id, name, permissions 세 키를 가집니다.
Literal — 좁은 union #
값 자체를 타입으로 씁니다. 특정 문자열/숫자만 허용하고 싶을 때 쓰는 도구입니다.
from typing import Literal
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
...
set_log_level("info") # OK
set_log_level("trace") # ✗ 허용된 4개에 없음호출 측 자동완성이 정확히 나오고, 오타가 컴파일 타임에 잡힙니다.
Literal과 union #
type Color = Literal["red", "green", "blue"]
type Mode = Literal["light", "dark", "auto"]
def render(color: Color, mode: Mode) -> str:
...상수와 결합 — Final
#
from typing import Final, Literal
DEFAULT_LEVEL: Final = "info"
# DEFAULT_LEVEL의 타입이 Literal["info"] 로 좁혀짐
DEFAULT_LEVEL = "warn" # ✗ Final — 재대입 불가Final은 “이 변수는 한 번만 대입"을 표시합니다. 모듈 상수에 자주 씁니다.
Discriminated union — 결정적 분기 #
서로 다른 모양의 dict가 한곳에 들어올 때, “어떤 모양인지"를 한 키로 식별하는 패턴입니다.
from typing import Literal, TypedDict
class ClickEvent(TypedDict):
type: Literal["click"]
x: int
y: int
class KeyEvent(TypedDict):
type: Literal["key"]
code: str
type Event = ClickEvent | KeyEvent
def handle(event: Event) -> None:
if event["type"] == "click":
# 여기서 event는 ClickEvent로 좁혀짐
print(event["x"], event["y"])
else:
# 여기는 KeyEvent
print(event["code"])type 키의 Literal 값을 보고 타입 체커가 자동으로 좁혀줍니다. 기초 #3의 match-case와 잘 어울리는 패턴입니다.
다른 자주 쓰는 도구들 #
Optional은 이제 안 씀
#
# 옛
from typing import Optional
def find(id: int) -> Optional[str]: ...
# 새 — 항상 이쪽
def find(id: int) -> str | None: ...Any는 마지막 수단
#
Any는 “타입 체크를 끄겠다"라는 선언입니다. 진짜로 모르겠을 때만 쓰고, object / Unknown (pyright) / unknown 같은 좁은 타입으로 갈 수 있는지 먼저 검토하세요.
Self — 메소드 반환에서 자기 클래스
#
from typing import Self
class Builder:
def __init__(self):
self.items: list[str] = []
def add(self, item: str) -> Self:
self.items.append(item)
return self-> Self는 “이 메소드는 호출된 클래스의 인스턴스를 반환"을 의미합니다. 빌더 패턴에 잘 어울립니다. 옛 방식은 -> "Builder" (forward reference)였는데 서브클래스로 호출되면 정확하지 않았습니다. Self가 그걸 풀어줍니다.
정리 #
이번 글에서 잡은 도구들:
- Generic —
def fn[T](...),class Stack[T]:(3.12+) — 타입 매개변수 - 제약:
[T: HasLength](bound),[T: (int, float)](constraints) - Protocol — 덕 타이핑을 타입으로, 명시적 implements 불필요
collections.abc가 표준 Protocol 모음 —Iterable,Sized,Mapping등@runtime_checkable로 isinstance가능 (단 시그니처 미검증)- TypedDict — dict의 모양 선언, JSON/외부 데이터에 어울림
- 옵션 키는
NotRequired, 필수 강제는Required - Literal — 값을 타입으로, discriminated union에 핵심
Final— 한 번만 대입, 상수 명시Optional[X]는X | None으로,Any는 마지막 수단,Self로 빌더 패턴
다음 글(#3 컨텍스트 매니저)에서는 자원 관리의 표준 도구 — with 문과 contextlib의 모든 패턴을 다룹니다.