모던 파이썬 중급 #5 데코레이터 패턴
#4 이터러블과 제너레이터에서 본 @contextmanager, #1의 @dataclass, 표준 라이브러리의 @functools.cache, FastAPI의 @app.get(...) — 전부 데코레이터입니다. 이번 글은 그 패턴을 직접 만드는 모든 방법을 정리합니다.
데코레이터 = 함수를 감싸는 함수 #
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 ← 메소드 호출이 아니라 속성 접근@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] 형태로 적으면 됩니다.
합성 — 데코레이터 여러 개 쌓기 #
@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): ...웹 프레임워크의 권한 처리는 보통 이 형태입니다.
결과 캐싱 (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(): ...정리 #
이번 글에서 본 패턴들:
- 데코레이터 = 함수 받아 함수 반환,
@d는f = d(f)의 설탕 functools.wraps— 이름/docstring/시그니처 보존, 사용자 데코레이터에 거의 항상- 인자 있는 데코레이터 = 3단계 중첩 (인자 → 함수 → 호출)
@cache/@lru_cache(메모이제이션),@property(속성처럼),@staticmethod/@classmethod- 클래스 데코레이터 — 클래스를 받아 클래스 반환,
@dataclass가 그 예 - 클래스 형태 데코레이터 —
__call__가진 인스턴스, 상태 필요할 때 - 메소드에 데코레이터를 붙여도
*args, **kwargs가 자동으로 처리 [**P, R](3.12+) —ParamSpec+TypeVar로 시그니처 보존- 여러 데코레이터는 아래에서 위로 적용 — 순서가 의미 있음
다음 글(#6 패턴 매칭 깊이)에서는 기초 #3에서 살짝 본 match-case의 모든 패턴 — 클래스 패턴, __match_args__, 캡처 변수, 가드의 깊이를 다룹니다.