typing 고급 — Variance, ParamSpec, Self, overload
중급 typing의 다음 단계 — covariance/contravariance, ParamSpec과 Concatenate, Self, TypeGuard/TypeIs, @overload까지 정리합니다.
9장 typing 본격에서 Generic, Protocol, TypedDict, Literal까지 잡았습니다. 본 챕터는 그 다음 단계 — typing 시스템의 어려운 부분들입니다. variance, ParamSpec / Concatenate, Self, TypeGuard / TypeIs, @overload 같은 도구들입니다.
본 챕터는 거의 라이브러리 작성자에게 필요한 영역이고, 일반 코드에는 안 나와도 됩니다. 그러나 라이브러리 코드를 읽거나 정확한 타입을 만들고 싶으면 알아 둬야 합니다. 4부의 24장 Pydantic v2 깊이에서 본 챕터의 Annotated가 FastAPI의 핵심 도구로 다시 등장합니다.
Variance — 공변성 / 반공변성 #
가장 헷갈리는 부분부터. list[Cat]은 list[Animal]의 서브타입인가?
직관적으로 “Cat이 Animal 이니까 list[Cat]도 list[Animal] 이지"라고 생각하기 쉽지만 — 틀렸습니다. 왜인지 봅시다.
def add_dog(animals: list[Animal]) -> None:
animals.append(Dog())
cats: list[Cat] = [Cat(), Cat()]
add_dog(cats) # ✗ 만약 허용되면, cats 안에 Dog 가 들어감list[Cat]은 list[Animal]이 아닙니다. 받는 쪽과 주는 쪽이 모두 가능하기 때문에 (mutable), 어느 쪽이든 안전을 보장하기 어렵습니다.
세 가지 variance #
| 종류 | 의미 | 예 |
|---|---|---|
| 불변(invariant) | Box[T]와 Box[U]는 무관 | list[T] |
| 공변(covariant) | T가 U의 서브타입이면 Box[T]도 Box[U]의 서브타입 | tuple[T, ...], Iterable[T] (read-only) |
| 반공변(contravariant) | T가 U의 서브타입이면 Box[U]가 Box[T]의 서브타입 | Callable[[T], R]의 인자 위치 |
직관 #
- 읽기만 한다 → 공변.
Iterable[Cat]는Iterable[Animal]로 사용 가능 (읽으면 Animal 인 Cat) - 쓰기 / 읽기 둘 다 → 불변.
list[Cat],dict[K, V] - 인자 위치 → 반공변. “Animal 받는 함수” 역할에 “Cat 받는 함수"를 넣으면 위험 (Dog가 와도 Animal 함수는 받아야)
함수 타입에서 보면 #
def feed_animal(a: Animal): ...
def feed_cat(c: Cat): ...
# 'Animal 을 받는 함수' 위치에 'Cat 만 받는 함수' 를 넣을 수 있나?
fn: Callable[[Animal], None] = feed_cat # ✗ — Dog 가 오면 깨짐
# 반대는?
fn2: Callable[[Cat], None] = feed_animal # ✅ — Cat 도 Animal 의 일부니 OK함수의 인자 위치는 반공변입니다. 반환 위치는 공변입니다.
어떻게 변화도를 명시하나 #
9장에서 본 PEP 695 새 문법 def fn[T]:은 자동으로 추론 합니다. 옛 방식 (TypeVar)에서는 직접 적었습니다.
from typing import TypeVar
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
class Producer(Generic[T_co]):
def get(self) -> T_co: ...
class Consumer(Generic[T_contra]):
def put(self, item: T_contra) -> None: ...새 코드는 보통 직접 명시하지 않아도 도구가 잘 처리합니다. 라이브러리 작성자라면 Iterable, Callable 같은 표준 ABC의 variance를 의식해서 사용 — 그 정도가 일상적인 사용 범위입니다.
ParamSpec과 Concatenate
#
12장 데코레이터 패턴에서 데코레이터의 시그니처를 보존하는 도구로 짧게 봤습니다. 더 깊이.
ParamSpec의 정체
#
def log[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
...
return wrapperP는 타입 매개변수가 아니라 매개변수 시그니처 자체입니다. 한 묶음의 (위치 인자 타입들 + 키워드 인자 타입들)을 표현합니다.
Concatenate — 인자 추가
#
데코레이터가 인자 하나를 추가 하고 싶을 때.
from typing import Concatenate, Callable
def with_logger[**P, R](
fn: Callable[Concatenate[Logger, P], R]
) -> Callable[P, R]:
logger = make_logger()
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(logger, *args, **kwargs)
return wrapper
@with_logger
def do_work(logger: Logger, x: int, y: int) -> int:
logger.info(f"{x} + {y}")
return x + y
do_work(2, 3) # logger 는 자동 주입Concatenate[Logger, P]의 의미: “첫 인자가 Logger, 그 뒤는 P 그대로”. 데코레이트 후 logger 인자가 빠진 시그니처가 됩니다.
데코레이터가 인자를 빼서 호출자가 안 적게 하는 패턴 — 의존성 주입의 한 형태입니다. 23장 라우팅, Pydantic 모델, 의존성 주입의 FastAPI Depends가 이 역할을 맡습니다.
Self — 메소드 반환에서 자기 클래스
#
9장에서 짧게 본 도구.
from typing import Self
class Builder:
def add(self, item: str) -> Self:
...
return self
class SubBuilder(Builder):
def special(self) -> Self:
...
return self
b = SubBuilder().add("x").special()
# b 의 타입이 정확히 SubBuilder 로 추론됨-> "Builder" (forward reference)를 쓰면 SubBuilder에서 호출해도 결과 타입이 Builder가 되어 .special() 호출이 막힙니다. Self가 그걸 풀어줍니다.
클래스 메소드에도 #
class Item:
@classmethod
def from_dict(cls, data: dict) -> Self:
return cls(**data)서브클래스에서 호출하면 결과 타입이 서브클래스로 정확히 추론됩니다.
@overload — 같은 함수, 다른 시그니처
#
호출 인자에 따라 반환 타입이 달라지는 함수에 정확한 타입을 붙이는 도구.
from typing import overload
@overload
def parse(value: str) -> str: ...
@overload
def parse(value: int) -> int: ...
@overload
def parse(value: list[str]) -> list[str]: ...
def parse(value):
# 실제 구현 — overload 시그니처 아래
return value
a = parse("hello") # str
b = parse(42) # int
c = parse(["a", "b"]) # list[str]@overload 데코레이터가 붙은 정의들은 타입 검사 전용입니다. 본문은 보통 .... 그 아래에 실제 본문 한 개가 옵니다.
언제 쓰나? #
- 인자 타입에 따라 반환 타입이 달라질 때
- Literal 인자에 따라 분기될 때
@overload
def get(key: Literal["count"]) -> int: ...
@overload
def get(key: Literal["name"]) -> str: ...
@overload
def get(key: str) -> object: ...
def get(key): ...호출 측이 get("count") 면 정확히 int를 받는 걸로 추론됩니다.
dict.get 같은 표준 함수가 이렇게 정의됨 #
@overload
def get(self, key: K) -> V | None: ...
@overload
def get(self, key: K, default: V) -> V: ...
@overload
def get(self, key: K, default: T) -> V | T: ...d.get("k") → V | None, d.get("k", 0) → int (0의 타입), 같은 호출의 정확한 추론이 가능해지는 패턴입니다.
TypeGuard와 TypeIs — 타입 좁히기 함수
#
isinstance 외에 사용자 정의 타입 좁히기 함수를 만드는 도구.
TypeGuard (3.10+)
#
from typing import TypeGuard
def is_str_list(items: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in items)
def process(data: list[object]) -> None:
if is_str_list(data):
# 여기서 data 는 list[str] 로 좁혀짐
print(", ".join(data))TypeGuard[T]를 반환하는 함수는 True 일 때 인자가 T라는 약속이 됩니다.
TypeIs (3.13+) — TypeGuard의 개선판
#
from typing import TypeIs
def is_str(x: object) -> TypeIs[str]:
return isinstance(x, str)
def handle(x: int | str) -> None:
if is_str(x):
print(len(x)) # str
else:
print(x + 1) # int ← 여기가 다름!차이: TypeIs는 False 일 때도 타입을 좁힙니다. TypeGuard는 True 일 때만. TypeIs가 더 직관적이고 안전한 경우에 쓰임.
TypeGuard는 호환성 유지용으로 남았고, **새 코드는 TypeIs**를 권장합니다 (3.13+).
Annotated — 타입에 메타데이터
#
from typing import Annotated
UserId = Annotated[int, "UserID — DB 의 users.id"]
Email = Annotated[str, "이메일 형식"]
def find(id: UserId, email: Email): ...Annotated[T, ...]의 추가 메타데이터는 타입 체커가 무시 합니다. 다만 라이브러리가 런타임에 그것을 읽어 동작에 활용 할 수 있습니다. FastAPI의 Depends, Query가 본 패턴을 쓰는 가장 유명한 예입니다.
from typing import Annotated
from fastapi import Depends, Query
def get_db(): ...
def search(
q: Annotated[str, Query(min_length=3)],
db: Annotated[Database, Depends(get_db)],
): ...타입 자체는 str, Database 그대로 읽히면서 추가 동작 명세가 같이 묶입니다. 24장 Pydantic v2 깊이와 23장 라우팅, Pydantic 모델, 의존성 주입에서 본격적으로 다룹니다.
LiteralString — SQL 인젝션 방어 (3.11+)
#
from typing import LiteralString
def execute(query: LiteralString) -> list[Row]:
...
execute("SELECT * FROM users WHERE id = 1") # OK
execute(f"SELECT * FROM users WHERE id = {user_input}") # ✗ — f-string 결과는 LiteralString 아님LiteralString은 컴파일 시점에 알려진 문자열만 받는 타입입니다. 사용자 입력으로 만든 문자열 (f"...", + user_id 등)은 거부됩니다. SQL 인젝션 방어를 정적으로 보장하는 도구.
NewType — 같은 모양, 다른 타입
#
from typing import NewType
UserId = NewType("UserId", int)
ProductId = NewType("ProductId", int)
def get_user(id: UserId): ...
uid = UserId(123)
pid = ProductId(456)
get_user(uid) # OK
get_user(pid) # ✗ — 다른 타입으로 취급
get_user(123) # ✗ — int 그대로는 못 들어감NewType은 런타임에는 그냥 int입니다. 타입 체커만 다른 타입으로 취급. 같은 int 인데 의미가 다른 경우들 (UserId vs ProductId, USD vs KRW)에서 강력합니다.
type 별칭과 차이
#
2장 변수, 기본 타입과 타입 힌트의 type 별칭 (type UserId = int)은 그냥 별칭 — int 위치에 UserId를 넣을 수 있고 그 반대도 가능. NewType은 한 방향만: int → UserId는 명시적 캐스팅 필요, 자동으로는 안 됨.
type 별칭은 이름만 다르게, NewType은 다른 타입으로 분리.
Generic class — 다시 깊이 #
9장에서 본 class Stack[T]:. 더 들어가면:
다중 매개변수 #
class Cache[K, V]:
def __init__(self):
self._data: dict[K, V] = {}
def get(self, key: K) -> V | None: ...
def put(self, key: K, value: V) -> None: ...
cache: Cache[str, int] = Cache()제약과 bound #
class SortedList[T: (int, str)]: # int 또는 str 만
...
class Container[T: Comparable]: # Comparable 의 서브타입만
...가변 길이 — TypeVarTuple (3.11+)
#
def stack[*Ts](*args: *Ts) -> tuple[*Ts]:
return args
t = stack(1, "hello", 3.14)
# t 의 타입: tuple[int, str, float]가변 개수의 타입 매개변수 — numpy 같은 다차원 배열 라이브러리에서 차원을 타입으로 표현할 때 활용.
cast, assert_type, assert_never
#
cast — 타입 강제
#
from typing import cast
raw: object = get_data()
data = cast(dict[str, int], raw)
# 런타임에는 그냥 raw, 타입 체커만 dict[str, int] 로 봄확실히 알지만 체커가 추론을 못 할 때 쓰는 마지막 수단. 잘못 쓰면 런타임 사고가 가능하므로 이유 주석을 같이 두는 게 좋습니다.
assert_type — 추론 검증
#
from typing import assert_type
x = some_function()
assert_type(x, int) # 체커가 x 를 int 로 추론하지 않으면 에러타입 체커에 “이 지점은 정확히 이 타입이어야 한다"는 단언. 라이브러리 테스트에서 자주 쓰입니다.
assert_never — 모든 경우 처리 보장
#
from typing import assert_never
def handle(event: Literal["click", "key"]):
if event == "click": ...
elif event == "key": ...
else:
assert_never(event) # 새 케이스가 생기면 여기가 깨짐union의 모든 경우를 처리했는지 체커가 검증 해 줍니다. discriminated union 분기에 유용합니다.
연습문제 #
Concatenate로 데코레이터를 만드세요.@with_db가 인자에db: Database를 자동 주입하고, 호출자는db를 적지 않도록. 시그니처가 pyright에서 정확히 추론되는지 확인합니다.@overload로def parse(value)함수를 작성하세요.str→str,int→int,list[str]→list[str]의 세 오버로드를 정의하고 본문은 단일.result = parse(42)에서result의 추론 타입이int인지 확인합니다.TypeIs로is_str_list(items: list[object]) -> TypeIs[list[str]]를 작성하세요.if is_str_list(data):안에서data가list[str]로 좁혀지는지,else:분기에서list[object]로 남는지 (TypeGuard와의 차이) 확인합니다.
한 줄 요약: Variance는 invariant (list) / covariant (Iterable, 읽기) / contravariant (Callable 인자).
ParamSpec+Concatenate로 데코레이터 시그니처 보존 + 인자 주입.Self는 메소드 반환 자기 클래스,@overload로 인자 따라 반환 타입 분기.TypeGuard/TypeIs(3.13+) 사용자 정의 좁힘.Annotated는 FastAPI / Pydantic의 핵심.LiteralString으로 SQL 인젝션 방어,NewType으로 같은 모양 다른 타입.cast/assert_type/assert_never명시적 단언.
다음 챕터 #
다음 21장 성능 — cProfile, py-spy, 메모리 프로파일링이 3부의 마지막 — 느린 코드를 찾고 고치는 도구들, cProfile, py-spy, line_profiler, 메모리 프로파일링까지 다룹니다.