- Published on
Python 데코레이터 3중 중첩 시 인자·메타데이터 보존법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 로깅, 재시도, 권한 체크 같은 횡단 관심사를 데코레이터로 쌓다 보면 어느 순간 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)
중요한 포인트는 “바깥쪽 logger 와 retry 는 wraps 를 잘 썼는데도” 깨진다는 점입니다. 중간에서 __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)이면 ParamSpec 과 TypeVar 로 데코레이터 타입을 보존할 수 있습니다.
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.signature가task_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__를 설정해 런타임 시그니처를 강제로 보존하세요. - 정적 타입까지 보존하려면
ParamSpec과TypeVar로 데코레이터 타입을 정의하세요.
재시도 같은 횡단 관심사는 데코레이터로 깔끔해지지만, 호출 규약과 인트로스펙션이 중요한 환경에서는 위 패턴을 기본 템플릿으로 두는 것이 안전합니다. 특히 HTTP 클라이언트 재시도처럼 데코레이터를 여러 겹 쌓기 쉬운 주제는 Python httpx ReadTimeout·ConnectError 재시도 설계와 함께 보면 구현 실수가 줄어듭니다.