Published on

Python 데코레이터 인자 유지 - wraps·signature 함정

Authors

서버 코드에서 데코레이터를 한 겹만 씌워도, 디버깅이나 자동 문서화(OpenAPI), DI 프레임워크(FastAPI 스타일), CLI 자동 생성(argparse/typer 류), 테스트 더블, APM 트레이싱에서 갑자기 함수 인자 정보가 사라지는 경험을 하게 됩니다. 겉보기엔 함수가 정상 동작하는데, 런타임에서 inspect.signature()(*args, **kwargs)로 바뀌거나 __name__, __doc__가 날아가면서 연쇄적으로 문제가 터집니다.

이 글은 “Python 데코레이터에서 인자(시그니처)를 유지하는 법”을 중심으로, wraps를 썼는데도 시그니처가 깨지는 케이스와 inspect.signature 관련 함정을 현실적으로 정리합니다.


왜 데코레이터가 시그니처를 망가뜨릴까

가장 흔한 데코레이터 구현은 아래처럼 래퍼 함수가 *args, **kwargs를 받도록 작성됩니다.

from typing import Callable

def deco(fn: Callable):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

이때 wrapper의 시그니처가 곧 “최종 함수의 시그니처”가 됩니다. 그래서 inspect.signature(래핑된_함수)는 원본이 아니라 wrapper(*args, **kwargs)를 보게 되죠.

또한 이름, 문서 문자열, 모듈 경로도 wrapper 기준으로 바뀝니다.


functools.wraps로 메타데이터 복구하기 (기본)

wraps는 원본 함수의 메타데이터를 래퍼로 복사해 줍니다. 최소한 아래 형태가 표준입니다.

import functools
from typing import Callable

def deco(fn: Callable):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

여기까지 하면 다음이 개선됩니다.

  • __name__, __qualname__, __doc__, __module__ 복구
  • __wrapped__ 속성 설정 (중요)

특히 __wrapped__inspect.signature()가 “원본을 따라가서” 시그니처를 복원할 수 있게 하는 핵심 단서입니다.


그런데 wraps를 썼는데도 signature가 깨지는 이유

현장에서 가장 많이 마주치는 원인은 크게 4가지입니다.

1) 중간에 __wrapped__ 체인이 끊겼다

데코레이터를 여러 겹 적용하는데, 그중 하나라도 wraps를 안 쓰면 체인이 끊깁니다.

import functools

def good(fn):
    @functools.wraps(fn)
    def w(*a, **k):
        return fn(*a, **k)
    return w

def bad(fn):
    def w(*a, **k):
        return fn(*a, **k)
    return w

@good
@bad
def f(x: int, y: str = "a"):
    return x, y

이 경우 inspect.signature(f)는 종종 (*a, **k)로 보이거나, 도구에 따라 원본 추적이 중단됩니다.

해결책은 단순합니다. “모든 데코레이터가 wraps를 사용”해야 합니다.

2) 데코레이터가 함수가 아니라 callable 객체를 반환한다

클래스 기반 데코레이터나 __call__을 가진 객체를 반환하면, inspect.signature()가 기대대로 동작하지 않는 경우가 있습니다.

import functools

class Deco:
    def __init__(self, fn):
        self.fn = fn
        functools.update_wrapper(self, fn)

    def __call__(self, *args, **kwargs):
        return self.fn(*args, **kwargs)

@Deco
def f(x: int, y: int):
    return x + y

update_wrapper(self, fn)로 메타데이터는 복사되지만, 도구에 따라 signature__call__ 기준으로 잡히기도 합니다.

이럴 땐 아래 “__signature__를 명시적으로 지정”하는 방식이 더 확실합니다.

3) C 확장/바운드 메서드/디스크립터가 섞여 있다

일부 내장 함수, C 확장 함수, 특정 프록시 객체는 inspect.signature()가 내부적으로 예외를 내거나, 원본 시그니처 추적이 제한될 수 있습니다.

이 경우도 __signature__를 직접 지정하는 편이 안전합니다.

4) 프레임워크가 __wrapped__를 따라가지 않거나, 반대로 너무 따라간다

도구마다 정책이 다릅니다.

  • 어떤 도구는 inspect.signature(fn)만 믿고 __wrapped__를 따라가지 않음
  • 어떤 도구는 __wrapped__를 끝까지 풀어 “원본”만 보고, 데코레이터가 의도한 인터페이스 변경을 무시함

따라서 “내가 외부에 노출하고 싶은 시그니처”를 고정하려면 __signature__ 오버라이드가 가장 명시적입니다.


가장 확실한 해결: __signature__를 강제로 세팅하기

Python의 inspect는 객체에 __signature__가 있으면 그것을 우선합니다. 즉, 데코레이터가 외부에 노출할 시그니처를 직접 지정할 수 있습니다.

import functools
import inspect

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

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)

    # 핵심: 외부에 보일 시그니처를 고정
    wrapper.__signature__ = sig
    return wrapper

@keep_signature
def f(x: int, y: str = "a") -> str:
    return f"{x}-{y}"

이 방식의 장점:

  • wraps로 메타데이터 유지
  • inspect.signature()가 항상 원본 시그니처를 반환
  • OpenAPI/CLI/DI/문서화 도구가 안정적으로 동작

주의할 점:

  • 데코레이터가 실제로 인자 규칙을 바꿨다면(예: 추가 파라미터 삽입), 원본 시그니처를 그대로 노출하면 호출자 입장에서 “문서와 실제”가 어긋날 수 있습니다.

데코레이터가 인자를 추가/변형한다면: 새 시그니처를 생성

예를 들어 request_id 같은 인자를 “사용자에게 노출하고 싶지 않게” 내부에서만 주입하거나, 반대로 “새 인자를 공식 인터페이스로 추가”하고 싶을 수 있습니다. 이때는 inspect.Signature.replace()로 새 시그니처를 만들어 지정합니다.

아래 예시는 trace_id를 키워드 전용 인자로 추가해 외부에 노출하는 패턴입니다.

import functools
import inspect

def add_trace_id(fn):
    old_sig = inspect.signature(fn)

    params = list(old_sig.parameters.values())
    params.append(
        inspect.Parameter(
            name="trace_id",
            kind=inspect.Parameter.KEYWORD_ONLY,
            default=None,
            annotation=str | None,
        )
    )
    new_sig = old_sig.replace(parameters=params)

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        trace_id = kwargs.pop("trace_id", None)
        # 여기서 trace_id를 로깅/컨텍스트에 주입한다고 가정
        return fn(*args, **kwargs)

    wrapper.__signature__ = new_sig
    return wrapper

@add_trace_id
def f(x: int, y: int) -> int:
    return x + y

이제 inspect.signature(f)trace_id까지 포함한 시그니처를 제공합니다.


“signature 버그”처럼 보이는 대표 디버깅 포인트

inspect.signature()가 뭔가 이상할 때 체크리스트

  1. functools.wraps가 모든 데코레이터에 적용됐는지 확인
  2. __wrapped__가 남아있는지 확인
  3. inspect.unwrap()로 원본이 무엇인지 확인
  4. __signature__가 어딘가에서 덮어써졌는지 확인

아래 코드는 문제를 빠르게 드러냅니다.

import inspect

def debug_callable(fn):
    print("name:", getattr(fn, "__name__", None))
    print("wrapped:", hasattr(fn, "__wrapped__"))
    print("signature:", inspect.signature(fn))
    print("unwrapped:", inspect.unwrap(fn))

# debug_callable(대상함수) 형태로 호출

특히 inspect.unwrap(fn) 결과가 기대한 원본 함수가 아니라면, 중간 데코레이터가 wraps를 누락했을 가능성이 큽니다.


실전 패턴: 인자 검증/로깅/리트라이 데코레이터에서 시그니처 유지

예를 들어 리트라이 데코레이터는 흔히 *args, **kwargs로 감싸는데, 이게 API 레이어(예: FastAPI 엔드포인트)까지 올라오면 문서/DI가 망가질 수 있습니다.

아래는 “리트라이 + 시그니처 유지” 템플릿입니다.

import functools
import inspect
import time

def retry(times: int = 3, delay_s: float = 0.1):
    def deco(fn):
        sig = inspect.signature(fn)

        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            last_exc = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_exc = e
                    time.sleep(delay_s)
            raise last_exc

        wrapper.__signature__ = sig
        return wrapper

    return deco

@retry(times=5, delay_s=0.05)
def fetch_user(user_id: int, *, timeout_s: float = 1.0) -> dict:
    return {"user_id": user_id, "timeout_s": timeout_s}

포인트는 “파라미터를 받는 데코레이터 팩토리”에서도 sig를 캡처하고 __signature__를 세팅하는 것입니다.


타입 힌트까지 깔끔하게 유지하려면 (선택적 고급)

런타임 시그니처만이 아니라 정적 타입 체크까지 신경 쓴다면 ParamSpec, TypeVar를 사용해 데코레이터의 타입을 보존할 수 있습니다.

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

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

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

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

    wrapper.__signature__ = sig
    return wrapper

이렇게 하면 IDE 자동완성과 타입 추론이 데코레이터 적용 후에도 비교적 잘 유지됩니다.

주의: 이 코드 블록의 ParamSpec은 Python 3.10+에서 일반적으로 사용하며, 프로젝트의 타입 체커 설정에 따라 동작이 달라질 수 있습니다.


wrapssignature를 둘 다 써야 하는 이유

  • wraps는 “메타데이터와 __wrapped__ 체인”을 유지하는 도구
  • __signature__는 “외부에 노출할 호출 규약을 강제”하는 도구

즉, wraps만으로 충분한 경우가 많지만, 프레임워크/도구/클래스 데코레이터/복합 데코레이터가 섞이면 __signature__가 최후의 안전장치가 됩니다.

이 관점은 분산 시스템에서 타임아웃/리트라이를 설계할 때도 비슷합니다. 겉으로는 동작하지만 관측과 계약(인터페이스)이 무너질 때 장애가 커집니다. 리트라이 폭주를 막는 계약 설계 관점은 gRPC MSA에서 데드라인·리트라이 폭주 막는 법에서도 참고할 만합니다.

또한, 배포 환경에서 관측/라우팅 계층이 얽히면 “겉으로는 정상인데 특정 조합에서만 실패”하는 일이 생기는데, 그런 디버깅 접근은 Nginx HTTPS 리다이렉트 루프(301·307) 해결법 같은 글의 트러블슈팅 흐름과도 통합니다.


결론: 데코레이터 품질의 기준은 “동작”이 아니라 “계약 유지”

데코레이터는 기능을 횡단 관심사로 분리해 코드를 깔끔하게 만들지만, 동시에 함수의 “계약”을 흐리게 만들기 쉽습니다. 다음 3가지만 지키면 대부분의 wraps·signature 관련 문제를 예방할 수 있습니다.

  1. 모든 데코레이터에 functools.wraps 적용
  2. 도구/프레임워크 의존이 있는 레이어(API, CLI, DI)에서는 wrapper.__signature__ = inspect.signature(fn)로 고정
  3. 의도적으로 인터페이스를 바꿀 때는 inspect.Signature로 새 시그니처를 만들어 명시

이렇게 해두면 “런타임은 되는데 문서/DI/테스트가 깨지는” 류의 고질적인 버그를 초기에 차단할 수 있습니다.