Published on

Python 데코레이터 인자·타입힌트 제대로 붙이기

Authors

서버/배치/웹 코드에서 데코레이터는 로깅, 재시도, 캐시, 권한 체크처럼 “가로(횡단) 관심사”를 깔끔하게 분리해줍니다. 하지만 @decorator를 조금만 복잡하게 만들면 두 가지 문제가 바로 터집니다.

  • 데코레이터에 인자를 받게 만들었더니, 함수 타입이 전부 Callable[..., Any]로 뭉개져 IDE 자동완성이 죽는다
  • 래핑한 함수의 시그니처/리턴 타입이 사라져 mypy·Pyright가 제대로 추론하지 못한다

이 글에서는 인자를 받는 데코레이터를 만드는 표준 패턴과, Python 3.10+ 기준으로 ParamSpec/TypeVar를 이용해 원본 함수 시그니처를 보존하는 타입힌트를 단계별로 정리합니다.

참고로 이런 “타입 시스템과 빌드/툴링의 충돌”은 프론트엔드에서도 자주 겪습니다. 예를 들어 TS 선언 생성이 깨질 때의 대응은 TS 5.5+ isolatedDeclarations 에러 실전 해결법처럼 원인을 분해해보면 해결이 빨라집니다.

1) 데코레이터의 기본 구조: 함수 데코레이터 vs 데코레이터 팩토리

함수 데코레이터(인자 없음)

가장 단순한 형태는 “함수를 받아서 함수를 반환”합니다.

from __future__ import annotations

from functools import wraps


def trace(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}")
        return func(*args, **kwargs)

    return wrapper


@trace
def add(a: int, b: int) -> int:
    return a + b

여기서 @wraps를 쓰는 이유는 __name__, __doc__, __module__ 같은 메타데이터를 복사해 디버깅/문서화에 도움을 주기 위해서입니다. 단, 타입 시그니처를 자동으로 보존해주지는 않습니다. 타입은 별도로 설계해야 합니다.

데코레이터 팩토리(인자 있음)

인자를 받는 데코레이터는 한 겹 더 감쌉니다.

  • decorator_factory(...)가 설정값을 받고
  • 내부에서 진짜 데코레이터 decorator(func)를 만들고
  • 그 데코레이터가 wrapper를 반환
from __future__ import annotations

from functools import wraps


def retry(times: int):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for _ in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:  # noqa: BLE001
                    last_exc = e
            raise last_exc  # type: ignore[misc]

        return wrapper

    return decorator


@retry(times=3)
def flaky(x: int) -> int:
    return 10 // x

문제는 여기서부터입니다. wrapper(*args, **kwargs)를 쓰는 순간, 타입 체커는 보통 “인자/리턴을 알 수 없음”으로 처리합니다.

2) 타입힌트의 목표: “원본 함수 시그니처를 보존”하기

데코레이터 타입힌트의 핵심 목표는 아래 두 가지입니다.

  1. 원본 함수의 파라미터 타입을 그대로 유지
  2. 원본 함수의 리턴 타입을 그대로 유지

이걸 위해 Python typing에는 두 개의 중요한 도구가 있습니다.

  • TypeVar: 리턴 타입 같은 “타입 변수”를 표현
  • ParamSpec: 함수의 파라미터 목록 전체를 캡처

Python 3.10+에서는 typing.ParamSpec을 쓸 수 있고, 3.9 이하에서는 typing_extensions가 필요합니다.

3) ParamSpec + TypeVar로 가장 흔한 패턴 완성

아래는 “인자 없는 데코레이터”를 제대로 타입힌트한 버전입니다.

from __future__ import annotations

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

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


def trace(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"call {func.__name__}")
        return func(*args, **kwargs)

    return wrapper

포인트는 다음과 같습니다.

  • func: Callable[P, R]로 “파라미터는 P, 리턴은 R”인 함수를 받는다
  • wrapper(*args: P.args, **kwargs: P.kwargs) -> R로 원본과 동일한 시그니처를 반환한다

이제 @trace를 붙여도 add(a: int, b: int) -> int 같은 정보가 유지됩니다.

4) 인자를 받는 데코레이터: “팩토리까지” 타입힌트하기

인자를 받는 데코레이터는 반환 타입이 한 번 더 감싸집니다.

from __future__ import annotations

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

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


def retry(times: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last_exc: Exception | None = None
            for _ in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:  # noqa: BLE001
                    last_exc = e
            assert last_exc is not None
            raise last_exc

        return wrapper

    return decorator

여기서 가장 중요한 줄은 이것입니다.

  • retry(times: int) -> Callable[[Callable[P, R]], Callable[P, R]]

retry(3)은 “함수를 받아서 같은 시그니처의 함수를 반환하는 데코레이터”입니다.

5) 데코레이터가 리턴 타입을 바꾸는 경우

데코레이터가 원본 리턴을 바꾸면 R을 그대로 유지하면 안 됩니다. 예를 들어 결과를 캐시하면서 “캐시 히트 여부”를 함께 반환한다고 해봅시다.

from __future__ import annotations

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

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


def with_cache_flag() -> Callable[[Callable[P, R]], Callable[P, tuple[R, bool]]]:
    def decorator(func: Callable[P, R]) -> Callable[P, tuple[R, bool]]:
        cache: dict[tuple[object, ...], R] = {}

        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> tuple[R, bool]:
            key = tuple(args) + tuple(sorted(kwargs.items()))
            if key in cache:
                return cache[key], True
            value = func(*args, **kwargs)
            cache[key] = value
            return value, False

        return wrapper

    return decorator

이 경우 반환 타입은 Callable[P, tuple[R, bool]]로 바뀌어야 하고, 호출부도 그에 맞게 처리해야 합니다.

캐시/재검증 정책은 서버 사이드에서도 자주 문제를 일으킵니다. Next.js의 재검증과 캐시 충돌 이슈는 Next.js ISR 500 - revalidate·캐시 충돌 해결처럼 “정책의 조합”이 핵심 원인이 되는 경우가 많습니다.

6) Concatenate로 “첫 인자에 self/ctx를 강제”하기

클래스 메서드나 컨텍스트를 강제하고 싶을 때가 있습니다. 예를 들어 “첫 번째 인자는 request_id: str이어야 한다” 같은 규칙을 데코레이터로 강제할 수 있습니다.

이때 typing.Concatenate를 사용합니다.

from __future__ import annotations

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

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


def require_request_id(
    func: Callable[Concatenate[str, P], R],
) -> Callable[Concatenate[str, P], R]:
    @wraps(func)
    def wrapper(request_id: str, *args: P.args, **kwargs: P.kwargs) -> R:
        if not request_id:
            raise ValueError("request_id is required")
        return func(request_id, *args, **kwargs)

    return wrapper


@require_request_id
def handle(request_id: str, user_id: int) -> str:
    return f"ok:{request_id}:{user_id}"

이 패턴을 쓰면 “첫 인자가 반드시 str”이라는 제약이 타입 레벨에서 유지됩니다.

7) 오버로드로 “데코레이터 인자 조합”을 깔끔하게

데코레이터 인자가 선택적이거나, @decorator@decorator(...)를 둘 다 지원하고 싶을 때가 있습니다. 예를 들어 @timer@timer(name="x")를 동시에 지원하는 경우입니다.

이때 @overload로 호출 시그니처를 분리하면 IDE 경험이 좋아집니다.

from __future__ import annotations

from functools import wraps
from time import perf_counter
from typing import Callable, Optional, ParamSpec, TypeVar, overload

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


@overload
def timer(func: Callable[P, R]) -> Callable[P, R]: ...


@overload
def timer(*, name: str | None = None) -> Callable[[Callable[P, R]], Callable[P, R]]: ...


def timer(func: Optional[Callable[P, R]] = None, *, name: str | None = None):
    def decorator(f: Callable[P, R]) -> Callable[P, R]:
        label = name or f.__name__

        @wraps(f)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            start = perf_counter()
            try:
                return f(*args, **kwargs)
            finally:
                elapsed = perf_counter() - start
                print(f"{label}: {elapsed:.6f}s")

        return wrapper

    if func is not None:
        return decorator(func)
    return decorator


@timer
def f1(x: int) -> int:
    return x + 1


@timer(name="custom")
def f2(x: int) -> int:
    return x + 2

구현부는 동적이지만, 오버로드 덕분에 타입 체커는 두 사용법을 모두 이해합니다.

8) 자주 하는 실수와 체크리스트

Callable[..., Any]로 도망치기

빠르게 끝내려면 Callable[..., Any]가 편하지만, 그 순간부터 데코레이터를 붙인 함수는 타입 안정성을 잃습니다. 팀 규모가 커질수록 비용이 커집니다.

wraps만 쓰면 타입도 보존된다고 착각

functools.wraps는 “메타데이터”를 복사합니다. 타입 시그니처는 ParamSpec 같은 typing 도구로 별도 설계해야 합니다.

데코레이터가 예외를 삼키거나 리턴을 바꾸는 경우

  • 예외를 다른 예외로 래핑하면 호출부의 예외 처리 계약이 바뀝니다
  • 리턴 타입을 바꾸면 Callable[P, R]가 아니라 Callable[P, NewR]로 명시해야 합니다

동기/비동기 함수 모두 지원

async def까지 동시에 지원하려면 보통 오버로드를 추가하거나, Awaitable[R]를 리턴으로 다루는 별도 데코레이터를 만듭니다. 무리하게 하나로 합치면 타입이 급격히 복잡해집니다.

9) 실전 팁: “데코레이터는 API”라고 생각하기

데코레이터는 함수 정의부에 붙는 만큼, 사실상 팀 내부에서 가장 많이 호출되는 API 중 하나가 됩니다. 따라서 아래를 권장합니다.

  • 데코레이터는 반드시 ParamSpec/TypeVar로 시그니처 보존을 기본값으로
  • 인자를 받는 데코레이터는 반환 타입을 Callable[[Callable[P, R]], Callable[P, R]]로 명확히
  • 사용법이 두 가지라면 @overload로 개발 경험을 고정
  • 첫 인자 강제가 필요하면 Concatenate

이런 원칙은 비용 최적화나 운영 안정화에도 그대로 적용됩니다. 예를 들어 호출 단위를 묶어 비용을 줄이는 접근은 OpenAI Batch API로 LangChain 비용 80% 줄이기처럼 “API 계약을 명확히 하고 자동화가 가능한 형태로 만드는 것”이 핵심입니다.

마무리

Python 데코레이터에 인자를 붙이는 건 문법적으로는 간단하지만, 타입힌트를 제대로 붙여 “원본 시그니처를 보존”하려면 ParamSpecTypeVar가 사실상 정답에 가깝습니다.

  • 인자 없는 데코레이터: def dec(func: Callable[P, R]) -> Callable[P, R]
  • 인자 있는 데코레이터: def factory(...) -> Callable[[Callable[P, R]], Callable[P, R]]
  • 첫 인자 강제: Concatenate
  • @decorator@decorator(...) 동시 지원: @overload

이 패턴들을 템플릿처럼 익혀두면, 로깅/재시도/권한/캐시 같은 데코레이터를 추가해도 타입 안정성과 IDE 경험을 잃지 않고 확장할 수 있습니다.