Python의 데코레이터: 기초부터 매개변수까지

1. 개요

Decorator는 Python의 가장 “마법 같은” 기능 중 하나지만, 이해하지 않고 복사-붙여넣기만 하면 쉽게 혼동될 수 있습니다. 이 글은 함수가 first-class citizen이라는 개념부터 시작해서, 기본 decorator, functools.wraps로 메타데이터를 유지하는 decorator, 매개변수를 받는 decorator, 그리고 클래스로 작성한 decorator까지 다룹니다. 이 글을 다 읽고 나면, 원본 함수의 이름을 잃지 않으면서도 자신의 프로젝트를 위해 logging, timing, 또는 retry decorator를 직접 작성할 수 있을 겁니다.

2. 함수는 First-Class Citizen

Decorator를 배우기 전에, 한 가지 사실을 이해해야 합니다: Python에서 함수도 객체입니다. 함수를 변수에 할당하고, 함수를 매개변수로 전달하고, 함수에서 함수를 반환할 수 있습니다.

def greet(name: str) -> str:
    return f"안녕하세요, {name}님!"

say_hello = greet
print(say_hello("Alice"))
안녕하세요, Alice님!

say_hello는 단지 같은 함수 객체 greet를 가리키는 또 다른 이름일 뿐입니다. 이 특성 덕분에 Python은 함수를 입력으로 받아 함수를 출력으로 반환하는 함수를 작성할 수 있게 해줍니다 — 이것이 바로 decorator의 기초입니다.

3. 기본 Decorator: 함수로 함수 감싸기

Decorator는 간단히 말해 다른 함수를 받아서, 원본 함수의 동작을 “감싸는” (wrap) 새로운 함수를 반환하는 함수입니다.

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"{func.__name__}을(를) 호출합니다 (args={args}, kwargs={kwargs})")
        result = func(*args, **kwargs)
        print(f"{func.__name__}의 반환값: {result}")
        return result
    return wrapper

def add(a: int, b: int) -> int:
    return a + b

add = log_call(add)
print(add(2, 3))
add을(를) 호출합니다 (args=(2, 3), kwargs={})
add의 반환값: 5
5
  • log_call은 func (원본 add 함수)을 받고 wrapper를 정의합니다 — 원본 함수를 호출하지만 전후에 로그를 추가하는 내부 함수입니다.
  • log_call(add)는 wrapper를 반환하고, 이를 add에 다시 할당합니다. 이제 add(...)를 호출하는 것은 실제로 wrapper(...)를 호출하는 것입니다.

3.1. @ 문법 — Syntactic Sugar

func = decorator(func)를 수동으로 쓰는 것은 꽤 장황합니다. Python은 @decorator 문법을 제공해서 같은 작업을 더 간결하게 해줍니다:

@log_call
def multiply(a: int, b: int) -> int:
    return a * b

print(multiply(4, 5))
multiply을(를) 호출합니다 (args=(4, 5), kwargs={})
multiply의 반환값: 20
20

@log_call을 def multiply 바로 위에 놓는 것은 multiply = log_call(multiply)와 정확히 같습니다. 이것은 단순한 syntactic sugar일 뿐 — 다른 마법은 없습니다.

4. 메타데이터 손실 문제와 functools.wraps

Decorator가 원본 함수를 wrapper로 “대체”할 때, __name__, __doc__ 같은 원본 함수의 속성을 잃게 됩니다.

@log_call
def subtract(a: int, b: int) -> int:
    """두 수를 뺍니다."""
    return a - b

print(subtract.__name__)
print(subtract.__doc__)
wrapper
None

이것은 디버그할 때나 문서 생성 도구를 사용할 때 흔한 문제입니다 — subtract를 호출하고 있다고 생각했는데 Python은 이름이 wrapper라고 말합니다. functools.wraps로 해결합니다:

import functools

def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"{func.__name__}을(를) 호출합니다")
        return func(*args, **kwargs)
    return wrapper

@log_call
def subtract(a: int, b: int) -> int:
    """두 수를 뺍니다."""
    return a - b

print(subtract.__name__)
print(subtract.__doc__)
subtract
두 수를 뺍니다.

functools.wraps(func)은 decorator의 decorator입니다: func에서 __name__, __doc__, __module__ 등을 복사해서 wrapper에 붙입니다.

주의: 실무의 decorator에서는 항상 functools.wraps를 사용하세요. 생략하면 디버그가 어려워지고 introspection이 깨집니다 (예: help(), 함수 이름에 의존하는 framework).

5. 매개변수를 받는 Decorator

많은 경우, decorator가 커스터마이징 가능하기를 원합니다. 예를 들어 @retry(times=3) 같은 형태입니다. 이 경우 함수 하나를 더 추가해야 합니다: 외부 함수는 매개변수를 받고 진짜 decorator를 반환하며, decorator는 func을 받습니다.

import functools
import time

def retry(times: int = 3, delay: float = 1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exception = exc
                    print(f"시도 {attempt} 실패: {exc}")
                    time.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
    raise ConnectionError(f"주문 {order_id}에 대한 타임아웃")
try:
    call_payment_api(101)
except ConnectionError as exc:
    print(f"포기합니다: {exc}")
시도 1 실패: 주문 101에 대한 타임아웃
시도 2 실패: 주문 101에 대한 타임아웃
포기합니다: 주문 101에 대한 타임아웃

기억해야 할 세 계층의 구조:

  1. retry(times, delay) — factory 함수, 설정 매개변수를 받고 decorator를 반환합니다.
  2. decorator(func) — 진짜 decorator 함수, 원본 함수를 받습니다.
  3. wrapper(*args, **kwargs) — decorator된 함수를 호출할 때 실행되는 함수입니다.

@retry(times=2, delay=0)는 먼저 retry(times=2, delay=0)를 호출하고 (이것이 decorator를 반환), 그 다음 decorator를 call_payment_api에 적용합니다.

5.1. 매개변수 있을 때와 없을 때 모두 사용 가능한 Decorator

때때로 @retry (매개변수 없음)와 @retry() 모두 사용할 수 있기를 원합니다. 깔끔한 방법은 functools.partial과 callable 체크를 조합하는 것입니다:

import functools

def retry(func=None, *, times: int = 3):
    if func is None:
        return functools.partial(retry, times=times)

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        for attempt in range(1, times + 1):
            try:
                return func(*args, **kwargs)
            except Exception as exc:
                print(f"시도 {attempt} 실패: {exc}")
        raise RuntimeError("모든 시도가 실패했습니다")
    return wrapper

@retry
def task_a():
    return "ok"

@retry(times=5)
def task_b():
    return "ok"

@retry를 사용할 때 (함수를 호출하지 않고), func은 직접 task_a 함수를 받으므로 if func is None 분기는 실행되지 않습니다. @retry(times=5)를 사용할 때, func은 처음에 None이므로, 함수는 functools.partial(retry, times=5)를 반환합니다 — 이것은 다음 단계에서 task_b를 받기를 기다리는 함수입니다.

6. 클래스로 작성한 Decorator

중첩된 함수 대신, __call__을 구현한 클래스로 decorator를 작성할 수 있습니다. 이 방식은 decorator가 복잡한 상태를 유지해야 할 때 유용합니다.

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"{self.func.__name__} {self.call_count}번 호출됨")
        return self.func(*args, **kwargs)

@CountCalls
def process_order(order_id: int) -> None:
    print(f"주문 {order_id} 처리 중")

process_order(1)
process_order(2)
print(process_order.call_count)
process_order 1번 호출됨
주문 1 처리 중
process_order 2번 호출됨
주문 2 처리 중
2
  • __init__은 원본 함수를 저장하고 call_count = 0을 초기화합니다.
  • functools.update_wrapper(self, func) — 클래스 instance에 대한 functools.wraps와 비슷하며, 메타데이터를 self 객체에 복사합니다.
  • __call__은 process_order instance (더 이상 일반 함수가 아니라 CountCalls 객체)를 함수처럼 호출 가능하게 만듭니다.

주의: 클래스 기반 decorator는 다른 클래스의 메서드를 decorate할 때 잘 작동하지 않습니다 (self가 겹칠 수 있음). 메서드의 경우, 중첩된 함수를 사용하거나, dispatch가 필요하면 functools.singledispatchmethod를 고려하세요.

7. 여러 Decorator 동시 적용 (Stacking)

한 함수에 여러 decorator를 겹칠 수 있습니다. 적용 순서는 아래에서 위로입니다 (함수 가장 가까운 decorator가 먼저 실행됨).

@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
    if amount <= 0:
        raise ValueError("금액은 양수여야 합니다")
    return f"{amount}원을 결제했습니다"

print(charge_card(10))
wrapper을(를) 호출합니다 (args=(10,), kwargs={})
wrapper의 반환값: 10원을 결제했습니다
10원을 결제했습니다

여기서 retry(times=2, delay=0)가 먼저 charge_card를 감싸서 retry 기능을 추가하고, 그 다음 log_call이 외부에서 다시 감싼 것입니다. 만약 retry에서 functools.wraps를 제대로 사용하지 않으면, 로그에 charge_card 대신 wrapper가 출력될 것입니다 — 이것이 섹션 4의 중요성을 다시 한 번 증명해줍니다.

8. Decorator 테스트 작성

Decorator도 일반 Python 함수이므로, 다른 로직처럼 pytest로 테스트할 수 있습니다. 아직 설치하지 않았다면:

pip install pytest

retry decorator 테스트 예제:

import pytest

def test_retry_raises_after_exhausting_attempts():
    call_log = []

    @retry(times=3, delay=0)
    def always_fail():
        call_log.append("호출됨")
        raise ValueError("오류 발생")

    with pytest.raises(ValueError):
        always_fail()

    assert len(call_log) == 3


def test_retry_succeeds_on_second_attempt():
    attempts = {"count": 0}

    @retry(times=3, delay=0)
    def fail_once_then_succeed():
        attempts["count"] += 1
        if attempts["count"] < 2:
            raise ValueError("오류 발생")
        return "ok"

    result = fail_once_then_succeed()

    assert result == "ok"
    assert attempts["count"] == 2

테스트 실행:

pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED

call_log와 attempts는 함수가 호출된 횟수를 확인하기 위한 spy 역할을 합니다 — 예상한 retry 동작을 확인하며, mock 라이브러리를 따로 쓸 필요가 없습니다.

9. 흔한 실수들

  • functools.wraps 빠뜨리기: 함수가 이름/docstring을 잃어서 디버그가 어렵고, 함수 __name__에 의존하는 framework (예: Flask routing)와 사용할 때 오류가 발생합니다.
  • Wrapper에서 mutable default argument 사용: Python의 일반적인 실수와 같으며, default로 args=[] 같은 것을 피하세요.
  • Decorator가 import 시점에 무거운 side-effect를 갖기: decorator가 wrapper 안이 아니라 decorator(func) 단계에서 I/O를 수행하면 (API 호출, 파일 읽기), 함수 호출할 때마다가 아니라 모듈을 import할 때마다 실행됩니다.
  • 복잡한 로직을 decorator에 몰아넣기: decorator가 너무 많은 분기를 포함하면, 직접 함수를 호출하는 것보다 코드 추적이 어려워집니다. 로직이 복잡하면, decorator로 감싸는 대신 context manager 또는 별도 클래스로 분리하는 것을 고려하세요.

Decorator를 사용하지 말아야 할 때: “감싸는” 동작이 한 번만 사용되고 재사용되지 않는다면, decorator를 작성하는 것이 직접 함수를 호출하는 것보다 코드 추적을 어렵게 만듭니다. Decorator는 logging, caching, retry, auth 같은 cross-cutting concern을 여러 함수에 적용할 때 가치를 발휘합니다.

10. 결론

Python의 decorator는 신비로울 것이 없습니다 — 함수를 받아서 함수를 반환하는 함수일 뿐이며, 이는 함수가 first-class citizen이라는 사실에 기반합니다. 기본 decorator에서 시작해서 functools.wraps로 메타데이터를 보존하고, 함수 계층을 하나 더 추가해서 설정 매개변수를 받거나, 복잡한 상태가 필요하면 클래스 기반으로 전환할 수 있습니다. 이러한 패턴들을 익히면, 실전 프로젝트를 위해 깔끔하고 테스트하기 쉬운 logging, caching, retry, validation decorator를 직접 작성할 수 있을 것입니다.

pythondecoratorclosurefunctional-programmingclean-code