함수 데코레이터 심화
**데코레이터(Decorator)**는 함수나 클래스를 감싸서 원본 코드를 수정하지 않고 동작을 추가하는 파이썬의 강력한 기능입니다.
functools.wraps — 메타데이터 보존
데코레이터를 적용하면 원본 함수의 __name__, __doc__ 등 메타데이터가 사라집니다. functools.wraps로 이를 보존합니다.
import functools
# wraps 없이 작성한 데코레이터 — 메타데이터 손실
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# wraps를 사용한 데코레이터 — 메타데이터 보존
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def greet(name: str) -> str:
"""인사말을 반환합니다."""
return f"안녕하세요, {name}!"
bad_greet = bad_decorator(greet)
good_greet = good_decorator(greet)
print(bad_greet.__name__) # wrapper (원본 이름 손실)
print(good_greet.__name__) # greet (원본 이름 보존)
print(bad_greet.__doc__) # None (독스트링 손실)
print(good_greet.__doc__) # 인사말을 반환합니다.
# inspect 모듈과 함께 사용 시 중요
import inspect
print(inspect.signature(good_greet)) # (name: str) -> str
인수 있는 데코레이터 (Parameterized Decorator)
데코레이터 자체에 인수를 전달하려면 3중 중첩 함수 구조를 사용합니다.
import functools
import time
def repeat(times: int):
"""함수를 n번 반복 실행하는 데코레이터"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for i in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
def retry(max_attempts: int = 3, delay: float = 0.5, exceptions: tuple = (Exception,)):
"""실패 시 재시도하는 데코레이터"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts:
print(f"[재시도 {attempt}/{max_attempts}] {func.__name__}: {e}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
def timeout(seconds: float):
"""실행 시간 제한 데코레이터 (Unix 전용)"""
import signal
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
def handler(signum, frame):
raise TimeoutError(f"{func.__name__} 실행 시간 초과 ({seconds}초)")
old_handler = signal.signal(signal.SIGALRM, handler)
signal.alarm(int(seconds))
try:
result = func(*args, **kwargs)
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, old_handler)
return result
return wrapper
return decorator
@repeat(3)
def say_hello():
print("안녕!")
say_hello() # "안녕!" 3번 출력
@retry(max_attempts=3, delay=0.1, exceptions=(ValueError,))
def risky_parse(text: str) -> int:
import random
if random.random() < 0.5:
raise ValueError("파싱 실패!")
return int(text)
try:
result = risky_parse("42")
print(f"파싱 결과: {result}")
except ValueError:
print("최대 재시도 횟수 초과")
데코레이터 팩토리 패턴
import functools
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def log_calls(level: str = "INFO", include_args: bool = True, include_result: bool = True):
"""함수 호출을 로깅하는 데코레이터"""
log_fn = getattr(logger, level.lower())
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if include_args:
log_fn(f"호출: {func.__name__}({args}, {kwargs})")
else:
log_fn(f"호출: {func.__name__}()")
start = time.perf_counter()
try:
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
if include_result:
log_fn(f"완료: {func.__name__} → {result!r} ({elapsed:.4f}초)")
else:
log_fn(f"완료: {func.__name__} ({elapsed:.4f}초)")
return result
except Exception as e:
elapsed = time.perf_counter() - start
logger.error(f"오류: {func.__name__} → {type(e).__name__}: {e} ({elapsed:.4f}초)")
raise
return wrapper
return decorator
def rate_limit(calls_per_second: float):
"""호출 빈도를 제한하는 데코레이터"""
min_interval = 1.0 / calls_per_second
last_call_time = [0.0]
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()
elapsed = now - last_call_time[0]
if elapsed < min_interval:
sleep_time = min_interval - elapsed
time.sleep(sleep_time)
last_call_time[0] = time.monotonic()
return func(*args, **kwargs)
return wrapper
return decorator
@log_calls(level="INFO", include_args=True)
def add(a: int, b: int) -> int:
return a + b
@rate_limit(calls_per_second=2)
def fetch_data(url: str) -> str:
return f"데이터: {url}"
add(3, 5)
add(10, 20)
데코레이터 스택 (여러 데코레이터 조합)
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"[타이머] {func.__name__}: {time.perf_counter() - start:.4f}초")
return result
return wrapper
def validate_positive(*param_names: str):
"""지정된 매개변수가 양수인지 검증"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
import inspect
sig = inspect.signature(func)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name in param_names:
if name in bound.arguments:
value = bound.arguments[name]
if isinstance(value, (int, float)) and value <= 0:
raise ValueError(f"{name}은 양수여야 합니다: {value}")
return func(*args, **kwargs)
return wrapper
return decorator
def memoize(func):
cache: dict = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
# 데코레이터 적용 순서: 아래에서 위로 (memoize → validate_positive → timer 순서로 감싸짐)
@timer
@validate_positive("n")
@memoize
def fibonacci(n: int) -> int:
"""n번째 피보나치 수 계산"""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # 55
print(fibonacci(10)) # 55 (캐시에서 반환, 타이머 여전히 실행)
try:
fibonacci(-1) # ValueError: n은 양수여야 합니다
except ValueError as e:
print(f"검증 오류: {e}")
# 적용 순서 확인
print(fibonacci.__name__) # fibonacci (wraps 덕분에 유지)
클래스로 데코레이터 구현
import functools
import time
class Timer:
"""클래스 기반 데코레이터 — __call__ 메서드 구현"""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
self.total_time = 0.0
def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = self.func(*args, **kwargs)
elapsed = time.perf_counter() - start
self.call_count += 1
self.total_time += elapsed
print(f"[{self.func.__name__}] 호출 #{self.call_count}: {elapsed:.4f}초")
return result
@property
def average_time(self) -> float:
return self.total_time / self.call_count if self.call_count else 0
def stats(self) -> str:
return (f"{self.func.__name__}: "
f"{self.call_count}회 호출, "
f"평균 {self.average_time:.4f}초, "
f"총 {self.total_time:.4f}초")
@Timer
def slow_function(n: int) -> int:
time.sleep(0.01)
return n ** 2
for i in range(3):
slow_function(i)
print(slow_function.stats()) # 통계 출력
print(f"호출 횟수: {slow_function.call_count}")
# 인수가 있는 클래스 기반 데코레이터
class Retry:
def __init__(self, max_attempts: int = 3, exceptions: tuple = (Exception,)):
self.max_attempts = max_attempts
self.exceptions = exceptions
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, self.max_attempts + 1):
try:
return func(*args, **kwargs)
except self.exceptions as e:
if attempt == self.max_attempts:
raise
print(f"재시도 {attempt}/{self.max_attempts}: {e}")
return wrapper
@Retry(max_attempts=3, exceptions=(ConnectionError,))
def connect_to_server(host: str) -> str:
import random
if random.random() < 0.7:
raise ConnectionError(f"{host}에 연결 실패")
return f"{host}에 연결 성공"
try:
print(connect_to_server("api.example.com"))
except ConnectionError as e:
print(f"최종 실패: {e}")
실전 예제: API 엔드포인트 데코레이터
import functools
import time
import hashlib
from typing import Callable, Any
# 간단한 인증 데코레이터
def require_auth(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
# 실제로는 토큰 검증 로직
token = kwargs.get("token") or (args[0] if args else None)
if not token or not token.startswith("Bearer "):
raise PermissionError("인증이 필요합니다.")
return func(*args, **kwargs)
return wrapper
# 캐싱 데코레이터 (TTL 지원)
def cache_with_ttl(ttl_seconds: int = 60):
def decorator(func: Callable) -> Callable:
_cache: dict[str, tuple[Any, float]] = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
# 캐시 키 생성
key = hashlib.md5(
f"{args}{sorted(kwargs.items())}".encode()
).hexdigest()
now = time.monotonic()
if key in _cache:
value, expire_at = _cache[key]
if now < expire_at:
return value
del _cache[key]
result = func(*args, **kwargs)
_cache[key] = (result, now + ttl_seconds)
return result
wrapper.cache_clear = lambda: _cache.clear()
wrapper.cache_size = lambda: len(_cache)
return wrapper
return decorator
# 응답 형식 표준화
def json_response(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
data = func(*args, **kwargs)
return {"success": True, "data": data, "error": None}
except ValueError as e:
return {"success": False, "data": None, "error": str(e)}
except PermissionError as e:
return {"success": False, "data": None, "error": f"권한 오류: {e}"}
return wrapper
@json_response
@cache_with_ttl(ttl_seconds=30)
@require_auth
def get_user_data(token: str, user_id: int) -> dict:
# 실제로는 DB 쿼리
if user_id <= 0:
raise ValueError(f"유효하지 않은 사용자 ID: {user_id}")
return {"id": user_id, "name": f"사용자_{user_id}", "email": f"user{user_id}@example.com"}
# 정상 요청
print(get_user_data("Bearer valid-token", 42))
# 인증 실패
print(get_user_data("invalid-token", 42))
# 잘못된 ID
print(get_user_data("Bearer valid-token", -1))
고수 팁
1. __wrapped__ 속성으로 원본 함수 접근
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def original():
"""원본 함수"""
return "original"
# functools.wraps는 __wrapped__ 속성을 설정
print(original.__wrapped__) # <function original at ...>
print(original.__wrapped__()) # original
print(original()) # original (데코레이터 통과)
2. 조건부 데코레이터 적용
import functools
import os
def maybe_decorate(condition: bool, decorator):
"""조건에 따라 데코레이터를 적용하거나 원본 그대로 반환"""
def apply(func):
if condition:
return decorator(func)
return func
return apply
def debug_mode_only(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[DEBUG] {func.__name__} 호출")
return func(*args, **kwargs)
return wrapper
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
@maybe_decorate(DEBUG, debug_mode_only)
def process_data(data: list) -> list:
return [x * 2 for x in data]
result = process_data([1, 2, 3])
print(result) # [2, 4, 6]
3. 데코레이터 체인 내성(introspection)
import functools
def decorator_a(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def decorator_b(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@decorator_a
@decorator_b
def my_func():
pass
# __wrapped__ 체인을 따라가며 원본에 접근
def get_original(func):
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
return func
print(get_original(my_func).__name__) # my_func
정리
| 패턴 | 용도 | 핵심 |
|---|---|---|
| 기본 데코레이터 | 함수 감싸기 | functools.wraps 필수 |
| 인수 있는 데코레이터 | 설정 가능한 동작 | 3중 중첩 함수 구조 |
| 클래스 기반 데코레이터 | 상태 유지 필요 시 | __call__ 구현 |
| 데코레이터 스택 | 여러 기능 조합 | 적용 순서 주의 (아래 → 위) |
functools.wraps를 항상 사용하세요. 빠뜨리면 디버깅과 내성(introspection)이 어려워집니다.