Python 강좌 : 제 47강 - 데코레이터
데코레이터(Decorator)
데코레이터(Decorator)란 함수나 클래스를 입력받아 기능을 덧붙인 새로운 함수나 클래스를 반환하는 호출 가능한 객체입니다.
원래 함수의 코드를 수정하지 않고, 함수의 실행 전후에 공통 기능을 추가할 수 있습니다.
로그 출력, 실행 시간 측정, 결과 캐싱, 재시도, 권한 검사처럼 여러 함수에 반복해서 적용해야 하는 기능을 구현할 때 주로 활용합니다.
데코레이터는 @데코레이터 구문을 함수나 클래스 정의 위에 작성해 적용합니다.
데코레이터는 제 40강 - 클로저에서 다룬 클로저(Closure)를 기반으로 동작하므로, 클로저를 먼저 이해하면 쉽게 익힐 수 있습니다.
이번 강좌에서는 데코레이터가 성립하는 바탕인 일급 객체부터 시작해, 기본 데코레이터의 동작 순서, functools.wraps로 메타데이터를 보존하는 방법, 인자를 받는 데코레이터, 중첩 순서, 클래스로 만드는 데코레이터, 표준 라이브러리가 제공하는 데코레이터, 그리고 실행 시간 측정·재시도 같은 실용 예제까지 차례대로 다룹니다.
데코레이터가 필요한 이유
def add(a, b):
print("add 함수 시작")
result = a + b
print("add 함수 종료")
return result
def multiply(a, b):
print("multiply 함수 시작")
result = a * b
print("multiply 함수 종료")
return result
print(add(1, 2))
print(multiply(3, 4))- 결과
- add 함수 시작
add 함수 종료
3
multiply 함수 시작
multiply 함수 종료
12
데코레이터 없이 여러 함수에 같은 부가 기능을 넣으면 위와 같이 작성하게 됩니다.
두 함수의 실제 역할은 a + b와 a * b 한 줄뿐이지만, 시작과 종료를 출력하는 코드가 함수마다 똑같이 반복됩니다.
이런 구조에는 세 가지 문제가 있습니다.
첫째, 함수가 10개, 100개로 늘어나면 같은 코드도 그만큼 복사해야 합니다.
둘째, 출력 형식을 바꾸려면 모든 함수를 찾아서 하나씩 수정해야 하며, 한 곳이라도 빠뜨리면 동작이 서로 달라집니다.
셋째, 함수 본문에 핵심 로직과 부가 기능이 섞여 있어 함수가 무엇을 하는지 한눈에 읽기 어렵습니다.
로그 출력, 실행 시간 측정, 권한 검사처럼 핵심 로직과 별개로 여러 곳에 공통으로 필요한 기능을 횡단 관심사(Cross-cutting Concern)라 부릅니다.
데코레이터는 이 횡단 관심사를 한 곳에 한 번만 작성하고, 필요한 함수 위에 @이름 한 줄만 붙여서 적용하는 방법입니다.
같은 기능을 데코레이터로 바꾸면 각 함수에는 return a + b처럼 핵심 로직만 남고, 출력 형식을 바꿀 때에도 데코레이터 한 곳만 수정하면 됩니다. 실제로 바꾼 코드는 아래의 기본 데코레이터 섹션에서 확인합니다.
일급 객체(First-class Object)
def greet(name):
return f"Hello, {name}"
say = greet
print(say("Python"))
print(say is greet)
def call(func, value):
return func(value)
print(call(greet, "World"))
print(type(greet))- 결과
- Hello, Python
True
Hello, World
<class ‘function’>
Python의 함수는 일급 객체(First-class Object)입니다.
일급 객체란 변수에 할당할 수 있고, 다른 함수의 인수로 전달할 수 있으며, 함수의 반환값으로 사용할 수 있는 객체를 의미합니다.
say = greet는 함수를 호출하지 않고 함수 객체 자체를 변수에 할당합니다. 그러므로 say와 greet는 같은 객체를 가리킵니다.
call() 함수처럼 함수를 인수로 받거나 함수를 반환하는 함수를 고차 함수(Higher-order Function)라 합니다.
데코레이터는 함수를 인수로 받아 새로운 함수를 반환하는 고차 함수입니다.
결과를 한 줄씩 보면, 첫 줄은 say를 통해 greet를 호출한 결과입니다. 두 번째 줄의 True는 say와 greet가 복사본이 아니라 같은 함수 객체라는 뜻입니다. 세 번째 줄은 call() 함수가 인수로 받은 greet를 내부에서 호출한 결과이며, 마지막 줄은 함수도 function 클래스의 인스턴스, 즉 하나의 값이라는 것을 보여 줍니다.
- Tip :
greet는 함수 객체이며,greet()는 함수를 호출한 결과입니다. 데코레이터를 작성할 때 두 표현을 혼동하지 않도록 주의합니다.
def make_multiplier(n):
def multiply(x):
return x * n
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))
print(double.__name__)- 결과
- 10 15
multiply
일급 객체의 세 번째 성질인 함수를 반환값으로 사용하는 예제입니다.
make_multiplier(2)를 호출하면 내부에서 multiply 함수를 새로 만들어 반환합니다. 반환된 함수는 외부 함수의 지역 변수 n을 기억하는 클로저이므로, double은 n = 2, triple은 n = 3을 각각 기억합니다.
double.__name__이 multiply인 이유는, 변수 이름이 double일 뿐 실제 객체는 내부에서 정의된 multiply 함수이기 때문입니다.
데코레이터는 이 두 성질을 합친 것입니다. 함수를 인수로 받고(전달), 내부 함수를 만들어(클로저), 그 함수를 반환(반환값)합니다. 이 __name__이 바뀌는 현상은 뒤의 functools.wraps 섹션에서 다시 다룹니다.
기본 데코레이터
앞의 데코레이터가 필요한 이유에서 반복되던 시작·종료 출력을 데코레이터 하나로 옮겨 봅니다.
먼저 @ 구문을 쓰지 않고 데코레이터를 직접 호출하는 방식으로 작성해, 내부에서 어떤 일이 일어나는지 확인합니다.
def logger(func):
def wrapper(*args, **kwargs):
print(f"{func.__name__} 함수 시작")
result = func(*args, **kwargs)
print(f"{func.__name__} 함수 종료")
return result
return wrapper
def add(a, b):
return a + b
add = logger(add)
print(add(1, 2))- 결과
- add 함수 시작
add 함수 종료
3
logger() 함수는 함수 func를 입력받아, 내부에서 정의한 wrapper() 함수를 반환합니다.
wrapper() 함수는 외부 함수의 변수인 func를 참조하는 클로저(Closure)입니다.
add = logger(add)는 원래의 add 함수를 wrapper 함수로 교체합니다.
이후 add(1, 2)를 호출하면 실제로는 wrapper(1, 2)가 호출되며, 내부에서 원래의 add 함수를 호출해 결과를 반환합니다.
*args와 **kwargs를 사용하면 위치 인자와 키워드 인자를 모두 그대로 전달할 수 있으므로, 매개변수 형태와 관계없이 어떤 함수에도 적용할 수 있습니다.
실행 순서를 단계별로 정리하면 다음과 같습니다.
logger(add)가 호출되면wrapper함수가 새로 만들어지고, 이때func에는 원래의add함수가 저장됩니다.logger는wrapper를 호출하지 않고 함수 객체 그대로 반환하며, 이 객체가add라는 이름에 다시 할당됩니다.add(1, 2)를 호출하면wrapper(1, 2)가 실행되어args에는(1, 2),kwargs에는{}가 담깁니다.wrapper가add 함수 시작을 출력하고,func(*args, **kwargs)로 원래add(1, 2)를 호출해3을result에 저장합니다.add 함수 종료를 출력한 다음result를 반환하므로, 마지막 줄의print()가3을 출력합니다.
원래의 add 함수 코드는 한 글자도 바뀌지 않았지만, 이름 add가 가리키는 대상이 wrapper로 바뀌었기 때문에 호출할 때마다 부가 기능이 함께 실행됩니다.
- Tip :
*args와**kwargs는 제 32강 - 인자에서 확인할 수 있습니다.
def logger(func):
def wrapper(*args, **kwargs):
print(f"{func.__name__} 함수 시작")
result = func(*args, **kwargs)
print(f"{func.__name__} 함수 종료")
return result
return wrapper
@logger
def multiply(a, b):
return a * b
print(multiply(3, 4))
print(multiply(5, b=6))- 결과
- multiply 함수 시작
multiply 함수 종료
12
multiply 함수 시작
multiply 함수 종료
30
@logger 구문은 multiply = logger(multiply)와 같은 의미입니다.
함수를 정의한 직후 데코레이터가 한 번 호출되어, 반환된 함수가 원래 이름에 할당됩니다.
즉, 데코레이터 구문은 함수를 감싸는 과정을 간결하게 표현하는 문법적 설탕(Syntactic Sugar)입니다.
multiply(5, b=6)처럼 키워드 인자를 섞어 호출해도 kwargs에 {'b': 6}이 담겨 그대로 전달되므로 정상적으로 30이 반환됩니다.
multiply = logger(multiply) 방식과 비교하면, @ 구문은 함수 정의 바로 위에 적용 사실이 드러나므로 코드를 읽는 사람이 이 함수에 어떤 부가 기능이 붙어 있는지 바로 알 수 있습니다. 또한 같은 이름을 두 번 적는 과정에서 생기는 오타도 막을 수 있습니다.
-
Tip : 데코레이터 내부 함수에서
return result를 생략하면 원래 함수의 반환값이 사라지고None이 반환됩니다. -
Tip : Python 3.9 이상부터는
@뒤에 이름뿐만 아니라@buttons[0].clicked처럼 임의의 표현식을 사용할 수 있습니다.
def logger(func):
print(f"{func.__name__} 함수에 데코레이터 적용")
def wrapper(*args, **kwargs):
print(f"{func.__name__} 함수 호출")
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
return a + b
print("함수 정의 완료")
print(add(1, 2))- 결과
- add 함수에 데코레이터 적용
함수 정의 완료
add 함수 호출
3
데코레이터가 언제 실행되는지 확인하는 예제입니다.
add 함수에 데코레이터 적용이 함수 정의 완료보다 먼저 출력된 것처럼, 데코레이터 함수 본문은 함수를 호출할 때가 아니라 def 문이 실행되는 순간 한 번 실행됩니다.
반면 wrapper 내부의 add 함수 호출은 add(1, 2)를 호출할 때마다 출력됩니다.
그러므로 데코레이터 함수 본문에는 등록·검사처럼 정의 시점에 한 번만 하면 되는 작업을, wrapper 내부에는 호출할 때마다 해야 하는 작업을 작성합니다.
- Tip : 모듈 안의 함수에 데코레이터가 적용되어 있다면, 그 모듈을
import하는 시점에 데코레이터 본문이 실행됩니다.
def logger(func):
def wrapper(*args, **kwargs):
print(f"{func.__name__} 함수 호출")
return func(*args, **kwargs)
return wrapper()
try:
@logger
def add(a, b):
return a + b
except TypeError as e:
print("TypeError :", e)- 결과
- add 함수 호출
TypeError : add() missing 2 required positional arguments: ‘a’ and ‘b’
데코레이터를 처음 작성할 때 가장 흔한 실수는 return wrapper 대신 return wrapper()를 작성하는 것입니다.
괄호를 붙이면 함수 객체가 아니라 함수를 호출한 결과를 반환하게 되므로, 함수를 정의하는 순간 인수 없이 wrapper()가 실행됩니다.
그 결과 원래 함수 add가 인수 없이 호출되어 TypeError가 발생합니다. 오류가 함수를 호출할 때가 아니라 정의할 때 발생한다는 점이 단서가 됩니다.
functools.wraps
def logger(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
"""두 수를 더합니다."""
return a + b
print(add.__name__)
print(add.__doc__)- 결과
- wrapper
None
데코레이터를 적용하면 원래 함수가 wrapper 함수로 교체되므로, 함수의 메타데이터도 함께 바뀝니다.
add.__name__은 add가 아닌 wrapper를 반환하며, add.__doc__은 원래의 문서 문자열이 아닌 None을 반환합니다.
이 경우 help() 함수, 디버거, 오류 메시지, 문서 생성 도구 등에서 원래 함수의 정보를 확인할 수 없게 됩니다.
이 현상은 버그가 아니라 데코레이터의 동작 원리에서 당연히 생기는 결과입니다. add라는 이름이 가리키는 객체는 이제 logger 안에서 만들어진 wrapper 함수이므로, __name__과 __doc__도 wrapper의 값이 조회됩니다.
여러 함수에 같은 데코레이터를 적용하면 모든 함수의 이름이 wrapper로 보이게 되어, 로그나 트레이스백에서 어떤 함수가 실행되었는지 구분하기 어려워집니다.
from functools import wraps
def logger(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
"""두 수를 더합니다."""
return a + b
print(add.__name__)
print(add.__doc__)
print(add.__wrapped__)
print(add.__wrapped__(1, 2))- 결과
- add
두 수를 더합니다.
<function add at 0x000001660B4C0300>
3
functools.wraps(func)는 wrapper 함수에 원래 함수의 메타데이터를 복사하는 데코레이터입니다.
__module__, __name__, __qualname__, __doc__, __annotations__ 등의 속성을 복사하고, __dict__를 갱신합니다.
또한, __wrapped__ 속성에 원래 함수를 저장하므로, 필요한 경우 데코레이터를 거치지 않고 원래 함수를 직접 호출할 수 있습니다.
결과의 첫 두 줄은 wraps가 이름과 문서 문자열을 원래 함수의 값으로 바꿔 놓았음을 보여 줍니다. 세 번째 줄은 __wrapped__에 저장된 원래의 add 함수 객체이며, 네 번째 줄은 그 함수를 직접 호출한 결과입니다.
wraps는 wrapper 함수 자체를 바꾸지 않습니다. 동작은 그대로 두고 겉에서 보이는 정보만 원래 함수처럼 보이도록 복사하므로, 데코레이터의 기능에는 아무런 영향이 없습니다.
데코레이터를 작성할 때에는 특별한 이유가 없다면 항상 @wraps(func)를 적용합니다.
- Tip :
__wrapped__의 출력에 포함된 메모리 주소는 실행할 때마다 달라집니다.
인자를 받는 데코레이터
지금까지의 데코레이터는 동작이 고정되어 있었습니다. 하지만 “3번 반복”, “최대 5번 재시도”, “로그 수준은 DEBUG”처럼 적용할 때마다 설정값을 다르게 주고 싶은 경우가 많습니다.
설정값마다 데코레이터를 따로 만들면 다시 같은 코드가 반복되므로, 설정값을 인자로 받아 알맞은 데코레이터를 만들어 주는 함수를 작성합니다.
from functools import wraps
def repeat(count):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = []
for _ in range(count):
result.append(func(*args, **kwargs))
return result
return wrapper
return decorator
@repeat(3)
def hello(name):
return f"Hello, {name}"
print(hello("Python"))- 결과
- [‘Hello, Python’, ‘Hello, Python’, ‘Hello, Python’]
데코레이터에 인자를 전달하려면 데코레이터를 반환하는 함수를 한 단계 더 감싸서 작성합니다.
@repeat(3)은 hello = repeat(3)(hello)와 같은 의미입니다.
먼저 repeat(3)이 호출되어 decorator 함수를 반환하고, 반환된 decorator가 hello 함수를 입력받아 wrapper 함수를 반환합니다.
wrapper 함수는 count와 func를 모두 기억하는 클로저가 됩니다.
세 단계 함수의 역할을 나누어 보면 다음과 같습니다.
| 함수 | 입력 | 반환 | 호출 시점 |
|---|---|---|---|
| repeat | 설정값(count) | decorator | @repeat(3)이 평가될 때 |
| decorator | 원래 함수(func) | wrapper | 함수 정의 직후 |
| wrapper | 호출 인자(args, kwargs) | 실제 결과 | 함수를 호출할 때마다 |
hello("Python")을 호출하면 wrapper가 count만큼, 즉 3번 원래 함수를 호출하고 결과를 리스트에 모아 반환합니다.
- Tip : 인자를 받는 데코레이터는 괄호를 생략할 수 없습니다.
@repeat로 작성하면hello함수가count로 전달되어 의도와 다르게 동작합니다.
from functools import wraps
def repeat(count):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return [func(*args, **kwargs) for _ in range(count)]
return wrapper
return decorator
@repeat
def hello(name):
return f"Hello, {name}"
print(hello)
print(hello("Python"))- 결과
- <function repeat.<locals>.decorator at 0x000001D7BD19D430>
<function repeat.<locals>.decorator.<locals>.wrapper at 0x000001D7BD19F5E0>
괄호를 생략했을 때 실제로 어떤 일이 일어나는지 보여 주는 예제입니다.
@repeat는 hello = repeat(hello)와 같으므로, count에 함수가 들어가고 hello에는 decorator 함수가 할당됩니다. 첫 번째 줄의 출력이 이를 보여 줍니다.
이어서 hello("Python")을 호출하면 문자열 "Python"이 func로 전달되어 wrapper 함수가 반환될 뿐, 원래 함수는 한 번도 실행되지 않습니다.
이처럼 오류 없이 엉뚱한 값이 반환되기 때문에 발견하기 어려운 실수입니다. 결과에 <function ...>이 출력된다면 데코레이터의 괄호를 먼저 확인합니다.
- Tip : 출력의 메모리 주소는 실행할 때마다 달라집니다.
데코레이터 중첩
실제 코드에서는 로그 출력과 실행 시간 측정, 캐시처럼 여러 부가 기능을 한 함수에 함께 적용하는 경우가 많습니다.
데코레이터는 함수를 받아 함수를 반환하므로, 반환된 함수에 다시 다른 데코레이터를 적용할 수 있습니다. 이때 중요한 것은 적용 순서와 실행 순서가 서로 반대라는 점입니다.
from functools import wraps
def bold(func):
print("bold 적용")
@wraps(func)
def wrapper():
return "<b>" + func() + "</b>"
return wrapper
def italic(func):
print("italic 적용")
@wraps(func)
def wrapper():
return "<i>" + func() + "</i>"
return wrapper
@bold
@italic
def text():
return "Python"
print(text())- 결과
- italic 적용
bold 적용
<b><i>Python</i></b>
하나의 함수에 여러 개의 데코레이터를 중첩해서 적용할 수 있습니다.
데코레이터는 함수와 가까운 아래쪽부터 위쪽 순서로 적용됩니다.
즉, 위의 코드는 text = bold(italic(text))와 같은 의미입니다.
그러므로 italic 데코레이터가 먼저 적용된 후, 그 결과에 bold 데코레이터가 적용됩니다.
반대로 함수를 호출할 때에는 바깥쪽인 bold의 wrapper부터 실행되므로, 결과는 <b> 태그가 <i> 태그를 감싼 형태가 됩니다.
호출 과정을 풀어 쓰면 다음과 같습니다.
text()는bold의wrapper를 호출합니다.bold의wrapper는 자신이 감싼 함수, 즉italic의wrapper를 호출합니다.italic의wrapper는 원래text함수를 호출해"Python"을 받고,<i>Python</i>을 반환합니다.bold의wrapper가 그 결과를 다시 감싸<b><i>Python</i></b>을 반환합니다.
양파 껍질을 생각하면 쉽습니다. 데코레이터를 붙일 때에는 안쪽부터 껍질을 씌우고, 호출할 때에는 바깥 껍질부터 안으로 들어갔다가 다시 바깥으로 나옵니다.
-
Tip : 데코레이터의 순서를 바꾸면 결과도 달라질 수 있으므로, 중첩할 때에는 적용 순서를 고려해야 합니다.
-
Tip : 예를 들어 실행 시간을 측정하는
@timer를@cache바깥에 두면 캐시에서 값을 꺼내는 시간까지 측정되고, 안쪽에 두면 캐시에 적중했을 때@timer가 아예 실행되지 않습니다. 무엇을 측정하고 싶은지에 따라 위치를 정합니다.
클래스 데코레이터
클래스 데코레이터라는 말은 두 가지 의미로 쓰입니다. 하나는 클래스로 구현한 데코레이터, 다른 하나는 클래스에 적용하는 데코레이터입니다. 이 섹션에서는 두 가지를 차례대로 다룹니다.
함수 데코레이터는 상태를 저장하려면 nonlocal 변수나 함수 속성을 사용해야 하지만, 클래스로 구현하면 인스턴스 변수에 상태를 자연스럽게 저장할 수 있습니다.
from functools import update_wrapper
class CountCalls:
def __init__(self, func):
update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.__name__} 호출 횟수 : {self.count}")
return self.func(*args, **kwargs)
@CountCalls
def square(x):
return x ** 2
print(square(2))
print(square(3))
print(square.count)- 결과
- square 호출 횟수 : 1
4
square 호출 횟수 : 2
9
2
__call__ 메서드를 구현한 클래스는 함수처럼 호출할 수 있으므로, 클래스로도 데코레이터를 구현할 수 있습니다.
@CountCalls는 square = CountCalls(square)와 같은 의미이며, square는 CountCalls의 인스턴스가 됩니다.
이후 square(2)를 호출하면 인스턴스의 __call__ 메서드가 실행됩니다.
클래스 데코레이터는 인스턴스 변수에 상태를 저장할 수 있으므로, 호출 횟수처럼 상태를 유지해야 하는 기능에 적합합니다.
functools.update_wrapper()는 wraps의 원형이 되는 함수로, 인스턴스에 원래 함수의 메타데이터를 복사합니다.
- Tip :
@wraps(func)는update_wrapper(wrapper, func)를 데코레이터 형태로 사용할 수 있게 만든 함수입니다.
결과를 보면 square(2), square(3)을 호출할 때마다 count가 1씩 늘어나며, 마지막 square.count로 누적 호출 횟수를 밖에서 읽을 수 있습니다. 함수 데코레이터였다면 이 값을 밖에서 읽기 위해 별도의 장치가 필요했을 것입니다.
from functools import update_wrapper
class CountCalls:
def __init__(self, func):
update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
class Calculator:
@CountCalls
def add(self, a, b):
return a + b
calc = Calculator()
try:
print(calc.add(1, 2))
except TypeError as e:
print("TypeError :", e)- 결과
- TypeError : Calculator.add() missing 1 required positional argument: ‘b’
클래스로 구현한 데코레이터를 메서드에 적용하면 위와 같은 오류가 발생합니다.
일반 함수는 인스턴스를 통해 접근하면 self가 자동으로 연결된 바운드 메서드(Bound Method)가 되지만, CountCalls의 인스턴스는 함수가 아니므로 이 연결이 일어나지 않습니다.
그러므로 calc.add(1, 2)는 self 없이 add(1, 2)로 호출되어, 1이 self, 2가 a에 들어가고 b가 빠졌다는 오류가 발생합니다.
메서드에도 적용할 데코레이터라면 함수로 구현한 데코레이터를 사용하는 것이 간단합니다. 클래스로 구현해야 한다면 __get__ 메서드를 추가로 구현해야 합니다.
registry = {}
def register(cls):
registry[cls.__name__] = cls
return cls
@register
class Dog:
def sound(self):
return "멍멍"
@register
class Cat:
def sound(self):
return "야옹"
print(registry)
print(registry["Cat"]().sound())- 결과
- {‘Dog’: <class ‘__main__.Dog’>, ‘Cat’: <class ‘__main__.Cat’>}
야옹
데코레이터는 함수뿐만 아니라 클래스 정의에도 적용할 수 있습니다.
@register는 Dog = register(Dog)와 같은 의미이며, 클래스 객체를 입력받아 클래스 객체를 반환합니다.
위의 예제는 클래스를 수정하지 않고 그대로 반환하면서, 정의된 클래스를 사전에 등록합니다.
클래스에 속성이나 메서드를 추가하는 데코레이터도 작성할 수 있으며, 표준 라이브러리의 dataclasses.dataclass가 대표적인 예입니다.
결과의 첫 줄은 두 클래스가 이름을 키로 사전에 등록된 모습이며, 두 번째 줄은 문자열 "Cat"으로 클래스를 찾아 인스턴스를 만들고 메서드를 호출한 결과입니다.
이 방식을 사용하면 새 클래스를 추가할 때 등록 코드를 따로 작성하지 않아도 되므로, 플러그인이나 명령어 목록처럼 이름으로 클래스를 찾아 생성해야 하는 구조에서 자주 활용됩니다.
-
Tip : 클래스 데코레이터가 클래스를 반환하지 않으면 클래스 이름에
None이 할당됩니다. 등록만 하더라도 반드시return cls를 작성합니다. -
Tip :
@dataclass는 제 50강 - 데이터 클래스에서 자세히 다룹니다.
표준 데코레이터
데코레이터를 직접 작성하지 않아도, 표준 라이브러리와 내장 함수에는 이미 자주 쓰는 데코레이터가 준비되어 있습니다.
functools.cache & functools.lru_cache
같은 인수로 여러 번 호출되는 함수가 매번 같은 계산을 반복한다면, 처음 계산한 결과를 저장해 두고 다시 쓰는 것이 효율적입니다. 이 기법을 메모이제이션(Memoization)이라 합니다.
from functools import cache, lru_cache
@cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100))
print(fibonacci.cache_info())
@lru_cache(maxsize=2)
def power(x):
print(f"{x} 계산")
return x ** 2
power(1)
power(2)
power(1)
power(3)
power(2)
print(power.cache_info())- 결과
- 354224848179261915075
CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)
1 계산
2 계산
3 계산
2 계산
CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)
functools.cache는 함수의 인수와 반환값을 저장해 두었다가, 같은 인수로 호출되면 저장된 값을 반환하는 데코레이터입니다. (Python 3.9 이상)
재귀로 구현한 피보나치 수열은 같은 값을 반복해서 계산하므로 매우 느리지만, 캐시를 적용하면 각 값을 한 번씩만 계산합니다.
functools.lru_cache(maxsize=개수)는 최대 maxsize개의 결과만 저장하며, 가득 차면 가장 오래 사용하지 않은(Least Recently Used) 결과부터 삭제합니다.
위의 예제에서 power(3)을 호출할 때 캐시가 가득 차서 가장 오래 사용하지 않은 2의 결과가 삭제되므로, 마지막 power(2)는 다시 계산됩니다.
cache_info() 메서드로 적중(hits), 실패(misses), 최대 크기(maxsize), 현재 크기(currsize)를 확인할 수 있으며, cache_clear() 메서드로 캐시를 비울 수 있습니다.
-
Tip :
@cache는@lru_cache(maxsize=None)과 같으며, 크기 제한이 없습니다. -
Tip : Python 3.8 이상부터는 괄호 없이
@lru_cache로도 작성할 수 있으며, 이 경우maxsize는 기본값인128이 됩니다. -
Tip : 캐시는 인수를 사전의 키로 사용하므로,
list나dict처럼 해시할 수 없는 값을 인수로 전달하면TypeError가 발생합니다.
fibonacci.cache_info()의 결과를 읽어 보면, fibonacci(0)부터 fibonacci(100)까지 서로 다른 101개의 값을 한 번씩 계산해 misses=101, currsize=101이 되었고, 이미 계산한 값을 다시 요청한 98번은 캐시에서 바로 꺼내 hits=98이 되었습니다.
캐시가 없다면 fibonacci(100)은 같은 값을 기하급수적으로 반복 계산하므로 현실적인 시간 안에 끝나지 않습니다.
import time
from functools import cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
@cache
def fib_cache(n):
return n if n < 2 else fib_cache(n - 1) + fib_cache(n - 2)
start = time.perf_counter()
print(fib(30))
print(f"캐시 없음 : {time.perf_counter() - start:.4f}s")
start = time.perf_counter()
print(fib_cache(30))
print(f"캐시 사용 : {time.perf_counter() - start:.4f}s")- 결과
- 832040
캐시 없음 : 1.1378s
832040
캐시 사용 : 0.0002s
캐시가 없을 때와 있을 때의 실행 시간을 비교한 예제입니다.
두 함수는 @cache 한 줄을 제외하면 완전히 같지만, 캐시를 사용한 쪽이 수천 배 빠릅니다. 원래 함수의 코드를 전혀 바꾸지 않고 성능을 개선했다는 점에서 데코레이터의 장점이 잘 드러납니다.
실행 시간은 실행 환경과 실행할 때마다 달라집니다.
from functools import cache
@cache
def total(values):
return sum(values)
print(total((1, 2, 3)))
try:
total([1, 2, 3])
except TypeError as e:
print("TypeError :", e)- 결과
- 6
TypeError : unhashable type: ‘list’
캐시는 내부적으로 인수를 키, 반환값을 값으로 하는 사전을 사용합니다. 사전의 키는 해시할 수 있어야 하므로, 튜플은 사용할 수 있지만 리스트는 TypeError가 발생합니다.
캐시를 적용할 함수는 다음 조건을 만족해야 합니다.
- 같은 인수에 대해 항상 같은 결과를 반환해야 합니다. 현재 시각이나 난수, 파일 내용에 따라 결과가 달라지는 함수에 적용하면 오래된 결과가 반환됩니다.
- 출력이나 파일 쓰기 같은 부수 효과가 없어야 합니다. 캐시에 적중하면 함수 본문이 실행되지 않으므로 부수 효과도 일어나지 않습니다.
@cache는 크기 제한이 없으므로, 인수의 종류가 끝없이 늘어나는 함수에는@lru_cache(maxsize=개수)로 메모리 사용량을 제한합니다.
property & staticmethod & classmethod
class Temperature:
unit = "섭씨"
def __init__(self, celsius):
self._celsius = celsius
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
@staticmethod
def is_valid(value):
return value >= -273.15
@classmethod
def from_fahrenheit(cls, value):
return cls((value - 32) * 5 / 9)
t = Temperature(100)
print(t.fahrenheit)
print(Temperature.is_valid(-300))
print(Temperature.from_fahrenheit(212)._celsius)
print(Temperature.unit)- 결과
- 212.0
False
100.0
섭씨
클래스에서 자주 사용하는 property, staticmethod, classmethod도 내장 데코레이터입니다.
@property는 메서드를 속성처럼 접근할 수 있게 만듭니다. t.fahrenheit처럼 괄호 없이 값을 읽을 수 있습니다.
@staticmethod는 self나 cls를 전달받지 않는 정적 메서드를 정의합니다. 클래스와 관련은 있지만 인스턴스의 상태를 사용하지 않는 기능에 활용합니다.
@classmethod는 첫 번째 인수로 클래스 자체(cls)를 전달받는 메서드를 정의합니다. 주로 다른 형태의 값으로 인스턴스를 생성하는 대체 생성자를 구현할 때 활용합니다.
결과를 순서대로 보면, t.fahrenheit는 메서드지만 괄호 없이 접근해 섭씨 100도를 화씨로 바꾼 212.0을 반환합니다. Temperature.is_valid(-300)은 인스턴스 없이 클래스 이름으로 호출해 절대 영도보다 낮은 값이므로 False를 반환합니다. from_fahrenheit(212)는 cls(...)로 새 인스턴스를 만들어 반환하므로 _celsius가 100.0이 됩니다.
세 데코레이터 모두 지금까지 작성한 데코레이터와 같은 원리입니다. @property는 fahrenheit = property(fahrenheit)와 같으며, 함수를 받아 속성처럼 동작하는 객체를 반환합니다.
| 데코레이터 | 첫 번째 인수 | 호출 방법 | 주 용도 |
|---|---|---|---|
| 없음(일반 메서드) | self(인스턴스) | t.method() |
인스턴스 상태를 사용하는 기능 |
| @staticmethod | 없음 | Temperature.method() |
클래스와 관련된 보조 함수 |
| @classmethod | cls(클래스) | Temperature.method() |
대체 생성자, 클래스 상태 사용 |
| @property | self(인스턴스) | t.attr (괄호 없음) |
계산된 값을 속성처럼 제공 |
- Tip :
property의getter,setter,deleter는 제 38강 - 속성(Property)에서 확인할 수 있습니다.
실행 시간 측정 데코레이터
여기서부터는 지금까지 다룬 내용을 조합한 실용 예제입니다.
어떤 함수가 느린지 찾으려면 여러 함수의 실행 시간을 재 봐야 합니다. 함수마다 시간 측정 코드를 넣었다 빼는 대신, 데코레이터를 붙였다 떼는 것만으로 측정할 수 있게 만듭니다.
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} : {end - start:.6f}s")
return result
return wrapper
@timer
def total(n):
return sum(i ** 2 for i in range(n))
print(total(10 ** 6))- 결과
- total : 0.222542s
333332833333500000
함수의 실행 전후 시간을 기록해 실행 시간을 측정하는 데코레이터입니다.
time.perf_counter()는 짧은 시간 간격을 측정하는 데 적합한 고해상도 시계로, 두 값의 차이로 경과 시간을 계산합니다.
측정 코드를 함수마다 작성하지 않고, 측정하고 싶은 함수에 @timer만 추가하면 됩니다.
결과에서 total : ...s가 반환값보다 먼저 출력된 이유는, wrapper가 원래 함수를 실행하고 시간을 출력한 다음에 결과를 반환하고, 그 반환값을 바깥의 print()가 출력하기 때문입니다.
@wraps(func)를 적용했으므로 func.__name__과 별개로 total.__name__도 total로 유지되어, 여러 함수에 적용해도 로그에서 함수를 구분할 수 있습니다.
result를 저장했다가 반환하는 구조이므로, 측정 기능을 덧붙여도 원래 함수의 반환값은 그대로 전달됩니다.
-
Tip : 실행 시간은 실행 환경과 실행할 때마다 달라집니다.
-
Tip : 원래 함수에서 예외가 발생하면
end이후의 코드가 실행되지 않아 시간이 출력되지 않습니다. 예외가 발생해도 시간을 출력하려면try-finally구문으로 감쌉니다.
재시도 데코레이터
네트워크 요청은 일시적인 문제로 실패했다가 잠시 후 다시 시도하면 성공하는 경우가 많습니다. 재시도 로직을 호출하는 곳마다 for와 try-except로 작성하면 코드가 길어지므로, 인자를 받는 데코레이터로 만들어 재사용합니다.
import time
from functools import wraps
def retry(times, exceptions=(Exception,), delay=0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
print(f"{attempt}번째 시도 실패 : {e!r}")
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
count = 0
@retry(times=3, exceptions=(ConnectionError,))
def connect():
global count
count += 1
if count < 3:
raise ConnectionError("연결 실패")
return "연결 성공"
print(connect())
@retry(times=2)
def fail():
raise ValueError("잘못된 값")
try:
fail()
except ValueError as e:
print("최종 실패 :", e)- 결과
- 1번째 시도 실패 : ConnectionError(‘연결 실패’)
2번째 시도 실패 : ConnectionError(‘연결 실패’)
연결 성공
1번째 시도 실패 : ValueError(‘잘못된 값’)
2번째 시도 실패 : ValueError(‘잘못된 값’)
최종 실패 : 잘못된 값
네트워크 연결처럼 일시적으로 실패할 수 있는 작업을 지정한 횟수만큼 다시 시도하는 데코레이터입니다.
times는 최대 시도 횟수, exceptions는 재시도할 예외의 튜플, delay는 재시도 사이의 대기 시간(초)을 의미합니다.
함수가 성공하면 즉시 결과를 반환하며, exceptions에 포함된 예외가 발생하면 대기 후 다시 호출합니다.
마지막 시도까지 실패하면 raise 구문으로 마지막 예외를 다시 발생시켜 호출한 쪽에서 처리할 수 있게 합니다.
exceptions에 포함되지 않은 예외는 재시도하지 않고 즉시 전달됩니다.
결과를 나누어 보면, 앞의 세 줄은 connect()의 실행입니다. 전역 변수 count가 1, 2일 때는 ConnectionError가 발생해 실패 메시지를 출력하고 다시 시도하며, 세 번째 시도에서 연결 성공을 반환합니다.
뒤의 세 줄은 fail()의 실행입니다. times=2이므로 두 번 실패한 뒤 raise로 ValueError를 다시 발생시키고, 바깥의 try-except가 이를 받아 최종 실패를 출력합니다.
{e!r}은 예외 객체를 repr() 형식으로 출력하므로, 메시지뿐 아니라 예외의 종류까지 함께 표시됩니다.
-
Tip :
except블록 안에서 인수 없이raise를 사용하면 현재 처리 중인 예외를 그대로 다시 발생시킵니다. -
Tip : 모든 예외를 재시도하면
TypeError처럼 다시 시도해도 해결되지 않는 오류까지 반복하게 됩니다.exceptions인자로 재시도할 가치가 있는 예외만 지정합니다.
정리
| 이름 | 설명 | 반환값/특징 |
|---|---|---|
| @데코레이터 | 함수 = 데코레이터(함수)의 축약 |
함수 정의 시점에 한 번 실행 |
wrapper(*args, **kwargs) |
원래 함수를 감싸는 내부 함수 | 호출할 때마다 실행, 원래 반환값을 반환해야 함 |
| functools.wraps(func) | 원래 함수의 메타데이터를 wrapper에 복사 | __name__, __doc__ 유지, __wrapped__ 추가 |
| 인자를 받는 데코레이터 | 데코레이터를 반환하는 함수 | @deco(인자)로 사용, 괄호 생략 불가 |
| 중첩 | 여러 데코레이터를 함께 적용 | 아래쪽부터 적용, 호출은 바깥쪽부터 |
| 클래스로 구현한 데코레이터 | __call__을 구현한 클래스 |
상태 저장에 유리, 메서드에 적용 시 주의 |
| 클래스에 적용하는 데코레이터 | 클래스를 받아 클래스를 반환 | 등록·속성 추가, return cls 필수 |
| functools.cache | 크기 제한 없는 결과 캐시 | 인수는 해시 가능해야 함 (Python 3.9 이상) |
| functools.lru_cache | 최대 개수가 있는 결과 캐시 | 가장 오래 사용하지 않은 결과부터 삭제 |
| property / staticmethod / classmethod | 클래스 메서드의 동작을 바꾸는 내장 데코레이터 | 속성 접근 / 인수 없음 / cls 전달 |
데코레이터는 함수가 일급 객체이고 클로저가 값을 기억한다는 두 성질 위에서 동작합니다.
데코레이터를 작성할 때에는 wrapper를 반환할 것(호출하지 않을 것), 원래 함수의 결과를 반환할 것, @wraps(func)를 적용할 것의 세 가지를 확인하면 대부분의 실수를 피할 수 있습니다.
댓글 남기기