- Published on
Decorator에서 args/kwargs 깨짐? wraps·signature 완전정복
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 로깅, 트레이싱, 권한 체크, 재시도 같은 횡단 관심사를 데코레이터로 빼는 순간, 이상한 문제가 연쇄적으로 터집니다.
*args,**kwargs로만 받으니 IDE 자동완성이 사라지고 타입 힌트가 무용지물이 됨- FastAPI/Click/Typer 같은 프레임워크가 파라미터를 못 읽어 라우팅/옵션 파싱이 깨짐
- DI 컨테이너(예:
Depends)가 함수 시그니처를 기준으로 주입하는데 주입이 실패함 help()/Sphinx 문서에서 함수 설명과 파라미터가 데코레이터 내부 wrapper로 바뀜
핵심 원인은 대부분 “원래 함수의 메타데이터와 시그니처가 wrapper로 덮어씌워졌기 때문”입니다. 이 글은 그 문제를 functools.wraps와 inspect.signature 관점에서 정확히 짚고, 실무에서 안전하게 쓰는 패턴까지 정리합니다.
왜 데코레이터를 쓰면 args/kwargs가 ‘깨져 보이나’?
파이썬 데코레이터는 결국 아래 변환과 같습니다.
@decorator
def f(a, b=1):
return a + b
# 대략 아래와 같음
f = decorator(f)
여기서 decorator(f)가 반환하는 것은 대개 wrapper(*args, **kwargs) 같은 새 함수입니다. 즉, 호출 자체는 되더라도 “함수의 정체성(identity)”과 “호출 규약(contract)”이 wrapper의 것으로 바뀝니다.
문제가 되는 지점은 크게 두 가지입니다.
- 메타데이터 손실
__name__,__qualname__,__doc__,__module__,__annotations__등
- 시그니처 손실
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줄이 실무 기준선입니다.
- 모든 함수형 데코레이터는
@wraps(func)를 기본으로 붙인다 - 시그니처 기반 프레임워크와 함께라면
wrapper.__signature__ = inspect.signature(func)를 적용한다 - wrapper가 인자를 소비/추가/변형한다면,
inspect.Signature.replace로 “일치하는 새 시그니처”를 만들어 노출한다
데코레이터는 코드량을 줄이지만, 시그니처를 망가뜨리면 팀 전체의 디버깅 시간을 늘립니다. wraps와 signature를 습관처럼 챙기면, 자동완성·문서·프레임워크 호환성까지 한 번에 지킬 수 있습니다.