파이썬 데코레이터 - 일급 객체와 functools.wraps (파이썬 중급 12단원)
이 단원에서 배우는 것
09단원에서 @dataclass와 @property를, 11단원에서는 함수를 다른 함수에 인자로 넘기는 코드를 이미 썼다. 그때 @ 기호가 무엇인지는 설명하지 않고 넘어갔다. 이번 단원에서 그 정체를 밝힌다. 주문 로그 분석 도구에 실행 시간 측정, 재시도, 결과 캐시를 붙이면서, 이 세 가지가 왜 데코레이터라는 형태로 표현되는지를 본다.
- 함수가 일급 객체라는 말이 코드에서 무엇을 가능하게 하는지 설명할 수 있다
- 인자 있는 데코레이터를 포함해 직접 만들 수 있고,
functools.wraps가 없으면 무엇이 깨지는지 안다 functools.cache같은 표준 데코레이터를 쓸 자리와 위험한 자리를 구분할 수 있다
왜 필요한가
주문 집계 함수가 느려서 시간을 재기로 했다.
def aggregate_by_sku(lines):
start = time.perf_counter()
result = {}
for line in lines:
result[line.sku] = result.get(line.sku, 0) + line.amount
print(f"aggregate_by_sku: {time.perf_counter() - start:.3f}초")
return result
여기까지는 괜찮다. 문제는 잴 함수가 20개라는 것이다. 20개 함수마다 같은 세 줄이 들어가고, 그중 하나는 return이 여러 개라서 시간 출력이 한 경로에서 빠진다. 나중에 출력 대신 로그로 바꾸기로 하면 20군데를 고쳐야 한다.
더 근본적인 문제는 이 세 줄이 집계 로직과 아무 상관이 없다는 것이다. 함수를 읽는 사람은 "SKU별로 금액을 더한다"를 알고 싶은데 시간 측정 코드가 시야를 가린다. 시간 측정, 재시도, 캐시, 권한 검사, 로깅은 전부 이런 성격이다. 여러 함수에 똑같이 필요하지만 그 함수의 본래 일은 아닌 것. 데코레이터는 이것을 함수 밖으로 꺼내 이름 하나로 붙였다 뗐다 할 수 있게 만든다.
문법과 예제
함수가 일급 객체라는 것
파이썬에서 def는 함수 객체를 만들어 이름에 대입하는 문장이다. 그래서 함수는 변수에 담을 수 있고, 다른 함수의 인자가 될 수 있고, 반환값이 될 수도 있다.
def total_amount(line):
return line.qty * line.unit_price
f = total_amount # 괄호가 없다. 호출이 아니라 객체 참조다
print(f.__name__) # total_amount
print(f(OrderLine("BOOK-01", 2, 12000))) # 24000
def apply_twice(func, value):
return func(func(value))
print(apply_twice(lambda n: n * 2, 3)) # 12
여기에 하나만 더하면 데코레이터가 된다. 함수를 받아서 함수를 돌려주는 함수다.
가장 단순한 데코레이터
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs) # 원래 함수 호출
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.6f}초")
return result # 반환값을 반드시 되돌려준다
return wrapper
@timed
def aggregate_by_sku(lines):
result = {}
for line in lines:
result[line.sku] = result.get(line.sku, 0) + line.qty * line.unit_price
return result
lines = [OrderLine("BOOK-01", 2, 12000), OrderLine("BOOK-01", 1, 12000)]
print(aggregate_by_sku(lines))
# aggregate_by_sku: 0.000004초
# {'BOOK-01': 36000}
@timed는 문법 설탕이다. 실제로 일어나는 일은 이것뿐이다.
aggregate_by_sku = timed(aggregate_by_sku)
wrapper가 *args, **kwargs를 받는 이유는 어떤 시그니처의 함수에도 붙을 수 있어야 하기 때문이다. 그리고 result를 return하는 줄을 빼먹으면 데코레이터를 붙인 모든 함수가 None을 반환하게 된다. 초보가 가장 자주 만드는 데코레이터 버그다.
functools.wraps 가 필요한 이유
위 코드에는 문제가 하나 남아 있다.
print(aggregate_by_sku.__name__) # wrapper <- 이름이 바뀌었다
print(aggregate_by_sku.__doc__) # None <- docstring 이 사라졌다
aggregate_by_sku라는 이름에 들어 있는 것은 이제 wrapper 함수 객체이므로 당연한 결과다. 그런데 이게 실제로 무엇을 깨뜨리는지가 중요하다.
- 트레이스백과 로그에 함수 이름이 전부
wrapper로 찍혀서 어느 함수가 실패했는지 알 수 없다 help()와 편집기 툴팁에 설명이 안 나온다- 같은 이름의 함수 두 개를 이름으로 구분해 등록하는 프레임워크(예: 웹 라우팅)가 충돌한다
고치는 방법은 한 줄이다.
import functools
import time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"{func.__name__}: {time.perf_counter() - start:.6f}초")
return wrapper
@timed
def aggregate_by_sku(lines):
"""SKU별 금액 합계를 낸다."""
result = {}
for line in lines:
result[line.sku] = result.get(line.sku, 0) + line.qty * line.unit_price
return result
print(aggregate_by_sku.__name__) # aggregate_by_sku
print(aggregate_by_sku.__doc__) # SKU별 금액 합계를 낸다.
print(aggregate_by_sku.__wrapped__) # 원본 함수에 접근할 수 있다
functools.wraps는 __name__, __doc__, __module__, __qualname__, __dict__, 그리고 __wrapped__를 옮겨 준다. 마지막 것 덕분에 inspect.signature가 원래 시그니처를 찾아낸다. 데코레이터를 만들면서 @functools.wraps를 빼는 것은 사실상 버그로 취급한다.
try/finally로 바꾼 것도 의도적이다. 10단원에서 finally에 return을 넣지 말라고 했는데, 여기서는 return이 아니라 출력만 하므로 안전하다. 이렇게 하면 함수가 예외로 죽어도 걸린 시간이 찍힌다. 느린 함수가 타임아웃으로 죽는 상황에서는 이쪽이 훨씬 쓸모 있다.
인자를 받는 데코레이터
재시도 횟수를 지정하고 싶다면 괄호가 한 겹 더 필요하다. @retry(times=3)은 retry(times=3)을 먼저 호출하고, 그 결과를 데코레이터로 쓰라는 뜻이기 때문이다.
import functools
import time
class OrderStoreError(Exception):
"""10단원에서 만든 재시도 가능한 예외."""
def retry(times: int = 3, delay: float = 0.1, exceptions=(OrderStoreError,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last = e
print(f"{func.__name__} {attempt}회 실패: {e}")
if attempt < times:
time.sleep(delay * attempt) # 시도할수록 더 기다린다
raise last
return wrapper
return decorator
calls = {"n": 0}
@retry(times=3, delay=0.01)
def fetch_order(order_id: str) -> dict:
calls["n"] += 1
if calls["n"] < 3:
raise OrderStoreError("일시적 연결 실패")
return {"id": order_id, "lines": []}
print(fetch_order("ORD-1001"))
# fetch_order 1회 실패: 일시적 연결 실패
# fetch_order 2회 실패: 일시적 연결 실패
# {'id': 'ORD-1001', 'lines': []}
세 겹 구조가 헷갈리면 안에서 밖으로 읽으면 된다. wrapper는 호출을 감싸고, decorator는 함수를 받고, retry는 설정을 받는다. exceptions를 인자로 뺀 덕분에 10단원에서 나눈 예외 계층이 그대로 살아난다. InvalidOrderError는 몇 번을 재시도해도 성공하지 않으므로 기본값에 넣지 않았다.
데코레이터를 여러 개 붙일 때의 순서
@timed
@retry(times=3, delay=0.01)
def fetch_order(order_id):
...
이것은 fetch_order = timed(retry(times=3, delay=0.01)(fetch_order))다. 가까운 것부터 안쪽으로 감긴다. 그래서 timed가 바깥에 있고, 재시도 3번을 전부 포함한 총 시간을 잰다. 순서를 뒤집으면 시도 한 번의 시간을 각각 재게 된다. 어느 쪽이 맞는지는 목적에 달렸지만, 순서가 결과를 바꾼다는 사실 자체를 모르는 경우가 많다.
functools.cache 로 반복 계산 없애기
import functools
@functools.cache # 파이썬 3.9+, lru_cache(maxsize=None) 과 같다
def unit_price_of(sku: str) -> int:
print(f" DB 조회: {sku}")
return {"BOOK-01": 12000, "PEN-07": 3000}[sku]
print(unit_price_of("BOOK-01")) # DB 조회: BOOK-01 / 12000
print(unit_price_of("BOOK-01")) # 12000 (조회 없음)
print(unit_price_of.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=None, currsize=1)
크기 제한이 필요하면 @functools.lru_cache(maxsize=1024)를 쓴다. 조건이 두 가지 있다. 인자가 해시 가능해야 하고(리스트를 넘기면 TypeError), 같은 인자에 항상 같은 결과가 나와야 한다. 단가가 바뀔 수 있다면 캐시된 값이 곧 틀린 값이 된다. 그런 경우 unit_price_of.cache_clear()를 호출할 자리를 반드시 만들어 둔다. functools 문서에 cached_property, partial, singledispatch처럼 같은 계열의 도구가 더 있다.
실무에서 자주 틀리는 것
1. 데코레이터 코드가 임포트 시점에 실행된다는 것을 놓친다
def register(func):
print(f"등록: {func.__name__}") # 모듈을 임포트하는 순간 실행된다
return func
@register
def aggregate_by_sku(lines):
...
@ 아래 함수를 한 번도 호출하지 않아도 "등록: aggregate_by_sku"가 찍힌다. 데코레이터 본체는 정의 시점에 딱 한 번 실행되기 때문이다. 여기에 DB 연결이나 설정 파일 읽기를 넣으면, 그 모듈을 임포트하는 것만으로 부작용이 생긴다. 테스트를 돌릴 때 원인 모를 연결 오류가 나는 사례가 여기서 나온다. 정의 시점에 실행할 것은 decorator 바깥에, 호출 시점에 실행할 것은 wrapper 안에 넣는다.
2. 인자 있는 데코레이터를 괄호 없이 쓴다
@retry # 괄호를 빠뜨렸다
def fetch_order(order_id):
return {"id": order_id}
print(fetch_order("ORD-1001"))
# <function retry.<locals>.decorator.<locals>.wrapper at 0x102cbdee0>
예외가 나지 않는 것이 이 실수의 고약한 점이다. retry가 fetch_order 함수 자체를 times 자리에 받아 버리고 decorator를 돌려주므로, fetch_order라는 이름에는 decorator가 들어간다. 그것을 호출하면 주문 딕셔너리가 아니라 함수 객체가 반환된다. 이 값을 그대로 딕셔너리에 담거나 JSON으로 직렬화하는 자리까지 흘러간 다음에야 엉뚱한 에러로 터지므로, 원인 지점과 증상 지점이 멀다. 반대로 인자 없는 데코레이터에 괄호를 붙이는 실수(@timed())도 같은 부류다. 헷갈릴 여지를 줄이려면 인자를 받는 데코레이터는 이름에서부터 설정을 받는다는 느낌이 나게 짓고, 기본값으로도 쓸 수 있게 하려면 두 경우를 모두 처리하는 코드를 명시적으로 넣는다.
3. 메서드에 lru_cache 를 붙인다
class OrderRepository:
@functools.cache # 위험하다
def find(self, order_id: str) -> dict:
return {"id": order_id}
캐시 키에 self가 포함된다. 캐시는 클래스에 하나뿐이고 프로세스가 끝날 때까지 살아 있으므로, 모든 OrderRepository 인스턴스가 영원히 회수되지 않는다. 요청마다 저장소 객체를 만드는 웹 서버라면 메모리가 계속 늘어난다. 게다가 self가 해시 가능해야 하는데, 09단원에서 본 대로 __eq__만 정의한 클래스라면 TypeError가 난다. 인스턴스마다 캐시를 두려면 functools.cached_property를 쓰거나 __init__ 안에서 캐시 딕셔너리를 직접 만든다.
4. wrapper 에서 반환값을 돌려주지 않는다
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
func(*args, **kwargs) # 결과를 버렸다
print(f"{time.perf_counter() - start:.6f}초")
return wrapper
측정 코드를 붙인 순간 그 함수를 쓰던 모든 코드가 None을 받는다. 데코레이터를 임시로 붙였다 떼는 습관이 있으면 이 버그가 조용히 들어온다. 데코레이터를 만들면 반환값과 예외가 그대로 통과하는지 반드시 확인한다.
스스로 확인하기
@timed를 붙인 함수의__name__이wrapper로 나온다. 어떤 실무 문제를 일으키며 어떻게 고치는가?- 아래 두 배치의 차이를 설명하라. 어느 쪽이 "재시도 전체에 걸린 시간"을 재는가?
@timed @retry(times=3) def a(): ... @retry(times=3) @timed def b(): ... @functools.cache를 붙인unit_price_of(sku)가 있다. 운영 중에 상품 단가를 바꿨는데 집계 결과가 옛날 단가로 나온다. 원인과 대처는?
정답
- 트레이스백과 로그에 원래 함수 이름이 안 남아 장애 원인을 좁히지 못하고, 함수 이름으로 등록하는 프레임워크에서 여러 함수가 충돌한다.
wrapper정의 위에@functools.wraps(func)를 붙이면 이름, docstring, 시그니처 정보가 원본에서 복사된다. a는timed(retry(times=3)(a))이므로timed가 바깥이고 재시도 3회를 모두 포함한 총 시간을 잰다.b는timed가 안쪽이라 시도할 때마다 한 번씩, 총 3번 시간이 찍힌다. 데코레이터는 함수에 가까운 것이 먼저 감긴다.- 캐시는 첫 조회 결과를 프로세스가 끝날 때까지 들고 있다. 단가처럼 바뀔 수 있는 값에는 무기한 캐시가 맞지 않는다. 단가 변경 시
unit_price_of.cache_clear()를 호출하거나, 캐시 대상을 바뀌지 않는 값으로 한정한다. 값이 자주 바뀐다면 만료 시간이 있는 캐시를 따로 쓴다.