Published on

Python 데코레이터 중첩 시 인자·반환값 깨짐 해결법

Authors

서버 코드에서 로깅, 메트릭, 재시도, 트랜잭션, 캐시 같은 횡단 관심사를 데코레이터로 쌓다 보면, 어느 순간부터 함수 호출이 이상해집니다.

  • 원래는 f(x, y=1) 인데 데코레이터를 2~3개 얹는 순간 TypeError: got an unexpected keyword argument가 터짐
  • 반환값이 Response여야 하는데 None이 돌아오거나, 코루틴이 그대로 흘러나와 await 누락 같은 증상이 발생
  • inspect.signature()(*args, **kwargs)로 뭉개져 FastAPI/DI/validation이 오작동
  • 타입 힌트가 Callable[..., Any]로 퇴화해 정적 분석이 무력화

이 글은 “중첩 데코레이터에서 인자·반환값이 깨지는 전형적인 원인”을 패턴으로 나누고, 안전하게 고치는 구현 템플릿을 제공합니다.

또한 재시도/타임아웃/데드라인 같은 제어 흐름을 데코레이터로 구현할 때 생기는 함정은 다른 글의 문제 양상과도 닮았습니다. 예를 들어 재시도는 설계가 조금만 어긋나도 호출 체인이 깨지는데, 이런 관점은 Claude API 529·429 재시도 전략과 구현 패턴에서도 유사하게 다룹니다.

왜 중첩 데코레이터에서 깨질까: 5가지 대표 원인

1) 래퍼가 *args, **kwargs를 “전달만 하고 끝”내며 반환을 누락

가장 흔한 실수는 래퍼 내부에서 원 함수를 호출하고 return을 빼먹는 것입니다. 데코레이터를 하나만 쓸 때는 테스트에서 눈치채기 쉬운데, 여러 개가 중첩되면 “어느 데코레이터가 반환을 먹었는지” 추적이 어려워집니다.

from functools import wraps

def bad_log(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print("call", fn.__name__)
        fn(*args, **kwargs)  # return 누락
    return wrapper

@bad_log
def add(a, b):
    return a + b

print(add(1, 2))  # None

중첩하면 더 치명적입니다. 바깥 데코레이터는 “안쪽이 값을 리턴할 것”이라 가정하는데, 안쪽에서 이미 None을 만들어버리니까 바깥에서 복구할 방법이 없습니다.

2) functools.wraps 미사용으로 메타데이터/시그니처가 붕괴

wraps는 단순히 __name__만 복사하는 게 아니라, __wrapped__ 체인을 만들어 inspect.signature()가 원 함수 시그니처를 복구할 수 있게 돕습니다.

wraps 없이 중첩하면 프레임워크가 인자 바인딩을 제대로 못 하거나, 문서화/DI가 깨집니다.

import inspect

def no_wrap(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@no_wrap
def f(x: int, y: int = 1) -> int:
    return x + y

print(inspect.signature(f))  # (*args, **kwargs)

3) 키워드 전용/가변 인자 처리 실수로 바인딩이 틀어짐

데코레이터가 인자를 “재배열”하거나 “일부만 전달”하는 순간, 중첩에서 폭발합니다.

  • kwargs.pop()으로 옵션을 빼먹고 다시 안 넣음
  • args를 리스트로 만들었다가 순서가 바뀜
  • * 키워드 전용 인자를 위치 인자로 넘겨버림

4) sync/async 혼합 데코레이터로 반환 타입이 뒤틀림

동기 함수에 async 데코레이터를 얹거나, async 함수에 sync 데코레이터를 얹으면 반환값이 코루틴/태스크로 바뀌어 호출부가 깨집니다.

import asyncio
from functools import wraps

def sync_deco(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        # async 함수면 여기서 코루틴 객체를 그대로 반환하게 됨
        return fn(*args, **kwargs)
    return wrapper

@sync_deco
async def af(x):
    await asyncio.sleep(0.01)
    return x

# print(af(1))  # 코루틴 객체

5) 타입 힌트가 소실되어 “인자/반환값 보존”이 어려워짐

런타임 동작은 맞아도, 타입 힌트가 망가지면 IDE/리뷰 단계에서 버그를 놓치기 쉽습니다. 특히 데코레이터 팩토리(인자를 받는 데코레이터)를 중첩하면 Callable[..., Any]로 쉽게 퇴화합니다.

기본 해결책: wraps + “전달과 반환”을 기계적으로 지켜라

가장 먼저 지켜야 할 규칙은 다음 두 가지입니다.

  1. 래퍼는 받은 *args, **kwargs그대로 원 함수에 전달한다
  2. 원 함수 호출 결과를 반드시 return 한다
from functools import wraps

def log_calls(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print("call", fn.__name__, args, kwargs)
        result = fn(*args, **kwargs)
        return result
    return wrapper

@log_calls
def add(a, b=1):
    return a + b

print(add(10, b=2))

이 원칙을 어기는 순간, 중첩에서 인자·반환값은 거의 반드시 깨집니다.

중첩 데코레이터의 “안전한” 타입 템플릿: ParamSpecTypeVar

Python 3.10+ (또는 typing_extensions)에서는 ParamSpec을 써서 “원 함수의 파라미터 목록”을 타입 수준에서 보존할 수 있습니다. 반환값은 TypeVar로 보존합니다.

from __future__ import annotations

from functools import wraps
from typing import Callable, TypeVar, ParamSpec

P = ParamSpec("P")
R = TypeVar("R")

def log_calls_typed(fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print("call", fn.__name__)
        return fn(*args, **kwargs)
    return wrapper

@log_calls_typed
def parse(x: str, *, base: int = 10) -> int:
    return int(x, base=base)

이 패턴을 모든 데코레이터에 적용하면, 중첩해도 “인자 시그니처/반환 타입”이 연쇄적으로 보존됩니다.

데코레이터 팩토리(인자를 받는 데코레이터)도 동일하게

from __future__ import annotations

import time
from functools import wraps
from typing import Callable, TypeVar, ParamSpec

P = ParamSpec("P")
R = TypeVar("R")

def timed(prefix: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def deco(fn: Callable[P, R]) -> Callable[P, R]:
        @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) * 1000
                print(f"{prefix}{fn.__name__}: {dt:.2f}ms")
        return wrapper
    return deco

@timed("[svc] ")
def work(x: int) -> int:
    return x * 2

핵심은 반환 타입 R을 절대 Any로 뭉개지 않게 유지하는 것입니다.

“반환값이 깨지는” 중첩 버그 3종과 처방

1) 예외를 삼켜서 호출자가 다른 흐름을 타게 됨

로깅/메트릭 데코레이터에서 try/except로 예외를 잡고 아무 것도 안 하면, 호출자는 성공으로 오해합니다.

from functools import wraps

def swallow(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        try:
            return fn(*args, **kwargs)
        except Exception:
            return None  # 위험: 실패를 성공처럼 보이게 함
    return wrapper

처방:

  • 예외는 기본적으로 재발생(raise)시키고, 정말 필요할 때만 “명시적 폴백”을 둡니다.
  • 폴백을 둔다면 반환 타입을 Optional[R]로 바꾸는 등 API 계약을 바꿔야 합니다.

2) 캐시 데코레이터가 키워드 인자를 키에서 누락

중첩 시 인자 전달은 정상인데 결과가 엉뚱하게 나오는 경우, 캐시 키 설계가 원인일 때가 많습니다.

from functools import wraps

def naive_cache(fn):
    store = {}

    @wraps(fn)
    def wrapper(*args, **kwargs):
        key = args  # kwargs 무시
        if key in store:
            return store[key]
        store[key] = fn(*args, **kwargs)
        return store[key]

    return wrapper

처방:

  • 키에 kwargs를 포함하고, 정렬/불변화해서 안정적으로 만듭니다.
from functools import wraps

def safe_cache(fn):
    store = {}

    @wraps(fn)
    def wrapper(*args, **kwargs):
        key = (args, tuple(sorted(kwargs.items())))
        if key in store:
            return store[key]
        value = fn(*args, **kwargs)
        store[key] = value
        return value

    return wrapper

3) 재시도 데코레이터가 “마지막 반환값”을 놓치거나, 마지막 예외를 잘못 처리

재시도는 중첩에서 자주 쓰이는데, 구현이 조금만 어긋나도 반환값/예외 계약이 깨집니다. 특히 마지막 시도에서 성공했는데도 break 후 반환을 안 하거나, 실패했는데도 예외를 덮어버리는 버그가 흔합니다.

재시도 전략 자체는 Claude API 529·429 재시도 전략과 구현 패턴처럼 “어떤 예외를 재시도할지, 지수 백오프/지터, 최대 시간”을 명확히 해야 하고, 데코레이터 구현은 “반환/예외 계약”을 정확히 지켜야 합니다.

sync/async 모두 안전하게 지원하는 데코레이터 패턴

중첩 환경에서 가장 안전한 방법은 “대상 함수가 코루틴 함수인지”를 보고 래퍼를 분기하는 것입니다.

from __future__ import annotations

import inspect
import time
from functools import wraps
from typing import Callable, TypeVar, ParamSpec, Awaitable, overload

P = ParamSpec("P")
R = TypeVar("R")

@overload
def timed_any(fn: Callable[P, R]) -> Callable[P, R]: ...

@overload
def timed_any(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...

def timed_any(fn):
    if inspect.iscoroutinefunction(fn):
        @wraps(fn)
        async def aw(*args, **kwargs):
            t0 = time.perf_counter()
            try:
                return await fn(*args, **kwargs)
            finally:
                dt = (time.perf_counter() - t0) * 1000
                print(f"{fn.__name__}: {dt:.2f}ms")
        return aw

    @wraps(fn)
    def sw(*args, **kwargs):
        t0 = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            dt = (time.perf_counter() - t0) * 1000
            print(f"{fn.__name__}: {dt:.2f}ms")
    return sw

이렇게 해두면, async 함수에 sync 래퍼가 씌워져 코루틴이 새는 문제를 크게 줄일 수 있습니다.

데코레이터 중첩 순서: “관찰”과 “제어”를 분리하라

중첩 순서는 인자·반환값뿐 아니라, 로깅/메트릭의 정확도와 예외 처리 의미까지 바꿉니다. 실무에서 권장하는 기본 규칙은 다음과 같습니다.

  • 관찰(로깅/메트릭/트레이싱): 가능한 바깥쪽
  • 제어(재시도/타임아웃/서킷브레이커/트랜잭션): 안쪽 또는 목적에 맞게
  • 캐시: 보통 재시도 바깥쪽(캐시 히트는 재시도 불필요), 단 “실패 캐시” 같은 특수 정책은 예외

예를 들어 “재시도 횟수까지 포함해 전체 호출 시간을 재고 싶다”면 timed를 재시도 바깥에 둡니다.

@timed("[total] ")
@retry(max_attempts=3)
@log_calls_typed
def call_api(x: int) -> int:
    ...

반대로 “각 시도별 시간을 측정”하고 싶다면 timed를 재시도 안쪽으로 둡니다.

이런 제어 흐름은 데드라인/타임아웃 전파와도 연결됩니다. 타임아웃 데코레이터를 잘못 중첩하면 바깥쪽 데코레이터가 시간을 소비해 실제 비즈니스 로직에 남는 시간이 줄어드는 식의 문제가 생길 수 있는데, 비슷한 진단 관점은 gRPC 데드라인 전파 실패, 원인과 진단법에서도 도움이 됩니다.

inspect.signature__wrapped__ 체인으로 “어느 데코레이터가 망가뜨렸는지” 찾기

중첩이 깊어질수록 원인 데코레이터를 빨리 찾는 게 중요합니다.

  • inspect.signature(func)(*args, **kwargs)로 보이면, 중간 어딘가에서 wraps가 빠졌을 확률이 큽니다.
  • func.__wrapped__를 따라가며 체인을 확인하면 “어느 레벨에서 메타데이터가 끊겼는지” 알 수 있습니다.
import inspect

def unwrap_chain(fn):
    i = 0
    cur = fn
    while True:
        print(i, cur, inspect.signature(cur))
        if not hasattr(cur, "__wrapped__"):
            break
        cur = cur.__wrapped__
        i += 1

이 출력에서 특정 단계부터 시그니처가 뭉개지면, 그 데코레이터 구현을 집중적으로 보면 됩니다.

실전용: “깨지지 않는” 데코레이터 체크리스트

중첩 데코레이터에서 인자·반환값을 보존하려면, 아래 항목을 코드 리뷰 체크리스트로 박아두는 게 가장 효과적입니다.

  1. 래퍼가 원 함수를 호출한 결과를 return 하는가
  2. @wraps(fn)을 붙였는가
  3. *args, **kwargs를 그대로 전달하는가(임의 변형이 필요한 경우, 그 계약이 문서화되어 있는가)
  4. 예외를 삼키지 않는가(삼킨다면 반환 타입/계약이 바뀌었음을 명시했는가)
  5. sync/async 혼용 가능성이 있으면 inspect.iscoroutinefunction 분기를 두었는가
  6. 타입 힌트는 ParamSpec/TypeVar로 보존되는가
  7. 캐시/재시도/타임아웃처럼 제어 흐름을 바꾸는 데코레이터는 중첩 순서가 의도대로인가

결론: 중첩을 견디는 데코레이터는 “계약 보존”이 전부다

데코레이터 중첩에서 인자·반환값이 깨지는 문제는 대부분 “사소한 구현 실수”에서 시작하지만, 중첩되면 증상이 멀리 전파되어 디버깅 난이도가 급격히 올라갑니다.

  • 런타임 계약: *args, **kwargs 전달 + 반환값 return
  • 메타데이터 계약: functools.wraps__wrapped__ 체인 유지
  • 타입 계약: ParamSpec/TypeVar로 시그니처와 반환 타입 보존
  • 제어 흐름 계약: 재시도/타임아웃/캐시의 순서와 예외 정책을 명확히

이 4가지를 지키면, 로깅·메트릭·재시도·캐시를 몇 겹으로 쌓아도 호출부가 흔들리지 않는 데코레이터를 만들 수 있습니다.