- Published on
Python 데코레이터 인자·타입힌트 제대로 붙이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치/웹 코드에서 데코레이터는 로깅, 재시도, 캐시, 권한 체크처럼 “가로(횡단) 관심사”를 깔끔하게 분리해줍니다. 하지만 @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) 타입힌트의 목표: “원본 함수 시그니처를 보존”하기
데코레이터 타입힌트의 핵심 목표는 아래 두 가지입니다.
- 원본 함수의 파라미터 타입을 그대로 유지
- 원본 함수의 리턴 타입을 그대로 유지
이걸 위해 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 데코레이터에 인자를 붙이는 건 문법적으로는 간단하지만, 타입힌트를 제대로 붙여 “원본 시그니처를 보존”하려면 ParamSpec과 TypeVar가 사실상 정답에 가깝습니다.
- 인자 없는 데코레이터:
def dec(func: Callable[P, R]) -> Callable[P, R] - 인자 있는 데코레이터:
def factory(...) -> Callable[[Callable[P, R]], Callable[P, R]] - 첫 인자 강제:
Concatenate @decorator와@decorator(...)동시 지원:@overload
이 패턴들을 템플릿처럼 익혀두면, 로깅/재시도/권한/캐시 같은 데코레이터를 추가해도 타입 안정성과 IDE 경험을 잃지 않고 확장할 수 있습니다.