데코레이터 패턴
함수를 감싸는 데코레이터의 모든 형태 — 기본형, 인자 받는 데코레이터, functools.wraps, 클래스 데코레이터, ParamSpec까지 정리합니다.
11장 이터러블, 제너레이터, yield from에서 본 @contextmanager, 8장 dataclass의 @dataclass, 표준 라이브러리의 @functools.cache, FastAPI의 @app.get(...) — 전부 데코레이터입니다. 본 챕터는 그 패턴을 직접 만드는 모든 방법을 정리합니다.
본 챕터의 마지막에 다루는 ParamSpec 기반 시그니처 보존은 20장 typing 고급 — Variance, ParamSpec, Self, overload에서 다시 한번 다룹니다. 데코레이터를 만드는 사람이 아니라 라이브러리 작성자의 관점에서 추가 도구가 등장합니다.
데코레이터 = 함수를 감싸는 함수 #
def log_calls(fn):
def wrapper(*args, **kwargs):
print(f"호출: {fn.__name__}({args}, {kwargs})")
result = fn(*args, **kwargs)
print(f"반환: {result}")
return result
return wrapper
@log_calls
def add(a: int, b: int) -> int:
return a + b
add(2, 3)
# 호출: add((2, 3), {})
# 반환: 5@log_calls 위에 함수를 정의한 건 단지 다음의 설탕입니다.
def add(a: int, b: int) -> int:
return a + b
add = log_calls(add)데코레이터는 함수를 받아 새 함수를 반환 합니다. 그 결과로 원래 이름이 새 함수에 묶이는 것입니다.
첫 번째 함정 — 메타데이터 손실 #
위 코드의 add는 더 이상 add가 아닙니다.
@log_calls
def add(a: int, b: int) -> int:
"""두 정수를 더한다."""
return a + b
print(add.__name__) # 'wrapper' ← !
print(add.__doc__) # None이걸 풀어주는 게 **functools.wraps**입니다.
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"호출: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""두 정수를 더한다."""
return a + b
print(add.__name__) # 'add'
print(add.__doc__) # '두 정수를 더한다.'@wraps(fn) 한 줄이 이름, docstring, 어노테이션, 시그니처를 원본에서 복사해 줍니다. 사용자 데코레이터를 만든다면 거의 항상 붙여야 한다고 보면 됩니다.
인자를 받는 데코레이터 #
@retry(times=3)처럼 데코레이터 자체에 인자를 주려면 한 단계가 더 추가됩니다.
@retry
def request(): ...
# 동작: request = retry(request)@retry(times=3)
def request(): ...
# 동작: request = retry(times=3)(request)
# ^^^^^^^^^^^^^^^^ 이게 데코레이터를 반환해야 함즉 retry(...)가 데코레이터를 반환하는 함수가 되어야 합니다. 세 단계 중첩이 됩니다.
from functools import wraps
def retry(times: int): # 1) 인자 받음
def decorator(fn): # 2) 함수 받음
@wraps(fn)
def wrapper(*args, **kwargs): # 3) 호출 받음
last_error = None
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last_error = e
raise last_error
return wrapper
return decorator
@retry(times=3)
def fetch():
...읽는 법:
- 가장 바깥
retry(times)— 데코레이터 인자 - 중간
decorator(fn)— 데코레이트할 함수 - 가장 안쪽
wrapper(*args, **kwargs)— 실제 호출
세 단계가 어색해 보이지만 모든 인자 있는 데코레이터의 표준 형태입니다.
functools.partial 트릭
#
@retry()처럼 인자 없이도, @retry(3)처럼 인자 줘도 동작하게 만들고 싶을 때.
from functools import wraps, partial
def retry(fn=None, *, times: int = 3):
if fn is None:
return partial(retry, times=times)
@wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception:
continue
raise
return wrapper
@retry
def a(): ...
@retry(times=5)
def b(): ...복잡해서 자주 쓰지는 않습니다. 보통은 둘 중 하나로 통일 하는 쪽이 깔끔합니다.
자주 쓰는 표준 데코레이터들 #
@functools.cache / @lru_cache — 메모이제이션
#
from functools import cache
@cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075 — 즉시 반환같은 인자로 다시 호출되면 계산 안 하고 결과를 그대로 줍니다. 순수 함수에만 쓰세요 (부수 효과 있는 함수에는 안 됨).
@lru_cache(maxsize=128)은 캐시 크기 제한이 있는 버전. 메모리 무한정 늘어나는 걸 막을 때.
@property — 속성처럼 호출
#
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
@property
def area(self) -> float:
return self.width * self.height
r = Rectangle(3, 4)
print(r.area) # 12.0 ← 메소드 호출이 아니라 속성 접근@property의 일반화가 16장 디스크립터와 __set_name__의 디스크립터 프로토콜입니다.
@staticmethod / @classmethod
#
class Math:
@staticmethod
def add(a: int, b: int) -> int:
return a + b
@classmethod
def from_pair(cls, pair: tuple[int, int]):
return cls(*pair)staticmethod — self도 cls도 안 받음. classmethod — 첫 인자로 클래스 자체를 받음 (대안 생성자에 자주 씀).
클래스 데코레이터 #
데코레이터는 클래스에도 붙일 수 있습니다. 함수와 동일하게 클래스를 받아 클래스를 반환 하는 함수입니다.
def add_repr(cls):
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
print(User(1, "커티스"))
# User(id=1, name='커티스')@dataclass가 정확히 이 패턴입니다 — 클래스를 받아 __init__ / __repr__ / __eq__가 추가된 클래스를 반환합니다.
데코레이터 클래스 — 호출 시 객체로 만드는 방식 #
함수 대신 __call__을 가진 클래스를 데코레이터로 쓸 수도 있습니다. 상태가 필요할 때 어울립니다.
class CallCounter:
def __init__(self, fn):
self.fn = fn
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.fn(*args, **kwargs)
@CallCounter
def greet():
print("hi")
greet()
greet()
greet()
print(greet.count) # 3greet는 이제 CallCounter 인스턴스입니다. .count 같은 속성 접근이 자연스럽게 가능합니다. 단점: 함수의 시그니처 / 메타데이터 보존이 까다롭고, 정적 분석기가 까다롭게 봅니다. 상태가 필요 없으면 함수 데코레이터가 더 낫습니다.
메소드에 데코레이터 — self까지 흘러감
#
데코레이터 안의 wrapper가 *args, **kwargs 형태로 받기 때문에 메소드의 self는 그냥 args[0]으로 들어옵니다.
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"호출: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
class Service:
@log_calls
def fetch(self, url: str):
return f"data from {url}"
s = Service()
s.fetch("https://api.example.com")
# 호출: fetch특별한 처리가 필요 없습니다. 그냥 잘 동작합니다.
데코레이터의 타입 힌트 — ParamSpec
#
데코레이터에 타입을 적으면 정적 분석이 정확해집니다. 그런데 데코레이터는 임의의 함수를 감싸기 때문에 타입을 그대로 보존하기가 까다롭습니다. 이걸 풀어주는 도구가 **ParamSpec**입니다.
from typing import Callable
from collections.abc import Awaitable
def log_calls[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"호출: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper[**P, R] (3.12+)의 의미:
P— 함수의 매개변수 시그니처 그대로R— 반환 타입
이렇게 적으면 데코레이트된 함수의 시그니처가 그대로 유지 됩니다. 호출 측 자동완성과 검증이 정확해집니다. 옛 방식은:
from typing import TypeVar, ParamSpec, Callable
from functools import wraps
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:
return fn(*args, **kwargs)
return wrapper새 코드는 메소드 옆 [**P, R] 형태로. 20장 typing 고급에서 ParamSpec의 다른 사용처(인자 일부만 받는 데코레이터 등)를 다시 다룹니다.
합성 — 데코레이터 여러 개 쌓기 #
@log_calls
@retry(times=3)
@cache
def fetch(url): ...위 코드는 아래에서 위로 적용됩니다.
fetch = log_calls(retry(times=3)(cache(fetch)))순서가 의미 있는 부분이니 주의하세요. 위 예에서는:
cache가 먼저 — 캐시 히트면 retry도 안 돈다retry가 그 다음 — 캐시 미스로 실제 호출이 일어날 때 재시도log_calls가 가장 바깥 — 재시도 포함 모든 호출을 로깅
자주 만드는 패턴 — 한곳에 #
시간 측정 #
import time
from functools import wraps
def timing[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__}: {elapsed:.3f}s")
return result
return wrapper권한 체크 #
def require_admin(fn):
@wraps(fn)
def wrapper(user, *args, **kwargs):
if not user.is_admin:
raise PermissionError("관리자만 가능")
return fn(user, *args, **kwargs)
return wrapper
@require_admin
def delete_user(actor, target_id): ...웹 프레임워크의 권한 처리는 보통 이 구조를 따릅니다. 4부 FastAPI에서는 23장 라우팅, Pydantic 모델, 의존성 주입의 Depends 시스템이 이 역할을 맡습니다.
결과 캐싱 (TTL 있음) #
from functools import wraps
import time
def ttl_cache(seconds: float):
def decorator(fn):
cache_data: dict = {}
@wraps(fn)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.monotonic()
if key in cache_data:
value, ts = cache_data[key]
if now - ts < seconds:
return value
value = fn(*args, **kwargs)
cache_data[key] = (value, now)
return value
return wrapper
return decorator
@ttl_cache(seconds=60)
def get_config(): ...연습문제 #
@timing데코레이터를[**P, R]시그니처로 작성하세요. 호출된 함수의 실행 시간을print(f"{fn.__name__}: {elapsed:.3f}s")형식으로 출력합니다.@wraps(fn)적용 후add.__name__이"add"로 남는지 확인합니다.@retry(times=3, delay=0.1)형태로 호출하는 인자 있는 데코레이터를 작성하세요. 실패 시delay초 대기 후 재시도하고, 모든 시도가 실패하면 마지막 예외를 다시 던집니다. 일부러 1/3 확률로 실패하는 함수를 만들어 통계가 맞는지 확인합니다.- 클래스 데코레이터
@add_repr를 작성해__dict__의 모든 필드를 출력하는__repr__를 자동으로 추가하세요. 같은 일을@dataclass가 어떻게 하는지 옛 8장 dataclass와 비교해 봅니다.
한 줄 요약: 데코레이터는 함수를 받아 함수를 반환하는 함수.
@d는f = d(f)의 설탕. 사용자 데코레이터에는 거의 항상@functools.wraps(fn). 인자 있는 데코레이터는 3단계 중첩. 클래스에도 붙일 수 있고(@dataclass), 클래스 형태(__call__)도 가능. 시그니처 보존은[**P, R](3.12+). 여러 데코레이터는 아래에서 위로 적용.
다음 챕터 #
다음 13장 패턴 매칭 깊이에서는 3장 제어 흐름에서 살짝 본 match-case의 모든 패턴 — 클래스 패턴, __match_args__, 캡처 변수, 가드의 깊이를 다룹니다.