파이썬 데코레이터: 개념, 동작 원리, 직접 만드는 방법
파이썬 데코레이터는 함수를 받아 새 함수(대개 래퍼)를 돌려주는 함수입니다. 래퍼는 호출 전후에 로깅, 시간 측정, 캐시, 권한 검사 같은 동작을 더합니다. @decorator를 def 위에 쓰는 것은 func = decorator(func)의 줄임 표현입니다. 원래 함수의 이름과 독스트링을 유지하려면 functools.wraps를 사용합니다.
파이썬 데코레이터는 함수를 받아서 다른 함수를 돌려주는 함수입니다. 보통은 원래 함수를 감싸는 래퍼(wrapper)를 돌려줍니다. 이렇게 하면 함수의 코드를 고치지 않고도 로깅, 실행 시간 측정, 캐시, 재시도 같은 기능을 덧붙일 수 있습니다. 데코레이터는 @이름 형태로 def 위에 씁니다. Flask, FastAPI, pytest, 표준 라이브러리 등 곳곳에서 사용됩니다.
데코레이터를 쉽게 이해하기
선물과 포장지를 떠올리면 됩니다. 선물(함수)은 그대로이고, 바깥에 한 겹 포장이 씌워져 있어서 먼저 그 포장이 반응합니다. 데코레이터가 바로 이 포장입니다. 함수를 호출하면 먼저 래퍼의 코드가 실행되고, 래퍼가 원래 함수를 호출하며, 결과를 가공할 수도 있습니다.
이것이 가능한 이유는 파이썬에서 함수가 일반 객체이기 때문입니다. 함수를 다른 함수에 인자로 넘길 수 있고, 함수에서 반환할 수 있고, 함수 안에서 정의할 수도 있습니다. 데코레이터는 이 세 가지 성질 위에 만들어집니다.
데코레이터의 동작 원리: @는 문법적 설탕
호출 전후에 메시지를 출력하는 간단한 데코레이터를 봅시다.
def log_calls(func):
def wrapper():
print(f"호출: {func.__name__}")
result = func()
print(f"{func.__name__} 종료")
return result
return wrapper
@log_calls
def hello():
print("안녕하세요!")
hello()
호출: hello
안녕하세요!
hello 종료
def 위에 @log_calls를 쓰는 것은 정의 아래에 다음 한 줄을 쓰는 것과 완전히 같습니다.
def hello():
print("안녕하세요!")
hello = log_calls(hello)
이제 hello라는 이름은 wrapper를 가리키고, 원래 함수는 클로저 안에 남아 있습니다. wrapper는 바깥 함수의 func 변수를 기억합니다. 중요한 점은 데코레이터가 def가 실행되는 순간, 즉 보통 모듈을 임포트할 때 한 번만 적용된다는 것입니다. 래퍼 안의 코드는 호출될 때마다 실행됩니다.
클로저와 데코레이터
클로저는 바깥 함수가 끝난 뒤에도 바깥 변수를 기억하는 안쪽 함수입니다. 클로저가 없다면 데코레이터도 없습니다. wrapper가 func에 접근할 수 있는 이유가 바로 이것입니다. 클로저에 상태를 담을 수도 있습니다. 예를 들어 호출 횟수를 세는 데코레이터는 다음과 같습니다.
def count_calls(func):
calls = 0
def wrapper(*args, **kwargs):
nonlocal calls
calls += 1
print(f"{func.__name__}: 호출 {calls}번째")
return func(*args, **kwargs)
return wrapper
파이썬 데코레이터 직접 만들기: 기본 틀
앞의 첫 예제에는 두 가지 문제가 있습니다. 래퍼가 인자를 받지 않고, 원래 함수의 이름을 가려 버립니다. 모든 데코레이터의 출발점으로 쓸 수 있는 틀은 다음과 같습니다.
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# 호출 전 코드
result = func(*args, **kwargs)
# 호출 후 코드
return result
return wrapper
이 틀에는 세 가지 규칙이 있습니다.
*args, **kwargs— 래퍼가 어떤 인자든 받아서 그대로 넘깁니다.return result— 결과를 돌려주는 것을 잊으면 데코레이트된 함수가 항상None을 반환합니다.@functools.wraps(func)— 원래 함수의 메타데이터를 래퍼에 옮깁니다.
실행 시간을 재는 데코레이터 예제입니다.
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"{func.__name__} 실행 시간: {elapsed:.3f}초")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(10_000_000) # slow_sum 실행 시간: 0.142초
functools.wraps가 필요한 이유
wraps가 없으면 데코레이트된 함수의 이름과 독스트링이 바뀌고, 바깥에서 보이는 것은 wrapper입니다.
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain
def add(a, b):
"""두 수를 더합니다."""
return a + b
print(add.__name__, add.__doc__) # wrapper None
@functools.wraps(func)를 쓰면 래퍼가 원래 함수의 __name__, __doc__, __module__, __qualname__, __annotations__을 받아 옵니다. 원본은 __wrapped__ 속성으로도 접근할 수 있습니다. 이것은 디버깅과 트레이스백, help(), 이름으로 함수를 등록하는 프레임워크, 실제 시그니처를 보여 주는 inspect.signature에 중요합니다.
인자를 받는 데코레이터
데코레이터에 설정값을 넘기고 싶을 때가 있습니다. @retry(times=3)이나 @app.route("/")가 그런 경우입니다. 이를 위해 한 단계를 더 씁니다. 바깥 함수가 매개변수를 받아서, 진짜 데코레이터를 돌려줍니다. 이런 구조를 데코레이터 팩토리라고 합니다.
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def ping():
print("ping")
ping() # ping이 세 줄 출력됩니다
호출 순서를 보면 이해가 쉽습니다. repeat(times=3)은 decorator를 돌려주고, 이것이 ping에 적용되어 wrapper를 돌려줍니다. 즉 @repeat(times=3)은 ping = repeat(times=3)(ping)과 같습니다.
실무에서 자주 쓰는 예는 오류가 나면 다시 시도하는 데코레이터입니다.
import functools
import time
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_rate():
...
데코레이터 여러 개 쓰기
한 함수에 데코레이터를 여러 개 붙일 수 있습니다. 적용은 아래에서 위로 진행됩니다. def에 가장 가까운 데코레이터가 먼저 적용됩니다.
@timer
@log_calls
def report():
...
# 같은 의미입니다:
report = timer(log_calls(report))
호출할 때는 바깥 층(timer)이 먼저 실행되므로, log_calls의 실행 시간까지 포함해서 측정합니다. 순서가 중요할 때도 많습니다. 예를 들어 한 데코레이터가 권한을 검사하고 다른 하나가 결과를 캐시한다면, 어느 쪽이 바깥에 있는지에 따라 동작이 달라집니다.
표준 데코레이터와 내장 데코레이터
파이썬 언어와 표준 라이브러리에는 이미 많은 데코레이터가 있습니다.
| 데코레이터 | 하는 일 |
|---|---|
@property | 메서드를 읽기 전용 속성처럼 만듦. @x.setter와 함께 쓰면 값 설정도 제어 가능 |
@staticmethod | self가 없는 메서드. 클래스 이름공간에 있는 일반 함수 |
@classmethod | 인스턴스 대신 클래스 cls를 받는 메서드. 대체 생성자에 쓰임 |
@functools.cache, @functools.lru_cache | 인자별 결과를 캐시 |
@functools.wraps | 원래 함수의 메타데이터를 래퍼에 복사 |
@dataclasses.dataclass | 클래스에 __init__, __repr__, __eq__를 생성 |
@contextlib.contextmanager | 제너레이터를 with에서 쓸 수 있는 컨텍스트 매니저로 바꿈 |
캐시: functools.cache와 lru_cache
캐시 데코레이터는 인자별로 결과를 저장해 두었다가, 같은 인자로 다시 호출되면 계산 없이 돌려줍니다. 대표적인 예는 재귀로 구한 피보나치 수입니다. 캐시가 없으면 계산량이 기하급수적으로 늘어납니다.
import functools
@functools.cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075
functools.cache는 Python 3.9에서 추가되었으며, 결과를 제한 없이 저장합니다. @functools.lru_cache(maxsize=128)은 최근 결과만 보관하고 오래된 것은 밀어냅니다. 오래 실행되는 서비스에는 이쪽이 더 안전합니다. 통계는 fib.cache_info()로 보고, 비우기는 fib.cache_clear()로 합니다. 이런 함수의 인자는 해시 가능해야 합니다. 리스트를 인자로 넘기면 TypeError: unhashable type: 'list'가 납니다. 또한 같은 인자에 항상 같은 결과를 내고 부작용이 없는 순수 함수에만 캐시를 적용하세요.
property 데코레이터의 역할
@property를 쓰면 메서드를 괄호 없이 속성처럼 부를 수 있고, 값을 대입할 때 검사도 할 수 있습니다.
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("잔액은 음수가 될 수 없습니다")
self._balance = value
acc = Account(100)
print(acc.balance) # 100
acc.balance = -5 # ValueError
staticmethod와 classmethod
class Money:
def __init__(self, kopecks):
self.kopecks = kopecks
@classmethod
def from_rubles(cls, rubles):
return cls(round(rubles * 100))
@staticmethod
def is_valid(value):
return value >= 0
m = Money.from_rubles(12.5)
print(m.kopecks, Money.is_valid(-1)) # 1250 False
@classmethod는 인스턴스가 아니라 클래스 자체를 다루는 메서드를 만듭니다. from_rubles는 하위 클래스로 호출해도 올바른 타입의 객체를 만듭니다.
클래스 데코레이터와 클래스형 데코레이터
데코레이터는 클래스에도 적용할 수 있습니다. 클래스를 받아 같은 클래스나 수정된 클래스를 돌려줍니다. @dataclass가 이런 방식입니다. 간단한 예로 클래스 등록부를 만들어 봅시다.
registry = {}
def register(cls):
registry[cls.__name__] = cls
return cls
@register
class CsvExporter:
pass
print(registry) # {'CsvExporter': <class '__main__.CsvExporter'>}
반대로 클래스를 데코레이터로 쓸 수도 있습니다. __call__ 메서드가 있는 객체라면 무엇이든 데코레이터가 될 수 있고, 상태는 인스턴스 속성에 저장합니다.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.func(*args, **kwargs)
@CountCalls
def tick():
pass
tick(); tick()
print(tick.calls) # 2
비동기 데코레이터
async def 함수를 감쌀 때는 래퍼도 코루틴이어야 하며, 결과를 await로 기다려야 합니다. 그렇지 않으면 실행되지 않은 코루틴 객체가 반환됩니다.
import functools
def log_async(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
print(f"시작: {func.__name__}")
result = await func(*args, **kwargs)
print(f"완료: {func.__name__}")
return result
return wrapper
데코레이터는 무엇에 쓰일까
- 공통 로직 — 로깅, 지표 수집, 실행 시간 측정을 모든 함수에 복사하지 않고 한 줄로 추가합니다.
- 캐싱 — 비용이 큰 순수 계산에
@functools.cache를 씁니다. - 검사 — 권한 확인, 인자 검증, 웹 핸들러의 인증.
- 등록 — Flask와 FastAPI의 경로(
@app.get("/")), pytest의 픽스처. - 안정성 — 재시도, 타임아웃, 오류 처리.
가장 큰 장점은 역할의 분리입니다. 함수는 자기 일만 하고, 부가 기능은 한 줄로 붙이거나 뗄 수 있습니다.
자주 하는 실수
- 래퍼가 결과를 반환하지 않음 — 데코레이트된 함수가
None을 돌려줍니다. *args, **kwargs누락 — 인자가 있는 호출에서TypeError가 납니다.wraps를 빠뜨림 —__name__과 독스트링이 틀려지고 로그가 헷갈립니다.- 팩토리의 괄호 누락 —
@repeat이라고 쓰면@repeat(3)이 아니므로 함수가times매개변수로 들어갑니다. - 데코레이터 본문에 무거운 코드 — 이 코드는 호출 때가 아니라 모듈 임포트 때 실행됩니다.
정리
파이썬 데코레이터는 고차 함수입니다. 함수를 받아 래퍼를 돌려주고, @는 대입을 짧게 쓴 표기일 뿐입니다. *args, **kwargs, return result, functools.wraps로 된 틀에서 시작하고, 설정이 필요하면 팩토리 단계를 추가하세요. 데코레이터의 바탕이 되는 함수, 클로저, 타입 힌트는 과정 강의에서 실습으로 다룹니다.
자주 묻는 질문
데코레이터를 제거할 수 있나요?
functools.wraps를 쓴 경우 원본은 __wrapped__ 속성에 있으므로 func.__wrapped__(...)로 래퍼 없이 호출할 수 있습니다. 그렇지 않다면 코드에서 @ 줄을 지우는 수밖에 없습니다.
데코레이터 코드는 언제 실행되나요?
데코레이터 함수 자체는 함수가 정의될 때 한 번 실행되며, 보통 모듈을 임포트할 때입니다. wrapper 안의 코드는 데코레이트된 함수를 호출할 때마다 실행됩니다.
클래스의 메서드에도 데코레이터를 쓸 수 있나요?
네. *args, **kwargs를 쓰는 일반 데코레이터는 메서드에도 그대로 동작합니다. self는 args의 첫 원소가 됩니다.
데코레이터와 상속은 어떻게 다른가요?
상속은 클래스 계층을 통해 동작을 바꾸고, 데코레이터는 특정 함수나 클래스를 바깥에서 감쌉니다. 데코레이터는 코드를 고치지 않고 여러 개를 조합할 수 있습니다.