- Published on
Python 데코레이터로 스택 추적 꼬임 해결 - wraps·signature 복원
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그에서 예외가 터졌는데 스택 트레이스가 전부 wrapper로만 보이고, APM이나 에러 리포팅 도구(Sentry 등)에서도 함수명이 뭉개진 경험이 있다면 데코레이터가 원인일 가능성이 큽니다. 더 나쁜 경우는 IDE 자동완성, help(), 타입 체커, CLI 자동 인자 파싱(예: argparse와의 연동)까지 같이 망가집니다.
이 글은 다음 두 가지를 목표로 합니다.
- 데코레이터로 인해 스택 추적이 “꼬이는” 이유를 정확히 이해한다.
functools.wraps와 시그니처 복원(__signature__)으로 관측 가능성과 개발자 경험을 되살린다.
관측/디버깅 문제는 런타임 성능 문제만큼이나 장애 대응 시간을 늘립니다. 비슷한 맥락으로, 인프라 레이어에서도 원인 관측이 흐려지면 504나 콜드스타트처럼 증상만 보이고 본질이 숨는 일이 생깁니다. 참고로 이 주제에 관심이 있다면 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드도 함께 보면 “관측 가능성” 관점이 연결됩니다.
왜 데코레이터가 스택 트레이스를 망가뜨릴까
데코레이터는 본질적으로 “함수를 다른 함수로 치환”합니다. 가장 흔한 구현은 다음 형태입니다.
def deco(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
여기서 호출되는 실제 함수는 fn이 아니라 wrapper입니다. 따라서:
- 스택 트레이스에는
wrapper프레임이 반복해서 등장합니다. __name__,__qualname__,__module__,__doc__같은 메타데이터가wrapper기준으로 바뀝니다.inspect.signature()는(*args, **kwargs)만 보여주거나, 프레임워크가 함수 인자를 추론하지 못합니다.
특히 웹 프레임워크(FastAPI, Flask), 태스크 큐(Celery), DI 컨테이너, CLI 프레임워크(Click, Typer)처럼 “함수 시그니처를 읽어서 동작하는” 도구들과 결합되면 문제가 더 빨리 드러납니다.
재현: wrapper만 보이는 스택 트레이스
import traceback
def bad_deco(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@bad_deco
def divide(a: int, b: int) -> float:
return a / b
def main():
try:
divide(1, 0)
except Exception:
traceback.print_exc()
if __name__ == "__main__":
main()
이 코드를 실행하면 트레이스에 divide 대신 wrapper가 강조되어 나타나고, 여러 데코레이터가 겹치면 wrapper가 연쇄적으로 쌓입니다. “어떤 비즈니스 함수에서 터졌는지”가 한눈에 안 들어오는 상태가 됩니다.
1차 처방: functools.wraps로 메타데이터 보존
functools.wraps는 “원본 함수의 메타데이터를 래퍼 함수로 복사”하고, 동시에 __wrapped__ 포인터를 심어줍니다. 이 __wrapped__는 디버깅/리플렉션 도구가 원본에 접근할 수 있게 해주는 핵심 단서입니다.
from functools import wraps
def good_deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
이렇게 바꾸면 최소한 다음이 개선됩니다.
fn.__name__,fn.__doc__등 식별 정보가 유지됨- 많은 도구가
__wrapped__를 따라가 원본을 찾을 수 있음
하지만 여기서 끝이 아닙니다. wraps만으로는 “시그니처”가 자동 복원되지 않는 경우가 많고, 특히 (*args, **kwargs)로 감싼 래퍼는 여전히 시그니처를 잃어버립니다.
2차 처방: inspect.signature와 __signature__로 시그니처 복원
파이썬의 많은 리플렉션 기반 도구는 inspect.signature(callable)을 호출합니다. 이때 호출 대상에 __signature__ 속성이 있으면 이를 우선 사용합니다. 즉, 데코레이터가 원본 시그니처를 잃어버렸다면 래퍼에 __signature__를 주입해 복원할 수 있습니다.
시그니처를 유지하는 데코레이터 템플릿
from functools import wraps
import inspect
def preserve_signature(deco_fn):
"""데코레이터를 감싸서, 원본 함수 시그니처를 wrapper에 주입한다."""
@wraps(deco_fn)
def decorator(fn):
sig = inspect.signature(fn)
@wraps(fn)
def wrapper(*args, **kwargs):
return deco_fn(fn, *args, **kwargs)
wrapper.__signature__ = sig
return wrapper
return decorator
@preserve_signature
def timing(fn, *args, **kwargs):
import time
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{fn.__name__} took {elapsed:.6f}s")
이 패턴의 포인트는 다음입니다.
- 바깥쪽
decorator는 “실제 데코레이터” 역할 - 안쪽
wrapper는 실행 래퍼 wrapper.__signature__ = inspect.signature(fn)로 원본 시그니처를 복원
이제 inspect.signature()가 래퍼가 아니라 원본 함수의 시그니처를 보여줄 가능성이 크게 올라갑니다.
확인 코드
import inspect
@timing
def add(a: int, b: int = 10) -> int:
return a + b
print(add.__name__)
print(inspect.signature(add))
출력에서 add라는 이름과 (a: int, b: int = 10) -> int 형태의 시그니처가 유지되는지 확인합니다.
데코레이터가 여러 겹일 때: __wrapped__ 체인과 언래핑
데코레이터를 여러 개 쌓으면 __wrapped__가 체인처럼 연결됩니다. 이 체인을 이용해 원본을 찾을 때는 inspect.unwrap()가 표준 해법입니다.
import inspect
from functools import wraps
def deco1(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def deco2(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@deco1
@deco2
def f(x: int) -> int:
return x * 2
orig = inspect.unwrap(f)
print(orig is f) # False
print(orig.__name__) # f
print(inspect.signature(orig))
운영 환경에서 “원본 함수 정보를 로깅하고 싶다”면, 래퍼에서 inspect.unwrap(fn)로 원본을 찾아 이름/모듈/시그니처를 기록하는 방식이 유용합니다.
스택 트레이스 자체를 더 읽기 좋게 만드는 팁
wraps와 시그니처 복원은 “함수 식별”을 개선하지만, 스택 트레이스에 래퍼 프레임이 남는 건 자연스러운 일입니다. 다만 다음처럼 관측 품질을 올릴 수 있습니다.
1) 래퍼에서 예외를 잡아 재발행하지 말 것
다음 패턴은 흔하지만, 트레이스를 불필요하게 바꾸거나 원인을 흐릴 수 있습니다.
from functools import wraps
def anti_pattern(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception as e:
raise RuntimeError("failed") from e
return wrapper
raise ... from e는 체이닝 정보를 남기지만, 에러 타입이 바뀌면서 상위 레이어가 분기 처리하기 어려워질 수 있습니다. 정말 “도메인 예외로 변환”해야 할 때만 쓰고, 단순 로깅 목적이라면 예외를 바꾸지 않는 편이 낫습니다.
2) 로깅 시에는 원본 함수 기준으로 식별자 남기기
import inspect
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
orig = inspect.unwrap(fn)
ident = f"{orig.__module__}.{orig.__qualname__}"
print(f"calling {ident}")
return fn(*args, **kwargs)
return wrapper
이렇게 하면 스택 트레이스가 래퍼를 포함하더라도 로그 상에서 “어떤 함수가 호출되었는지”가 안정적으로 남습니다.
실전에서 자주 터지는 케이스: 파라미터 추가형 데코레이터
인자를 받는 데코레이터(데코레이터 팩토리)는 한 단계 더 복잡해지고, 여기서 wraps를 빼먹기 쉽습니다.
from functools import wraps
import inspect
def retry(max_attempts: int = 3):
def decorator(fn):
sig = inspect.signature(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
wrapper.__signature__ = sig
return wrapper
return decorator
포인트는 다음입니다.
retry(...)가decorator를 반환decorator(fn)이wrapper를 반환@wraps(fn)는 반드시wrapper에 적용- 시그니처 복원도
wrapper에 적용
타입 힌트와 문서화까지 챙기려면
wraps는 기본적으로 assigned와 updated 목록을 통해 어떤 속성을 복사할지 제어합니다. 일반적으로는 기본값으로 충분하지만, 문서 생성기나 타입 관련 도구를 강하게 쓰는 팀이라면 다음을 점검하세요.
__annotations__가 유지되는지__doc__이 유지되는지__wrapped__가 존재하는지
대부분의 경우 @wraps(fn)만으로 해결되지만, “래퍼에서 주석을 덮어쓰는” 코드가 섞이면 깨질 수 있습니다.
체크리스트: 데코레이터가 많은 코드베이스에서의 가드레일
- 데코레이터 구현 시
@wraps(fn)는 규칙으로 강제한다. (*args, **kwargs)래퍼를 쓸 경우wrapper.__signature__복원을 고려한다.- 리플렉션 기반 프레임워크를 쓰면, 테스트에서
inspect.signature()를 스냅샷처럼 검증한다. - 로깅/트레이싱에 함수 식별자가 필요하면
inspect.unwrap()기준으로 남긴다. - 예외를 잡아 새 예외로 바꾸는 패턴은 최소화하고, 바꿀 때는 “의미 있는 도메인 경계”에서만 수행한다.
마무리
데코레이터는 코드 중복을 줄이고 횡단 관심사를 깔끔하게 분리하지만, 관측 가능성을 쉽게 망가뜨립니다. functools.wraps는 필수이고, 여기에 __signature__ 복원까지 더하면 스택 트레이스 해석, 자동 문서화, 프레임워크 연동 품질이 눈에 띄게 좋아집니다.
운영에서 디버깅 시간이 길어지는 문제는 결국 “정보 손실”에서 시작합니다. 데코레이터를 설계할 때부터 메타데이터와 시그니처를 보존하는 습관을 들이면, 장애 대응 속도는 체감될 정도로 빨라집니다.