모던 파이썬 고급 #6 typing 고급 — Variance, ParamSpec, Self, overload

8 분 소요

중급 #2 typing 본격에서 Generic, Protocol, TypedDict, Literal까지 잡았습니다. 이번 글은 그 다음 단계인 typing 시스템의 어려운 부분들입니다. variance, ParamSpec/Concatenate, Self, TypeGuard/TypeIs, @overload 같은 도구들을 다룹니다.

이것은 거의 라이브러리 작성자에게 필요한 영역이고, 일반 코드에는 나오지 않아도 됩니다. 그러나 라이브러리 코드를 읽거나 정확한 타입을 만들고 싶으면 알아 둬야 합니다.

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)TU의 서브타입이면 Box[T]Box[U]의 서브타입tuple[T, ...], Iterable[T] (read-only)
반공변(contravariant)TU의 서브타입이면 Box[U]Box[T]의 서브타입Callable[[T], R]인자 위치

직관 #

  • 읽기만 할 때 → 공변. Iterable[Cat]Iterable[Animal]로 사용 가능 (읽으면 Animal인 Cat)
  • 쓰기/읽기 둘 다 할 때 → 불변. list[Cat], dict[K, V]
  • 인자 위치 → 반공변. “Animal 받는 함수"가 들어갈 자리에 “Cat 받는 함수"를 넣으면 위험 (Dog가 와도 Animal 함수는 받아야 하므로)

함수 타입에서 보면 #

Callable의 variance
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

함수의 인자 위치는 반공변입니다. 반환 위치는 공변입니다.

어떻게 변화도를 명시하나 #

중급 #2에서 본 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를 의식해서 사용합니다. 그 정도가 일상적인 코드에서 variance를 다루는 범위입니다.

ParamSpecConcatenate #

중급 #5에서 데코레이터의 시그니처를 보존하는 도구로 짧게 봤습니다. 더 깊이.

ParamSpec의 정체 #

ParamSpec 다시
def log[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        ...
    return wrapper

P타입 매개변수가 아니라 매개변수 시그니처 자체입니다. 한 묶음의 (위치 인자 타입들 + 키워드 인자 타입들)을 표현합니다.

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 인자가 빠진 시그니처가 됩니다.

데코레이터가 인자를 빼서 호출자가 적지 않게 하는 패턴으로, 의존성 주입의 한 형태입니다.

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

중급 #2에서 짧게 본 도구입니다.

Self
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 — 같은 함수, 다른 시그니처 #

호출 인자에 따라 반환 타입이 달라지는 함수에 정확한 타입을 붙이는 도구입니다.

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 인자에 따라 분기될 때
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의 타입), 같은 호출의 정확한 추론이 가능해지는 패턴입니다.

TypeGuardTypeIs — 타입 좁히기 함수 #

isinstance 외에 사용자 정의 타입 좁히기 함수를 만드는 도구입니다.

TypeGuard (3.10+) #

TypeGuard
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의 개선판 #

TypeIs
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 — 타입에 메타데이터 #

Annotated
from typing import Annotated

UserId = Annotated[int, "UserID — DB의 users.id"]
Email = Annotated[str, "이메일 형식"]

def find(id: UserId, email: Email): ...

Annotated[T, ...]의 추가 메타데이터는 타입 체커가 무시합니다. 다만 라이브러리가 런타임에 그것을 읽어 동작에 활용할 수 있습니다. FastAPIDepends, Query가 이 패턴을 쓰는 가장 유명한 예입니다.

FastAPI가 쓰는 패턴
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 그대로 읽히면서 추가 동작 명세가 같이 묶입니다.

LiteralString — SQL 인젝션 방어 (3.11+) #

LiteralString
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 — 같은 모양, 다른 타입 #

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 별칭과 차이 #

기초 #2type 별칭 (type UserId = int)은 그냥 별칭이라 int 위치에 UserId를 넣을 수 있고 그 반대도 가능합니다. NewType은 한 방향만 됩니다. int → UserId는 명시적 캐스팅이 필요하고, 자동으로는 안 됩니다.

type 별칭은 이름만 다르게 하고, NewType다른 타입으로 분리합니다.

Generic class — 다시 깊이 #

중급 #2에서 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+) #

TypeVarTuple
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 — 타입 강제 #

cast
from typing import cast

raw: object = get_data()
data = cast(dict[str, int], raw)
# 런타임에는 그냥 raw, 타입 체커만 dict[str, int] 로 봄

확실히 알지만 체커가 추론을 못 할 때 쓰는 마지막 수단. 잘못 쓰면 런타임 사고가 가능하므로 이유 주석을 같이 두는 게 좋습니다.

assert_type — 추론 검증 #

assert_type
from typing import assert_type

x = some_function()
assert_type(x, int)    # 체커가 x를 int로 추론하지 않으면 에러

타입 체커에 “이 지점은 정확히 이 타입이어야 한다"는 단언입니다. 라이브러리 테스트에서 자주 쓰입니다.

assert_never — 모든 경우 처리 보장 #

exhaustiveness
from typing import assert_never

def handle(event: Literal["click", "key"]):
    if event == "click": ...
    elif event == "key": ...
    else:
        assert_never(event)    # 새 케이스가 생기면 여기가 깨짐

union의 모든 경우를 처리했는지 체커가 검증 해 줍니다. discriminated union 분기에 정말 유용합니다.

정리 #

이번 글에서 본 것:

  • Variance — invariant (list), covariant (Iterable, 읽기), contravariant (Callable 인자)
  • ParamSpec + Concatenate — 데코레이터의 시그니처 보존, 인자 주입
  • Self — 메소드 반환에서 자기 클래스, 빌더/대안 생성자
  • @overload — 인자 따라 반환 타입 다른 함수의 정확한 시그니처
  • TypeGuard vs TypeIs (3.13+) — 사용자 정의 타입 좁힘, TypeIs가 새 표준
  • Annotated — 타입에 메타데이터, FastAPI/Pydantic의 핵심
  • LiteralString (3.11+) — 컴파일 타임 문자열 강제, SQL 인젝션 방어
  • NewType — 같은 모양 다른 타입 (UserId vs int)
  • 가변 매개변수 TypeVarTuple (3.11+)
  • cast / assert_type / assert_never — 명시적 단언

다음 글(#7 성능 — 프로파일링)에서는 고급 시리즈의 마지막 — 느린 코드를 찾고 고치는 도구들, cProfile, py-spy, line_profiler, 메모리 프로파일링까지 다룹니다.

X