ParamSpec: 데코레이터의 시그니처를 보존하기

ParamSpec과 P.args·P.kwargs로 데코레이터가 원본 함수의 매개변수 시그니처를 그대로 유지하게 만드는 법, Concatenate까지 완전 정복합니다.

· 5 min read · PALDYN Team

지난 글에서 구조적 타이핑을 다뤘다. 데코레이터에 타입을 입혀 본 적이 있다면, 한 가지 답답한 문제를 만났을 것이다. 데코레이터로 함수를 감싸는 순간, 원본 함수의 매개변수 정보가 (*args, **kwargs)로 뭉개져 버린다. IDE 자동완성도 사라지고, 잘못된 인자를 넘겨도 검사기가 잡지 못한다. ParamSpec은 이 “시그니처 소실” 문제를 해결하기 위해 파이썬 3.10에 도입됐다.

문제: 데코레이터가 시그니처를 지운다

타입을 어설프게 단 데코레이터가 어떤 문제를 일으키는지 보자.

from typing import Callable, Any

def logged(fn: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        print("호출:", fn.__name__)
        return fn(*args, **kwargs)
    return wrapper

@logged
def add(a: int, b: int) -> int:
    return a + b

add("x", "y")   # 검사기가 잡지 못한다! Callable[..., Any]이므로

Callable[..., Any]는 “아무 인자나 받아 아무거나 반환”을 뜻한다. 감싼 뒤의 add는 원래의 (a: int, b: int) -> int 정보를 완전히 잃었다.

ParamSpec과 P.args, P.kwargs

ParamSpec함수의 매개변수 목록 전체를 하나의 변수로 잡아낸다. TypeVar가 하나의 타입을 담는다면, ParamSpec은 “매개변수 시그니처”를 통째로 담는다. 그리고 P.argsP.kwargs로 그 시그니처를 wrapper에 전달한다.

from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def logged(fn: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print("호출:", fn.__name__)
        return fn(*args, **kwargs)
    return wrapper

ParamSpec — 데코레이터가 시그니처를 보존

Callable[P, R]은 “매개변수 시그니처 P, 반환 타입 R인 함수”를 뜻한다. 데코레이터가 같은 PR을 그대로 반환하므로, 감싼 함수가 원본과 동일한 시그니처를 유지한다.

ParamSpec로 타입 안전한 데코레이터

시그니처가 살아 있다

이제 감싼 함수도 원본처럼 타입 검사를 받는다.

@logged
def add(a: int, b: int) -> int:
    return a + b

add(1, 2)        # OK, 반환은 int로 추론
add("x", "y")    # 오류: int 자리에 str
add(1)           # 오류: 인자 부족

P.argsP.kwargs는 반드시 짝으로, *args**kwargs에만 써야 한다. 이 둘이 함께 있어야 검사기가 “원본의 모든 인자를 그대로 전달한다”고 이해한다.

Concatenate: 인자를 추가하거나 제거하기

데코레이터가 인자를 하나 더 추가하거나, 앞쪽 인자를 소비하는 경우도 있다. 이때 Concatenate를 쓴다.

from typing import Callable, Concatenate, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

# 첫 인자로 Connection을 주입하는 데코레이터
def with_conn(
    fn: Callable[Concatenate[Connection, P], R],
) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        conn = get_connection()
        return fn(conn, *args, **kwargs)
    return wrapper

Concatenate[Connection, P]는 “맨 앞에 Connection 인자가 있고, 그 뒤로 P의 나머지 인자가 이어진다”는 뜻이다. 데코레이터가 conn을 주입하므로, 감싼 함수의 호출자는 conn을 넘기지 않아도 된다.

functools.wraps와 함께

ParamSpec은 타입 정보를 보존하지만, __name__이나 __doc__ 같은 런타임 메타데이터는 별개다. 둘을 함께 챙기려면 functools.wraps를 같이 쓴다.

import functools
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def logged(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn(*args, **kwargs)
    return wrapper

ParamSpec은 정적 타입을, functools.wraps는 런타임 메타데이터를 보존한다. 둘을 함께 써야 비로소 완전히 투명한 데코레이터가 된다. 다음 글에서는 이런 타입들을 실제로 검사하는 도구인 mypy와 pyright를 비교한다.


지난 글: typing.Protocol: 상속 없는 구조적 타이핑

다음 글: mypy와 pyright: 두 정적 타입 검사기 비교


읽어주셔서 감사합니다. 😊