Published on

Python 데코레이터 3중 중첩 시 인자·메타데이터 보존법

Authors

서버 코드에서 로깅, 재시도, 권한 체크 같은 횡단 관심사를 데코레이터로 쌓다 보면 어느 순간 inspect.signature(*args, **kwargs) 로 바뀌고, __name__, __doc__, __annotations__ 가 엉키며, FastAPI 같은 프레임워크가 의존성 주입 파라미터를 못 읽는 문제가 터집니다. 특히 데코레이터가 3중 이상 중첩될 때 이런 증상이 눈에 띄게 증가합니다.

이 글은 “왜 3중 중첩에서 더 자주 깨지는가”, “functools.wraps 로 해결되는 것과 안 되는 것”, “시그니처까지 보존하는 실전 패턴”을 코드로 정리합니다.

관련해서 네트워크 재시도 로직을 데코레이터로 만들 때 자주 같이 터지는 사례는 Python httpx ReadTimeout·ConnectError 재시도 설계도 참고하면 좋습니다.

3중 중첩에서 인자·메타데이터가 깨지는 대표 증상

다음 중 하나라도 겪었다면 거의 같은 원인입니다.

  • help(func) 혹은 func.__doc__ 이 원래 함수가 아니라 wrapper 설명으로 바뀜
  • func.__name__wrapper 같은 이름으로 바뀜
  • inspect.signature(func) 가 원래 파라미터가 아니라 (*args, **kwargs) 로 바뀜
  • typing.get_type_hints(func) 가 빈 dict 이 되거나 wrapper의 타입만 남음
  • FastAPI, Typer, Click 등에서 파라미터 인식 실패

핵심은 “각 데코레이터가 원본 함수를 wrapper로 감싸면서, 원본의 메타데이터와 시그니처가 단계적으로 손실된다”는 점입니다. 1겹에서는 눈치 못 채다가 3겹에서 폭발하는 이유는, 중간 데코레이터 중 하나만 wraps 를 빠뜨려도 그 시점부터 바깥쪽이 복구할 근거를 잃기 때문입니다.

functools.wraps 가 해주는 일과 한계

functools.wraps 는 내부적으로 functools.update_wrapper 를 호출해서 대략 다음을 복사합니다.

  • __module__, __name__, __qualname__, __doc__
  • __annotations__ (파이썬 버전에 따라 동작 차이 가능)
  • __dict__
  • 그리고 가장 중요한 __wrapped__ 를 설정

여기서 __wrapped__ 가 매우 중요합니다. inspect.signature 는 가능하면 __wrapped__ 체인을 따라가서 “원본”의 시그니처를 복원하려고 시도합니다.

하지만 한계가 있습니다.

  • wrapper 자체가 *args, **kwargs 로만 받으면, 어떤 도구는 wrapper 시그니처를 그대로 사용해 버립니다
  • 데코레이터 팩토리(인자를 받는 데코레이터)를 구현할 때 wraps 위치를 잘못 두면 __wrapped__ 체인이 끊깁니다
  • 비동기 함수 async def 를 동기 wrapper로 감싸거나 그 반대로 만들면 런타임 에러 또는 타입/시그니처 불일치가 생깁니다

결론적으로 “모든 데코레이터에 wraps 는 필수”지만, “wraps 만으로 100퍼센트 해결되지는 않는다”가 실무 결론입니다.

재현: 3중 데코레이터에서 한 군데만 실수해도 깨진다

아래 예시는 로깅, 타이밍, 재시도 데코레이터 3개를 쌓은 상황입니다. 일부러 timer 에서 wraps 를 빼서 문제가 어떻게 전파되는지 보여줍니다.

import time
import inspect
from functools import wraps


def logger(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"[log] calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper


def timer(fn):
    # 실수: wraps 누락
    def wrapper(*args, **kwargs):
        start = time.time()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"[timer] {time.time() - start:.3f}s")
    return wrapper


def retry(max_attempts=3):
    def deco(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            last = None
            for _ in range(max_attempts):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last = e
            raise last
        return wrapper
    return deco


@logger
@timer
@retry(max_attempts=2)
def fetch(user_id: int, q: str = "hello") -> str:
    """Fetch greeting."""
    return f"{user_id}:{q}"


print(fetch.__name__)
print(fetch.__doc__)
print(inspect.signature(fetch))

출력은 보통 이런 식으로 망가집니다.

  • __name__wrapper
  • docstring 사라짐
  • 시그니처가 (*args, **kwargs)

중요한 포인트는 “바깥쪽 loggerretrywraps 를 잘 썼는데도” 깨진다는 점입니다. 중간에서 __wrapped__ 체인이 끊겨서 바깥이 원본을 찾을 방법이 없습니다.

해결 1: 모든 데코레이터에 wraps 를 정확한 위치에 적용

가장 먼저 할 일은 “모든 데코레이터가 반드시 wraps 를 적용”하도록 통일하는 겁니다. 특히 데코레이터 팩토리에서 흔히 위치를 헷갈립니다.

import time
from functools import wraps


def timer(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.time()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"[timer] {time.time() - start:.3f}s")
    return wrapper

데코레이터 팩토리는 다음 구조를 지키면 안전합니다.

from functools import wraps


def deco_factory(x):
    def deco(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            return fn(*args, **kwargs)
        return wrapper
    return deco

이 단계만으로도 __name__, __doc__, __wrapped__ 체인 기반의 시그니처 복원은 대부분 해결됩니다.

해결 2: 시그니처까지 강제로 보존해야 하는 경우 __signature__ 를 설정

문제는 프레임워크/도구에 따라 inspect.signature__wrapped__ 체인을 충분히 따라가지 않거나, wrapper의 *args, **kwargs 를 그대로 신뢰하는 경우입니다. 대표적으로 CLI/DI 프레임워크, 자동 문서화 도구, 런타임 타입 검사기 조합에서 발생합니다.

이때는 wrapper에 __signature__ 를 직접 지정하는 패턴이 강력합니다.

import inspect
from functools import wraps


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

    def deco(wrapper_fn):
        wrapper_fn.__signature__ = sig
        return wrapper_fn

    return deco


def logger(fn):
    @wraps(fn)
    @preserve_signature(fn)
    def wrapper(*args, **kwargs):
        print(f"[log] {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

여기서 주의할 점이 있습니다.

  • @preserve_signature(fn)fn 을 캡처해야 하므로 wraps 와 함께 wrapper 정의 직후에 적용하는 형태가 깔끔합니다.
  • __signature__ 는 사실상 “이 함수는 이 시그니처를 가진 것처럼 보이게 하라”는 힌트라서, wrapper가 실제로 그 인자를 처리할 수 있어야 합니다.

3중 중첩에서도 각 데코레이터가 wraps 를 잘 쓰고, 필요 시 __signature__ 까지 세팅하면 도구 호환성이 크게 올라갑니다.

해결 3: 타입 힌트까지 보존하려면 ParamSpec 패턴을 사용

메타데이터만이 아니라 “타입 검사 관점에서의 인자/리턴 타입”도 깨지는 경우가 많습니다. Callable[..., Any] 로 붕괴하면 IDE 자동완성과 정적 분석 품질이 떨어집니다.

파이썬 3.10 이상(또는 3.9+에서 typing_extensions)이면 ParamSpecTypeVar 로 데코레이터 타입을 보존할 수 있습니다.

from __future__ import annotations

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

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


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

이 패턴의 장점은 다음과 같습니다.

  • wrapper가 원본과 동일한 호출 규약을 가진다고 타입 시스템에 알려줌
  • 여러 데코레이터가 중첩되어도 타입 붕괴를 최소화

다만 런타임 시그니처 자체를 바꾸는 것은 아니므로, 앞서 말한 __signature__ 패턴과는 목적이 다릅니다. “정적 타입”은 ParamSpec, “런타임 인트로스펙션”은 __signature__ 로 접근한다고 생각하면 정리됩니다.

실전 예제: 로깅 + 재시도 + 권한 체크 3중 중첩을 안전하게

아래는 실무에서 자주 겪는 3종 세트입니다.

  • require_role: 권한 체크
  • retry: 일시적 오류 재시도
  • logger: 호출 로깅

그리고 다음을 모두 만족하도록 구성합니다.

  • wraps 로 메타데이터 보존
  • 필요 시 __signature__ 로 도구 호환
  • ParamSpec 으로 타입 보존
from __future__ import annotations

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

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


def preserve_runtime_signature(fn: Callable[P, R]):
    sig = inspect.signature(fn)

    def apply(wrapper_fn: Callable[P, R]) -> Callable[P, R]:
        wrapper_fn.__signature__ = sig
        return wrapper_fn

    return apply


def logger(fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    @preserve_runtime_signature(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"[log] {fn.__qualname__}")
        return fn(*args, **kwargs)
    return wrapper


def retry(max_attempts: int = 3):
    def deco(fn: Callable[P, R]) -> Callable[P, R]:
        @wraps(fn)
        @preserve_runtime_signature(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last: Exception | None = None
            for _ in range(max_attempts):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last = e
            assert last is not None
            raise last
        return wrapper
    return deco


def require_role(role: str):
    def deco(fn: Callable[P, R]) -> Callable[P, R]:
        @wraps(fn)
        @preserve_runtime_signature(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            user = kwargs.get("user")
            if not user or role not in user.get("roles", []):
                raise PermissionError("forbidden")
            return fn(*args, **kwargs)
        return wrapper
    return deco


@logger
@retry(max_attempts=2)
@require_role("admin")
def do_admin_task(task_id: int, *, user: dict) -> str:
    """Run an admin task."""
    return f"ok:{task_id}"


print(do_admin_task.__name__)
print(do_admin_task.__doc__)
print(inspect.signature(do_admin_task))

이렇게 하면 3중 중첩이어도 다음이 유지됩니다.

  • __name__, __qualname__, __doc__
  • inspect.signaturetask_id: int, *, user: dict 로 유지
  • 타입 체커가 do_admin_task 호출을 올바르게 검사

흔한 함정 체크리스트

데코레이터가 원본 함수를 “호출하지 않는” 경우

캐싱 데코레이터나 라우팅 데코레이터처럼 “원본을 감싸지만 실제 실행은 다른 함수가 하는” 패턴에서는 __wrapped__ 체인이 의미를 잃을 수 있습니다. 그래도 최소한 wraps 로 메타데이터는 보존하고, 외부 도구가 원본 시그니처를 요구한다면 __signature__ 를 명시하세요.

비동기 함수에 동기 wrapper를 씌우는 경우

async def 를 감쌀 때 wrapper도 async def 여야 합니다. 아니면 호출자가 await 해야 할 객체를 못 받고 런타임 오류가 납니다. 이 경우 메타데이터 이전에 실행 자체가 깨집니다.

한 데코레이터라도 wraps 를 빼먹으면 바깥이 복구 못 한다

3중 중첩에서 가장 흔한 실수입니다. 코드 리뷰 규칙으로 “데코레이터는 무조건 wraps”를 강제하는 편이 비용 대비 효과가 큽니다.

정리

  • 3중 데코레이터에서 인자/메타데이터가 깨지는 근본 원인은 wrapper 체인에서 __wrapped__ 및 관련 메타데이터가 단계적으로 손실되기 때문입니다.
  • 1차 방어선은 “모든 데코레이터에 functools.wraps 를 올바른 위치에 적용”입니다.
  • 도구 호환성까지 완전히 잡으려면 wrapper에 __signature__ 를 설정해 런타임 시그니처를 강제로 보존하세요.
  • 정적 타입까지 보존하려면 ParamSpecTypeVar 로 데코레이터 타입을 정의하세요.

재시도 같은 횡단 관심사는 데코레이터로 깔끔해지지만, 호출 규약과 인트로스펙션이 중요한 환경에서는 위 패턴을 기본 템플릿으로 두는 것이 안전합니다. 특히 HTTP 클라이언트 재시도처럼 데코레이터를 여러 겹 쌓기 쉬운 주제는 Python httpx ReadTimeout·ConnectError 재시도 설계와 함께 보면 구현 실수가 줄어듭니다.