- Published on
Python 데코레이터 args/kwargs 깨짐 완벽 복구
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에 로깅, 트레이싱, 리트라이, 레이트리밋 같은 횡단 관심사를 넣다 보면 데코레이터가 빠르게 늘어납니다. 그런데 어느 순간부터 IDE 자동완성이나 문서 생성, FastAPI 같은 프레임워크의 파라미터 인식이 이상해지고, 타입체커가 Any로 무너지는 경험을 하게 됩니다. 흔히 말하는 args/kwargs 깨짐 현상입니다.
이 글에서는 왜 깨지는지(런타임 메타데이터와 타입 정보 관점), 무엇을 어디까지 복구할 수 있는지, 그리고 파이썬 버전별로 가장 안전한 해결책을 정리합니다.
1) args/kwargs 깨짐이란 정확히 무엇인가
대부분의 데코레이터는 내부에서 원본 함수를 감싸는 wrapper(*args, **kwargs)를 반환합니다. 이때 다음이 함께 망가질 수 있습니다.
- 런타임 시그니처:
inspect.signature()가 원본이 아니라wrapper(*args, **kwargs)를 가리킴 - 메타데이터:
__name__,__qualname__,__doc__,__module__,__annotations__등이 wrapper로 바뀜 - 프레임워크 동작: FastAPI, Typer, Click, dependency injection 도구가 파라미터를 잘못 해석
- 타입 정보: mypy, pyright가 데코레이터 적용 후 원본 시그니처를 추적하지 못해
Callable[..., Any]로 붕괴
즉, args/kwargs는 단지 실행 인자 전달 방식일 뿐이고, 진짜 문제는 "원본 함수의 계약(contract)"이 wrapper로 대체된다는 데 있습니다.
2) 깨지는 최소 재현 코드
아래 데코레이터는 기능적으로는 동작하지만, 시그니처와 메타데이터를 전부 잃습니다.
import inspect
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
print(add.__name__) # wrapper
print(add.__doc__) # None
print(inspect.signature(add)) # (*args, **kwargs)
이 상태에서 문서 자동 생성이나 API 파라미터 추론이 틀어지기 시작합니다.
3) 1차 복구: functools.wraps로 메타데이터 복원
가장 먼저 해야 할 일은 functools.wraps를 적용하는 것입니다. 이는 __name__, __doc__, __module__, __annotations__와 __wrapped__를 복사합니다.
import functools
import inspect
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@good_decorator
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Add two integers.
print(inspect.signature(add)) # (a: int, b: int) -> int (대부분의 경우)
핵심은 __wrapped__입니다. 많은 도구가 inspect.unwrap() 또는 __wrapped__ 체인을 따라가 원본을 찾습니다.
그런데도 시그니처가 여전히 깨지는 경우
- 데코레이터가 여러 겹이고, 중간에
wraps를 누락한 래퍼가 하나라도 있으면 체인이 끊깁니다. - C 확장 기반 함수, 일부 프레임워크, 혹은 커스텀 callable 객체는
__wrapped__만으로 복구가 완전하지 않을 수 있습니다. - 데코레이터가 인자를 받는 팩토리 형태일 때 구현 실수로
wraps적용 위치가 틀어지는 경우가 많습니다.
4) 2차 복구(타입까지): ParamSpec와 TypeVar로 계약 유지
런타임 메타데이터 복구만으로는 부족합니다. 타입체커 입장에서 아래는 여전히 문제입니다.
- 데코레이터의 반환 타입이
Callable[..., Any]로 추론됨 - wrapper가
(*args, **kwargs)라서 원본 파라미터 타입이 사라짐
파이썬 3.10+(정확히는 typing_extensions를 쓰면 더 낮아도 가능)에서 **ParamSpec**을 사용하면, 원본 함수의 파라미터 스펙을 그대로 전달할 수 있습니다.
from __future__ import annotations
import functools
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"call {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logged
def add(a: int, b: int) -> int:
return a + b
이 패턴을 쓰면 mypy/pyright가 add(1, "x") 같은 호출을 정확히 오류로 잡습니다. 즉, 런타임과 타입 수준에서 모두 args/kwargs 깨짐을 복구합니다.
데코레이터 팩토리(인자 받는 데코레이터)도 동일하게
from __future__ import annotations
import functools
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def retry(times: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
def deco(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exc: Exception | None = None
for _ in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last_exc = e
assert last_exc is not None
raise last_exc
return wrapper
return deco
@retry(times=3)
def fetch(url: str, timeout: float) -> str:
return f"GET {url} with {timeout}"
retry처럼 실무에서 자주 쓰는 패턴도 ParamSpec을 쓰면 시그니처 계약이 유지됩니다.
5) 3차 복구: inspect.signature까지 강제로 맞추기 (__signature__)
대부분은 wraps + ParamSpec으로 끝납니다. 하지만 다음 케이스에서는 런타임 시그니처가 여전히 도구에 따라 깨질 수 있습니다.
- 프레임워크가
__wrapped__를 따라가지 않고, 현재 객체의__signature__만 보는 경우 - callable 클래스 인스턴스를 반환하는 데코레이터
- 일부 복잡한 합성 데코레이터(데코레이터가 또 다른 callable을 반환)
이때는 __signature__를 원본으로 덮어쓰기가 최후의 수단입니다.
from __future__ import annotations
import functools
import inspect
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def force_signature(func: Callable[P, R]) -> Callable[P, R]:
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
wrapper.__signature__ = sig # type: ignore[attr-defined]
return wrapper
주의점
__signature__는 비공식에 가깝지만 널리 사용되는 관례입니다.- 원본이 동적으로 변하는 callable이면 시그니처가 고정되어 오히려 문제를 만들 수 있습니다.
- 여러 데코레이터가 모두
__signature__를 만지면 마지막 적용이 덮어씁니다.
6) 흔한 실수 6가지와 체크리스트
1) wraps를 wrapper가 아니라 다른 함수에 붙임
import functools
def wrong(func):
@functools.wraps(func)
def not_wrapper(x):
return x
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper # wraps가 적용된 함수를 반환하지 않음
반드시 return하는 callable에 wraps가 적용되어야 합니다.
2) 데코레이터 체인 중 하나라도 wraps 누락
로깅 데코레이터는 잘 만들었는데, 권한 체크 데코레이터가 wraps를 안 쓰면 전체가 깨집니다.
3) *args, **kwargs 전달을 일부 누락
# 잘못된 예: kwargs를 빼먹음
return func(*args)
이건 시그니처 문제가 아니라 실제 동작 버그로 이어집니다.
4) kwargs를 수정하면서 원본 계약을 깨뜨림
예를 들어 데코레이터가 kwargs["timeout"] = 3 같은 기본값 주입을 하면, 원본 함수가 timeout을 받지 않는 경우 런타임 오류가 납니다. 이 경우엔 데코레이터가 대상 함수의 시그니처를 확인하고 조건부로 주입해야 합니다.
5) 타입 힌트를 Callable[..., R]로 뭉개버림
ParamSpec 없이 Callable[..., R]를 쓰면 타입체커는 파라미터 정보를 잃습니다. args/kwargs 깨짐을 "완벽 복구"하려면 ParamSpec이 사실상 표준입니다.
6) 비동기 함수에서 동기 wrapper를 씀
async def를 감싸면서 def wrapper로 만들면 반환이 코루틴이 되어버려 호출부가 꼬입니다.
from __future__ import annotations
import functools
from typing import Callable, TypeVar, ParamSpec, Awaitable
P = ParamSpec("P")
R = TypeVar("R")
def async_logged(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"async call {func.__name__}")
return await func(*args, **kwargs)
return wrapper
7) 실전 예제: 레이트리밋 데코레이터를 시그니처 보존으로 만들기
레이트리밋이나 재시도는 호출 폭주를 막는 관점에서 중요합니다. 특히 외부 API 호출에서 429가 나면 재시도 정책과 큐잉이 필요해집니다. 관련해서는 OpenAI 429/Rate Limit 재시도·큐잉 패턴 7가지도 함께 보면 좋습니다.
여기서는 간단한 토큰 버킷 형태를 흉내 내며, 데코레이터 자체는 ParamSpec으로 계약을 유지합니다.
from __future__ import annotations
import functools
import time
from dataclasses import dataclass
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
@dataclass
class SimpleRateLimiter:
interval_sec: float
last_called: float = 0.0
def acquire(self) -> None:
now = time.time()
wait = self.interval_sec - (now - self.last_called)
if wait > 0:
time.sleep(wait)
self.last_called = time.time()
def rate_limited(limiter: SimpleRateLimiter) -> Callable[[Callable[P, R]], Callable[P, R]]:
def deco(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
limiter.acquire()
return func(*args, **kwargs)
return wrapper
return deco
limiter = SimpleRateLimiter(interval_sec=0.2)
@rate_limited(limiter)
def call_api(model: str, prompt: str, temperature: float = 0.7) -> str:
return f"model={model}, prompt={prompt[:10]}, temp={temperature}"
이제 call_api는 데코레이터를 거쳤음에도 파라미터와 기본값, 반환 타입이 그대로 유지됩니다.
8) 디버깅 팁: 어디서 깨졌는지 추적하기
데코레이터가 여러 겹이면 "어느 레이어에서 계약이 무너졌는지"부터 찾아야 합니다.
import inspect
def debug_wrapped_chain(func):
i = 0
cur = func
while True:
print(i, cur, getattr(cur, "__name__", None), inspect.signature(cur))
nxt = getattr(cur, "__wrapped__", None)
if nxt is None:
break
cur = nxt
i += 1
- 체인이 중간에 끊기면 그 데코레이터가
wraps를 누락했을 확률이 큽니다. - 시그니처가 중간부터
(*args, **kwargs)로 바뀌면 그 지점에서 래퍼가 계약을 덮어쓴 것입니다.
운영 환경에서 이런 작은 메타데이터 손실이 큰 장애로 번지는 경우가 있습니다. 예를 들어 재시도/타임아웃이 꼬이면서 데드라인이 폭주하는 현상은 gRPC에서도 흔합니다. 관련 패턴은 gRPC MSA에서 데드라인·재시도 폭주 막는 법에서 더 깊게 다룹니다.
9) 결론: "완벽 복구"의 권장 조합
정리하면 우선순위는 다음과 같습니다.
- 반드시
functools.wraps사용: 메타데이터와__wrapped__체인 복구 - 타입까지 살리려면
ParamSpec + TypeVar: 데코레이터 전후 함수 계약 보존 - 특수 케이스에서만
__signature__오버라이드: 프레임워크가 시그니처를 직접 읽는 경우 대응
이 3단계를 적용하면 데코레이터를 몇 겹을 쌓아도 args/kwargs로 뭉개지는 문제를 런타임과 타입 수준에서 모두 안정적으로 복구할 수 있습니다.