1. 파이썬의 데코레이터
데코레이터는 PEP-318에서 함수와 메서드의 기능을 쉽게 수정하기 위한 수단으로 소개되었다.
데코레이터는 데코레이터 이후에 나오는 것을 데코레이터의 첫 번재 파라미터로 하고 데코레이터의 결과 값을 반환하게 하는 syntax sugar 일 뿐이다.
syntax sugar 는 동일한 기능이지만 타이밍의 수고를 덜어주기 위해 또는 읽기 쉽게 하기 위해 다른 표현으로 코딩할 수 있게 해주는 기능을 의미한다.
1-1. 함수 데코레이터
파이썬에서 데코레이터를 사용하여 기능을 변경하는 가장 간단한 방법은 함수에 적용하는 것이다. 파라미터의 유효성 검사를 하거나 사전조건을 검사하거나, 기능 전체를 새롭게 정의할 수도 있다.
예를 들어 다음과 같이 도메인의 특정 예외에 대해서 특정 횟수만큼 재시도하는 데코레이터를 만들어 볼 수 있다.
class ControlledException(Exception):
"""도메인에서 발생하는 일반적인 예외"""
def retry(operation):
@wraps(operation)
def wrapped(*args, **kwargs):
last_raised = None
RETRIES_LIMIT = 3
for _ in range(RETRIES_LIMIT):
try:
return operation(*args, **kwargs)
except ControlledException as e:
logger.info("retrying %s", operation.__qualname__)
last_raised = e
raise last_raised
return wrapped
retry 데코레이터는 파라미터가 필요 없으므로 어떤 함수에도 쉽게 적용할 수 있다.
@retry
def run_operation(task):
"""실행 중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
return task.run()
@retry 는 실제로 파이썬에서 run_operation = retry(run_operation)을 실행하게 해주는 syntax sugar 일 뿐이다.
1-2. 클래스 데코레이터
함수에서처럼 클래스에도 데코레이터를 사용할 수 있다. 차이점은 데코레이터 함수의 파라미터로 클래스를 받는다는 점이다.
클래스 데코레이터의 장점은 아래와 같다.
- 클래스 데코레이터는 코드 재사용과 DRY 원칙의 모든 이점을 공유한다. 클래스 데코레이터를 통해 여러 클래스가 특정 인터페이스나 기준을 따르도록 강제할 수 있다. 여러 클래스에 적용할 검사를 데코레이터에서 한 번만 하면 된다.
- 작고 간단한 클래스를 생성하고 나중에 데코레이터로 기능을 보강할 수 있다.
- 어떤 클래스에 대해서는 유지보수 시 데코레이터를 사용해 기존 로직을 쉽게 변경할 수 있다. 메타클래스와 같은 방법으로 복잡하게 만드는 것은 주로 권장되지 않는다.
데코레이터가 유용하게 사용될 수 있는 예제를 살펴본다.
모니터링 플랫폼을 위한 이벤트 시스템이 있으며 각 이벤트의 데이터를 변환하여 외부 시스템으로 보내야 한다. 그러나 각 이벤트 유형은 데이터 전송 방법에 특별한 점이 있을 수 있다.
특히 로그인 이벤트에는 자격 증명과 같은 중요한 정보를 숨겨야 한다. timestamp 와 같은 필드는 특별한 포맷으로 표시하기 때문에 변환이 필요할 수도 있다. 이러한 요구 사항을 준수하기 위한 가장 간단한 방법은 각 이벤트마다 직렬화 방법을 정의한 클래스를 만드는 것이다.
class LoginEventSerializer:
def __init__(self, event):
self.event = event
def serialize(self) -> dict:
return {
"username": self.event.username,
"password": "**민감한 정보 삭제**",
"ip": self.event.ip,
"timestamp": self.event.timestamp.strftime("%Y-%m-%d %H:%M"),
}
class LoginEvent:
SERIALIZER = LoginEventSerializer
def __init__(self, username, password, ip, timestamp):
self.username = username
self.password = password
self.ip = ip
self.timestamp = timestamp
def serialize(self) -> dict:
return self.SERIALIZER(self).serialize()
여기서는 로그인 이벤트에 직접 매핑할 클래스를 선언했다. 이 클래스는 password 필드를 숨기고, timestamp 필드를 포매팅하는 기능이 들어있다.
작동에 문제가 없어보이지만 시스템을 확장할수록 다음과 같은 문제가 발생한다.
- 클래스가 너무 많아진다 : 이벤트 클래스와 직렬화 클래스가 1 대 1로 매핑되어 있으므로 직렬화 클래스가 점점 많이지게 된다.
- 유연하지 않다 : 만약 password를 가진 다른 클래스에서도 이 필드를 숨기려면 함수로 분리한 다음 여러 클래스에서 호출해야 한다.
- 표준화 : serialize() 메서드는 모든 이벤트 클래스에 있어야만 한다. 비록 믹스인을 사용해 다른 클래스로 분리할 수 있지만 상속을 제대로 사용했다고 볼 수 없다.
또 다른 방법은 이벤트 인스턴스와 변형 함수를 필터로 받아서 동적으로 객체를 만드는 것이다. 필터를 이벤트 인스턴스의 필드들에 적용해 직렬화하는 것이다. 각 필드를 변형할 함수를 만든 다음 이들을 조합해 직렬화 객체를 만들면 된다.
def hide_field(field) -> str:
return "**민감한 정보 삭제**"
def format_time(field_timestamp: datetime) -> str:
return field_timestamp.strftime("%Y-%m-%d %H:%M")
def show_original(event_field):
return event_field
class EventSerializer:
"""각 필드에 적용할 함수의 정의와 속성을 기준으로 Event 객체에 변형을 적용"""
def __init__(self, serialization_fields: dict) -> None:
self.serialization_fields = serialization_fields
def serialize(self, event) -> dict:
return {
field: transformation(getattr(event, field))
for field, transformation in self.serialization_fields.items()
}
class Serialization:
"""인스턴스의 형태를 변형시키는 클래스 데코레이터"""
def __init__(self, **transformations):
self.serializer = EventSerializer(transformations)
def __call__(self, event_class):
def serialize_method(event_instance):
return self.serializer.serialize(event_instance)
event_class.serialize = serialize_method
return event_class
@Serialization(
username=str.lower,
password=hide_field,
ip=show_original,
timestamp=format_time,
)
class LoginEvent:
def __init__(self, username, password, ip, timestamp):
self.username = username
self.password = password
self.ip = ip
self.timestamp = timestamp
데코레이터를 사용하면 다른 클래스의 코드를 확인하지 않고도 각 필드가 어떻게 처리되는지 알 수 있다. 클래스 데코레이터에 전달된 인수를 읽는 것만으로도 username과 ip는 수정되지 않고, password 필드는 숨겨지고, timestamp 는 포매팅된다는 것을 알 수 있다.
파이썬 3.7 이상의 버전에서 데코레이터를 사용하면 init 함수의 템플릿화된 단순 코드를 작성하지 않고도 다음과 같이 간단하게 작성할 수 있다.
from dataclasses import dataclass
@Serialization(
username=show_original,
password=hide_field,
ip=show_original,
timestamp=format_time,
)
@dataclass
class LoginEvent:
username: str
password: str
ip: str
timestamp: datetime
1-3. 다른 유형의 데코레이터
제너레이터나 코루틴, 심지어 이미 데코레이트된 객체도 데코레이트 가능하다. 데코레이터는 스택 형태로 쌓일 수 있다.
앞선 예제에서 먼저 클래스를 정의하고 @dataclass 를 적용하여 속성의 컨테이너 역할을 하는 데이터 클래스로 변환한다. 그런 다음 @Serialization 에서 serialize() 메서드가 추가된 새로운 클래스를 반환한다.
데코레이터의 또 다른 좋은 사용 예는 코루틴으로 사용되는 제너레이터이다. 주요 아이디어는 새로 생성된 제너레이터에 데이터를 보내기 전에 next() 를 호출하여 다음 yield 문으로 넘어가야 한다는 것이다. 이런 경우 제너레이터를 파라미터로 받아 next() 를 호출한 다음 다시 제너레이터를 반환하는 데코레이터를 만들면 쉽게 해결된다.
해당 내용은 7장에서 더 깊게 알아본다.
1-4. 데코레이터에 인자 전달
# 중첩 함수의 데코레이터
크게 보면 데코레이터는 함수를 파라미터로 받아서 함수를 반환하는 함수이다. 이런 함수를 고차 함수(higher-order function) 라고 부른다. 실제로는 데코레이터의 본문에 정의된 함수가 호출된다.
데코레이터를 파라미터에 전달하려면 다른 수준의 간접 참조가 필요하다. 첫 번째 함수는 파라미터를 받아서 내부 함수에 전달한다. 두 번째 함수는 데코레이터가 될 함수이다. 세 번째는 데코레이팅의 결과를 반환하는 함수이다. 즉 최소 세 단계의 중첩 함수가 필요하다는 뜻이다.
앞서 봤던 재시도 데코레이터를 인스턴스마다 재시도 횟수를 지정할 수 있도록 수정해본다.
코드는 다음과 같은 형태가 된다.
@retry(arg1, arg2, ...)
@ 구문은 데코레이팅 객체에 대한 연산 결과를 반환하는 것이기 때문에 위의 코드는 의미상 다음과 같다.
<original_function> = retry(arg1, arg2, ...)(<original_function>)
RETRIES_LIMIT = 3
def with_retry(retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
allowed_exceptions = allowed_exceptions or (ControlledException,)
def retry(operation):
@wraps(operation)
def wrapped(*args, **kwargs):
last_raised = None
for _ in range(retries_limit):
try:
return operation(*args, **kwargs)
except allowed_exceptions as e:
logger.warning(
"retrying %s due to %s", operation.__qualname__, e
)
last_raised = e
raise last_raised
return wrapped
return retry
다음은 이 데코레이터를 함수에 적용한 예이다.
@with_retry()
def run_operation(task):
return task.run()
@with_retry(retries_limit=5)
def run_with_custom_retries_limit(task):
return task.run()
@with_retry(allowed_exceptions=(AttributeError,))
def run_with_custom_exceptions(task):
return task.run()
@with_retry(
retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError)
)
def run_with_custom_parameters(task):
return task.run()
# 데코레이터 객체
앞의 예제에서는 세 단계의 중첩된 함수가 필요하다. 첫 번째는 데코레이터의 파라미터를 받는 함수이다. 함수 내부의 다른 함수는 이렇게 전달된 파라머티를 로직에서 사용하는 클로저이다.
이것을 보다 깔끔하게 구현하기 위해 클래스를 사용하여 데코레이터를 정의할 수 있다. 이 경우 __init__ 메서드에 파라미터를 전달한 다음 __call__ 이라는 매직 메서드에서 데코레이터의 로직을 구현하면 된다.
RETRIES_LIMIT = 3
class WithRetry:
def __init__(self, retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
self.retries_limit = retries_limit
self.allowed_exceptions = allowed_exceptions or (ControlledException,)
def __call__(self, operation):
@wraps(operation)
def wrapped(*args, **kwargs):
last_raised = None
for _ in range(self.retries_limit):
try:
return operation(*args, **kwargs)
except self.allowed_exceptions as e:
logger.info(
"retrying %s due to %s", operation.__qualname__, e
)
last_raised = e
raise last_raised
return wrapped
사용 방법은 이전과 거의 유사하다.
@WithRetry(retries_limit=5)
def run_with_custom_retries_limit(task):
return task.run()
여기서 파이썬 구문이 어떻게 처리되는지 이해하는 것이 중요하다. 먼저 @ 연산 전에 전달된 파라미터를 사용해 데코레이터 객체를 생성한다. 데코레이터 객체는 __init__ 메서드에서 정해진 로직에 따라 초기화를 진행한다. 그 다음 @ 연산이 호출된다. 데코레이터 객체는 run_with_custom_reries_limit 함수를 래핑하여 __call__ 매직 메서드를 호출한다.
__call__ 매직 메서드는 앞의 데코레이터에서 하던 것처럼 원본 함수를 래핑하여 우리가 원하는 로직이 적용된 새로운 함수를 반환한다.
1-5. 데코레이터 활용 우수 사례
- 파라미터 변환 : 함수의 서명을 변경하는 경우, 파라미터가 어떻게 처리되고 변환되는지를 캡슐화하여 숨길 수 있다.
- 코드 추적 : 파라미터와 함께 함수의 실행을 로깅하려는 경우
- 파라미터 유효성 검사
- 재시도 로직 구현
- 일부 반복 작업을 데코레이터로 이동하여 클래스 단순화
# 파라미터 변환
일반적으로 파라미터를 다룰 때 데코레이터를 많이 사용하게 된다. 특히 유사한 객체를 반복적으로 생성하거나 추상화를 위해 유사한 변형을 반복하는 경우에 데코레이터를 만들어 쉽게 처리할 수 있다.
# 코드 추적
- 실제 함수의 실행 경로 추적(예를 들어 실행 함수 로깅)
- 함수 지표 모니터링(예를 들어 CPU 사용률이나 메모리 사용량 등)
- 함수의 실행 시간 측정
- 언제 함수가 실행되고 전달된 파라미터는 무엇인지 로깅
추적이란 위와 같은 시나리오에서 사용하려는 것으로, 모니터링 하고자하는 함수의 실행과 관련된 것이다.
2. 데코레이터의 활용 - 흔한 실수 피하기
2-1. 래핑된 원본 객체의 데이터 보존
데코레이터를 함수에 적용할 때 가장 많이 실수하는 것 중에 하나는 원본 함수의 일부 프로퍼티나 속성을 유지하지 않아 원하지 않는 부작용을 유발한다는 것이다.
from log import logger
def trace_decorator(function):
def wrapped(*args, **kwargs):
logger.info("running %s", function.__qualname__)
return function(*args, **kwargs)
return wrapped
@trace_decorator
def process_account(account_id):
"""id별 계정 처리"""
logger.info("processing account %s", account_id)
...
데코레이터는 원래 함수의 어떤 것도 변경하지 않아야 하지만 코드에 결함이 있어서 이름이나 docstring을 변경하는 경우가 있다.
이 함수의 help를 보면 아래와 같다.
>>> help(process_account)
Help on function wrapped in module __main__:
wrapped(*args, **kwargs)
그리고 실제 호출이 어떻게 되는지 살펴보면 아래와 같다.
>>> print(process_account.__qualname__)
trace_decorator.<locals>.wrapped
데코레이터가 실제로 원본 함수를 wrapped라 불리는 새로운 함수로 변경했기 때문에 원본 함수의 이름이 아닌 새로운 함수의 이름을 출력하게 된다.
만약 이 데코레이터를 다른 여러 함수에 적용하더라도 wrapped 라는 이름만 출력하게 된다. 이렇게 되면 개별 함수를 확인하고 싶은 경우에 실제 실행된 함수를 알 수 없으므로 오히려 디버깅이 더 어려워지는 문제가 발생한다.
또 다른 문제는 docsting 을 작성한 경우 데코레이터에 의해 덮어써진다는 점이다.
수정은 간단하다. 래핑된 함수, 즉 wrapped 함수에 @wraps 데코레이터를 적용하여 실제로는 function 파라미터 함수를 래핑한 것이라고 알려주는 것이다.
def trace_decorator(function):
"""함수가 호출되었을 때의 로그"""
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("running %s", function.__qualname__)
return function(*args, **kwargs)
return wrapped
help 함수 결과와 __qualname__ 은 아래와 같다.
>>> help(process_account)
Help on function process_account in module __main__:
process_account(account_id)
id별 계정 처리
>>> print(process_account.__qualname__)
process_account
wraps 데코레이터를 사용하면 __wrapped__ 속성을 통해 수정되지 않은 원본에도 접근할 수 있다.
일반적인 데코레이터의 경우 아래와 같은 구조에 따라 functools.wraps 를 추가하면 된다.
def decorator(original_function):
@wraps(original_function)
def decorated_function(*args, **kwargs):
# 데코레이터에 의한 수정 작업 ...
return original_function(*args, **kwargs)
return decorated_function
데코레이터를 만들 때는 앞의 예제에서와 같이 항상 래핑된 함수 위에 functools.wraps 를 사용한다.
2-2. 데코레이터 부작용 처리
데코레이터 함수가 되기 위해 필요한 하나의 조건은 가장 안쪽에 정의된 함수여야 한다는 것이다. 그렇지 않으면 임포트에 문제가 발생할 수 있다. 그럼에도 불구하고 임포트 시에 실행하기 위해 이러한 부작용이 필요한 경우도 그 반대의 경우도 있다.
# 데코레이터 부작용의 잘못된 처리
함수 실행과 실행 시간을 로깅하는 데코레이터가 있다.
def traced_function_wrong(function):
"""잘못 정의된 데코레이터 예제"""
logger.info("%s 함수 실행", function)
start_time = time.time()
@wraps(function)
def wrapped(*args, **kwargs):
result = function(*args, **kwargs)
logger.info(
"함수 %s의 실행시간: %.2fs", function, time.time() - start_time
)
return result
return wrapped
일반 함수에 위 데코레이터를 적용하면 문제없이 동작한다.
@traced_function_wrong
def process_with_delay(callback, delay=0):
time.sleep(delay)
return callback()
그러나 중요한 버그가 하나 있다.
먼저 함수를 여러 번 임포트할 경우 어떤 일이 발생하는지 보자.
>>> from decorator_side_effect_1 import process_with_delay
INFO:<function process_with_delay at 0x...> 함수 실행
함수가 호출되지 않았음에도 로그가 남는다.
이제 함수를 실행하고 실행하는 데 걸리는 시간을 확인하면 아래와 같다.
>>> if __name__ == "__main__":
process_with_delay("test", delay=1)
process_with_delay("test", delay=1)
process_with_delay("test", delay=1)
process_with_delay("test", delay=1)
INFO: <function process_with_delay at 0x104c077a0> 함수 실행
INFO: 함수 <function process_with_delay at 0x104c077a0>의 실행시간: 1.00s
INFO: 함수 <function process_with_delay at 0x104c077a0>의 실행시간: 2.01s
INFO: 함수 <function process_with_delay at 0x104c077a0>의 실행시간: 3.01s
INFO: 함수 <function process_with_delay at 0x104c077a0>의 실행시간: 4.01s
동일한 기능을 여러 번 호출했는데 실행할 때마다 실행시간이 늘어난다.
@traced_function_wrong 은 실제로 다음을 의미한다.
process_with_delay = traced_function_wrong(process_with_delay)
이 문장은 모듈을 임포트할 때 실행된다. 따라서 함수에 설정된 start_time 은 모듈을 처음 임포트할 때의 시간이 된다. 함수를 연속적으로 호출하면 함수의 실행시간으로 최초 시작 시점과 시간차를 계산한다. 또한 함수가 실제로 호출될 때가 아니라 잘못된 시점에 기록된다.
수정은 매우 간단하다. 래핑된 함수 내부로 코드를 이동하면 된다.
def traced_function(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("%s 함수 실행", function)
start_time = time.time()
result = function(*args, **kwargs)
logger.info(
"함수 %s의 실행시간: %.2fs", function.__qualname__, time.time() - start_time
)
return result
return wrapped
INFO: <function call_with_delay at 0x107e68dd0> 함수 실행
INFO: 함수 call_with_delay의 실행시간: 1.00s
INFO: <function call_with_delay at 0x107e68dd0> 함수 실행
INFO: 함수 call_with_delay의 실행시간: 1.00s
INFO: <function call_with_delay at 0x107e68dd0> 함수 실행
INFO: 함수 call_with_delay의 실행시간: 1.00s
INFO: <function call_with_delay at 0x107e68dd0> 함수 실행
INFO: 함수 call_with_delay의 실행시간: 1.00s
# 데코레이터 부작용의 활용
때로는 이러한 부작용을 의도적으로 사용하여 실제 실행이 가능한 시점까지 기다리지 않는 경우도 있다.
대표적인 예로 모듈의 공용 레지스트리에 객체를 등록하는 경우가 있다.
예를 들어 이전 이벤트 시스템에서 일부 이벤트만을 사용하려는 경우를 살펴보면, 이벤트 계층 구조의 중간에 가상의 클래스를 만들고 일부 파생 클래스에 대해서만 이벤트를 처리하도록 할 수 있다.
각 클래스마다 처리 여부에 플래그 표시를 하는 대신에 데코레이터를 사용해 명시적으로 표시할 수 있다.
EVENTS_REGISTRY = {}
def register_event(event_cls):
"""모듈에서 접근 가능하도록 이벤트 클래스를 레지스트리에 등록"""
EVENTS_REGISTRY[event_cls.__name__] = event_cls
return event_cls
class Event:
"""기본 이벤트 객체"""
class UserEvent:
TYPE = "user"
@register_event
class UserLoginEvent(UserEvent):
"""사용자가 시스템에 접근했을 때 발생하는 이벤트"""
@register_event
class UserLogoutEvent(UserEvent):
"""사용자가 시스템에서 나갈 때 발생하는 이벤트"""
위 코드를 보면 처음에 EVENT_REGISTRY 는 비어 있는 것처럼 보이지만 이 모듈의 일부를 임포트하면 register_event 데코레이터로 지정한 클래스로 채워지게 된다.
코드만 봐서는 오해할 수 있다. EVENT_REGISTRY는 런타임 중에 모듈을 임포트한 직후에야 최종 값을 가지므로 코드만 봐서는 쉽게 예측하기 어렵다.
그러나 어떤 경우에는 이 패턴이 필요한 경우가 있다. 많은 웹 프레임워크나 널리 알려진 라이브러리들은 이 원리로 객체를 노출하거나 활용하고 있다.
2-3. 어느 곳에서나 동작하는 데코레이터 만들기
보통 데코레이터를 만들면 장식하고 싶은 첫 번째 유형의 객체만을 지원하려고 생각하게 된다. 그러나 같은 데코레이터를 다른 유형에 적용하려고 하면 오류가 발생한다는 것을 알 수 있다. 전형적인 예로 함수에 사용될 데코레이터를 클래스의 메서드에 적용하려는 경우이다.
데코레이터를 만들 때는 일반적으로 재사용을 고려하여 함수 뿐만 아니라 메서드에서도 동작하길 바란다.
*args, **kwargs 를 사용하여 데코레이터를 정의하면 모든 경우에 사용할 수 있다. 그러나 다음 두 가지 이유로 원래 함수의 서명과 비슷하게 데코레이터를 정의하는 것이 좋을 때가 있다.
- 원래의 함수와 모양이 비슷하기 때문에 읽기 쉽다.
- 파라미터를 받아서 뭔가를 하려면 오히려 *args, **kwargs 를 사용하는 것이 불편하다.
파라미터를 받아서 특정 객체를 생성하는 경우가 많다고 가정해본다. 예를 들어 문자열을 받아서 빈번히 드라이버 객체를 초기화하는 경우이다. 이런 경우 파라미터를 변환해주는 데코레이터를 만들어 중복을 제거할 수 있다.
class DBDriver:
def __init__(self, dbstring):
self.dbstring = dbstring
def execute(self, query):
return f"query {query} at {self.dbstring}"
def inject_db_driver(function):
"""데이터베이스 dsn 문자열을 받아서 DBDriver 인스턴스를 생성하는 데코레이터"""
@wraps(function)
def wrapped(dbstring):
return function(DBDriver(dbstring))
return wrapped
@inject_db_driver
def run_query(driver):
return driver.execute("test_function")
위 예시에서 inject_db_driver 메서드는 DB 정보 문자열을 받아서 DBDriver 인스턴스를 생성한다. 데코레이터는 이러한 변환을 자동화하여 문자열을 받아 DBDriver 를 생성하고 함수에 전달한다. 따라서 마치 객체를 직접 받은 것처럼 가정할 수 있다.
>>> run_query("test_ok")
'query test_function at test_ok'
함수에 문자열을 전달하면 위와 같이 인스턴스로 자동 변환되어 동작한다.
하지만 이제 같은 기능을 하는 데코레이터를 클래스 메서드에서 재사용하면 아래와 같이 동작하지 않는다.
class DataHandler:
"""데코레이터가 동작하지 않음"""
@inject_db_driver
def run_query(self, driver):
return driver.execute(self.__class__.__name__)
>>> DataHandler().run_query("test_ok")
Traceback (most recent call last):
...
TypeError: wrapped() takes 1 positional argument but 2 were given
클래스의 메서드에는 self 라는 첫 번째 파라미터가 있다. 따라서 단 하나의 파라미터만 받는 데코레이터에 self를 전달하고 두 번째 파라미터를 전달하지 않아서 에러가 발생한다.
이 문제를 해결하려면 메서드와 함수에 대해서 동일하게 동작하는 데코레이터를 만들어야 한다. 디스크립터 프로토콜을 구현한 데코레이터 객체를 만든다.
class inject_db_driver:
"""문자열을 DBDriver 인스턴스로 반환하여 래핑된 함수에 전달"""
def __init__(self, function):
self.function = function
wraps(self.function)(self)
def __call__(self, dbstring):
return self.function(DBDriver(dbstring))
def __get__(self, instance, owner):
if instance is None:
return self
return self.__class__(MethodType(self.function, instance))
3. 데코레이터와 DRY 원칙
데코레이터의 가장 큰 장점은 여러 객체에 데코레이터를 적용하여 코드를 재사용할 수 있다는 것이다. 이는 특정 기능을 한 번만 정의하기 때문에 DRY(Don't Repeat Yourself) 원칙을 잘 따른다.
단, 코드 재사용을 위해 데코레이터를 사용할 때 실질적으로 코드 사용량을 줄일 수 있다는 확실한 믿음이 있어야 한다.
신중하게 설계되지 않은 데코레이터는 코드의 복잡성을 증가시킨다. 어떤 사용자는 함수의 논리를 완전히 이해하기 위해 데코레이터의 경로를 따라 가볼 수도 있다. 따라서 복잡성이 가치가 있어야 한다.
재사용이 많다는 기준과 기존 코드를 데코레이터로 리팩토링할지 결정하는 기준은 무엇일까?
특별한 기준은 없지만 소프트웨어 공학에서 일반적으로 적용되는 원칙을 따를 수 있다. 컴포넌트가 충분히 재사용 가능한 추상화를 했다고 인정받기 위해서는 적어도 3가지 이상의 애플리케이션에서 시험해봐야 한다는 것이다. 또한 재사용 가능한 컴포넌트를 만드는 것은 일반 컴포넌트를 만드는 것보다 세 배나 더 어렵다는 내용이 있다. (Facts and Fallacies of Software Engineering - "우리가 미처 알지 못한 소프트웨어 공학의 사실과 오해" 참고)
결론은 다음과 같은 사항을 고려했을 경우에만 데코레이터 사용을 권한다는 것이다.
- 처음부터 데코레이터를 만들지 않는다. 패턴이 생기고 데코레이터에 대한 추상화가 명확해지면 그 때 리팩토링을 한다.
- 데코레이터가 적어도 3회 이상 필요한 경우에만 구현한다.
- 데코레이터 코드를 최소한으로 유지한다.
4. 데코레이터와 관심사 분리
코드 재사용의 핵심은 응집력 있는 컴포넌트를 만드는 것이다. 컴포넌트가 작을 수록 재사용성이 높아진다. 그리고 결합과 종속성을 유발하고 소프트웨어의 유연성을 떨어뜨리는 추가 동작이 필요 없이 여러 상황에서 쓰일 수 있다.
def traced_function(function):
@functools.wraps(function)
def wrapped(*args, **kwargs):
logger.info("started execution of %s", function.__qualname__)
start_time = time.time()
result = function(*args, **kwargs)
logger.info(
"function %s took %.2fs",
function.__qualname__,
time.time() - start_time,
)
return result
return wrapped
위의 예시에서 데코레이터는 동작에 문제가 있다. 하나 이상의 작업을 수행하고 있다. 특정 함수가 호출된 시간을 기록하고 실행 소요 시간도 기록한다. 오직 한 가지만 원하는 경우에도 두 가지 책임을 수행하고 있다.
이것을 좀 더 구체적이고 제한적인 책임을 지닌 더 작은 데코레이터로 분류해야 한다.
def log_execution(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("started execution of %s", function.__qualname__)
return function(*kwargs, **kwargs)
return wrapped
def measure_time(function):
@wraps(function)
def wrapped(*args, **kwargs):
start_time = time.time()
result = function(*args, **kwargs)
logger.info(
"function %s took %.2f",
function.__qualname__,
time.time() - start_time,
)
return result
return wrapped
동일한 기능을 다음과 같이 조합하여 수행할 수 있다.
@measure_time
@log_execution
def operation():
...
데코레이터에 하나 이상의 책임을 두면 안된다. SRP 는 데코레이터에도 적용된다.
5. 좋은 데코레이터 분석
좋은 데코레이터가 갖추어야 할 특성을 분석해보면 아래와 같이 정리할 수 있다.
- 캡슐화와 관심사 분리 : 좋은 데코레이터는 실제로 하는 일과 데코레이팅하는 일의 책임을 명확히 구분해야 한다. 어설프게 추상화를 하면 안된다. 즉 데코레이터의 클라이언트는 내부에서 어떻게 구현했는지 전혀 알 수 없는 블랙박스 모드로 동작해야 한다.
- 독립성 : 데코레이터가 하는 일은 독립적이어야 하며 데코레이팅되는 객체와 최대한 분리되어야 한다.
- 재사용성 : 데코레이터는 하나의 함수 인스턴스에만 적용되는 것이 아니라 여러 유형에 적용 가능한 형태가 바람직하다.
좋은 데코레이터의 사용 예시는 Celery 프로젝트에서 볼 수 있다. Celery 프로젝트에서는 app.task 데코레이터를 함수에 적용하여 작업을 정의한다.
@app.task
def mytask():
...
이것이 좋은 데코레이터인 이유 중 하나는 캡슐화가 잘 되어 있기 때문이다. 라이브러리 사용자는 함수 본문을 정의하기만 하면 데코레이터가 이를 자동으로 작업으로 변환한다. "@app.task" 데코레이터는 많은 로직과 코드를 래핑하지만 아무 것도 "mytask()" 의 본문과 관련이 없다. 완벽한 캡슐화와 관심사의 분리이다. 아무도 그 데코레이터가 하는 일을 살펴볼 필요가 없으므로 어떤 세부 사항도 누설하지 않는 정확한 추상화이다.
데코레이터의 또 다른 일반적인 사용 예는 웹 프레임워크(Flask, Pyramid 등)이다. 뷰 핸들러는 데코레이터를 통해 URL 로 등록된다.
@route("/", method=["GET"])
def view_handler(request):
...
좋은 데코레이터는 깔끔한 인터페이스를 제공하고 사용자가 내부 동작 원리를 자세히 몰라도 기대하는 바를 정확히 알 수 있게 해준다.
'python' 카테고리의 다른 글
파이썬 클린 코드 - 7장 (제너레이터) (0) | 2022.03.10 |
---|---|
파이썬 클린 코드 - 6장 (디스크립터로 더 멋진 객체 만들기) (0) | 2022.03.03 |
파이썬 클린 코드 - 4장 (SOLID 원칙) (0) | 2022.02.09 |
파이썬 클린 코드 - 3장 (좋은 코드의 일반적인 특징) (0) | 2022.02.03 |
파이썬 클린 코드 - 2장 (Pythonic code) (0) | 2022.01.27 |