Published on

Python 데코레이터 중첩 시 args/return 깨짐 해결

Authors

프로덕션 코드에서 로깅, 권한 체크, 캐시, 재시도 같은 관심사를 함수에 얹다 보면 데코레이터가 자연스럽게 중첩됩니다. 문제는 이때 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/**kwargsP.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)) 형태

따라서 다음 규칙을 권장합니다.

  1. 인자 검증/권한 체크는 가능한 바깥쪽(더 먼저 실행)으로
  2. 재시도/서킷브레이커는 예외를 관찰해야 하므로, 예외를 삼키는 데코레이터보다 바깥쪽으로
  3. 캐시는 함수가 순수함수에 가까울수록 바깥쪽에 두되, 로깅이 필요하면 로깅을 캐시 바깥에 둘지 안에 둘지 의도적으로 결정
  4. 타이밍 측정은 “무엇을 측정할지”에 따라 위치를 결정(캐시 히트까지 포함할지, 실제 계산만 잴지)

실전 템플릿: 안전한 데코레이터 골격

중첩을 전제로 할 때, 아래 골격을 기본으로 두면 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로 데코레이터 시그니처를 고정

이 기본기만 지켜도 중첩 데코레이터가 “편리한 도구”에서 “디버깅 지옥”으로 변하는 일을 상당 부분 막을 수 있습니다.