파이썬 코드를 보다 보면 @something 한 줄이 함수 위에 떡 붙어 있는 걸 자주 봅니다. @app.route("/users"), @functools.lru_cache, @pytest.fixture 같은 것들요. 처음엔 "이게 뭔가 마법 같은 어노테이션인가" 싶지만, 까보면 그냥 함수를 인자로 받아 함수를 반환하는 함수일 뿐입니다. 원리만 잡으면 직접 만드는 것도 어렵지 않아요. 오늘은 데코레이터의 정체부터 시작해서, 인자를 받는 데코레이터, 그리고 functools.wraps를 왜 꼭 써야 하는지까지 정리해 볼게요.
개념 / 원리
데코레이터를 한 줄로 정의하면 "함수를 감싸서 동작을 추가하는 고차 함수"입니다. 비유하자면 선물 포장 같은 거예요. 안에 든 선물(원래 함수)은 그대로 있는데, 겉포장(데코레이터)이 추가 기능을 입혀 줍니다.
@decorator가 함수 위에 붙으면 다음 두 코드가 완전히 동일하게 동작합니다.
@my_decorator
def hello():
print("hello")
# ↑ 이 두 줄은 ↓ 와 같습니다
def hello():
print("hello")
hello = my_decorator(hello)
즉 @는 "정의한 함수를 데코레이터에 넣고, 그 결과로 원래 이름을 다시 바인딩"하라는 문법 설탕(syntactic sugar)이에요.
가장 단순한 데코레이터는 이렇게 생겼습니다.
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"before {func.__name__}")
result = func(*args, **kwargs)
print(f"after {func.__name__}")
return result
return wrapper
my_decorator는 함수를 받아서 wrapper라는 새 함수를 반환합니다. wrapper는 자기 안에서 원본 func을 호출하면서 앞뒤에 동작을 끼워 넣어요. 여기서 wrapper가 바깥 변수 func을 기억하는 게 바로 클로저입니다.
실전 예제
가장 흔히 만드는 게 "함수 실행 시간 재는 데코레이터"입니다.
import time
from functools import wraps
def timing(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.4f}s")
return result
return wrapper
@timing
def slow_add(a, b):
"""덧셈을 천천히"""
time.sleep(0.5)
return a + b
print(slow_add(1, 2))
# slow_add: 0.5012s
# 3
여기서 @wraps(func)를 안 쓰면 slow_add.__name__이 'wrapper'로 바뀌고, 도큐멘트(slow_add.__doc__)도 사라집니다. 디버깅이나 자동 문서화가 깨지니까 데코레이터에는 거의 항상 @wraps를 같이 씁니다.
다음으로 인자를 받는 데코레이터입니다. 위의 @timing은 인자가 없지만, @retry(times=3)처럼 인자를 받으려면 한 단계 더 감싸야 해요.
import time
from functools import wraps
def retry(times=3, delay=1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for i in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last_exc = e
print(f"attempt {i+1} failed: {e}")
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(times=3, delay=0.5)
def fetch():
import random
if random.random() < 0.7:
raise ConnectionError("flaky network")
return "ok"
print(fetch())
구조가 3중이라 처음 보면 어지러운데, 핵심은 이거예요. retry(times=3)이 먼저 호출돼서 decorator를 반환하고, 그 decorator가 func을 받아 wrapper를 반환합니다. @retry(times=3)이라는 한 줄에 함수 호출이 두 번 있는 셈이죠.
# @retry(times=3) 가 풀어쓰면
fetch = retry(times=3)(fetch)
# ↑ decorator 반환 ↑ wrapper 반환
주의할 점
1) @wraps를 빼먹지 마세요. 안 쓰면 __name__, __doc__, __wrapped__ 같은 메타데이터가 wrapper의 것으로 덮입니다. 디버거 stack trace에 함수 이름이 wrapper로만 찍히기 시작하면 어디 코드인지 찾기가 힘들어져요. 표준 라이브러리 functools.wraps(func) 한 줄이면 끝나니 습관처럼 쓰는 게 낫습니다.
2) 데코레이터 순서는 아래에서 위로 적용됩니다.
@a
@b
def f(): ...
# 위 코드는 ↓ 와 같음
f = a(b(f))
즉 f를 먼저 b가 감싸고, 그걸 다시 a가 감쌉니다. 호출은 위부터 들어와요(a → b → f). 인증 체크와 로깅을 같이 걸 때 순서가 의미를 가지니 주의가 필요합니다.
3) 클래스 메서드에 직접 데코레이터 쓰면 self까지 따라옵니다. 위에 만든 @timing을 self 받는 메서드에 그대로 붙여도 동작은 하지만, wrapper에서 *args[0]을 건드릴 때 self가 첫 인자로 들어온다는 걸 잊지 말아야 합니다. 메서드 전용으로 만들 때는 args[1:]로 슬라이싱하거나, inspect로 시그니처를 확인합니다.
4) @staticmethod나 @classmethod와 같이 쓸 땐 순서가 중요합니다. 보통 @staticmethod가 가장 바깥(맨 위)에 있어야 합니다. 안 그러면 self 처리 때문에 깨지는 경우가 많아요.
5) 상태를 가진 데코레이터는 함수 속성 또는 클로저 변수로. 호출 횟수 카운팅 같은 게 필요하면 wrapper.calls = 0처럼 함수 객체에 속성을 박거나, nonlocal 변수를 클로저로 잡아 씁니다. 전역 변수 쓰면 같은 데코레이터를 두 함수에 붙였을 때 값이 섞입니다.
마무리
정리하면 세 줄입니다.
@decorator는 단지f = decorator(f)의 문법 설탕, 데코레이터는 "함수를 받아 함수를 반환하는 함수"다.- 인자 받는 데코레이터는 한 단계 더 감싸서 3중 구조 (
데코팩토리(인자)(원본함수))가 된다. - 거의 모든 경우
functools.wraps를 같이 쓰자 — 안 쓰면 디버깅 지옥의 시작이다.
'IT·리눅스 > Python' 카테고리의 다른 글
| 제너레이터 vs 이터레이터 - Python yield는 언제 쓰나 (0) | 2026.07.09 |
|---|---|
| dataclass vs Pydantic - 언제 뭘 써야 할까 (0) | 2026.06.23 |
| multiprocessing vs threading - GIL 때문에 헷갈리는 두 모듈 정리 (0) | 2026.06.09 |
| asyncio - 코루틴은 GIL을 어떻게 피해 다니는가 (0) | 2026.05.27 |
| GIL의 정체 - 왜 멀티스레드가 안 빠를까 (0) | 2026.05.26 |