모던 파이썬 중급 #5 데코레이터 패턴

6 분 소요

#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가 아닙니다.

이름과 docstring이 사라짐
@log_calls
def add(a: int, b: int) -> int:
    """두 정수를 더한다."""
    return a + b

print(add.__name__)   # 'wrapper'  ← !
print(add.__doc__)    # None

이걸 풀어주는 게 functools.wraps 입니다.

✅ 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(...)데코레이터를 반환하는 함수가 되어야 합니다. 세 단계 중첩이 됩니다.

3단계 중첩
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 — 메모이제이션 #

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 — 속성처럼 호출 #

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 #

static / class method
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)

staticmethodselfcls도 받지 않습니다. 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)   # 3

greet는 이제 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 입니다.

ParamSpec (3.10+)
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)))

순서가 의미 있는 부분이니 주의하세요. 위 예에서는:

  1. cache가 먼저 — 캐시 히트면 retry도 안 돕니다
  2. retry가 그 다음 — 캐시 미스로 실제 호출이 일어날 때 재시도
  3. log_calls가 가장 바깥 — 재시도 포함 모든 호출을 로깅

자주 만드는 패턴 — 한곳에 #

시간 측정 #

@timing
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

권한 체크 #

@require_admin
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 있음) #

TTL cache
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(): ...

정리 #

이번 글에서 본 패턴들:

  • 데코레이터 = 함수 받아 함수 반환, @df = 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__, 캡처 변수, 가드의 깊이를 다룹니다.

X