Published on

Python 데코레이터에서 인자·메타데이터 보존하기

Authors

데코레이터는 로깅, 캐시, 권한 검사처럼 횡단 관심사를 깔끔하게 분리해주지만, 잘못 작성하면 함수의 인자 정보와 메타데이터가 손실됩니다. 그 결과로 다음 문제가 자주 터집니다.

  • help()/Sphinx 문서에서 함수 설명(__doc__)이 사라짐
  • 디버깅 시 함수 이름이 전부 wrapper로 보임
  • 타입 체커가 반환 타입을 Any로 취급하거나 오탐을 냄
  • FastAPI 같은 프레임워크가 시그니처 기반으로 의존성 주입/검증을 하는데, 데코레이터가 시그니처를 망가뜨려 런타임 오류가 발생

이 글에서는 인자(시그니처)와 메타데이터를 보존하는 데코레이터 작성법을 functools.wraps부터 최신 타입 힌트(ParamSpec)까지 단계적으로 정리합니다.

참고로, 파이썬에서 작은 경고를 무시했다가 나중에 더 큰 장애로 번지는 패턴은 흔합니다. 예를 들어 판다스에서도 SettingWithCopyWarning을 방치하면 데이터가 조용히 잘못 바뀌는 일이 생기죠. 비슷한 맥락의 실전 대응은 pandas SettingWithCopyWarning 완전 해결 - loc·copy도 함께 참고하면 좋습니다.

데코레이터가 메타데이터를 깨뜨리는 이유

가장 단순한 데코레이터는 원본 함수를 감싼 wrapper 함수를 반환합니다.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a: int, b: int) -> int:
    """두 수를 더합니다."""
    return a + b

print(add.__name__)  # wrapper
print(add.__doc__)   # None

이 상태는 다음을 의미합니다.

  • add라는 이름으로 호출하지만 실제 객체는 wrapper
  • add.__doc__, add.__annotations__ 등 메타데이터가 wrapper의 것으로 덮임

즉, “원본 함수처럼 보이는 감싼 함수”를 만들었지만, 파이썬은 그걸 원본으로 착각해주지 않습니다.

1단계: functools.wraps로 기본 메타데이터 보존

표준 해법은 functools.wraps입니다. 내부적으로 functools.update_wrapper를 호출해 __name__, __doc__, __module__, __qualname__, __annotations__ 등을 복사하고, 무엇보다 __wrapped__ 속성을 설정합니다.

from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a: int, b: int) -> int:
    """두 수를 더합니다."""
    return a + b

print(add.__name__)         # add
print(add.__doc__)          # 두 수를 더합니다.
print(add.__annotations__)  # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

__wrapped__가 중요한 이유

wrapsadd.__wrapped__에 원본 함수를 연결합니다. 이 덕분에 inspect가 “래핑을 벗겨서” 원본을 추적할 수 있습니다.

import inspect

print(add.__wrapped__)              # 원본 함수 객체
print(inspect.unwrap(add) is add.__wrapped__)  # True

문서화 도구, 디버거, 일부 프레임워크는 이 속성을 통해 원본 정보를 복구합니다. 그래서 데코레이터를 만든다면 wraps는 사실상 필수입니다.

2단계: 인자 시그니처 보존(또는 복구)하기

문제는 wraps를 써도 런타임 시그니처는 여전히 (*args, **kwargs)로 남는 경우가 많다는 점입니다.

import inspect

@my_decorator
def f(x: int, y: str = "a") -> str:
    return y * x

print(inspect.signature(f))  # (x: int, y: str = 'a') -> str 로 보일 수도 있지만
                             # 구현/도구에 따라 (*args, **kwargs)로 보이는 케이스가 존재

대부분의 inspect.signature__wrapped__를 따라가서 원본 시그니처를 보여주지만, 프레임워크/도구가 항상 그렇게 동작하진 않습니다. 특히 다음 상황에서 문제가 커집니다.

  • 프레임워크가 래핑을 “언랩”하지 않고 현재 callable의 시그니처를 그대로 사용
  • 여러 데코레이터가 중첩되어 __wrapped__ 체인이 꼬임
  • functools.partial, C 확장 함수, 바운드 메서드 등과 섞이며 추론이 실패

해결책 A: __signature__를 명시적으로 지정

파이썬은 함수 객체에 __signature__를 설정하면 inspect.signature가 그것을 우선합니다.

import inspect
from functools import wraps

def preserve_signature(func):
    sig = inspect.signature(func)

    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)

    wrapper.__signature__ = sig
    return wrapper

@preserve_signature
def hello(name: str, times: int = 1) -> str:
    return " ".join([f"hi {name}"] * times)

print(inspect.signature(hello))  # (name: str, times: int = 1) -> str

이 패턴은 FastAPI, Typer 같은 시그니처 기반 프레임워크에서 특히 유용합니다.

해결책 B: 데코레이터를 “시그니처를 바꾸지 않게” 설계

가장 좋은 건 wrapper가 실제로도 동일한 시그니처를 갖는 것입니다. 다만 파이썬에서 동적으로 동일 시그니처 함수를 생성하는 건 복잡하고 보안/가독성 이슈가 있어 보통 권장되진 않습니다.

대신, 타입 레벨에서 시그니처를 보존하는 방법(다음 섹션의 ParamSpec)을 적극 권합니다.

3단계: 타입 힌트까지 보존하기(ParamSpecTypeVar)

wraps는 런타임 메타데이터를 복사하지만, 타입 체커 입장에서는 wrapper가 (*args: Any, **kwargs: Any) -> Any로 보일 수 있습니다. 이를 해결하려면 typing.ParamSpec을 사용해 “원본 함수의 파라미터 타입”을 그대로 전달해야 합니다.

다음은 반환 타입과 인자 타입을 모두 보존하는 정석 패턴입니다.

from __future__ import annotations

from functools import wraps
from typing import Callable, ParamSpec, TypeVar

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

def logged(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)

    return wrapper

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

이제 mypy/pyrightadd가 여전히 (int, int) -> int임을 이해합니다.

자주 하는 실수: Callable[..., R]만 쓰기

from typing import Callable, TypeVar

R = TypeVar("R")

def bad_logged(func: Callable[..., R]) -> Callable[..., R]:
    def wrapper(*args, **kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

이 방식은 반환 타입은 유지하지만, 인자 타입은 사실상 포기하는 셈이라 IDE 자동완성/정적 분석 품질이 확 떨어집니다.

4단계: 인자를 받는 데코레이터(데코레이터 팩토리)에서 보존하기

실무에서는 @retry(max_attempts=3)처럼 데코레이터 자체가 인자를 받는 형태가 많습니다. 이때도 ParamSpec을 그대로 적용할 수 있습니다.

from __future__ import annotations

import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar

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

def retry(max_attempts: int, delay_sec: float = 0.0) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last_exc: Exception | None = None
            for _ in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exc = e
                    if delay_sec:
                        time.sleep(delay_sec)
            assert last_exc is not None
            raise last_exc

        return wrapper

    return decorator

@retry(max_attempts=3, delay_sec=0.1)
def flaky(n: int) -> int:
    if n < 0:
        raise ValueError("n must be non-negative")
    return n

핵심은 반환 타입이 Callable[[Callable[P, R]], Callable[P, R]] 형태가 된다는 점입니다. “함수를 받아 함수를 반환”하는 타입을 정확히 적어주면, 타입 체커가 데코레이터 적용 전후의 시그니처를 추적할 수 있습니다.

5단계: 비동기 함수(async def) 데코레이터에서 보존하기

async 함수는 반환 타입이 Awaitable[R]처럼 보이기도 하고, wrapper를 async def로 작성해야 await 체인이 자연스럽습니다.

from __future__ import annotations

from functools import wraps
from typing import Awaitable, Callable, ParamSpec, TypeVar

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

def async_logged(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
    @wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {func.__name__}")
        return await func(*args, **kwargs)

    return wrapper

@async_logged
async def fetch(user_id: int) -> str:
    return f"user:{user_id}"

여기서 wrapper의 반환 타입을 R로 둔 이유는 async def 자체가 Awaitable[R]를 반환하는 함수이기 때문입니다. 즉, wrapper의 함수 타입은 Callable[P, Awaitable[R]]로 맞춰집니다.

6단계: 커스텀 메타데이터(속성)까지 안전하게 붙이기

데코레이터가 단순히 감싸는 것뿐 아니라, 원본 함수에 태그/설정 값을 붙여 라우팅이나 레지스트리에 쓰는 경우가 많습니다.

패턴 A: wrapper에 속성 부여(기본)

from functools import wraps

def route(path: str):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)

        wrapper.route_path = path
        return wrapper

    return decorator

@route("/health")
def health() -> dict:
    return {"ok": True}

print(health.route_path)  # /health

패턴 B: 원본 함수에도 속성 유지(언랩 시에도 필요할 때)

도구가 inspect.unwrap로 원본을 꺼내서 검사한다면, wrapper에만 속성을 달아두면 놓칠 수 있습니다. 이때는 원본에도 동일 속성을 붙이거나, 검사 시 wrapper와 __wrapped__를 모두 확인하는 규칙을 정하세요.

from functools import wraps

def tag(name: str):
    def decorator(func):
        func.tag = name  # 원본에 부여

        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)

        wrapper.tag = name  # wrapper에도 부여
        return wrapper

    return decorator

7단계: 여러 데코레이터 중첩 시 메타데이터가 깨지는 케이스

데코레이터를 여러 개 겹치면, 하나라도 wraps를 빼먹는 순간 체인이 끊깁니다.

from functools import wraps

def good(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def bad(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@good
@bad
def f(x: int) -> int:
    """doc"""
    return x

print(f.__name__)  # wrapper (bad 때문에 깨짐)
print(f.__doc__)   # None

체크리스트

  • 모든 데코레이터가 @wraps(func)를 사용했는지
  • 시그니처가 중요한 환경이면 __signature__ 지정이 필요한지
  • 타입 보존이 필요하면 ParamSpec을 사용했는지
  • 메타데이터를 wrapper에만 달지, 원본에도 달지 정책이 있는지

이런 “한 군데에서만 깨져도 전체가 무너지는” 성격은 분산 시스템의 트랜잭션 설계와도 닮았습니다. 실패 복구를 체계화하는 관점은 MSA Saga 패턴 실패 복구 - 보상·재시도 설계에서도 비슷한 통찰을 얻을 수 있습니다.

실전 예제: 시그니처·타입·메타데이터를 모두 살리는 로깅 데코레이터

아래는 실무에서 바로 써먹기 좋은 “풀 패키지” 예시입니다.

  • wraps로 기본 메타데이터 보존
  • ParamSpec으로 타입 보존
  • __signature__로 런타임 시그니처 강제 보존
  • 커스텀 속성(log_category) 부여
from __future__ import annotations

import inspect
import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar

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

def log_timing(category: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        sig = inspect.signature(func)

        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            start = time.perf_counter()
            try:
                return func(*args, **kwargs)
            finally:
                elapsed_ms = (time.perf_counter() - start) * 1000
                print(f"[{category}] {func.__name__} took {elapsed_ms:.2f}ms")

        wrapper.__signature__ = sig
        wrapper.log_category = category
        return wrapper

    return decorator

@log_timing("db")
def query(user_id: int, limit: int = 10) -> list[int]:
    """유저의 최근 항목 id를 조회합니다."""
    return list(range(user_id, user_id + limit))

이제 다음이 모두 만족됩니다.

  • query.__name__, query.__doc__, query.__annotations__ 보존
  • 타입 체커가 query의 인자/반환 타입을 추론
  • inspect.signature(query)가 원본 시그니처를 안정적으로 반환
  • query.log_category 같은 커스텀 메타데이터도 유지

마무리: 어떤 수준까지 보존할지 기준 세우기

데코레이터는 “감싸기”가 쉬운 만큼, 메타데이터/시그니처/타입을 어디까지 보존할지 명확한 기준이 필요합니다.

  • 단순 로깅/측정: wraps + ParamSpec만으로 충분한 경우가 많음
  • 프레임워크가 시그니처에 민감: __signature__까지 고려
  • 문서화/레지스트리/라우팅: 커스텀 속성 부여 정책(원본 vs wrapper) 결정

한 줄로 정리하면, **런타임은 wraps__signature__, 정적 분석은 ParamSpec**이 핵심입니다. 이를 기본 템플릿으로 삼아두면, 데코레이터를 늘려도 인자·메타데이터 손실로 인한 디버깅 비용을 크게 줄일 수 있습니다.