- Published on
Python 데코레이터 중첩 시 args/return 깨짐 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
프로덕션 코드에서 로깅, 권한 체크, 캐시, 재시도 같은 관심사를 함수에 얹다 보면 데코레이터가 자연스럽게 중첩됩니다. 문제는 이때 args 전달이 깨지거나, 반환값이 None으로 바뀌거나, 디버깅/테스트 도구가 함수 메타데이터를 잃는 일이 생각보다 흔하다는 점입니다.
이 글은 “왜 깨지는지”를 원인별로 분해한 뒤, 중첩 데코레이터에서도 인자/반환값/시그니처를 보존하는 패턴을 코드로 정리합니다.
참고로 이런 류의 문제는 데이터 파이프라인에서도 비슷하게 나타납니다. 예를 들어 Pandas에서 경고나 오류가 "연쇄"로 터질 때 원인 추적이 어려운데, 아래 글들도 같은 맥락의 트러블슈팅 감각을 제공합니다.
증상: 중첩 후 args/return이 깨졌다는 신호
대표적인 증상은 다음과 같습니다.
- 데코레이터를 2개 이상 붙였더니 원래 함수가 받던 인자가
TypeError를 내며 깨짐 - 반환값이 있어야 하는데
None이 나옴 help(func)나 IDE 자동완성에서 함수 시그니처가(*args, **kwargs)로 뭉개짐__name__,__doc__,__wrapped__가 사라져서 로깅/트레이싱/테스트가 난해해짐
이 증상들은 보통 “데코레이터 내부에서 원래 함수를 제대로 호출하지 않았거나”, “반환을 누락했거나”, “메타데이터/시그니처 보존을 하지 않았거나” 셋 중 하나(혹은 복합)입니다.
가장 흔한 원인 1: return을 빼먹는 실수
중첩이 아니어도 치명적이지만, 중첩되면 더 발견이 늦습니다.
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print("calling", fn.__name__)
fn(*args, **kwargs) # 반환 누락
return wrapper
@log_calls
def add(a, b):
return a + b
print(add(1, 2)) # None
해결
- 원래 함수 호출 결과를 변수로 받거나 즉시 반환합니다.
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print("calling", fn.__name__)
return fn(*args, **kwargs)
return wrapper
중첩 데코레이터에서 이 실수가 더 위험한 이유는, 바깥 데코레이터는 “안쪽이 값을 반환할 것”이라 가정하고 후처리를 하다가 연쇄적으로 None을 전파하기 때문입니다.
가장 흔한 원인 2: *args/**kwargs 전달을 일부만 하는 실수
인자 전달을 부분적으로만 하면 특정 호출에서만 깨져서 디버깅이 어려워집니다.
from functools import wraps
def only_positional(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
# kwargs를 버려버림
return fn(*args)
return wrapper
@only_positional
def greet(name, *, prefix="hi"):
return f"{prefix} {name}"
print(greet("kim", prefix="hello")) # TypeError
해결
- 전달은 원칙적으로
fn(*args, **kwargs)형태로 유지합니다. - 특정 인자를 조작해야 한다면,
kwargs를 복사한 뒤 명시적으로 수정합니다.
from functools import wraps
def force_prefix(prefix_value):
def deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
new_kwargs = dict(kwargs)
new_kwargs["prefix"] = prefix_value
return fn(*args, **new_kwargs)
return wrapper
return deco
@force_prefix("hello")
def greet(name, *, prefix="hi"):
return f"{prefix} {name}"
print(greet("kim"))
가장 흔한 원인 3: functools.wraps 미사용으로 메타데이터/시그니처가 붕괴
wraps는 단순히 __name__만 예쁘게 유지하는 게 아닙니다.
__wrapped__체인을 유지해 디버거/테스터/문서화 도구가 원본 함수에 접근 가능- 여러 프레임워크(예: DI, 라우팅, CLI, validation)가 리플렉션할 때 원본 정보를 찾을 가능성 증가
wraps 없이 중첩하면 바깥쪽 래퍼가 안쪽 래퍼를 또 감싸면서 원본 함수 정보가 계속 소실됩니다.
def deco_a(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def deco_b(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@deco_a
@deco_b
def f(x: int) -> int:
"""original"""
return x + 1
print(f.__name__) # wrapper
print(f.__doc__) # None
해결: 모든 데코레이터에 @wraps(fn) 적용
from functools import wraps
def deco_a(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def deco_b(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
중첩에서 특히 까다로운 포인트: “반환값 후처리” 데코레이터
예를 들어 실행 시간을 재고, 결과를 캐시하고, 마지막에 결과 형태를 표준화하는 데코레이터가 섞이면 반환 타입이 바뀌거나, 예외가 삼켜지거나, 코루틴이 미실행 되는 문제가 생깁니다.
나쁜 예: 예외를 삼키며 None 반환
from functools import wraps
def swallow_errors(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception:
return None
return wrapper
@swallow_errors
def div(a, b):
return a / b
print(div(1, 0)) # None (장애를 숨김)
이런 데코레이터가 중첩에 끼면, 바깥쪽 로깅/메트릭은 "정상 반환"으로 착각할 수 있습니다.
개선: 예외는 다시 던지고, 필요한 경우에만 변환
from functools import wraps
class DomainError(RuntimeError):
pass
def map_errors(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except ZeroDivisionError as e:
raise DomainError("invalid division") from e
return wrapper
시그니처까지 보존하고 싶다면: ParamSpec/TypeVar로 타입 안전하게
런타임에서 시그니처를 완벽히 유지하는 건 어렵지만(특히 인자 조작형 데코레이터), 타입 체크 관점에서는 Python 3.10+의 ParamSpec이 사실상 표준 패턴입니다.
아래 패턴은 중첩해도 args/return 타입이 깨지지 않게 도와줍니다.
from __future__ import annotations
from functools import wraps
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("calling", fn.__name__)
return fn(*args, **kwargs)
return wrapper
def timeit(fn: Callable[P, R]) -> Callable[P, R]:
import time
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
t0 = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
dt = time.perf_counter() - t0
print(fn.__name__, "took", dt)
return wrapper
@timeit
@log_calls
def add(a: int, b: int) -> int:
return a + b
x = add(1, 2)
핵심은 다음입니다.
Callable[P, R]형태로 “입력 파라미터 스펙”과 “반환 타입”을 분리- 래퍼의
*args/**kwargs도P.args,P.kwargs로 연결 - 반드시
return fn(*args, **kwargs)로 반환을 보존
이 패턴은 데코레이터를 여러 개 중첩해도 타입 체인이 유지되는 편이라, 대규모 코드베이스에서 특히 유용합니다.
비동기 함수에서의 함정: async 데코레이터 중첩
async def를 감싸는 데코레이터가 동기 래퍼를 반환하면, 호출 측에서 await 타이밍이 꼬이거나 코루틴이 그대로 반환되는 문제가 생깁니다.
나쁜 예: async 함수를 동기 wrapper로 감쌈
from functools import wraps
def bad_async_deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
# fn은 async인데 await하지 않음
return fn(*args, **kwargs)
return wrapper
해결 1: 애초에 async def wrapper로 감싸기
from functools import wraps
from typing import Callable, TypeVar, ParamSpec, Awaitable
P = ParamSpec("P")
R = TypeVar("R")
def async_log_calls(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("calling", fn.__name__)
return await fn(*args, **kwargs)
return wrapper
해결 2: sync/async 모두 지원하는 범용 데코레이터
inspect.iscoroutinefunction으로 분기하는 방식이 실무에서 자주 쓰입니다.
from __future__ import annotations
import inspect
from functools import wraps
from typing import Callable, TypeVar, ParamSpec, Any
P = ParamSpec("P")
R = TypeVar("R")
def log_calls_any(fn: Callable[P, R]) -> Callable[P, R]:
if inspect.iscoroutinefunction(fn):
@wraps(fn)
async def aw(*args: P.args, **kwargs: P.kwargs): # type: ignore
print("calling", fn.__name__)
return await fn(*args, **kwargs) # type: ignore
return aw # type: ignore
@wraps(fn)
def sw(*args: P.args, **kwargs: P.kwargs) -> R:
print("calling", fn.__name__)
return fn(*args, **kwargs)
return sw
타입은 약간 복잡해지지만, “중첩 시 런타임 동작이 깨지는” 문제는 확실히 줄어듭니다.
데코레이터 순서가 결과를 바꾼다: 중첩 순서 설계 체크리스트
중첩 데코레이터는 위에서 아래로 적용되는 것처럼 보이지만, 실제로는 아래에서 위로 감싸집니다.
@A위에@B가 있으면, 실행 흐름은A(B(fn))형태
따라서 다음 규칙을 권장합니다.
- 인자 검증/권한 체크는 가능한 바깥쪽(더 먼저 실행)으로
- 재시도/서킷브레이커는 예외를 관찰해야 하므로, 예외를 삼키는 데코레이터보다 바깥쪽으로
- 캐시는 함수가 순수함수에 가까울수록 바깥쪽에 두되, 로깅이 필요하면 로깅을 캐시 바깥에 둘지 안에 둘지 의도적으로 결정
- 타이밍 측정은 “무엇을 측정할지”에 따라 위치를 결정(캐시 히트까지 포함할지, 실제 계산만 잴지)
실전 템플릿: 안전한 데코레이터 골격
중첩을 전제로 할 때, 아래 골격을 기본으로 두면 args/return 깨짐을 대부분 예방합니다.
from __future__ import annotations
from functools import wraps
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def safe_decorator(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# 1) 필요하면 args/kwargs를 복사해 수정
# new_kwargs = dict(kwargs)
# 2) 원본 호출 결과를 반드시 반환
result = fn(*args, **kwargs)
# 3) 후처리가 필요하면 result 기반으로 처리하되,
# 타입/의미가 바뀌는지 명확히 의도
return result
return wrapper
디버깅 팁: 원본 함수 찾기와 중첩 체인 확인
@wraps를 잘 썼다면 __wrapped__ 체인을 따라가며 원본에 접근할 수 있습니다.
import inspect
def unwrap_all(fn):
cur = fn
chain = [cur]
while hasattr(cur, "__wrapped__"):
cur = cur.__wrapped__ # type: ignore
chain.append(cur)
return chain
def show_chain(fn):
for i, f in enumerate(unwrap_all(fn)):
print(i, f.__name__, inspect.signature(f))
이 출력이 전부 (*args, **kwargs)로만 보인다면, 어딘가에서 wraps가 누락되었을 확률이 높습니다.
자주 묻는 케이스별 처방 요약
- 반환값이
None으로 바뀜: 데코레이터 내부에서return누락 여부부터 확인 - 특정 호출에서만 인자 오류:
fn(*args, **kwargs)형태로 전달되는지,kwargs를 버리거나 덮어쓰지 않는지 확인 - 시그니처/문서가 사라짐: 모든 데코레이터에
@wraps(fn)적용 async함수가 이상함: 래퍼도async def로 만들고await fn(...)수행- 타입 체킹에서 데코레이터가 전부
Any가 됨:ParamSpec/TypeVar기반 시그니처로 정의
마무리
데코레이터 중첩에서 args/return이 깨지는 문제는 대체로 “작은 실수”에서 시작하지만, 로깅/캐시/재시도처럼 여러 관심사가 겹치면 원인 추적 비용이 급격히 커집니다.
정리하면, 실무에서의 정답은 대부분 다음 3줄로 귀결됩니다.
- 모든 데코레이터에
@wraps(fn) - 호출은
fn(*args, **kwargs)로, 반환은 반드시return - 타입 안정성이 필요하면
ParamSpec/TypeVar로 데코레이터 시그니처를 고정
이 기본기만 지켜도 중첩 데코레이터가 “편리한 도구”에서 “디버깅 지옥”으로 변하는 일을 상당 부분 막을 수 있습니다.