목차
9 장

typing 본격 — Generic, Protocol, TypedDict, Literal

기초의 타입 힌트 다음 단계 — 타입을 매개변수화하는 Generic, 덕 타이핑을 정확히 적는 Protocol, dict 모양 명시 TypedDict, 좁은 union Literal까지 정리합니다.

8장 dataclass와 __slots__에서 데이터 모양을 짧게 적는 도구를 봤다면, 본 챕터는 그 모양을 더 정확하고 표현력 있게 적는 도구들입니다. typing 모듈의 본격 무기 네 개 — Generic, Protocol, TypedDict, Literal을 다룹니다.

본 챕터의 네 도구는 책 뒷부분의 거의 모든 곳에서 다시 만납니다. Protocol은 15장 매직 메소드 깊이와 프로토콜의 표면 위에 올라가는 추상이고, Generic은 20장 typing 고급 — Variance, ParamSpec, Self, overload의 출발점입니다. TypedDict와 Literal의 결합은 4부 FastAPI에서 24장 Pydantic v2 깊이의 discriminated union으로 다시 등장합니다.

출발점 — 어디까지가 기초였나 #

2장 변수, 기본 타입과 타입 힌트에서 다음까지 잡혔습니다.

  • int, str, bool, None
  • list[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 라 에디터가 그 정보를 잃습니다. 매개변수화가 필요합니다.

✅ 타입 매개변수 (3.12+)
def first[T](items: list[T]) -> T:
    return items[0]

x = first([1, 2, 3])      # x: int
y = first(["a", "b"])     # y: str

def first[T] 형태가 Python 3.12의 새 문법입니다. PEP 695. 이전에는 다음과 같이 적었습니다.

옛 방식 — 새 코드에선 안 씀
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

새 코드는 메소드 / 함수 옆 [T] 위치에 직접 쓰는 방식으로 통일하면 됩니다.

여러 타입 매개변수 #

2개 이상
def pair[K, V](key: K, value: V) -> tuple[K, V]:
    return (key, value)

p = pair("name", 42)    # tuple[str, int]

타입 제약 — bound / 명시적 제한 #

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이 더 어울립니다. (아래에서 다룸)

명시적 union 제약
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 #

제네릭 클래스 (3.12+)
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 | None

Stack[int]처럼 사용 시점에 T를 채워서 쓰는 것이 제네릭 클래스의 사용 패턴입니다. Variance(공변·반공변·불변) 같은 주제는 20장 typing 고급에서 다룹니다.

Protocol — 덕 타이핑을 타입으로 #

파이썬 관용구는 **“어떤 클래스 인스턴스인가"가 아니라 “어떤 메소드 / 속성을 가지는가”**입니다. 이걸 정적 타입으로 표현하는 게 **Protocol**입니다.

자바 / C# 의 인터페이스가 아님 #

자바 인터페이스는 명시적으로 implements를 적어야 그 인터페이스가 됩니다. 파이썬 Protocol은 그게 필요 없습니다. 모양만 맞으면 그 Protocol을 충족합니다 (structural typing).

Protocol 정의
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이 유용한 지점 — “원하는 만큼만 약속” #

실용 예 — sized와 iterable
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 들이 이미 있습니다.

자주 쓰는 ABC 들
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로 다룰 때, 어떤 키와 어떤 값 타입이 들어가는지 적고 싶을 때.

TypedDict 기본
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와 차이 #

dataclassTypedDict
런타임 형태사용자 정의 클래스 인스턴스일반 dict
메소드__init__, __repr__, __eq__ 자동없음 (dict 메소드만)
접근u.nameu["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:

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]

AdminUserid, name, permissions 세 키를 가집니다.

Literal — 좁은 union #

값 자체를 타입으로. 특정 문자열 / 숫자만 허용하고 싶을 때.

Literal 기본
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 #

여러 Literal
type Color = Literal["red", "green", "blue"]
type Mode = Literal["light", "dark", "auto"]

def render(color: Color, mode: Mode) -> str:
    ...

상수와 결합 — Final #

Final
from typing import Final, Literal

DEFAULT_LEVEL: Final = "info"
# DEFAULT_LEVEL 의 타입이 Literal["info"] 로 좁혀짐

DEFAULT_LEVEL = "warn"   # ✗ Final — 재대입 불가

Final은 “이 변수는 한 번만 대입"을 표시합니다. 모듈 상수에 자주 씁니다.

Discriminated union — 결정적 분기 #

서로 다른 모양의 dict가 한곳에 들어올 때, “어떤 모양인지"를 한 키로 식별하는 패턴.

discriminated union
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와 잘 어울리는 패턴이고, 13장 패턴 매칭 깊이에서 본격적으로 다시 다룹니다.

다른 자주 쓰는 도구들 #

Optional은 이제 안 씀 #

✗ 옛 / ✅ 새
# 옛
from typing import Optional
def find(id: int) -> Optional[str]: ...

# 새 — 항상 이쪽
def find(id: int) -> str | None: ...

Any는 마지막 수단 #

Any는 “타입 체크를 끄겠다"라는 선언입니다. 진짜로 모르겠을 때만 쓰고, object / Unknown (pyright) / unknown 같은 좁은 타입으로 갈 수 있는지 먼저 검토하세요.

Self — 메소드 반환에서 자기 클래스 #

Self (3.11+)
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가 그걸 풀어줍니다. 20장 typing 고급에서 좀 더 자세히 보겠습니다.

연습문제 #

  1. def last[T](items: list[T]) -> T | None: 시그니처의 함수를 작성하세요. last([1, 2, 3])int | None, last(["a", "b"])str | None으로 추론되는지 pyright로 확인합니다.
  2. ProtocolDrawable (def draw(self) -> str: ...)를 정의하고, def render_all(items: list[Drawable]) -> list[str]:를 작성하세요. 상속 없이 draw 메소드만 가진 클래스 두 개를 만들어서 render_all에 그 인스턴스 리스트를 넘겨 정상 동작하는 것을 확인합니다.
  3. TypedDict + Literal로 discriminated union을 만드세요. ClickEvent (type: Literal["click"], x: int, y: int) / KeyEvent (type: Literal["key"], code: str) 두 타입을 합쳐 Event = ClickEvent | KeyEvent로 만들고, if event["type"] == "click": 분기 안에서 event["x"] 접근이 타입 에러 없이 동작하는지 확인합니다.

한 줄 요약: Generic은 def fn[T](...) / class Stack[T]:로 타입 매개변수화. Protocol은 명시적 implements 없이 모양으로 약속 — collections.abc가 표준 모음. TypedDict는 dict 모양 선언으로 JSON / 외부 데이터에 적합 (NotRequired / Required). Literal은 값을 타입으로 좁히기, discriminated union의 핵심. Optional은 폐기, Any는 마지막 수단, Self는 빌더 패턴.

다음 챕터 #

다음 10장 컨텍스트 매니저 (with, contextlib)에서는 자원 관리의 표준 도구 — with 문과 contextlib의 모든 패턴을 다룹니다. 6장 예외 처리에서 본 finally 정리 코드를 더 안전하고 읽기 쉽게 표현하는 방법입니다.

X