Published on

Python 데코레이터 중첩 시 인자 깨짐 7가지 원인

Authors

서버 코드에서 로깅, 트레이싱, 캐싱, 권한 체크를 붙이다 보면 데코레이터가 2~4겹으로 쉽게 중첩됩니다. 그런데 어느 순간부터 TypeError: ... takes N positional arguments but M were given 같은 오류가 터지거나, kwargs 가 사라지거나, IDE/타입체커가 함수 시그니처를 잃어버리는 일이 생깁니다. 이런 현상을 흔히 “인자 깨짐”이라고 부르는데, 대부분은 데코레이터 구현에서 args·kwargs 전달/보존 규칙을 어긴 결과입니다.

이 글에서는 중첩 데코레이터에서 인자가 깨지는 원인 7가지를 재현 코드와 함께 정리하고, 실무에서 안전한 패턴까지 제시합니다.

참고로, “원인별로 빠르게 분류하고 단계적으로 진단한다”는 접근은 인프라 트러블슈팅에서도 동일하게 유효합니다. 예를 들어 쿠버네티스에서 증상이 비슷해 보여도 원인이 다양한 것처럼, 데코레이터 인자 문제도 케이스가 여러 갈래입니다: EKS Pod CrashLoopBackOff 로그 없을 때 7단계 진단

준비: 문제를 빨리 드러내는 테스트 함수

먼저 인자 전달이 깨졌는지 확인하기 쉬운 타깃 함수를 하나 둡니다.

def target(a, b, *, c=0, **kwargs):
    return {"a": a, "b": b, "c": c, "kwargs": kwargs}

이제 데코레이터를 겹쳐 붙였을 때 a, b, 키워드 전용 인자(c), 추가 키워드(kwargs)가 안전하게 유지되는지 확인하면 됩니다.

원인 1) *args, **kwargs 를 전달하지 않음

가장 흔한 실수입니다. 바깥 데코레이터가 인자를 받긴 하지만 내부 호출에서 누락합니다.

def deco1(fn):
    def wrapper(*args, **kwargs):
        # 실수: args/kwargs를 전달하지 않음
        return fn()
    return wrapper

@deco1
def f(a, b, *, c=0):
    return (a, b, c)

# f(1, 2, c=3)  # TypeError 발생

해결

import functools

def deco1(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

중첩이 깊어질수록 한 겹이라도 누락되면 바로 깨지므로, 데코레이터를 만들 때 무조건 fn(*args, **kwargs) 를 기본값으로 두는 습관이 중요합니다.

원인 2) 인자를 “수정”하면서 규약을 깨뜨림

로깅/메트릭을 위해 인자를 가공하다가, 키워드 전용 인자나 이름을 바꿔버리는 경우가 있습니다.

def deco2(fn):
    def wrapper(*args, **kwargs):
        # 실수: c를 positional로 밀어 넣거나, kwargs에서 제거
        if "c" in kwargs:
            args = (*args, kwargs.pop("c"))
        return fn(*args, **kwargs)
    return wrapper

@deco2
def f(a, b, *, c=0):
    return (a, b, c)

# f(1, 2, c=3)  # TypeError: c는 키워드 전용인데 positional로 들어감

해결

  • 키워드 전용 인자는 키워드로만 전달
  • “가공”이 필요하면 inspect.signature 로 바인딩 후 안전하게 재구성
import functools
import inspect

def safe_deco(fn):
    sig = inspect.signature(fn)

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        bound = sig.bind_partial(*args, **kwargs)
        bound.apply_defaults()

        # 여기서 bound.arguments를 안전하게 읽거나 수정
        # 예: bound.arguments["c"] = bound.arguments["c"] + 1

        return fn(*bound.args, **bound.kwargs)

    return wrapper

이 방식은 비용이 조금 들지만, 인자 규약을 깨는 실수를 크게 줄입니다.

원인 3) 데코레이터 팩토리(인자 있는 데코레이터)에서 레이어를 잘못 구성

인자를 받는 데코레이터는 보통 “데코레이터 팩토리” 형태입니다. 이때 레이어를 하나 빼먹으면 fn 이 아니라 다른 값이 들어오며, 호출 시점에 인자가 엉킵니다.

# 의도: @tag("x") 형태
def tag(name):
    def wrapper(fn):
        def inner(*args, **kwargs):
            return fn(*args, **kwargs)
        return inner
    return wrapper

# 흔한 실수: tag를 데코레이터처럼 직접 씀
# @tag  # name 자리에 함수가 들어가 버림

해결

  • “팩토리”인지 “데코레이터”인지 사용법을 통일
  • 이름을 명확히: tag_decorator(name) 같은 네이밍도 도움
  • 타입 힌트로 사용 오류를 조기 탐지
from collections.abc import Callable
from typing import TypeVar

F = TypeVar("F", bound=Callable[..., object])

def tag(name: str) -> Callable[[F], F]:
    def deco(fn: F) -> F:
        import functools
        @functools.wraps(fn)
        def inner(*args, **kwargs):
            return fn(*args, **kwargs)
        return inner  # type: ignore[return-value]
    return deco

원인 4) functools.wraps 누락으로 시그니처/메타데이터가 붕괴

wraps 를 안 쓰면 함수 이름/독스트링뿐 아니라, 많은 프레임워크가 의존하는 시그니처 기반 인자 주입이 깨질 수 있습니다.

대표 사례:

  • FastAPI/Dependency Injection이 함수 시그니처를 보고 파라미터를 주입
  • Click/Typer가 CLI 옵션을 시그니처로 생성
  • dataclass/validation 라이브러리가 __signature__ 를 활용
def deco_no_wraps(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@deco_no_wraps
def f(user_id: int, *, verbose: bool = False):
    return user_id, verbose

# 외부 도구가 f의 시그니처를 wrapper(*args, **kwargs)로 오해할 수 있음

해결

모든 데코레이터는 기본적으로 wraps 를 사용하세요.

import functools

def deco(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

추가로, 정말 시그니처 보존이 중요한 경우 inspect.signature 기반으로 __signature__ 를 명시적으로 복사하는 패턴도 있습니다.

원인 5) 중첩 순서에 따른 “인자 해석 시점” 불일치

두 데코레이터가 모두 인자를 해석/변형하면, 어느 데코레이터가 먼저 적용되느냐에 따라 결과가 달라집니다.

import functools

def add_default_user(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        kwargs.setdefault("user", "anonymous")
        return fn(*args, **kwargs)
    return wrapper

def only_admin(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        # user가 없으면 KeyError 또는 잘못된 분기
        if kwargs["user"] != "admin":
            raise PermissionError("admin only")
        return fn(*args, **kwargs)
    return wrapper

@only_admin
@add_default_user
def action(*, user=None):
    return "ok"

# action() 는 add_default_user가 먼저 실행되어 user가 채워진 뒤 only_admin이 검사

@add_default_user
@only_admin
def action2(*, user=None):
    return "ok"

# action2() 는 only_admin이 먼저 실행되어 kwargs["user"] 접근에서 깨질 수 있음

해결

  • 데코레이터는 가능하면 “검사만” 하고, 인자 변형은 최소화
  • 변형이 필요하면 명시적 계약을 문서화
  • 순서 의존이 있으면 하나로 합치거나, 내부에서 필요한 기본값을 자급

이 문제는 “재시도 데코레이터”와 “멱등성 키 부여 데코레이터”를 섞을 때도 자주 발생합니다. 재시도가 먼저면 같은 요청이 여러 번 실행될 수 있으니, 멱등성 부여가 먼저여야 하는 식입니다. 이런 사고방식은 스트리밍 재시도에서도 동일합니다: OpenAI SSE 스트리밍 끊김·중복 토큰 재시도 패턴

원인 6) classmethod/staticmethod/디스크립터와의 결합으로 바인딩이 꼬임

메서드 데코레이션은 “함수”가 아니라 “디스크립터”를 다루게 되어, 중첩 순서에 따라 self 또는 cls 바인딩이 깨질 수 있습니다.

import functools

def deco(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

class C:
    @deco
    @classmethod
    def m(cls, x):
        return (cls.__name__, x)

# 위 조합은 wrapper가 classmethod 객체를 fn으로 받게 되어 호출이 깨질 수 있음

해결

  • classmethod/staticmethod 는 보통 가장 바깥 또는 가장 안쪽으로 고정해 규칙화
  • 안전한 패턴 예시: classmethod 를 바깥에 두고, 내부는 순수 함수 데코레이터로
import functools

def deco(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

class C:
    @classmethod
    @deco
    def m(cls, x):
        return (cls.__name__, x)

프레임워크에서 제공하는 디스크립터(예: ORM 필드/프로퍼티)와 섞일 때도 동일한 원리로 깨질 수 있으니, “무엇이 호출 가능한 객체인지”를 항상 확인하는 게 좋습니다.

원인 7) 클로저/루프 변수 캡처로 데코레이터가 잘못된 함수를 참조

데코레이터를 동적으로 생성하거나, 여러 함수를 한꺼번에 감싸는 코드를 작성할 때 “마지막 값 캡처” 문제가 생깁니다. 그 결과 다른 함수의 시그니처/인자 규칙으로 호출되어 깨진 것처럼 보입니다.

import functools

decorated = []

def make_wrappers(funcs):
    for fn in funcs:
        def deco():
            @functools.wraps(fn)
            def wrapper(*args, **kwargs):
                return fn(*args, **kwargs)
            return wrapper
        decorated.append(deco())


def f1(a):
    return a

def f2(a, b):
    return a + b

make_wrappers([f1, f2])

# decorated[0] 도 f2를 가리키게 되어, f1처럼 1개 인자로 호출하면 깨짐
# decorated[0](1)  # TypeError

해결

루프 변수는 기본 인자로 바인딩해 캡처합니다.

import functools

decorated = []

def make_wrappers(funcs):
    for fn in funcs:
        @functools.wraps(fn)
        def wrapper(*args, __fn=fn, **kwargs):
            return __fn(*args, **kwargs)
        decorated.append(wrapper)

이 이슈는 데코레이터 “중첩”처럼 보이지만, 실제로는 잘못된 대상 함수로 위임하는 버그라 인자 깨짐 증상이 동일하게 나타납니다.

안전한 중첩 데코레이터 템플릿

실무에서 재사용하기 좋은 최소 템플릿입니다.

import functools
from collections.abc import Callable
from typing import TypeVar, ParamSpec

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

def decorator_template(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        # pre
        result = fn(*args, **kwargs)
        # post
        return result
    return wrapper
  • ParamSpec 을 쓰면 “인자를 그대로 전달한다”는 의도가 타입 레벨에서 보장됩니다.
  • 중첩이 깊어질수록 wraps*args·**kwargs 보존이 가장 중요합니다.

체크리스트: 인자 깨짐을 5분 내로 좁히는 방법

  1. 가장 바깥 데코레이터부터 fn(*args, **kwargs) 로 제대로 위임하는지 확인
  2. 데코레이터 내부에서 kwargs.pop, positional 재배치 등 “변형”이 있는지 검색
  3. functools.wraps 누락 여부 확인
  4. 데코레이터 팩토리 레이어(함수 반환 구조) 점검
  5. 적용 순서가 의미를 바꾸는지(기본값 주입 vs 검증, 재시도 vs 멱등성 등) 확인
  6. classmethod/staticmethod/property 등 디스크립터 결합 여부 확인
  7. 루프/클로저로 데코레이터를 생성하는 코드가 있다면 캡처 버그 점검

마무리

Python 데코레이터 중첩에서 인자 깨짐은 “언젠가 한 번은” 겪는 문제지만, 원인은 대부분 위 7가지 범주로 수렴합니다. 특히 wraps*args·**kwargs 전달은 기본 중의 기본이고, 인자 변형/적용 순서/디스크립터 결합에서 사고가 많이 납니다.

다음에 데코레이터를 추가할 때는 “이 데코레이터가 인자를 읽기만 하는가, 바꾸는가”를 먼저 분류하고, 바꾼다면 inspect.signature 바인딩 같은 안전장치를 고려하세요.