- Published on
Python 데코레이터 중첩 순서 버그 재현·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 데코레이터는 로깅, 권한, 트랜잭션, 캐시, 재시도 같은 횡단 관심사를 한 줄로 붙이는 강력한 도구입니다. 문제는 여러 데코레이터를 중첩했을 때의 적용 순서와 실행 순서를 머릿속에서 뒤집어 이해하기 쉽다는 점입니다. 이 착각은 에러가 즉시 터지지 않고, “재시도 횟수가 이상하다”, “캐시가 안 먹는다”, “권한 체크가 우회되는 것 같다” 같은 형태로 늦게 나타납니다.
이 글에서는 중첩 순서 버그를 의도적으로 재현하고, 왜 그런 일이 생기는지(평가/적용/호출 관점), 그리고 실무에서 안전하게 고치는 패턴(구조화, 합성, 테스트)을 정리합니다.
관련해서 재시도 데코레이터 자체 구현 패턴은 Python 데코레이터로 재시도·백오프 구현 5패턴도 함께 보면 좋습니다.
데코레이터 중첩의 핵심 규칙 2가지
1) 적용(래핑) 순서: 아래에서 위로
다음 코드에서 @A 와 @B 가 있을 때:
@A
@B
def f():
...
파이썬이 실제로 만드는 것은 아래와 같습니다.
f = A(B(f))
즉, 가장 아래 데코레이터가 먼저 함수에 적용되고, 그 결과를 위 데코레이터가 다시 감쌉니다.
2) 실행(호출) 순서: 위에서 아래로
하지만 f() 를 호출하면, 바깥쪽(위에 있던) 래퍼가 먼저 실행됩니다.
- 바깥 래퍼(위 데코레이터)
before - 안쪽 래퍼(아래 데코레이터)
before - 원본 함수 실행
- 안쪽 래퍼
after - 바깥 래퍼
after
이 “적용은 아래에서 위로, 실행은 위에서 아래로” 규칙을 놓치면 대부분의 버그가 시작됩니다.
버그 재현 1: 권한 체크가 캐시에 의해 우회되는 사례
가장 흔한 실수는 캐시가 권한 체크보다 바깥에 있어서 캐시 히트 시 권한 체크가 실행되지 않는 경우입니다.
재현 코드
from __future__ import annotations
from functools import wraps
from typing import Any, Callable, Dict, Tuple
CacheKey = Tuple[str, Tuple[Any, ...], Tuple[Tuple[str, Any], ...]]
def cacheable(cache: Dict[CacheKey, Any]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
key: CacheKey = (fn.__name__, args, tuple(sorted(kwargs.items())))
if key in cache:
print("[cache] hit")
return cache[key]
print("[cache] miss")
out = fn(*args, **kwargs)
cache[key] = out
return out
return wrapper
return deco
def require_role(role: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
user_role = kwargs.get("user_role")
if user_role != role:
raise PermissionError(f"role '{role}' required")
print("[auth] ok")
return fn(*args, **kwargs)
return wrapper
return deco
_cache: Dict[CacheKey, Any] = {}
@cacheable(_cache)
@require_role("admin")
def get_admin_report(*, user_role: str) -> str:
print("[biz] generating report")
return "TOP-SECRET"
def demo() -> None:
print("--- 1) admin first call ---")
print(get_admin_report(user_role="admin"))
print("--- 2) guest second call (should fail, but...) ---")
print(get_admin_report(user_role="guest"))
if __name__ == "__main__":
demo()
실행 결과(문제 상황)
- admin 호출 때 캐시 miss, 권한 ok, 결과 생성 후 캐시 저장
- guest 호출 때 캐시 hit 이면 권한 체크가 아예 실행되지 않고 캐시된 결과가 반환
이 버그는 특히 “캐시 키에 권한/사용자 컨텍스트가 포함되지 않는” 경우에 치명적입니다.
해결 1: 데코레이터 순서를 바꿔 권한을 바깥으로
권한 체크가 항상 먼저 실행되도록 바깥(위)에 두어야 합니다.
_cache = {}
@require_role("admin")
@cacheable(_cache)
def get_admin_report(*, user_role: str) -> str:
print("[biz] generating report")
return "TOP-SECRET"
이제 호출은 다음 순서로 진행됩니다.
require_role가 먼저 실행되어 실패/성공 결정- 성공한 요청만
cacheable로 들어감
해결 2: 캐시 키에 보안 컨텍스트를 포함
순서를 바꾸기 어렵거나, “권한은 통과했지만 사용자별로 다른 결과”가 필요하다면 캐시 키에 user_id 또는 user_role 을 포함해야 합니다.
key = (fn.__name__, args, tuple(sorted(kwargs.items())))
위처럼 kwargs 전체를 키에 넣으면 단순하지만, 민감정보가 키로 남지 않도록(로그/메트릭/덤프) 주의해야 합니다. 실무에선 “필요한 필드만 선택”하는 편이 안전합니다.
버그 재현 2: 재시도와 로깅/메트릭의 순서가 뒤틀리는 사례
재시도 데코레이터를 로깅/메트릭과 섞으면 “시도 횟수 로그가 한 번만 남는다” 또는 “실패 메트릭이 과다/과소 집계된다” 같은 문제가 자주 생깁니다.
재현 코드: 로깅이 재시도 바깥에 있을 때
from __future__ import annotations
from functools import wraps
from typing import Any, Callable
def log_calls(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
print(f"[log:{name}] enter")
try:
return fn(*args, **kwargs)
finally:
print(f"[log:{name}] exit")
return wrapper
return deco
def retry(max_attempts: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
print(f"[retry] attempt={attempt}")
return fn(*args, **kwargs)
except Exception as e:
last_exc = e
assert last_exc is not None
raise last_exc
return wrapper
return deco
n = 0
@log_calls("outer")
@retry(3)
def flaky() -> str:
global n
n += 1
print(f"[biz] n={n}")
if n < 3:
raise RuntimeError("boom")
return "ok"
if __name__ == "__main__":
print(flaky())
이 경우 log_calls("outer") 는 재시도 전체를 한 번 감싸므로, 로그는 enter/exit 가 한 번만 찍힙니다. “매 시도마다 enter/exit 를 남기고 싶다”면 이 순서는 요구사항과 불일치합니다.
해결: 로깅을 재시도 안쪽으로
n = 0
@retry(3)
@log_calls("per-attempt")
def flaky() -> str:
global n
n += 1
print(f"[biz] n={n}")
if n < 3:
raise RuntimeError("boom")
return "ok"
이제 retry 가 바깥에서 여러 번 호출하고, 각 호출이 log_calls 를 통과하므로 시도별 로그를 남길 수 있습니다.
정리하면:
- “재시도 전체를 하나의 작업으로 로깅”하려면 로깅을 바깥에
- “시도마다 로깅/메트릭”하려면 로깅을 안쪽에
재시도/백오프를 더 견고하게 만드는 패턴(지수 백오프, 지터, 예외 필터링 등)은 Python 데코레이터로 재시도·백오프 구현 5패턴에서 확장해서 볼 수 있습니다.
왜 이런 버그가 ‘조용히’ 발생하나
1) 타입/문법 에러가 아니라 정책 에러다
중첩 순서 문제는 대부분 문법적으로 완벽합니다. 반환값도 정상이고 테스트가 얕으면 통과합니다. 하지만 정책(보안, 관측, 성능) 레벨에서 요구사항을 위반합니다.
2) 데코레이터는 “경계”를 만든다
각 데코레이터는 함수 호출의 경계를 하나 더 만듭니다.
- 캐시는 “함수 호출을 생략”할 수 있는 경계
- 권한은 “호출을 차단”하는 경계
- 트랜잭션은 “커밋/롤백 범위” 경계
- 재시도는 “호출을 반복”하는 경계
경계의 조합에서 바깥/안쪽이 바뀌면 의미가 달라집니다. 이건 이벤트 순서/중복/재처리 문제가 시스템에서 버그를 만드는 방식과 유사합니다. 관심 있다면 DDD 이벤트 소싱 마이그레이션 - 중복·순서·재처리도 같은 결의 사고방식을 제공합니다.
실무 해결 전략 1: “정책 우선순위”를 문서로 고정
중첩 데코레이터는 팀이 커질수록 “누가 위에 와야 하는가”가 암묵지가 됩니다. 다음처럼 우선순위를 정해두면 실수를 줄입니다.
예시(일반적인 권장 순서, 상황에 따라 조정):
- 인증/인가(반드시 캐시보다 바깥)
- 입력 검증
- 관측(트레이싱/로깅) - 요구사항에 따라 재시도 바깥/안쪽 결정
- 재시도/회로차단기
- 캐시(보안 컨텍스트 포함 여부 확인)
- 비즈니스 로직
이 우선순위는 “정답”이 아니라 합의된 규약입니다. 중요한 건 일관성입니다.
실무 해결 전략 2: 데코레이터 합성(Composite)으로 순서를 고정
데코레이터를 여기저기서 조합하면 호출부마다 순서가 달라집니다. 그럴 바엔 “표준 스택”을 하나로 합성해 제공하는 편이 안전합니다.
from __future__ import annotations
from typing import Any, Callable, Dict
def standard_endpoint(cache: Dict[Any, Any]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
# 여기서 순서를 고정한다: auth -> retry -> cache
wrapped = require_role("admin")(fn)
wrapped = retry(3)(wrapped)
wrapped = cacheable(cache)(wrapped)
return wrapped
return deco
_cache = {}
@standard_endpoint(_cache)
def get_admin_report(*, user_role: str) -> str:
return "TOP-SECRET"
이 방식의 장점:
- 순서 실수를 구조적으로 차단
- 엔드포인트/유스케이스별로 표준 정책을 재사용
주의할 점:
- 합성 함수 내부에서
wraps가 유지되도록 각 데코레이터가@wraps를 잘 써야 디버깅/스택트레이스/문서화가 편합니다.
실무 해결 전략 3: “순서 버그”를 잡는 테스트를 작성
중첩 순서 버그는 결과값만 비교하면 놓치기 쉽습니다. 따라서 부수효과(로그/카운터/호출 횟수/권한 체크 호출 여부) 를 검증하는 테스트가 필요합니다.
아래는 pytest 스타일의 간단한 예시입니다.
from __future__ import annotations
from typing import Any, Callable, Dict, List, Tuple
def record(name: str, sink: List[str]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args: Any, **kwargs: Any) -> Any:
sink.append(f"{name}:before")
try:
return fn(*args, **kwargs)
finally:
sink.append(f"{name}:after")
return wrapper
return deco
def test_decorator_order() -> None:
events: List[str] = []
@record("A", events)
@record("B", events)
def f() -> None:
events.append("f")
f()
assert events == [
"A:before",
"B:before",
"f",
"B:after",
"A:after",
]
이런 테스트는 “실행 순서”를 명시적으로 고정해주므로, 누군가 데코레이터 위치를 바꾸면 바로 깨집니다.
실무 해결 전략 4: 캐시/재시도 같은 데코레이터는 ‘컨텍스트’를 명시적으로 받게 설계
중첩에서 가장 위험한 조합은 “호출을 생략하거나 반복하는” 데코레이터(캐시/재시도/서킷브레이커)입니다. 이런 데코레이터는 가능하면 아래를 명시적으로 설계하세요.
- 캐시 키 함수
key_fn을 받는다 - 재시도는 재시도 대상 예외를 필터링한다
- 관측은 “시도 단위”인지 “작업 단위”인지 옵션으로 선택한다
예: 캐시 키를 명시하는 형태
from __future__ import annotations
from functools import wraps
from typing import Any, Callable, Dict, Hashable, Optional
def cacheable2(
cache: Dict[Hashable, Any],
key_fn: Callable[..., Hashable],
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
key = key_fn(*args, **kwargs)
if key in cache:
return cache[key]
out = fn(*args, **kwargs)
cache[key] = out
return out
return wrapper
return deco
_cache: Dict[Hashable, Any] = {}
@require_role("admin")
@cacheable2(_cache, key_fn=lambda *, user_role: ("get_admin_report", user_role))
def get_admin_report(*, user_role: str) -> str:
return "TOP-SECRET"
이렇게 하면 “이 캐시는 사용자 역할에 따라 분리된다”는 의도가 코드에 박힙니다.
체크리스트: 중첩 순서 버그를 줄이는 7가지 질문
- 이 데코레이터는 호출을 반복하거나 생략하는가(재시도/캐시)?
- 권한/검증이 캐시보다 바깥에 있는가?
- 로깅/메트릭은 “시도 단위”인가 “작업 단위”인가?
- 트랜잭션 범위는 재시도와 어떤 관계인가(시도마다 트랜잭션 vs 전체를 하나로)?
- 캐시 키에 사용자/테넌트/권한 컨텍스트가 포함되는가?
@wraps가 적용되어 함수 메타데이터가 유지되는가?- 실행 순서를 검증하는 테스트(부수효과 기반)가 있는가?
마무리
데코레이터 중첩 순서 버그는 파이썬 문법의 함정이라기보다, “경계의 의미”를 잘못 쌓았을 때 생기는 정책 버그입니다. 가장 안전한 접근은
- 팀 규약으로 우선순위를 고정하고
- 자주 쓰는 조합은 합성 데코레이터로 제공하며
- 부수효과 기반 테스트로 순서를 잠그는 것
입니다.
특히 재시도/백오프를 함께 쓰는 경우는 요구사항(시도 단위 관측 vs 작업 단위 관측)에 따라 정답이 달라지므로, 구현 패턴을 더 확장하고 싶다면 Python 데코레이터로 재시도·백오프 구현 5패턴을 같이 참고하세요.