- Published on
Python 데코레이터 순서 버그 - wraps·stack 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 인증, 로깅, 캐시, 트랜잭션 같은 횡단 관심사를 데코레이터로 쌓다 보면 “어느 날부터” 동작이 미묘하게 바뀌는 순간이 있습니다. 특히 데코레이터 순서가 뒤집히거나, wraps 를 빼먹어 함수 메타데이터가 깨지면서 라우팅/DI/권한체크가 엉뚱하게 동작하는 버그가 자주 발생합니다.
이 글에서는 다음을 목표로 합니다.
- 데코레이터 순서가 실제 실행 순서에 어떤 영향을 주는지 정확히 이해
functools.wraps누락으로 생기는 대표 증상과 재현inspect와 stack 추적(콜스택/데코레이터 체인)으로 “누가” 함수를 감쌌는지 디버깅- 실전에서 안전한 데코레이터 작성 규칙
비슷한 결의 문제로 “겉으로는 설정이 맞는데 실제 실행 경로가 꼬이는” 사례를 겪었다면, 예를 들어 Spring Boot 3에서 @Transactional이 안먹는 6가지 같은 글에서 다루는 프록시/래핑 이슈와도 사고방식이 닮아 있습니다.
데코레이터 순서: 위에서 아래로 읽지만, 아래부터 적용된다
Python 데코레이터는 문법적으로는 위에서 아래로 나열하지만, 적용은 아래에서 위로 됩니다. 즉 다음 코드에서 dec2 가 먼저 적용되고, 그 결과를 dec1 이 감쌉니다.
@dec1
@dec2
def f():
pass
# 의미적으로는 아래와 같다
# f = dec1(dec2(f))
실행 시점 관점에서는 dec1 이 가장 바깥 wrapper, dec2 가 안쪽 wrapper 입니다. 그래서 “로깅을 가장 바깥에 둬서 전체 시간을 재고 싶다” 같은 요구가 있다면, 로깅 데코레이터를 가장 위에 둬야 합니다.
흔한 순서 버그 1: 인증보다 캐시가 먼저 먹는 문제
예를 들어 사용자별로 다른 결과를 반환해야 하는 API에서 캐시가 인증보다 바깥에 있으면, 인증 전의 키로 캐시가 조회되거나(혹은 사용자 컨텍스트 없이) 다른 사용자의 결과가 섞일 수 있습니다.
import functools
def auth_required(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
user = kwargs.get("user")
if user is None:
raise PermissionError("no user")
return fn(*args, **kwargs)
return wrapper
_cache = {}
def cached(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
key = (fn.__name__, args, tuple(sorted(kwargs.items())))
if key in _cache:
return _cache[key]
v = fn(*args, **kwargs)
_cache[key] = v
return v
return wrapper
# 잘못된 예: 캐시가 바깥이면, 인증 실패/성공 경로가 섞이거나
# 사용자 컨텍스트가 키에 제대로 반영되지 않으면 큰 사고로 이어진다.
@cached
@auth_required
def get_profile(*, user):
return {"user": user, "profile": "..."}
위 예시는 kwargs 를 키에 포함했기 때문에 그나마 안전하지만, 실무에서는 키를 단순화하다가 user 를 누락하는 순간 데이터 누출이 됩니다. 이런 류의 버그는 “순서” 와 “키 설계” 가 함께 얽혀 발생합니다.
functools.wraps 누락이 만드는 2차 버그
데코레이터 순서 버그를 더 어렵게 만드는 요소가 wraps 누락입니다. wraps 를 빼먹으면 wrapper 함수의 __name__, __qualname__, __doc__, __module__ 등이 원본으로 유지되지 않고, 무엇보다 __wrapped__ 체인이 만들어지지 않습니다.
증상
- 로깅에서 함수 이름이 전부
wrapper로 찍힘 - FastAPI/Flask 라우팅, Click/Typer 커맨드, DI 프레임워크가 시그니처를 오인
inspect.signature가 wrapper 의 시그니처를 보고 인자 바인딩 실패- 디버깅 시 “원본 함수가 어디 갔는지” 추적이 어려움
아래는 wraps 가 없을 때 시그니처가 어떻게 망가지는지 재현입니다.
import inspect
def bad_decorator(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@bad_decorator
def hello(name: str, times: int = 1):
return " ".join([f"hi {name}"] * times)
print(hello.__name__) # wrapper
print(inspect.signature(hello)) # (*args, **kwargs)
반대로 wraps 를 쓰면 원본 정보가 유지되고, inspect.unwrap 같은 도구도 제대로 동작합니다.
import functools
import inspect
def good_decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@good_decorator
def hello(name: str, times: int = 1):
return " ".join([f"hi {name}"] * times)
print(hello.__name__) # hello
print(inspect.signature(hello)) # (name: str, times: int = 1)
“순서 버그” 를 stack 으로 잡는 방법: 현재 wrapper 가 누구인지 추적
데코레이터가 여러 겹일 때 문제는 “어떤 wrapper 가 바깥인지” 와 “그 wrapper 가 무엇을 바꿨는지” 를 확인하는 것입니다. 이를 위해 다음 2가지를 함께 씁니다.
inspect.unwrap로__wrapped__체인을 따라가며 원본 함수까지 추적traceback.format_stack또는inspect.stack으로 호출 스택을 찍어 실제 실행 경로 확인
1) 데코레이터 체인 출력기 만들기
wraps 를 제대로 썼다면 __wrapped__ 가 연결됩니다. 이를 이용해 “현재 함수가 어떤 데코레이터들로 감싸졌는지” 출력할 수 있습니다.
import inspect
def print_decorator_chain(fn):
chain = []
cur = fn
while True:
chain.append(f"{cur.__module__}.{cur.__qualname__}")
if not hasattr(cur, "__wrapped__"):
break
cur = cur.__wrapped__
return chain
# 사용 예
# print(" -> ".join(print_decorator_chain(get_profile)))
주의: 본문 텍스트에서 -> 같은 기호도 MDX에서 문제를 만들진 않지만, 요구사항에 따라 본문 일반 텍스트에 부등호가 섞일 여지를 줄이기 위해 코드 블록 안에서만 사용하거나, 텍스트에서는 “화살표” 라고 풀어쓰는 편이 안전합니다.
2) 호출 스택을 최소 침습으로 로깅하기
순서 버그는 “A 데코레이터가 먼저 실행돼야 하는데 B가 먼저 실행” 되는 형태가 많습니다. 각 데코레이터에서 호출 스택 일부를 찍어두면, 실제로 어떤 wrapper 가 먼저 진입했는지 확인할 수 있습니다.
import functools
import traceback
def debug_stack(tag: str, limit: int = 8):
stack = "".join(traceback.format_stack(limit=limit))
return f"[{tag}]\n{stack}"
def logged(tag: str):
def deco(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
print(debug_stack(f"enter {tag}:{fn.__qualname__}"))
try:
return fn(*args, **kwargs)
finally:
print(debug_stack(f"exit {tag}:{fn.__qualname__}"))
return wrapper
return deco
이제 두 데코레이터의 순서를 바꿔가며 진입 로그를 비교하면, “바깥 wrapper” 가 누구인지 즉시 보입니다.
@logged("OUTER")
@logged("INNER")
def work(x):
return x * 2
work(10)
출력에서 enter OUTER 가 먼저 찍히면 OUTER가 바깥입니다. 이런 식으로 관찰 기반으로 순서를 확정할 수 있습니다.
순서 버그의 실전 패턴 4가지
1) 예외 변환 데코레이터가 너무 바깥에 있을 때
예외를 특정 타입으로 변환하는 데코레이터가 바깥에 있으면, 내부 데코레이터가 던진 원래 예외 타입 정보가 사라져서 리트라이/서킷브레이커/알림 정책이 꼬일 수 있습니다.
권장: 예외 변환은 가능한 “경계” (예: API 핸들러) 에 가깝게 두고, 내부에서는 원본 예외를 유지합니다.
2) 리트라이와 타임아웃의 순서
- 타임아웃이 바깥이면 전체 호출에 대한 총 시간 제한이 됩니다.
- 타임아웃이 안쪽이면 각 리트라이 시도마다 시간 제한이 따로 적용됩니다.
둘 다 의미가 다르므로 요구사항을 명확히 하고 순서를 고정해야 합니다.
3) 트랜잭션과 캐시/락
트랜잭션이 바깥인지 안쪽인지에 따라 캐시 일관성, 락 유지 시간, 데드락 가능성이 달라집니다. Java 진영에서 프록시 순서로 @Transactional 이 안 먹는 문제를 겪듯, Python에서도 “어떤 wrapper 가 실제 함수를 호출하는 주체인지” 가 중요합니다. 관련 사고방식은 Spring Boot 3에서 @Transactional이 안먹는 6가지 를 참고하면 도움이 됩니다.
4) 인증/인가와 로깅의 순서
보안 측면에서 로깅이 인증보다 먼저 실행되면, 인증 실패 요청의 민감정보가 로깅될 수 있습니다. 반대로 인증이 너무 바깥이면, 인증 실패 자체가 로깅되지 않아 감사 추적이 약해질 수 있습니다.
권장: “민감정보 마스킹” 데코레이터를 가장 바깥에 두고, 그 안쪽에 로깅, 그 안쪽에 인증/인가를 두는 방식으로 설계합니다.
wraps 를 써도 남는 함정: wrapper 시그니처와 인자 전달
wraps 는 메타데이터를 복사하지만, wrapper 가 실제로 *args, **kwargs 를 받는지, 혹은 특정 인자를 강제하는지에 따라 프레임워크 호환성이 달라집니다.
예를 들어 “user 를 kwargs 에 주입” 하는 데코레이터를 만들 때, 원본 함수가 user 를 positional-only 로 받거나 이름이 다르면 문제가 됩니다. 이런 경우 inspect.signature 로 바인딩을 강제해 조기에 오류를 내는 편이 낫습니다.
import functools
import inspect
def inject_user(get_user):
def deco(fn):
sig = inspect.signature(fn)
@functools.wraps(fn)
def wrapper(*args, **kwargs):
bound = sig.bind_partial(*args, **kwargs)
if "user" not in bound.arguments:
kwargs["user"] = get_user()
return fn(*args, **kwargs)
return wrapper
return deco
이렇게 하면 “호출자가 user 를 넘겼는지 여부” 를 시그니처 기준으로 판단할 수 있어, 순서가 바뀌었을 때 생기는 묵시적 동작 변화도 줄어듭니다.
디버깅 체크리스트: 순서 버그를 재현하고 고정하기
- 문제 함수를 최소 재현 코드로 줄인다
- 네트워크/DB 없이 순수 함수로 축소
- 모든 데코레이터에
wraps적용 여부 확인__wrapped__가 없으면 체인 추적이 막힘
- 데코레이터 체인 출력
print_decorator_chain같은 도구로 “누가 감쌌는지” 확인
- 진입/탈출 로그로 실행 순서 관찰
logged("OUTER")같은 태그로 바깥/안쪽 확정
- 순서가 의미를 바꾸는 지점을 문서화
- 타임아웃, 리트라이, 캐시, 인증, 예외 변환, 트랜잭션
- 테스트로 고정
- “인증 실패 시 캐시 조회가 일어나지 않는다” 같은 행위 기반 테스트를 추가
CI에서 이런 류의 회귀를 막는 관점은, 빌드/테스트 파이프라인에서 원인 추적을 체계화하는 것과도 연결됩니다. 캐시나 래핑이 기대대로 동작하지 않을 때의 점검 습관은 GitHub Actions 캐시가 안먹을 때 9가지 원인 같은 글의 접근과도 유사합니다.
결론: 데코레이터는 “순서가 곧 정책” 이다
Python 데코레이터는 강력하지만, 여러 겹이 되는 순간 “정책의 합성” 문제가 됩니다. 순서가 바뀌면 의미가 바뀌고, wraps 를 빼먹으면 그 변화가 관측 불가능해져 디버깅 난이도가 급상승합니다.
실무에서의 최적해는 단순합니다.
- 모든 데코레이터에
functools.wraps를 기본값으로 강제 - 순서가 중요한 데코레이터 조합은 팀 규칙으로 고정하고 테스트로 잠금
- 문제가 생기면
inspect.unwrap과 stack 로깅으로 “바깥 wrapper 부터” 관찰
이 3가지만 지켜도, 데코레이터 순서 버그는 재현 가능하고 수정 가능한 문제로 바뀝니다.