- Published on
Python 데코레이터 스택 버그 - wraps·순서·인자전달
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
파이썬 데코레이터는 “함수를 감싸서 동작을 추가”하는 강력한 도구지만, 여러 개를 스택으로 쌓는 순간(예: 로깅 + 캐싱 + 권한체크) 작은 실수가 큰 버그로 번지기 쉽습니다. 특히 아래 3가지가 자주 겹칩니다.
functools.wraps누락으로 메타데이터와 시그니처가 깨짐- 데코레이터 적용 순서를 반대로 이해해 부작용이 발생
*args/**kwargs전달 또는 반환값 처리 실수로 런타임 오류/논리 오류
이 글에서는 “데코레이터 스택 버그”를 재현해 보고, 어떤 원리로 깨지는지와 안전한 구현 패턴을 코드로 정리합니다.
비슷한 결로, 겉보기엔 정상인데 내부 동작이 달라져서 문제를 키우는 사례로는 pandas SettingWithCopyWarning 완전 정복 같은 글도 참고할 만합니다. 겉으로는 한 줄이지만 내부 규칙을 모르고 쓰면 추적이 어려워진다는 점이 닮았습니다.
데코레이터 스택 적용 순서: “위에서 아래로”가 아니다
가장 흔한 오해는 “위에 있는 데코레이터가 먼저 실행된다”입니다. 실제로는 적용(감싸는) 순서와 실행(호출 시 진입) 순서를 분리해서 봐야 합니다.
from functools import wraps
def deco_a(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print("A: before")
out = fn(*args, **kwargs)
print("A: after")
return out
return wrapper
def deco_b(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print("B: before")
out = fn(*args, **kwargs)
print("B: after")
return out
return wrapper
@deco_a
@deco_b
def f():
print("f")
f()
출력은 다음과 같습니다.
A: before
B: before
f
B: after
A: after
정리하면:
@deco_a@deco_b는f = deco_a(deco_b(f))로 해석됩니다.- 호출 시에는 가장 바깥(wrapper)이 먼저 진입하므로
A before가 먼저 찍힙니다.
순서 버그가 실제로 터지는 경우: 권한체크와 캐싱
예를 들어 “권한체크 후 캐싱”을 기대했는데, 순서를 착각하면 권한 없는 사용자의 결과가 캐시에 들어가 다른 사용자에게도 재사용되는 사고가 날 수 있습니다.
from functools import wraps
_cache = {}
def cache(fn):
@wraps(fn)
def wrapper(user_id, *args, **kwargs):
key = (fn.__name__, user_id, args, tuple(sorted(kwargs.items())))
if key in _cache:
return _cache[key]
out = fn(user_id, *args, **kwargs)
_cache[key] = out
return out
return wrapper
def require_admin(fn):
@wraps(fn)
def wrapper(user_id, *args, **kwargs):
if user_id != "admin":
raise PermissionError("admin only")
return fn(user_id, *args, **kwargs)
return wrapper
# 의도: 권한체크가 먼저, 그 다음 캐시
# 잘못된 순서(캐시가 바깥): 캐시 키/저장 타이밍이 기대와 달라질 수 있음
@cache
@require_admin
def get_secret(user_id):
return "TOP-SECRET"
위 코드는 그나마 require_admin이 내부에서 먼저 실행되므로 “권한 없는 호출은 예외”로 끝나지만, 실무에서는 캐시 키가 user_id를 포함하지 않거나(실수), 캐시 데코레이터가 예외도 캐싱하거나(실수), 혹은 권한체크가 반환값을 변형하는 등(정책) 순서에 따라 결과가 달라지는 포인트가 많습니다.
권장 패턴은 다음과 같습니다.
- 정책/검증/권한은 바깥(먼저 진입)에서 빠르게 실패시키기
- 캐시/리트라이/서킷브레이커는 그 다음 계층에서 처리
- 로깅/트레이싱은 가장 바깥 또는 가장 안쪽 중 한 곳으로 일관되게(팀 규칙)
wraps 누락 버그: 디버깅, 리플렉션, 타입/검증이 무너진다
데코레이터를 직접 만들 때 @wraps(fn)을 빼먹으면 다음이 깨집니다.
__name__,__qualname__,__module__,__doc__inspect.signature()결과- 일부 프레임워크의 라우팅/DI/검증(예: FastAPI, Click, Typer 등)
- 로깅에서 함수명이 전부
wrapper로 찍힘
재현 코드:
import inspect
from functools import wraps
def bad_deco(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def good_deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@bad_deco
def add(a: int, b: int) -> int:
"""add two ints"""
return a + b
@good_deco
def mul(a: int, b: int) -> int:
"""multiply two ints"""
return a * b
print(add.__name__, add.__doc__)
print(mul.__name__, mul.__doc__)
print(inspect.signature(add))
print(inspect.signature(mul))
전형적으로 add는 이름/문서/시그니처가 wrapper(*args, **kwargs)로 바뀌고, mul은 유지됩니다.
스택에서 더 위험해지는 이유: “중간 하나만” 빠져도 끝
데코레이터를 여러 개 쌓았을 때, 중간 레이어 하나라도 wraps가 없으면 그 위쪽 레이어들이 fn.__name__ 같은 정보를 읽을 때 이미 망가진 상태를 보게 됩니다.
예를 들어 로깅 데코레이터가 fn.__name__을 찍는다면, 중간에 wraps가 빠진 순간부터 로그가 전부 wrapper가 됩니다.
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"call: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
def missing_wraps(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@log_calls
@missing_wraps
def work(x):
return x * 2
work(10) # call: wrapper
해결책은 단순합니다.
- 모든 데코레이터에서
@wraps(fn)을 습관화 - 팀 코드리뷰 체크리스트에 포함
- 린터/정적분석으로 강제(예: 커스텀 룰)
인자 전달 버그: *args/**kwargs와 반환값은 “투명”해야 한다
데코레이터는 기본적으로 “원래 함수에 대한 투명한 프록시”여야 합니다. 그런데 다음 실수가 매우 흔합니다.
*args/**kwargs를 빼먹고 특정 시그니처만 가정return을 빼먹어 반환값이None이 됨- 위치 인자/키워드 인자를 재배치하면서 호출 규약을 깨뜨림
- 예외를 삼켜서(로깅만 하고) 상위에서 장애 감지가 안 됨
실수 1: 인자 누락으로 TypeError
from functools import wraps
def timing(fn):
@wraps(fn)
def wrapper(): # 버그: 인자를 받지 않음
return fn()
return wrapper
@timing
def greet(name):
return f"hi {name}"
# greet("neo") 호출 시 TypeError 발생
올바른 형태:
import time
from functools import wraps
def timing(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
dt = (time.perf_counter() - t0) * 1000
print(f"{fn.__name__} took {dt:.2f}ms")
return wrapper
try/finally를 쓰면 예외가 나도 측정 로그는 남고, 예외는 그대로 전파됩니다.
실수 2: 반환값 누락으로 논리 버그
from functools import wraps
def uppercase(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
fn(*args, **kwargs).upper() # 버그: return 없음
return wrapper
@uppercase
def get_name():
return "alice"
print(get_name()) # None
수정:
def uppercase(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs).upper()
return wrapper
실수 3: 특정 인자를 “몰래” 꺼내 쓰며 충돌
데코레이터가 kwargs에서 특정 키를 pop하는 패턴은 편하지만, 스택에서 충돌을 일으키기 쉽습니다.
from functools import wraps
def with_trace(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
trace_id = kwargs.pop("trace_id", None) # 다른 데코레이터도 pop하면 충돌
if trace_id:
print("trace", trace_id)
return fn(*args, **kwargs)
return wrapper
스택에서 안전하게 하려면:
- 가능한 한
kwargs를 파괴적으로 변경하지 않기 - 필요하면
trace_id같은 메타 파라미터를 명시적으로 분리(예: 컨텍스트 변수)
파이썬에서는 contextvars를 이용해 호출 인자에 섞지 않고 트레이싱 정보를 전달할 수 있습니다.
import contextvars
from functools import wraps
trace_id_var = contextvars.ContextVar("trace_id", default=None)
def with_trace(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
trace_id = trace_id_var.get()
if trace_id:
print("trace", trace_id)
return fn(*args, **kwargs)
return wrapper
@with_trace
def handler(x):
return x + 1
token = trace_id_var.set("t-123")
try:
print(handler(10))
finally:
trace_id_var.reset(token)
스택에서 wraps만으로 부족한 케이스: 시그니처 보존이 필요한 경우
wraps는 기본적으로 __wrapped__ 체인을 만들어 inspect가 원본을 추적할 수 있게 도와줍니다. 하지만 어떤 프레임워크/도구는 “실제 호출 가능한 객체의 시그니처”를 더 직접적으로 요구하기도 합니다.
- CLI 라이브러리에서 함수 파라미터를 그대로 옵션으로 매핑
- DI 컨테이너가 파라미터 이름을 기준으로 주입
- 런타임 검증(어노테이션 기반)이 데코레이터 이후에 수행
이때는 데코레이터가 *args/**kwargs로만 받으면 정보가 손실될 수 있어, 상황에 따라 다음을 고려합니다.
- 가능하면 데코레이터를 최소화하고, 프레임워크가 제공하는 훅/미들웨어 사용
inspect.signature기반으로 바인딩하는 고급 패턴(복잡도 증가)
간단한 “바인딩 검증” 예시(디버깅용):
import inspect
from functools import wraps
def enforce_bindable(fn):
sig = inspect.signature(fn)
@wraps(fn)
def wrapper(*args, **kwargs):
# 호출이 시그니처에 맞는지 즉시 검증(디버깅에 유용)
sig.bind(*args, **kwargs)
return fn(*args, **kwargs)
return wrapper
@enforce_bindable
def api(a, b, *, q=None):
return a, b, q
api(1, 2, q=3)
# api(1, q=2) # TypeError 대신 bind 단계에서 명확한 에러
자주 터지는 “데코레이터 스택 버그” 체크리스트
여러 데코레이터를 쌓아 쓰는 코드리뷰에서 아래 항목을 빠르게 확인하면 사고를 많이 줄일 수 있습니다.
1) 모든 데코레이터가 wraps를 쓰는가
- 하나라도 빠지면 상단 레이어의 로깅/메트릭/라우팅이 오염될 수 있음
2) wrapper(*args, **kwargs)가 원본 호출을 그대로 전달하는가
- 인자를 추가/삭제/재배열하지 않는가
kwargs.pop같은 파괴적 변경이 스택 충돌을 만들지 않는가
3) 반환값을 정확히 return 하는가
- 특히 예외 처리/리트라이/로깅 데코레이터에서
return누락이 잦음
4) 적용 순서가 의도와 일치하는가
f = outer(inner(f))형태로 다시 써서 검증- 권한/검증은 바깥, 캐시/리트라이는 그 안쪽 등 규칙을 정해두기
5) 예외를 삼키지 않는가
- “로그만 남기고 성공처럼 반환”은 장애를 더 키움
- 예외를 변환해야 한다면 명확한 정책(예: 도메인 예외로 래핑)
오류 전파를 명시적으로 다루는 설계 관점은 언어는 달라도 통하는 부분이 있습니다. 예외 없이 오류를 전달하는 접근을 다룬 C++23 std
expected로 예외 없이 오류전파+자원관리도 함께 읽으면 “실패를 어떻게 다룰지”에 대한 사고가 정리됩니다.
실전 권장 템플릿: 안전한 데코레이터 골격
마지막으로, 스택에서 안전하게 동작하는 “기본 골격”을 템플릿처럼 두고 복사해 쓰는 것을 권합니다.
from functools import wraps
def safe_decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
# 1) 사전 처리(가능하면 부작용 최소화)
# 2) 원본 호출
try:
result = fn(*args, **kwargs)
except Exception:
# 3) 필요 시 로깅/메트릭만 하고 예외는 재전파
raise
# 4) 사후 처리(반환값 변형 시 정책을 명확히)
return result
return wrapper
여기에 데코레이터가 “정책적으로 반환값을 바꿔야” 한다면(예: None이면 기본값 제공), 그 규칙을 문서화하고 테스트를 붙이세요. 스택에서는 작은 반환값 변형이 다음 레이어의 가정(예: 캐시 키, 직렬화, 타입)을 깨뜨리기 쉽습니다.
마무리: 데코레이터는 ‘작은 미들웨어’, 스택은 ‘계층 구조’다
데코레이터 하나는 간단하지만, 여러 개를 쌓는 순간부터는 미들웨어 체인처럼 계층 구조가 됩니다. 이때 버그는 대개 “기능 로직”이 아니라 메타데이터(wraps), 순서, 인자/반환 투명성에서 발생합니다.
wraps는 선택이 아니라 필수- 순서는
f = outer(inner(f))로 다시 써서 항상 검증 *args/**kwargs전달과return은 투명하게 유지
이 3가지만 팀 규칙으로 고정해도, 데코레이터 스택에서 발생하는 디버깅 지옥의 상당 부분을 예방할 수 있습니다.