Python 데코레이터 완벽 가이드 — 함수부터 클래스까지

데코레이터란?

데코레이터는 기존 함수나 클래스를 수정하지 않고 기능을 추가하는 파이썬의 강력한 패턴입니다. @ 기호를 함수 위에 붙여 사용하며, 내부적으로는 함수를 인자로 받아 새로운 함수를 반환하는 **고차 함수(Higher-Order Function)**입니다.

로깅, 인증 검사, 캐싱, 성능 측정 등 반복되는 횡단 관심사를 깔끔하게 분리할 수 있습니다. 이 글에서는 데코레이터의 동작 원리부터 실전 패턴까지 단계별로 정리합니다.

동작 원리 — 함수는 일급 객체

파이썬에서 함수는 **일급 객체(First-Class Object)**입니다. 변수에 할당하고, 인자로 전달하고, 반환값으로 사용할 수 있습니다. 데코레이터는 이 특성을 활용합니다.

# 함수를 인자로 받아 새로운 함수를 반환하는 패턴
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"[LOG] {func.__name__} 호출됨")  # 원래 함수 실행 전 로직
        result = func(*args, **kwargs)
        print(f"[LOG] {func.__name__} 완료됨")  # 원래 함수 실행 후 로직
        return result
    return wrapper

@my_decorator
def greet(name):
    """인사 메시지를 반환합니다."""
    return f"안녕하세요, {name}님!"

# 실행
print(greet("철수"))
# [LOG] greet 호출됨
# [LOG] greet 완료됨
# 안녕하세요, 철수님!

@my_decoratorgreet = my_decorator(greet)와 동일합니다. 원래 greet 함수가 wrapper 함수로 교체되며, 호출 전후에 로깅 로직이 추가됩니다.

구성 요소역할
my_decorator데코레이터 함수 — 원래 함수를 감싸는 래퍼를 생성
wrapper래퍼 함수 — 실제로 호출되는 함수
func원래 함수 — 래퍼 내부에서 호출
*args, **kwargs원래 함수의 모든 인자를 그대로 전달

functools.wraps의 중요성

데코레이터를 적용하면 원래 함수의 메타데이터(__name__, __doc__ 등)가 래퍼 함수의 것으로 덮어씌워집니다. functools.wraps는 이 문제를 해결합니다.

import functools

def my_decorator(func):
    @functools.wraps(func)  # 원래 함수의 메타데이터를 보존
    def wrapper(*args, **kwargs):
        print(f"[LOG] {func.__name__} 호출됨")
        result = func(*args, **kwargs)
        return result
    return wrapper

@my_decorator
def greet(name):
    """인사 메시지를 반환합니다."""
    return f"안녕하세요, {name}님!"

# wraps 적용 시 메타데이터가 보존됨
print(greet.__name__)  # greet (wraps 없으면 wrapper)
print(greet.__doc__)   # 인사 메시지를 반환합니다.

@functools.wraps(func)를 빠뜨리면 디버깅 시 함수 이름이 모두 wrapper로 표시되어 추적이 어려워집니다. 실무에서는 반드시 사용해야 합니다.

인자를 받는 데코레이터

데코레이터 자체에 설정값을 전달하려면 3중 중첩 함수 구조가 필요합니다. 바깥 함수가 인자를 받고, 실제 데코레이터를 반환합니다.

import functools
import time

def retry(max_attempts=3, delay=1.0):
    """실패 시 재시도하는 데코레이터"""
    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 Exception as e:
                    last_exception = e
                    print(f"[재시도] {func.__name__} 실패 "
                          f"({attempt}/{max_attempts}): {e}")
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise last_exception  # 모든 시도 실패 시 마지막 예외 발생
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
    """외부 API에서 데이터를 가져옵니다."""
    import random
    if random.random() < 0.7:  # 70% 확률로 실패
        raise ConnectionError("네트워크 오류")
    return {"status": "ok"}

# 실행 (실패 시 자동 재시도)
# [재시도] fetch_data 실패 (1/3): 네트워크 오류
# [재시도] fetch_data 실패 (2/3): 네트워크 오류
# {'status': 'ok'}  ← 3번째 성공 시

@retry(max_attempts=3)fetch_data = retry(max_attempts=3)(fetch_data)와 동일합니다. retry()가 먼저 호출되어 decorator를 반환하고, 그 decoratorfetch_data를 감쌉니다.

실전 패턴 — 실행 시간 측정

import functools
import time

def timer(func):
    """함수 실행 시간을 측정하는 데코레이터"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"[TIMER] {func.__name__}: {elapsed:.4f}초")
        return result
    return wrapper

@timer
def heavy_computation(n):
    """무거운 연산 시뮬레이션"""
    return sum(i * i for i in range(n))

result = heavy_computation(1_000_000)
# [TIMER] heavy_computation: 0.0523초

클래스 기반 데코레이터

__call__ 메서드를 구현한 클래스도 데코레이터로 사용할 수 있습니다. 상태를 유지해야 하는 경우에 유용합니다.

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"[COUNT] {self.func.__name__}: "
              f"{self.call_count}번째 호출")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello(name):
    return f"Hello, {name}!"

say_hello("Alice")  # [COUNT] say_hello: 1번째 호출
say_hello("Bob")    # [COUNT] say_hello: 2번째 호출
print(say_hello.call_count)  # 2

클래스 데코레이터는 call_count처럼 호출 간 상태를 자연스럽게 유지할 수 있습니다. 함수 데코레이터에서는 클로저 변수나 함수 속성을 사용해야 하므로 코드가 복잡해집니다.

데코레이터 조합 (스택)

여러 데코레이터를 동시에 적용할 수 있습니다. 아래에서 위로 적용되며, 위에서 아래로 실행됩니다.

@timer          # 3번째 적용, 1번째 실행
@retry(max_attempts=2)  # 2번째 적용, 2번째 실행
@CountCalls     # 1번째 적용, 3번째 실행
def unstable_api_call():
    import random
    if random.random() < 0.5:
        raise ConnectionError("타임아웃")
    return "성공"

# 실행 순서: timer → retry → CountCalls → 원래 함수

데코레이터 스택의 적용 순서는 아래에서 위로, 실행 순서는 위에서 아래로입니다. unstable_api_call = timer(retry(max_attempts=2)(CountCalls(unstable_api_call)))와 동일합니다.

정리

데코레이터는 반복되는 횡단 관심사를 깔끔하게 분리하는 파이썬의 핵심 패턴입니다.

  • 기본 구조: 함수를 받아 래퍼를 반환하는 고차 함수
  • functools.wraps: 반드시 사용하여 원래 함수의 메타데이터 보존
  • 인자가 있는 데코레이터: 3중 중첩 함수 구조 (데코레이터 팩토리)
  • 클래스 데코레이터: 상태 유지가 필요한 경우에 활용
  • 실전 활용: 로깅, 캐싱, 재시도, 권한 검사, 성능 측정 등에 폭넓게 사용
  • 스택 순서: 아래에서 위로 적용, 위에서 아래로 실행

데코레이터를 잘 활용하면 비즈니스 로직과 부가 기능을 명확히 분리하여 유지보수성이 크게 향상됩니다.

이 글이 도움이 되었나요?