Flask나 FastAPI를 일주일 이상 사용했다면 이미 데코레이터를 사용한 겁니다 — @app.route, @login_required, @pytest.mark.parametrize. 처음에는 마법처럼 느껴지다가, 누군가가 실제로 일어나는 일을 설명해 주면 즉시 이해가 됩니다. 데코레이터는 단지 다른 함수를 감싸는 함수입니다. @ 문법은 순수한 문법적 설탕입니다 — 함수 정의 위의 @my_decorator는 그 뒤에 func = my_decorator(func)를 작성하는 것과 정확히 동일합니다. 그것이 전체 비밀입니다. 나머지는 그 하나의 아이디어 위에 구축된 패턴들입니다. 이 글은 처음부터 정신 모델을 구축하고, 실제로 사용할 패턴들을 설명합니다: @functools.wraps, 인수가 있는 데코레이터, 클래스 기반 데코레이터, 그리고 @lru_cache, @dataclass, 지수 백오프가 있는 적절한 @retry를 포함한 실제 예제들.

함수는 일급 객체입니다

데코레이터를 이해하기 전에, 파이썬의 한 가지 사실을 확실히 알아야 합니다: 함수는 객체입니다. 변수에 할당하고, 다른 함수의 인수로 전달하고, 함수에서 반환하고, 리스트나 딕셔너리에 저장할 수 있습니다. 함수라는 이유만으로 특별한 일이 일어나지 않습니다.

python
def greet(name: str) -> str:
    return f"Hello, {name}"

# 변수에 할당 — ()가 없으면 호출이 아닌 참조
say_hello = greet
print(say_hello("Alice"))   # 'Hello, Alice'

# 함수를 인수로 전달
def run_twice(fn, value):
    return fn(value), fn(value)

run_twice(greet, "Bob")     # ('Hello, Bob', 'Hello, Bob')

# 함수에서 함수 반환 — "팩토리"
def make_prefixer(prefix: str):
    def prefixed_greet(name: str) -> str:
        return f"{prefix}, {name}"
    return prefixed_greet

morning_hello = make_prefixer("Good morning")
morning_hello("Carol")      # 'Good morning, Carol'

내부 함수 prefixed_greet는 둘러싸는 스코프의 변수 prefix를 "클로즈 오버"합니다 — make_prefixer가 반환된 후에도, 내부 함수는 여전히 prefix에 접근할 수 있습니다. 이것은 클로저이며, 데코레이터를 작동하게 하는 메커니즘입니다. 스코핑 규칙에 대한 파이썬 문서에서 전체 그림을 설명합니다.

데코레이터를 처음부터 만들기

데코레이터는 함수를 받아 (보통 수정된) 함수를 반환하는 함수입니다. 고전적인 첫 번째 예제는 타이밍 데코레이터입니다 — 모든 함수를 감싸서 실행 시간을 기록합니다.

python
import time
import functools

def timer(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__} finished in {elapsed:.4f}s")
        return result
    return wrapper

@timer
def fetch_user_records(db, user_id: int):
    """데이터베이스에서 주어진 사용자의 모든 레코드를 가져옵니다."""
    return db.query("SELECT * FROM records WHERE user_id = ?", user_id)

# fetch_user_records를 호출하는 것은 이제: timer(fetch_user_records)(db, 42)
# @timer가 역설탕화되는 것과 정확히 같습니다

주목할 점이 있습니다: wrapper는 서명을 모르고 원래 함수에 어떤 조합의 인수도 전달할 수 있도록 *args, **kwargs를 사용합니다. 반환 값을 result에 캡처하고 반환하므로, 감싸진 함수는 여전히 호출자 관점에서 동일하게 동작합니다 — 추가 출력 부작용만 있을 뿐입니다. 타이밍을 제거하면 거의 모든 데코레이터의 골격이 됩니다.

역설탕화 규칙: 함수 정의 위에 @some_decorator를 볼 때마다, def 블록 바로 뒤에 fn = some_decorator(fn)으로 정신적으로 대체하세요. 둘은 정확히 동일합니다. 마법은 없습니다 — 함수 호출입니다.

@functools.wraps를 반드시 사용해야 하는 이유

위의 예제에서 wrapper에 @functools.wraps(fn) 라인이 있습니다. 이것은 선택사항이 아닙니다. 이것 없이는 데코레이션된 함수가 자신의 정체성을 잃습니다 — __name__, __doc__, __qualname__ 속성들이 모두 내부 wrapper 함수의 것으로 교체됩니다. 이로 인해 실제 상황에서 미묘한 오류가 발생합니다:

  • 독스트링이 사라집니다. help(fetch_user_records)"Fetch all records for a given user..." 대신 wrapper의 빈 독스트링을 보여줍니다.
  • 스택 트레이스가 거짓말합니다. 감싸진 함수 안에서 예외가 발생하면 트레이스백은 실제 함수 이름 대신 wrapper를 보여줍니다 — 디버깅하기 어렵습니다.
  • 인트로스펙션이 깨집니다. pytest, Flask의 라우팅 시스템, inspect.signature() 같은 도구들은 모두 __name____wrapped__에 의존합니다. Flask의 라우터는 두 라우트가 같은 (wrapper) 이름을 공유하면 오류를 던집니다.
  • functools.lru_cache와 유사한 도구들은 캐시 키잉을 위해 함수의 정체성을 사용합니다 — wraps 없이는 예상치 못한 캐시 충돌이 발생할 수 있습니다.
python
import functools

# @functools.wraps 없이 — 깨진 버전
def bad_timer(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@bad_timer
def process_batch(batch_id: int):
    """단일 배치 작업을 처리합니다."""
    pass

print(process_batch.__name__)   # 'wrapper'   ← 잘못됨
print(process_batch.__doc__)    # None         ← 독스트링 없어짐

# @functools.wraps 사용 — 올바른 버전
def good_timer(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@good_timer
def process_batch(batch_id: int):
    """단일 배치 작업을 처리합니다."""
    pass

print(process_batch.__name__)   # 'process_batch'  ← 올바름
print(process_batch.__doc__)    # '단일 배치 작업을 처리합니다.'  ← 유지됨

functools.wraps는 그 자체로 데코레이터입니다 — __module__, __name__, __qualname__, __annotations__, __doc__를 복사하고 __wrapped__를 원래 함수로 설정합니다. 모든 wrapper 함수에 이것을 사용하세요, 완전히. 안 할 이유가 없습니다.

인수가 있는 데코레이터

일반 데코레이터는 함수를 받아 함수를 반환합니다. 인수가 있는 데코레이터는 한 수준 더 필요합니다: 인수를 받아 데코레이터를 반환하는 함수. 세 단계의 중첩이고 처음 볼 때 거의 모든 사람을 혼란스럽게 합니다. 다음은 예외 발생 시 최대 max_attempts번 함수를 재시도하는 @retry 데코레이터입니다:

python
import functools
import time

def retry(max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0):
    """
    지수 백오프로 예외 발생 시 함수를 재시도합니다.

    사용법:
        @retry(max_attempts=5, delay=0.5, backoff=2.0)
        def call_external_api(endpoint: str) -> dict:
            ...
    """
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            current_delay = delay
            last_exception = None

            for attempt in range(1, max_attempts + 1):
                try:
                    return fn(*args, **kwargs)
                except Exception as exc:
                    last_exception = exc
                    if attempt < max_attempts:
                        print(
                            f"{fn.__name__}: 시도 {attempt}/{max_attempts} 실패 "
                            f"({exc!r}), {current_delay:.1f}s 후 재시도..."
                        )
                        time.sleep(current_delay)
                        current_delay *= backoff
                    else:
                        print(f"{fn.__name__}: 모든 {max_attempts}번의 시도 실패.")

            raise last_exception

        return wrapper
    return decorator

@retry(max_attempts=4, delay=0.5, backoff=2.0)
def fetch_price_data(ticker: str) -> dict:
    """외부 API에서 주가 데이터를 가져옵니다."""
    response = requests.get(f"https://api.example.com/prices/{ticker}", timeout=5)
    response.raise_for_status()
    return response.json()

파이썬이 @retry(max_attempts=4, delay=0.5, backoff=2.0)를 처리할 때의 호출 체인: 먼저 retry(max_attempts=4, delay=0.5, backoff=2.0)가 호출되어 decorator를 반환합니다. 그런 다음 decorator(fetch_price_data)가 호출되어 wrapper를 반환합니다. 마지막으로 fetch_price_datawrapper에 재바인딩됩니다. 따라서 @retry(...)fetch_price_data = retry(...)(fetch_price_data)입니다 — 세 번의 호출, 두 단계의 래핑. 그 패턴을 이해하면 데코레이터 팩토리가 더 이상 혼란스럽지 않습니다.

클래스 기반 데코레이터

__call__을 정의하여 클래스로 데코레이터를 구현할 수도 있습니다. 데코레이터가 호출 간에 상태를 유지해야 할 때 유용합니다 — 호출 카운터, 캐시, 연결 풀 — 왜냐하면 인스턴스 변수가 클로저 변수보다 그 상태를 위한 더 자연스러운 장소이기 때문입니다.

python
import functools
import time

class RateLimiter:
    """
    함수가 호출될 수 있는 빈도를 제한하는 데코레이터.
    이전 호출로부터 `min_interval` 초 이내에 함수가 호출되면
    RuntimeError를 발생시킵니다.
    """

    def __init__(self, min_interval: float):
        self.min_interval = min_interval
        self._last_called: float = 0.0

    def __call__(self, fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            since_last = now - self._last_called
            if since_last < self.min_interval:
                wait = self.min_interval - since_last
                raise RuntimeError(
                    f"{fn.__name__} 너무 빨리 호출됨 — "
                    f"다시 호출하기 전에 {wait:.2f}s 기다리세요."
                )
            self._last_called = now
            return fn(*args, **kwargs)
        return wrapper

# 사용 — 2초당 한 번 호출
@RateLimiter(min_interval=2.0)
def send_sms_alert(phone: str, message: str) -> None:
    """게이트웨이 API를 통해 SMS 알림을 보냅니다."""
    sms_gateway.send(phone, message)

@RateLimiter(min_interval=2.0)는 데코레이터 팩토리와 정확히 같이 작동합니다: RateLimiter(2.0)이 인스턴스를 생성하고, 그 인스턴스가 __call__을 가지고 있기 때문에 send_sms_alert와 함께 호출됩니다. 인스턴스는 _last_called를 속성으로 저장합니다 — 클로저 변수 조작이 필요 없습니다. @ 문법의 설계 근거에 대해서는 PEP 318(원래 데코레이터 제안)을, 파이썬 3.9에서 추가된 완화된 데코레이터 문법에 대해서는 PEP 614를 참고하세요.

표준 라이브러리의 실제 데코레이터

직접 작성하기 전에 stdlib에 이미 필요한 것이 있는지 확인하세요. 프로덕션 파이썬 코드에서 자주 등장하는 functools의 데코레이터 세 가지가 있습니다.

python
from functools import lru_cache, cache
import requests

# @cache — 무한 메모이제이션 (파이썬 3.9+)
# 모든 고유한 인수 조합을 영구적으로 캐시합니다.
# 입력 도메인이 작은 순수 함수에 좋습니다.
@cache
def get_country_name(country_code: str) -> str:
    """ISO 3166 코드에서 국가 이름을 조회합니다. 첫 번째 호출 후 캐시됩니다."""
    response = requests.get(f"https://restcountries.com/v3.1/alpha/{country_code}")
    return response.json()[0]["name"]["common"]

get_country_name("DE")   # API 호출
get_country_name("DE")   # 캐시에서 제공, 네트워크 호출 없음

# @lru_cache(maxsize=N) — 경계가 있는 LRU 캐시
# 캐시가 `maxsize`에 도달하면 최근에 가장 적게 사용된 항목을 제거합니다.
# 입력 도메인이 크고 메모리가 걱정될 때 더 좋습니다.
@lru_cache(maxsize=256)
def compute_discount(base_price: float, tier: str) -> float:
    """무거운 계산 — 가격은 등급에 따라 다릅니다. 상위 256개 조합을 캐시합니다."""
    discount_table = load_discount_table()          # 비용이 많이 드는 DB 호출
    rate = discount_table.get(tier, 0.0)
    return round(base_price * (1 - rate), 2)

# 캐시 성능 검사
print(compute_discount.cache_info())
# CacheInfo(hits=142, misses=14, maxsize=256, currsize=14)

@dataclass는 다른 범주에 있습니다 — 필드 어노테이션에서 __init__, __repr__, __eq__를 자동으로 생성하는 클래스 데코레이터입니다. 데이터를 보유하는 클래스의 많은 보일러플레이트를 제거합니다:

python
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime

@dataclass
class WebhookEvent:
    event_type: str
    source_id: int
    payload: dict
    received_at: datetime = field(default_factory=datetime.utcnow)
    retry_count: int = 0
    error_message: Optional[str] = None

# @dataclass는 이 모든 것을 무료로 생성합니다:
# - __init__(self, event_type, source_id, payload, received_at=..., retry_count=0, error_message=None)
# - 모든 필드를 보여주는 __repr__
# - 필드별로 비교하는 __eq__

event = WebhookEvent(event_type="order.created", source_id=9912, payload={"order_id": 44501})
print(event)
# WebhookEvent(event_type='order.created', source_id=9912, payload={...}, retry_count=0, ...)

@property 데코레이터는 메서드를 속성 스타일 접근자로 변환합니다 — 호출자가 user.get_display_name() 대신 user.display_name을 읽습니다. @property.setter와 결합하여 쓸 때 유효성 검사를 수행합니다:

python
class UserProfile:
    def __init__(self, first_name: str, last_name: str, email: str):
        self._first_name = first_name
        self._last_name = last_name
        self._email = email.strip().lower()

    @property
    def display_name(self) -> str:
        return f"{self._first_name} {self._last_name}"

    @property
    def email(self) -> str:
        return self._email

    @email.setter
    def email(self, value: str) -> None:
        if "@" not in value:
            raise ValueError(f"유효하지 않은 이메일 주소: {value!r}")
        self._email = value.strip().lower()

profile = UserProfile("Alice", "Smith", "  [email protected]  ")
print(profile.display_name)   # 'Alice Smith'
print(profile.email)          # '[email protected]'

profile.email = "[email protected]"    # setter가 유효성 검사 실행
profile.email = "not-an-email"           # ValueError 발생

라우트 핸들러를 위한 @require_auth 데코레이터

웹 프레임워크의 인증 확인은 데코레이터의 교과서적인 사용 사례입니다. 모든 라우트 핸들러 상단에 "사용자가 로그인했나?" 확인을 중복하는 대신, 한 번 데코레이터로 작성하고 필요한 곳에 적용합니다. 다음은 Flask와 함께 작동하지만 모든 프레임워크에 전이 가능한 패턴입니다:

python
import functools
from flask import request, jsonify, g

def require_auth(fn):
    """
    라우트 핸들러가 실행되기 전에 Bearer 토큰을 검증하는 데코레이터.
    성공 시 g.current_user를 설정하고, 실패 시 401 JSON을 반환합니다.
    """
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return jsonify({"error": "Authorization 헤더가 없거나 잘못됨"}), 401

        token = auth_header[len("Bearer "):]
        user = verify_token(token)          # 토큰 검증 로직
        if user is None:
            return jsonify({"error": "유효하지 않거나 만료된 토큰"}), 401

        g.current_user = user               # 라우트 핸들러에서 사용 가능
        return fn(*args, **kwargs)
    return wrapper

# 사용 — auth 확인이 핸들러 본문 전에 실행됨
@app.route("/api/v1/reports/<int:report_id>")
@require_auth
def get_report(report_id: int):
    report = Report.query.get_or_404(report_id)
    if report.owner_id != g.current_user.id:
        return jsonify({"error": "접근 금지됨"}), 403
    return jsonify(report.to_dict())

순서에 주목하세요: @app.route가 먼저 (가장 바깥쪽), @require_auth가 두 번째입니다. 이것이 중요합니다 — 다음 섹션을 보세요. 패턴이 자연스럽게 확장됩니다: @require_auth가 이미 사용자의 존재를 확인한 후 g.current_user.role을 확인하는 @require_role("admin") 데코레이터 팩토리를 추가할 수 있습니다.

데코레이터 스택 쌓기 — 순서가 중요합니다

여러 데코레이터를 쌓을 때, 그것들은 아래에서 위로 적용됩니다(함수에 가장 가까운 데코레이터가 먼저 적용), 하지만 함수가 호출될 때는 위에서 아래로 실행됩니다. 이것이 사람들을 놀라게 합니다.

python
@decorator_a
@decorator_b
@decorator_c
def my_function():
    pass

# 이것은 다음과 정확히 동일합니다:
my_function = decorator_a(decorator_b(decorator_c(my_function)))

# my_function()이 호출될 때:
# 1. decorator_a의 wrapper가 먼저 실행됩니다 (가장 바깥쪽)
# 2. decorator_b의 wrapper가 두 번째 실행됩니다
# 3. decorator_c의 wrapper가 세 번째 실행됩니다 (가장 안쪽, 실제 함수에 가장 가까움)
# 4. 실제 my_function 본문이 실행됩니다
# 5. 언와인딩: decorator_c → decorator_b → decorator_a
python
# 실용적인 예: @timer와 @retry의 순서가 중요합니다
# timer가 가장 바깥쪽이면 재시도 대기 시간을 포함한 총 시간을 측정합니다.
# retry가 가장 바깥쪽이면 마지막 성공적인 호출만 측정합니다.

@timer          # 측정: 모든 재시도 + 대기 시간을 포함한 총 시간
@retry(max_attempts=3, delay=1.0)
def sync_with_partner_api(partner_id: int) -> dict:
    ...

# vs.

@retry(max_attempts=3, delay=1.0)  # 측정: 마지막 성공적인 호출만
@timer
def sync_with_partner_api(partner_id: int) -> dict:
    ...

# 보통 @timer를 가장 바깥쪽으로 하는 것이 좋습니다 — 작업의 실제 벽시계 비용을 알려줍니다.
# 호출자에게 "이 함수를 호출하는 것"이 무엇을 의미하는지 생각하고 그 순서로 감싸세요.
스택 트레이스 팁: 데코레이션된 함수가 예외를 발생시키면, 트레이스백은 콜 스택에서 각 wrapper를 보여줍니다. @functools.wraps를 올바르게 사용했다면, 이름이 원래 함수를 반영할 것입니다. wrapper 프레임의 바다를 보게 된다면, 누군가가 @functools.wraps를 잊은 것입니다. Real Python 데코레이터 입문서에 쌓인 데코레이터를 디버깅하는 방법에 대한 좋은 설명이 있습니다.

마무리

앞으로 가져갈 정신 모델: @decoratorfn = decorator(fn)입니다. 나머지 모든 것 — 인수가 있는 데코레이터, 클래스 기반 데코레이터, 쌓인 데코레이터 — 은 그 하나의 대체에 대한 변형입니다. 모든 내부 wrapper에 @functools.wraps를 사용하고, 항상 감싸진 함수에 *args, **kwargs를 전달하고, 그 결과를 반환하세요. 인수가 있는 데코레이터는 세 단계의 중첩이 필요합니다: 인수 함수, 데코레이터, wrapper. 클래스 기반 데코레이터는 데코레이터가 호출 간에 상태를 유지해야 할 때 올바른 선택입니다.

추가 읽을거리: 데코레이터에 대한 파이썬 용어집 항목은 짧지만 정확합니다. 원래 데코레이터 제안인 PEP 318은 대안들에 비해 @ 문법이 선택된 이유에 대한 맥락을 위해 읽을 가치가 있습니다. 데이터 처리를 많이 하는 코드베이스에서 데코레이터를 사용하고 있다면 — 파일 읽기, 레코드 변환 — 여기의 패턴은 파이썬 파일 처리에서 다루는 내용과 자연스럽게 쌍을 이룹니다. 컬렉션을 변환하거나 조회 구조를 구축하기 위해 데코레이터를 사용하고 있다면, 파이썬 리스트 컴프리헨션이 그 그림의 데이터 변환 측면을 다룹니다. 데코레이션된 함수가 JSON 출력을 반환하고 빠르게 검사하고 싶다면, 이 사이트의 JSON Formatter가 편리합니다.