- Published on
Python 데코레이터에서 인자·메타데이터 보존하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
데코레이터는 로깅, 캐시, 권한 검사처럼 횡단 관심사를 깔끔하게 분리해주지만, 잘못 작성하면 함수의 인자 정보와 메타데이터가 손실됩니다. 그 결과로 다음 문제가 자주 터집니다.
help()/Sphinx 문서에서 함수 설명(__doc__)이 사라짐- 디버깅 시 함수 이름이 전부
wrapper로 보임 - 타입 체커가 반환 타입을
Any로 취급하거나 오탐을 냄 - FastAPI 같은 프레임워크가 시그니처 기반으로 의존성 주입/검증을 하는데, 데코레이터가 시그니처를 망가뜨려 런타임 오류가 발생
이 글에서는 인자(시그니처)와 메타데이터를 보존하는 데코레이터 작성법을 functools.wraps부터 최신 타입 힌트(ParamSpec)까지 단계적으로 정리합니다.
참고로, 파이썬에서 작은 경고를 무시했다가 나중에 더 큰 장애로 번지는 패턴은 흔합니다. 예를 들어 판다스에서도 SettingWithCopyWarning을 방치하면 데이터가 조용히 잘못 바뀌는 일이 생기죠. 비슷한 맥락의 실전 대응은 pandas SettingWithCopyWarning 완전 해결 - loc·copy도 함께 참고하면 좋습니다.
데코레이터가 메타데이터를 깨뜨리는 이유
가장 단순한 데코레이터는 원본 함수를 감싼 wrapper 함수를 반환합니다.
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a: int, b: int) -> int:
"""두 수를 더합니다."""
return a + b
print(add.__name__) # wrapper
print(add.__doc__) # None
이 상태는 다음을 의미합니다.
add라는 이름으로 호출하지만 실제 객체는wrapperadd.__doc__,add.__annotations__등 메타데이터가 wrapper의 것으로 덮임
즉, “원본 함수처럼 보이는 감싼 함수”를 만들었지만, 파이썬은 그걸 원본으로 착각해주지 않습니다.
1단계: functools.wraps로 기본 메타데이터 보존
표준 해법은 functools.wraps입니다. 내부적으로 functools.update_wrapper를 호출해 __name__, __doc__, __module__, __qualname__, __annotations__ 등을 복사하고, 무엇보다 __wrapped__ 속성을 설정합니다.
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a: int, b: int) -> int:
"""두 수를 더합니다."""
return a + b
print(add.__name__) # add
print(add.__doc__) # 두 수를 더합니다.
print(add.__annotations__) # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
__wrapped__가 중요한 이유
wraps는 add.__wrapped__에 원본 함수를 연결합니다. 이 덕분에 inspect가 “래핑을 벗겨서” 원본을 추적할 수 있습니다.
import inspect
print(add.__wrapped__) # 원본 함수 객체
print(inspect.unwrap(add) is add.__wrapped__) # True
문서화 도구, 디버거, 일부 프레임워크는 이 속성을 통해 원본 정보를 복구합니다. 그래서 데코레이터를 만든다면 wraps는 사실상 필수입니다.
2단계: 인자 시그니처 보존(또는 복구)하기
문제는 wraps를 써도 런타임 시그니처는 여전히 (*args, **kwargs)로 남는 경우가 많다는 점입니다.
import inspect
@my_decorator
def f(x: int, y: str = "a") -> str:
return y * x
print(inspect.signature(f)) # (x: int, y: str = 'a') -> str 로 보일 수도 있지만
# 구현/도구에 따라 (*args, **kwargs)로 보이는 케이스가 존재
대부분의 inspect.signature는 __wrapped__를 따라가서 원본 시그니처를 보여주지만, 프레임워크/도구가 항상 그렇게 동작하진 않습니다. 특히 다음 상황에서 문제가 커집니다.
- 프레임워크가 래핑을 “언랩”하지 않고 현재 callable의 시그니처를 그대로 사용
- 여러 데코레이터가 중첩되어
__wrapped__체인이 꼬임 functools.partial, C 확장 함수, 바운드 메서드 등과 섞이며 추론이 실패
해결책 A: __signature__를 명시적으로 지정
파이썬은 함수 객체에 __signature__를 설정하면 inspect.signature가 그것을 우선합니다.
import inspect
from functools import wraps
def preserve_signature(func):
sig = inspect.signature(func)
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper.__signature__ = sig
return wrapper
@preserve_signature
def hello(name: str, times: int = 1) -> str:
return " ".join([f"hi {name}"] * times)
print(inspect.signature(hello)) # (name: str, times: int = 1) -> str
이 패턴은 FastAPI, Typer 같은 시그니처 기반 프레임워크에서 특히 유용합니다.
해결책 B: 데코레이터를 “시그니처를 바꾸지 않게” 설계
가장 좋은 건 wrapper가 실제로도 동일한 시그니처를 갖는 것입니다. 다만 파이썬에서 동적으로 동일 시그니처 함수를 생성하는 건 복잡하고 보안/가독성 이슈가 있어 보통 권장되진 않습니다.
대신, 타입 레벨에서 시그니처를 보존하는 방법(다음 섹션의 ParamSpec)을 적극 권합니다.
3단계: 타입 힌트까지 보존하기(ParamSpec와 TypeVar)
wraps는 런타임 메타데이터를 복사하지만, 타입 체커 입장에서는 wrapper가 (*args: Any, **kwargs: Any) -> Any로 보일 수 있습니다. 이를 해결하려면 typing.ParamSpec을 사용해 “원본 함수의 파라미터 타입”을 그대로 전달해야 합니다.
다음은 반환 타입과 인자 타입을 모두 보존하는 정석 패턴입니다.
from __future__ import annotations
from functools import wraps
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logged
def add(a: int, b: int) -> int:
return a + b
이제 mypy/pyright는 add가 여전히 (int, int) -> int임을 이해합니다.
자주 하는 실수: Callable[..., R]만 쓰기
from typing import Callable, TypeVar
R = TypeVar("R")
def bad_logged(func: Callable[..., R]) -> Callable[..., R]:
def wrapper(*args, **kwargs) -> R:
return func(*args, **kwargs)
return wrapper
이 방식은 반환 타입은 유지하지만, 인자 타입은 사실상 포기하는 셈이라 IDE 자동완성/정적 분석 품질이 확 떨어집니다.
4단계: 인자를 받는 데코레이터(데코레이터 팩토리)에서 보존하기
실무에서는 @retry(max_attempts=3)처럼 데코레이터 자체가 인자를 받는 형태가 많습니다. 이때도 ParamSpec을 그대로 적용할 수 있습니다.
from __future__ import annotations
import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def retry(max_attempts: int, delay_sec: float = 0.0) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exc: Exception | None = None
for _ in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
last_exc = e
if delay_sec:
time.sleep(delay_sec)
assert last_exc is not None
raise last_exc
return wrapper
return decorator
@retry(max_attempts=3, delay_sec=0.1)
def flaky(n: int) -> int:
if n < 0:
raise ValueError("n must be non-negative")
return n
핵심은 반환 타입이 Callable[[Callable[P, R]], Callable[P, R]] 형태가 된다는 점입니다. “함수를 받아 함수를 반환”하는 타입을 정확히 적어주면, 타입 체커가 데코레이터 적용 전후의 시그니처를 추적할 수 있습니다.
5단계: 비동기 함수(async def) 데코레이터에서 보존하기
async 함수는 반환 타입이 Awaitable[R]처럼 보이기도 하고, wrapper를 async def로 작성해야 await 체인이 자연스럽습니다.
from __future__ import annotations
from functools import wraps
from typing import Awaitable, Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def async_logged(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {func.__name__}")
return await func(*args, **kwargs)
return wrapper
@async_logged
async def fetch(user_id: int) -> str:
return f"user:{user_id}"
여기서 wrapper의 반환 타입을 R로 둔 이유는 async def 자체가 Awaitable[R]를 반환하는 함수이기 때문입니다. 즉, wrapper의 함수 타입은 Callable[P, Awaitable[R]]로 맞춰집니다.
6단계: 커스텀 메타데이터(속성)까지 안전하게 붙이기
데코레이터가 단순히 감싸는 것뿐 아니라, 원본 함수에 태그/설정 값을 붙여 라우팅이나 레지스트리에 쓰는 경우가 많습니다.
패턴 A: wrapper에 속성 부여(기본)
from functools import wraps
def route(path: str):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper.route_path = path
return wrapper
return decorator
@route("/health")
def health() -> dict:
return {"ok": True}
print(health.route_path) # /health
패턴 B: 원본 함수에도 속성 유지(언랩 시에도 필요할 때)
도구가 inspect.unwrap로 원본을 꺼내서 검사한다면, wrapper에만 속성을 달아두면 놓칠 수 있습니다. 이때는 원본에도 동일 속성을 붙이거나, 검사 시 wrapper와 __wrapped__를 모두 확인하는 규칙을 정하세요.
from functools import wraps
def tag(name: str):
def decorator(func):
func.tag = name # 원본에 부여
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper.tag = name # wrapper에도 부여
return wrapper
return decorator
7단계: 여러 데코레이터 중첩 시 메타데이터가 깨지는 케이스
데코레이터를 여러 개 겹치면, 하나라도 wraps를 빼먹는 순간 체인이 끊깁니다.
from functools import wraps
def good(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def bad(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@good
@bad
def f(x: int) -> int:
"""doc"""
return x
print(f.__name__) # wrapper (bad 때문에 깨짐)
print(f.__doc__) # None
체크리스트
- 모든 데코레이터가
@wraps(func)를 사용했는지 - 시그니처가 중요한 환경이면
__signature__지정이 필요한지 - 타입 보존이 필요하면
ParamSpec을 사용했는지 - 메타데이터를 wrapper에만 달지, 원본에도 달지 정책이 있는지
이런 “한 군데에서만 깨져도 전체가 무너지는” 성격은 분산 시스템의 트랜잭션 설계와도 닮았습니다. 실패 복구를 체계화하는 관점은 MSA Saga 패턴 실패 복구 - 보상·재시도 설계에서도 비슷한 통찰을 얻을 수 있습니다.
실전 예제: 시그니처·타입·메타데이터를 모두 살리는 로깅 데코레이터
아래는 실무에서 바로 써먹기 좋은 “풀 패키지” 예시입니다.
wraps로 기본 메타데이터 보존ParamSpec으로 타입 보존__signature__로 런타임 시그니처 강제 보존- 커스텀 속성(
log_category) 부여
from __future__ import annotations
import inspect
import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def log_timing(category: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
sig = inspect.signature(func)
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"[{category}] {func.__name__} took {elapsed_ms:.2f}ms")
wrapper.__signature__ = sig
wrapper.log_category = category
return wrapper
return decorator
@log_timing("db")
def query(user_id: int, limit: int = 10) -> list[int]:
"""유저의 최근 항목 id를 조회합니다."""
return list(range(user_id, user_id + limit))
이제 다음이 모두 만족됩니다.
query.__name__,query.__doc__,query.__annotations__보존- 타입 체커가
query의 인자/반환 타입을 추론 inspect.signature(query)가 원본 시그니처를 안정적으로 반환query.log_category같은 커스텀 메타데이터도 유지
마무리: 어떤 수준까지 보존할지 기준 세우기
데코레이터는 “감싸기”가 쉬운 만큼, 메타데이터/시그니처/타입을 어디까지 보존할지 명확한 기준이 필요합니다.
- 단순 로깅/측정:
wraps+ParamSpec만으로 충분한 경우가 많음 - 프레임워크가 시그니처에 민감:
__signature__까지 고려 - 문서화/레지스트리/라우팅: 커스텀 속성 부여 정책(원본 vs wrapper) 결정
한 줄로 정리하면, **런타임은 wraps와 __signature__, 정적 분석은 ParamSpec**이 핵심입니다. 이를 기본 템플릿으로 삼아두면, 데코레이터를 늘려도 인자·메타데이터 손실로 인한 디버깅 비용을 크게 줄일 수 있습니다.