모던 파이썬 중급 #2 typing 본격 — Generic, Protocol, TypedDict, Literal

7 분 소요

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

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

기초 #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를 채워서 쓰는 것이 제네릭 클래스의 사용 패턴입니다.

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 값을 보고 타입 체커가 자동으로 좁혀줍니다. 기초 #3match-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 — 메소드 반환에서 자기 클래스 #

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가 그걸 풀어줍니다.

정리 #

이번 글에서 잡은 도구들:

  • Genericdef 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의 모든 패턴을 다룹니다.

X