Published on

Decorator에서 args/kwargs 깨짐? wraps·signature 완전정복

Authors

서버 코드에서 로깅, 트레이싱, 권한 체크, 재시도 같은 횡단 관심사를 데코레이터로 빼는 순간, 이상한 문제가 연쇄적으로 터집니다.

  • *args, **kwargs로만 받으니 IDE 자동완성이 사라지고 타입 힌트가 무용지물이 됨
  • FastAPI/Click/Typer 같은 프레임워크가 파라미터를 못 읽어 라우팅/옵션 파싱이 깨짐
  • DI 컨테이너(예: Depends)가 함수 시그니처를 기준으로 주입하는데 주입이 실패함
  • help()/Sphinx 문서에서 함수 설명과 파라미터가 데코레이터 내부 wrapper로 바뀜

핵심 원인은 대부분 “원래 함수의 메타데이터와 시그니처가 wrapper로 덮어씌워졌기 때문”입니다. 이 글은 그 문제를 functools.wrapsinspect.signature 관점에서 정확히 짚고, 실무에서 안전하게 쓰는 패턴까지 정리합니다.

왜 데코레이터를 쓰면 args/kwargs가 ‘깨져 보이나’?

파이썬 데코레이터는 결국 아래 변환과 같습니다.

@decorator
def f(a, b=1):
    return a + b

# 대략 아래와 같음
f = decorator(f)

여기서 decorator(f)가 반환하는 것은 대개 wrapper(*args, **kwargs) 같은 새 함수입니다. 즉, 호출 자체는 되더라도 “함수의 정체성(identity)”과 “호출 규약(contract)”이 wrapper의 것으로 바뀝니다.

문제가 되는 지점은 크게 두 가지입니다.

  1. 메타데이터 손실
  • __name__, __qualname__, __doc__, __module__, __annotations__
  1. 시그니처 손실
  • inspect.signature(f)가 원래 (a, b=1)이 아니라 (*args, **kwargs)로 보임
  • 프레임워크는 이 시그니처를 읽어 파라미터 바인딩/검증/주입을 수행함

functools.wraps가 해결하는 것과 못 하는 것

가장 먼저 해야 할 일은 wraps를 붙이는 것입니다.

from functools import wraps

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("call", func.__name__, args, kwargs)
        return func(*args, **kwargs)
    return wrapper

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

이제 다음이 복원됩니다.

  • add.__name__ == "add"
  • add.__doc__가 원래 docstring 유지
  • add.__annotations__ 유지
  • add.__wrapped__가 원래 함수로 연결됨

하지만 많은 분들이 오해하는 부분이 있습니다.

wraps만으로 시그니처가 항상 복원되진 않는다

inspect.signature(add)는 파이썬 버전과 도구에 따라 다르게 보일 수 있습니다. 표준 inspect.signature__wrapped__ 체인을 따라가 원래 시그니처를 보여주려 시도하지만,

  • 프레임워크가 inspect.signature를 그대로 쓰지 않거나
  • __signature__를 직접 보거나
  • wrapper가 추가 파라미터를 강제하거나
  • 데코레이터가 여러 번 중첩되면서 특정 레이어에서 메타가 끊기면

여전히 (*args, **kwargs)로 인식될 수 있습니다.

즉, wraps는 “필수”지만 “충분”하진 않습니다.

inspect.signature와 바인딩: 문제를 재현해보자

아래는 프레임워크가 하는 일과 유사하게, 시그니처 기반으로 인자를 바인딩하는 예시입니다.

import inspect

def bind_demo(func, *args, **kwargs):
    sig = inspect.signature(func)
    bound = sig.bind_partial(*args, **kwargs)
    bound.apply_defaults()
    return bound.arguments

def f(a, b=2, *, c=3):
    return a + b + c

print(bind_demo(f, 10, c=99))
# {'a': 10, 'b': 2, 'c': 99}

이런 식으로 프레임워크는

  • 어떤 인자가 positional-only인지
  • keyword-only인지
  • 기본값이 무엇인지
  • 타입 힌트는 무엇인지

등을 판단합니다.

그런데 데코레이터가 wrapper(*args, **kwargs)로 바뀌면 바인딩 정보가 증발합니다.

__signature__로 “보이는 시그니처”를 고정하는 패턴

실무에서 가장 확실한 방법은 wrapper에 __signature__를 설정하는 것입니다. 많은 도구가 이 값을 우선합니다.

from functools import wraps
import inspect

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

    def decorator(inner):
        inner.__signature__ = sig  # 핵심
        return inner

    return decorator

def log_calls(func):
    @wraps(func)
    @preserve_signature(func)
    def wrapper(*args, **kwargs):
        print("call", func.__name__)
        return func(*args, **kwargs)
    return wrapper

@log_calls
def add(a: int, b: int = 1) -> int:
    return a + b

print(inspect.signature(add))
# (a: int, b: int = 1) -> int

이 패턴의 장점

  • IDE/문서/프레임워크가 wrapper를 보더라도 원래 시그니처로 인식
  • 중첩 데코레이터에서도 비교적 안정적

주의점

  • wrapper가 실제로는 *args, **kwargs로 받기 때문에 런타임 호출 검증은 원래 함수에서 일어납니다
  • wrapper 단계에서 파라미터를 추가/삭제하는 데코레이터라면 원래 시그니처를 그대로 노출하면 오히려 혼란이 생깁니다

“파라미터를 추가하는 데코레이터”의 올바른 시그니처 설계

예를 들어, 요청 컨텍스트를 주입하거나, request_id 같은 값을 keyword-only로 강제하고 싶을 수 있습니다.

이때는 “원래 시그니처 유지”가 아니라 “새 시그니처 구성”이 맞습니다.

from functools import wraps
import inspect

def with_request_id(func):
    base = inspect.signature(func)

    new_params = list(base.parameters.values())
    new_params.append(
        inspect.Parameter(
            name="request_id",
            kind=inspect.Parameter.KEYWORD_ONLY,
            default=None,
        )
    )
    new_sig = base.replace(parameters=new_params)

    @wraps(func)
    def wrapper(*args, **kwargs):
        # request_id는 wrapper에서 소비하고, 원 함수로는 넘기지 않는다고 가정
        request_id = kwargs.pop("request_id", None)
        if request_id:
            print("request_id=", request_id)
        return func(*args, **kwargs)

    wrapper.__signature__ = new_sig
    return wrapper

@with_request_id
def work(a, b=1):
    return a + b

print(inspect.signature(work))
# (a, b=1, *, request_id=None)

여기서 중요한 포인트는 두 가지입니다.

  • wrapper가 소비(pop)하는 인자는 원 함수에 전달되지 않도록 관리
  • 외부에 노출되는 시그니처(__signature__)는 실제 동작과 일치해야 함

wraps를 썼는데도 깨지는 대표 케이스 5가지

1) 데코레이터 팩토리에서 wraps 위치가 잘못됨

def deco(arg):
    def decorator(func):
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper
    return decorator

위 코드는 wraps가 없어 메타데이터가 전부 사라집니다. 올바른 형태는 아래입니다.

from functools import wraps

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

2) 여러 데코레이터 중 하나가 wraps를 안 씀

데코레이터가 3개 중 1개라도 wraps를 누락하면 그 지점에서 체인이 끊겨 __wrapped__ 추적이 망가질 수 있습니다.

해결: 모든 데코레이터에 wraps를 적용하거나, 최종 wrapper에 __signature__를 강제 설정하세요.

3) 클래스 기반 데코레이터에서 __call__ 메타데이터가 사라짐

class Log:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print("call")
        return self.func(*args, **kwargs)

이 경우 wraps를 메서드에 직접 붙일 수 없기 때문에 functools.update_wrapper를 써야 합니다.

import functools

class Log:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        print("call", self.__name__)
        return self.func(*args, **kwargs)

추가로, 프레임워크가 시그니처를 읽어야 한다면 self.__signature__도 설정하는 편이 안전합니다.

import inspect

class Log:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)
        self.__signature__ = inspect.signature(func)

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

4) partial/partialmethod와 결합되며 시그니처가 의도와 다르게 보임

functools.partial은 일부 인자를 고정하지만, 도구에 따라 시그니처가 다르게 표현됩니다. 이때도 inspect.signature로 새 시그니처를 만들어 __signature__에 넣으면 일관성을 얻을 수 있습니다.

5) 런타임 인자 검증을 wrapper에서 하고 싶을 때

wrapper에서 kwargs를 검사하거나 특정 키를 강제하면, 원 함수의 TypeError 메시지와 충돌해 디버깅이 어려워집니다.

권장 패턴은 아래 중 하나입니다.

  • wrapper는 최소 개입(로깅/메트릭)만 하고 검증은 원 함수에 맡김
  • wrapper가 파라미터를 바꾸면, __signature__도 그에 맞게 바꿈

실전 예제: 재시도 데코레이터를 “프레임워크 친화적”으로 만들기

재시도 데코레이터는 흔하지만, 대충 만들면 시그니처가 바로 망가집니다. 아래는 wraps__signature__를 함께 적용한 예시입니다.

from functools import wraps
import inspect
import time

def retry(times: int = 3, delay_sec: float = 0.2, exc_types=(Exception,)):
    def decorator(func):
        sig = inspect.signature(func)

        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except exc_types as e:
                    last_exc = e
                    if i < times - 1:
                        time.sleep(delay_sec)
            raise last_exc

        wrapper.__signature__ = sig
        return wrapper

    return decorator

@retry(times=5, delay_sec=0.1, exc_types=(ValueError,))
def parse_int(text: str, base: int = 10) -> int:
    return int(text, base)

print(inspect.signature(parse_int))
# (text: str, base: int = 10) -> int

이렇게 하면

  • FastAPI 같은 곳에서 파라미터를 제대로 인식
  • 문서화 도구가 원래 함수 설명을 유지
  • 호출은 wrapper가 받아도 “보이는 계약”은 원 함수와 동일

디버깅 체크리스트: 내 데코레이터가 시그니처를 망가뜨렸나?

아래를 순서대로 확인하면 원인 파악이 빠릅니다.

import inspect

def debug_func(f):
    print("name=", f.__name__)
    print("doc=", (f.__doc__ or "")[:60])
    print("sig=", inspect.signature(f))
    print("has_wrapped=", hasattr(f, "__wrapped__"))
    print("has_signature=", hasattr(f, "__signature__"))
  • __wrapped__가 없다면 wraps/update_wrapper 누락 가능성이 큼
  • inspect.signature(*args, **kwargs)로만 보이면 __signature__ 설정을 고려
  • 데코레이터가 중첩이라면 “중간 레이어” 중 누가 메타를 끊었는지 찾아야 함

이 과정은 인프라 장애를 추적할 때 “캐시가 끊긴 레이어”를 찾는 것과 비슷합니다. 예를 들어 Docker 레이어 캐시가 안 먹는 지점을 찾는 접근과도 닮았는데, 관련해서는 GitHub Actions에서 Docker 레이어 캐시가 안 먹힐 때 글의 사고방식이 참고가 됩니다.

언제 __signature__를 써야 하고, 언제 과한가?

  • 단순 로깅/메트릭/트레이싱: wraps만으로도 충분한 경우가 많음
  • 프레임워크가 시그니처 기반으로 동작(FastAPI, Click/Typer, DI 등): __signature__까지 설정 권장
  • 데코레이터가 파라미터를 변경: 새 시그니처를 구성해서 __signature__에 반영

“겉으로 보이는 계약”과 “실제 동작”이 어긋나면 디버깅 비용이 폭발합니다. 이는 예를 들어 JWT 401에서 시계 오차나 키 롤오버처럼 겉으로는 단순 인증 실패로 보이지만 실제 원인이 다른 레이어에 있을 때와 유사합니다. 관심 있다면 Spring Security JWT 401 원인 - 시계오차·키롤오버도 같이 읽어보면 문제 분해 방식에 도움이 됩니다.

결론: 데코레이터 품질은 “호출 규약 보존”에서 갈린다

정리하면 아래 3줄이 실무 기준선입니다.

  1. 모든 함수형 데코레이터는 @wraps(func)를 기본으로 붙인다
  2. 시그니처 기반 프레임워크와 함께라면 wrapper.__signature__ = inspect.signature(func)를 적용한다
  3. wrapper가 인자를 소비/추가/변형한다면, inspect.Signature.replace로 “일치하는 새 시그니처”를 만들어 노출한다

데코레이터는 코드량을 줄이지만, 시그니처를 망가뜨리면 팀 전체의 디버깅 시간을 늘립니다. wrapssignature를 습관처럼 챙기면, 자동완성·문서·프레임워크 호환성까지 한 번에 지킬 수 있습니다.