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에 대한 타임아웃
기억해야 할 세 계층의 구조:
retry(times, delay)— factory 함수, 설정 매개변수를 받고decorator를 반환합니다.decorator(func)— 진짜 decorator 함수, 원본 함수를 받습니다.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_orderinstance (더 이상 일반 함수가 아니라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를 직접 작성할 수 있을 것입니다.