마리아노 아나야의 파이썬 클린코드를 읽고 정리한 포스트입니다.
주제
- 파이썬에서 데코레이터가 동작하는 방식을 이해한다.
- 함수와 클래스에 적용되는 데코레이터를 구현하는 방법을 배운다.
- 일반적인 실수를 피하여 데코레이터를 효과적으로 구현하는 방법을 배운다.
- 데코레이터를 활용한 코드 중복을 회피(DRY 원칙 준수)
- 데코레이터를 활용한 관심사의 분리
- 좋은 데코레이터 사례
- 데코레이터가 좋은 선택이 될 수 있는 일반적인 상황, 관용구, 패턴
파이썬의 데코레이터
파이썬에서 데코레이터는 오래전에 PEP-318에서 기존 함수와 메서드의 기능을 쉽게 수정하기 위한 수단으로 소개되었다.
먼저 파이썬에서 함수는 일반적인 객체일 뿐이라는 것을 이해해야 한다. 즉, 변수에 할당하거나 파라미터로 전달하거나 또는 심지어 다른 함수에서 다시 기존 함수를 호출하도록 할 수 있다. 함수를 다시 호출하는 것은 보통 작은 함수를 만들고 해당 함수에 어떤 변환을 거쳐 수정된 새로운 형태의 함수를 반환하는 순서로 이뤄진다. 수학에서 g(f(x))처럼 합성함수의 동작 방식과 유사하다.
데코레이터를 개발하기 전에는 classmethod나 staticmethod와 같은 함수를 사용해서 기존 메서드의 정의를 변형하고 있었는데, 이 방법은 추가적인 코드가 필요하고, 기존 함수의 정의를 별도 문장에서 변경해야 하는 불편함이 있었다.
다시 말해서 함수를 변형하고 싶을 때마다 변형을 담당하는 modifier 함수(역주: modifier는 표준 라이브러리 함수가 아니고 기존 함수를 수정한다는 의미로 이름을 붙인 가상의 사용자 정의 함수이다)를 호출하고, 기존 함수와 같은 이름으로 변환 결과를 다시 저장해야 했다.
예를 들어 original이라는 함수가 있고, modifier라는 사용자 정의 변환 함수가 있다고 하면, 다음과 같이 원본 함수를 변경할 수 있다.
def original(...):
...
original = modifier(original)
함수를 동일한 이름으로 다시 할당하는 것에 주의하자. 이것은 혼란스럽고 오류가 발생하기 쉽고 번거롭다(함수를 재할당하는 것을 잊어버리거나 함수 정의가 멀리 떨어져 있는 경우). 이러한 이유로 새로운 구문이 추가되었다.
앞의 예제는 다음과 같이 작성하면 된다.
@modifier
def original(...):
...
즉 데코레이터는 데코레이터 이후에 나오는 것을 데코레이터의 첫 번째 파라미터로 하고 데코레이터의 결과 값을 반환하게 하는 문법적 설탕(syntax sugar)일 뿐이다. (역주: 어떤 언어에서 동일한 기능이지만 타이핑의 수고를 덜어주기 위해 또는 읽기 쉽게 하기 위해 다른 표현으로 코딩할 수 있게 해주는 기능을 Syntax Sugar 또는 Syntactic Sugar라고 한다.)
데코레이터 구문은 가독성을 크게 향상시킨다. 왜냐하면 이제 독자는 한 곳에서 함수의 전체 정의를 찾을 수 있기 때문이다. 물론 이전과 같이 수동으로 기능을 수정하는 방법도 여전히 계속 사용할 수 있다.
TIP: 일반적으로 데코레이터 구문을 사용하지 않고 기존 함수를 다시 할당하는 방식은 피하도록 하자. 특히, 함수를 재할당하는 코드가 원래 함수가 정의된 곳에서 멀리 떨어진 경우 코드를 읽기가 어려워진다.
이번 예제에서 말하는 modifier는 파이썬 용어로 데코레이터
라고 하고, original을 데코레이팅된(decorated) 함수 또는 래핑된(wrapped) 객체
라고 한다.
원래는 함수와 메서드를 위해 고안되었지만 실제로는 어떤 종류의 객체에도 적용이 가능하기 때문에 여기서는 함수와 메서드, 제너레이터, 클래스에 데코레이터를 적용하는 방법을 살펴본다.
한 가지 주의할 점은 데코레이터라는 이름은 래핑된 함수의 기능을 수정하고 확장하기 때문에 정확한 이름이지만 “데코레이터 디자인 패턴”과 혼용하면 안 된다.
함수 데코레이터
파이썬에서 데코레이터를 사용하여 기능을 변경하는 가장 간단한 방법은 함수에 적용하는 것이다. 함수에 데코레이터를 사용하면 어떤 종류의 로직이라도 적용할 수 있다. 파라미터의 유효성을 검사하거나 사전조건을 검사하거나, 기능 전체를 새롭게 정의할 수도 있고, 서명을 변경할수도 있고, 원래 함수의 결과를 캐시하는 등의 작업을 모두 할 수 있다.
예를 들어 다음과 같이 도메인의 특정 예외에 대해서 특정 횟수만큼 재시도하는 데코레이터를 만들어 볼 수 있다.
# decorator_function_1.py
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("%s 재시도", operation.__qualname__)
last_raised = e
raise last_raised
return wrapped
@wraps를 사용한 부분은 지금은 무시해도 된다. ‘데코레이터의 활용 ~ 흔한 실수 피하기’ 섹션에서 다시 다룰 예졍이다.
TIP: for 루프에서 _는 (해당 변수를 반복문 내에서 사용하지 않을 것이므로) 변수의 값에 관심이 없음을 의미한다(일반적으로 파이썬에서 _는 무시하는 변수를 의미한다).
retry 데코레이터는 파라미터가 필요 없으므로 어떤 함수에도 쉽게 적용할 수 있다. 다음은 적용 예제이다.
@retry
def run_operation(task):
"""실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
return task.run()
run_operation 위에 있는 @retry는 실제로 파이썬에서 다음과 같은 코드와 동일하다.
run_operation = retry(run_operation)
클래스 데코레이터
파이썬에서는 클래스(class) 역시 객체(object)이다. 솔직히 파이썬에서는 거의 모든 것이 객체이고 그렇지 않은 경우를 찾기가 어렵다. 약간의 가술적 뉘앙스의 차이가 있을 뿐이다. 따라서 클래스에서도 객체와 동일한 것들이 적용된다. 파라미터로 전달되거나, 변수에 할당되거나, 메서드를 갖거나, 변형(decorated)될 수 있다.
클래스 데코레이터는 PE-3129에서 도입되었으며, 방금 살펴본 함수 데코레이터와 매우 유사하다. 유일한 차이점은 래퍼(wrapper)가 함수가 아니라 클래스라는 점이다.
우리는 이미 2장, “파이썬스러운 코드”에서 dataclass 모듈의 dataclass 클래스 데코레이터를 사용하는 방법을 살펴보았다. 이 장에서는 클래스 데코레이터를 직접 작성하는 방법에 대해서 알아본다.
어떤 개발자들은 클래스 데코레이터가 복잡하고 가독성을 떨어뜨릴 수 있다고 말할 수 있다. 왜냐하면 클래스에서 정의한 속성과 메서드를 데코레이터 안에서 완전히 다른 용도로 변경할 수 있기 때문이다.
데코레이터를 남용할 경우 이것 또한 사실이다. 파이썬에서 보면 함수 데코레이터와 클래스 데코레이터는 다른 타입을 사용하는 것만 다를 뿐 차이가 없다. 이 문제에 대한 장단점은 이후 ‘데코레이터와 관심사의 분리’에서 살펴보고 지금은 클래스 데코레이터의 장점에 대해 집중하자.
- 클래스 데코레이터는 코드 재사용과 DRY(Don’t Repeat Yourself) 원칙의 모든 이점을 제공한다. 클래스 데코레이터를 사용하면 여러 클래스가 특정 인터페이스나 기준을 따르도록 강제할 수 있다. 여러 클래스에 적용할 검사를 데코레이터에서 한 번만 하면 된다.
- 당장은 작고 간단한 클래스를 생성하고 나중에 데코레이터로 기능을 보강할 수 있다.
- 어떤 클래스에 대해서는 유지보수 시 데코레이터를 사용해 기존 로직을 훨씬 쉽게 변경할 수 있다. 메타클래스와 같은 방법을 사용해 보다 복잡하게 만드는 것은 일반적으로 권장되지 않는다.
데코레이터가 유용하게 사용될 수 있는 예제를 살펴보자. 이 예제가 클래스 데코레이터의 유일한 방법은 아니며 실제로는 다양한 방법이 있지만, 이 예제는 클래스 데코레이터의 장점을 보여주는 좋은 예제이다.
모니터링 플랫폼을 위한 이벤트 시스템은 각 이벤트의 데이터를 변환하여 외부 시스템으로 보내야한다. 그러나 각 이벤트 유형은 데이터 전송 방법에 특별한 점이 있을 수 있다.
특히 로그인 이벤트에는 자격 증명과 같은 중요한 정보를 숨겨야만 한다. 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"),
}
@dataclass
class LoginEvent:
SERIALIZER = LoginEventSerializer
username: str
password: str
ip: str
timestamp: datetime
def serialize(self) -> dict:
return self.SERIALIZER(self).serialize()
여기서는 로그인 이벤트에 직접 매핑을 클래스를 선언했다. 이 클래스는 password 필드를 숨기고, timestamp 필드를 포매팅하는 기능이 들어있다.
이 방법은 처음에는 잘 동작하지만, 시간이 지나면서 시스템을 확장할수록 다음과 같은 문제가 발생하게 된다.
- 클래스가 너무 많아진다: 이벤트 클래스와 직렬화 클래스가 1대 1로 매핑되어 있으므로 직렬화 클래스가 점점 많아지게 된다.
- 이러한 방법은 충분히 유연하지 않다: 만약 password를 가진 다른 클래스에서도 이 필드를 숨기려고 하면 함수로 분리한 다음 여러 클래스에서 호출해야 한다. 이는 코드를 충분히 재사용했다고 볼 수 없다.
- 표준화: serialize() 메서드는 모든 이벤트 클래스에 있어야 한다. 비록 믹스인을 사용해 다른 클래스로 분리할 수 있지만 상속을 제대로 사용했다고 볼 수 없다.
또 다른 방법은 이벤트 인스턴스와 변형 함수를 필터로 받아서 동적으로 객체를 만드는 것이다. 필터를 이벤트 인스턴스의 필드들에 적용해 직렬화하는 것이다. 각 필드를 변형할 함수를 만든 다음 이들을 조합해 직렬화 객체를 만들면 된다.
다음과 같이 클래스에 serialize() 메서드를 추가하기 위해 Serialization 객체를 활용한다.
from datetime import datetime
def hide_field(field) -> str:
return "**민감한 정보 삭제**"
def format_time(field: datetime) -> str:
return field.strftime("%Y-%m-%d %H:%M")
def show_original(event_field) -> str:
return event_field
class EventSerializer:
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=show_original,
password=hide_field,
ip=show_original,
timestamp=format_time,
)
@dataclass
class LoginEvent:
username: str
password: str
ip: str
timestamp: datetime
데코레이터를 사용하면 다른 클래스의 코드를 확인하지 않고도 각 필드가 어떻게 처리되는지 쉽게 알 수 있다. 클래스 데코레이터에 전달된 인수를 읽는 것만으로도 어떤 변형이 적용되는지 알 수 있다.
다른 유형의 데코레이터
데코레이터의 @ 구문이 실제로 무엇을 의미하는지 알았으므로 데코레이터가 단지 함수나 메서드, 클래스에만 적용되지 않는다는 것도 알 수 있다. 사실 제너레이터나 코루틴, 심지어 이미 데코레이트된 객체도 데코레이트 가능하다. 즉 데코레이터는 스택 형태로 쌓일 수 있다.
앞의 예는 데코레이터가 어떻게 연결될 수 있는지를 보여준다. 먼저 클래스를 정의하고 @dataclass를 적용하여 속성의 컨테이너 역할을 하는 데이터 클래스로 변환한다. 그런 다음 @Serialization에서 serialize() 메서드가 추가된 새로운 클래스를 반환한다.
데코레이터의 기본 사항과 작성 방법을 알았으므로 더 복잡한 예제로 넘어갈 수 있다. 다음 섹션에서는 데코레이터에 파라미터가 있는 경우 어떻게 구현하고 사용할 수 있는지 알아보자.
고급 데코레이터
지금까지 데코레이터가 무엇인지, 그 구문과 의미는 무엇인지에 대해서 살펴보았다. 이제는 코드를 보다 깔끔하게 만들기 위해 데코레이터를 어떻게 사용할 수 있는지 알아보자.
데코레이터를 사용하여 관심사를 더 작은 기능으로 분리하고 코드르르 재사용할 수 있다는 것을 알게 되었다. 그러나 이를 효율적으로 사용하려면 파라미터를 추가할 수 있어야 한다.
데코레이터에 인자 전달
파라미터를 갖는 데코레이터를 구현하는 방법은 여러 가지가 있지만 가장 일반적인 방법을 살펴볼 것이다. 첫 번째는 간접 참조(indirection)를 통해 새로운 레벨의 중첩 함수를 만들어 데코레이터의 모든 것을 한 단계 더 깊게 만드는 것이다. (역주: indirection은 a=1; b=a; c=b;처럼 실제 값을 직접적인 경로를 통해 가져오는 것이 아니라 간접적인 경로를 거친 다음에 가져오기 때문에 간접 참고라고 부른다.) 두 번째 방법은 데코레이터를 위한 클래스를 만드는 것이다(데코레이터는 호출 가능한 객체라면 무엇이든 가능하다).
일반적으로 두 번째 방법이 가독성이 좋다. 왜냐하면 세 단계 이상 중첩된 클로저 함수보다 객체가 이해하기 쉽기 때문이다. 그러나 완벽을 기하기 위해 두 가지 모두를 살펴볼 것이며 상황에 알맞은 최선의 결정을 내리면 된다.
중첩 함수를 사용한 데코레이터
크게 보면 데코레이터는 함수를 파라미터로 받아서 함수를 반환하는 함수이다. 함수형 프로그래밍(functional programming)에서 함수를 받아서 반환하는 함수를 고차 함수(higher-order function)라고 한다. 여기에서 말하는 것과 같은 개념이다. 실제로는 데코레이터 안에서 정의된 함수가 호출된다.
이제 데코레이터에 파라미터를 추가하려면 다른 수준의 간접 참조가 필요하다. 첫 번째 함수는 파라미터를 받아서 내부 함수에 전달한다. 두 번째 함수는 데코레이터가 될 함수다. 세 번째는 데코레이팅의 결과를 반환하는 함수이다. 즉 최소 세 단계의 중첩 함수가 필요하다.
앞서 살펴 본 예제는 재시도 기능을 구현했다. 재시도 횟수가 데코레이터 안에 고정되어 있다는 점을 제외하면 좋은 시도였다.
이제는 인스턴스마다 재시도 횟수를 지저앟려고 하며 파라미터에 기본 값도 추가할 것이다. 이렇게 하려면 함수를 한 단계 더 추가해야 한다. 먼저 파라미터에 대한 것과 그리고 데코레이터 자체에 대한 것이다.
코드는 다음과 같은 형태가 될 것이다.
@retry(arg1, arg2, ...)
@ 구문은 데코레이팅 객체에 대한 연산 결과를 반환하는 것이기 때문에 위의 코드는 의미상 다음과 같다.
<original_function> = retry(arg1, arg2, ...)(<original_function>)
원하는 재시도 횟수 외에도 제어하려는 예외 유형을 나타낼 수도 있다. 새 요구 사항을 반영한 새로운 코드는 다음과 같다.
_DEFAULT_RETRIES_LIMIT = 3
def with_retry(
retries_limit: int = _DEFAULT_RETRIES_LIMIT,
allowed_exceptions: Optional[Squence[Eception]] = None,
):
allowed_exceptions = allowed_exceptions or (ControlledException,) # type: ignore
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("%s 재시도, 원인: %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=5, allowed_exceptions=(ZeroDivisionError, AttributeError))
def run_with_custom_retries_limit_and_exceptions(task):
"""실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
return task.run()
파라미터를 갖는 데코레이터를 구현하려고 할 때 이와 같이 중첩 함수를 사용하여 구현하는 방법을 가장 먼저 생각해볼 수 있다. 이 방법은 대부분의 경우에 잘 작동하지만, 이미 확인한 것처럼 새로운 함수가 추가될 때마다 들여쓰기가 추가되어 너무 많은 중첩 함수가 필요할 수 있다.
또한 함수는 상태를 저장하지 않기 때문에 객체가 하는 것처럼 내부 데이터를 관리하기가 어렵다.
다음 섹션에서는 중첩 함수 대신에 객체를 사용하여 데코레이터를 구현하는 방법을 살펴본다.
데코레이터 객체
앞의 예제에서는 세 단계의 중첩된 함수가 필요하다. 첫 번째는 데코레이터의 파라미터를 받는 함수이다. 함수 내부의 다른 함수는 이렇게 전달된 파라미터를 로직에서 사용하는 클로저이다.
이것을 보다 깔끔하게 구현하기 위해 클래스를 사용하여 데코레이터를 정의할 수 있다. 이 경우 __init__
메서드에서 파라미터를 받고, __call__
메서드에서 데코레이터의 로직을 정의한다.
_DEFAULT_RETRIES_LIMIT = 3
class WithRetry:
def __init__(
self,
retries_limit: int = _DEFAULT_RETRIES_LIMIT,
allowed_exceptions: Optional[Sequence[Exception]] = None,
) -> 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(retries_limit):
try:
return operation(*args, **kwargs)
except allowed_exceptions as e:
logger.warning("%s 재시도, 원인: %s", operation.__qualname__, e)
last_raised = e
raise last_raised
return wrapped
사용 방법은 이전과 거의 유사하다.
@WithRetry(retries_limit=5)
def run_operation(task):
"""실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
return task.run()
여기서 파이썬 구문이 어떻게 처리되는지 이해하는 것이 중요하다. 먼저 @ 연산 전에 전달된 파라미터를 사용해 데코레이터 객체를 생성한다. 데코레이터 객체는 __init__
메서드에서 정해진 로직에 따라 초기화를 진행한다. 그 다음 @ 연산이 호출된다. 데코레이터 개체는 run_operation 함수를 받아서 __call__
메서드를 호출한다.
__call__
매직 메서드는 앞의 데코레이터에서 하던 것처럼 원본 함수를 래핑하여 우리가 원하는 로직이 적용된 새로운 함수를 반환한다.
기본 값을 가진 데코레이터
이전 예에서 파라미터를 가진 데코레이터를 살펴보았는데 파라미터에 기본 값이 있었다. 여기서 기본 값을 제공하는 이유는 사용자가 깜빡 잊고 파라미터를 전달하지 않아도 되도록 하기 위해서이다.
예를 들어 기본 값을 사용하려면 다음과 같이 호출하면 된다(괄호가 있다).
@retry()
def run_operation(task):
"""실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
return task.run()
그러나 다음과 같이 호출하면 동작하지 않는다(괄호가 없다).
@retry
def run_operation(task):
"""실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
return task.run()
괄호가 없는 경우는 첫 번째 파라미터로 함수가 전달되지만, 괄호가 있는 경우는 첫 번째 파라미터로 None이 전달된다. 그러나 문서를 보면 첫 번째 방식이 올바른 방식이라는 것을 확인할 수 있다. 두 번째 방식으로 호출시 에러가 발생하기 떄문에 주의해서 사용해야 한다.
물론 데코레이터 기본값이 없는 경우 두 번째 구문은 한 눈에 보아도 문제가 있기 때문에 훨씬 간단하게 이해할 수 있다.
또는 데코레이터가 두 가지 경우 모두 지원하게 할 수도 있다. 예상한 것처럼 두 가지 방식을 모두 지원하려면 추가적인 작업이 필요하므로 꼭 필요한 것인지 잘 판단해보아야 한다.
그럼 두 가지 방식을 모두 지원하려면 어떻게 해야 하는지 예제를 통해 살펴보자. 다음 예제에서 원본 함수는 x와 y 두 개의 파라미터를 사용하며, 데코레이터도 동일한 파라미터를 가지고 있다. 그리고 함수에는 아무런 파라미터를 넘기지 않고, 대신에 데코레이터에서 넘긴 파라미터를 사용해보자.
@decorator(x=1, y=2)
def my_function(x, y):
return x + y
my_function() # 7
위에서 본 것처럼 데코레이터에 기본값이 있기 때문에 함수의 인자가 없이도 호출할 수 있다. 이제 데코레이터 선언 시 괄호가 없는 경우도 처리를 해보자.
가장 간단한 방법으로는 조건문으로 function이 None인지에 따라 분기하는 방법이다.
def decorator(function=None, *, x=DFAULT_X, y=DEFAULT_Y):
if function is None:
# '@decorator(...)' 형태로 괄호를 사용해서 호출한 경우
def decorated(function):
@wraps(function)
def wrapped():
return function(x, y)
return wrapped
return decorated
else:
# '@decorator' 형태로 괄호를 사용하지 않고 호출한 경우
@wraps(function)
def wrapped():
return function(x, y)
return wrapped
데코레이터의 서명을 주의 깊게 살펴보자. 여기서 파라미터는 키워드 전용이다. 이렇게 함으로써 데코레이터의 서명이 간단해졌다. 왜나하면 이렇게 하면 괄호를 사용하여 x, y를 전달하는 경우 function 파라미터의 값은 None이 될 것이기 때문이다(위치 기반으로 파라미터를 넘기면 첫 번째 인자가 무엇인지 헷갈릴 것이다). 만약에 좀 더 기능을 추가하자면 None(또는 어떤 센티넬 값이든)(역주: 센티넬 값(sentinel value)은 고유한 자리 표시자(placeholder) 값으로서 파라미터의 초기값 등에 유용하게 쓰인다. flag value, single value, dummy data라고도 불리운다.)을 사용하는 대신에, 첫번째 파라미터의 타입이 함수인지를 확인하여 파라미터의 위치를 조절할 수도 있겠지만 데코레이터가 더욱 복잡해질 것이다.
다음 대안은 래핑된(wrapped) 데코레이터의 일부를 추상화한 다음 (functools.partial을 사용하여) 함수의 일부분에 적용하는 것이다. 이를 위해 중간 상태를 취하고 lambda 함수를 사용하여 래핑된 함수를 반환하는 것이다. 다음 코드에서 데코레이터의 인자가 어떻게 이동(shift)하는지 주의깊게 살펴보자.
def decorator(function=None, *, x=DEFAULT_X, y=DEFAULT_Y):
if function is None:
return lambda f: decorator(f, x=x, y=y)
@wraps(function)
def wrapped():
return function(x, y)
return wrapped
이것은 wrapped 함수를 정의하고 데코레이딩한다는 점에서 이전 예제와 유사하다. 만약 함수가 제공되지 않으면 함수를 인자로 받아서 파라미터와 함께 재귀적으로 decorator를 다시 호출하는 lambda 함수를 반환한다. 이렇게 하면 두 번째 재귀 호출에서는 function 파라미터의 값이 존재하고 일반적인 데코레이터 함수(위 예제에서는 def wrapped 부분의 코드)가 반환된다.
여기서 lambda 부분은 다음과 같이 수정할 수 있다.
return partial(decorator, x=x, y=y)
이렇게 하는 것이 너무 복잡하다면 항상 필수 값을 받도록 결정할 수도 있다.
어떻게 하든 데코레이터의 파라미터는 (기본값을 가지고 있는지에 관계없이) 키워드 전용으로 하는 것이 좋다. 왜냐하면 데코레이터를 적용할 때 각 값이 하는 일에 대한 컨텍스트 정보가 너무 많지 않고, 위치 파라미터를 사용하면 변수의 의미를 명확히 알 수 없으므로, 보다 많은 정보를 담고 있는 키워드 파라미터를 사용하는 것이 좋다.
TIP: 파라미터를 가진 데코레이터를 정의할 때는 키워드 전용 파라미터를 사용하도록 하자.
비슷한 원리로 데코레이터에서 파라미터를 사용하지 않을 것이라면 2장, “파이썬스러운 코드” 에서 배운 구문을 사용하여 명시할 수도 있다. 예를 들면 다음과 같이 데코레이터가 단일 위치 전용 파라미터를 갖도록 정의하면 된다.
def retry(operation, /): ...
그러나 이것은 엄격하게 권장되는 것은 아니며 데코레이터의 호출 방식을 명시적으로 표현하기 위한 방법이다.
코루틴(coroutine)을 위한 데코레이터
파이썬에서는 거의 모든 것이 객체이기 때문에 거의 모든 것을 데코레이터로 꾸밀 수 있으며 코루틴도 마찬가지이다.
그러나 여기에는 주의 사항이 있다. 즉, 이전에 설명했듯이 파이썬의 비동기 프로그래밍 구문은 몇 가지 차이점이 있다. 데코레이터에도 몇 가지 구문 차이가 있다.
간단하게 생각하면 코루틴에 대한 데코레이터를 작성하려면 새로운 구문을 사용하면 된다(래핑할 객체는 def가 아니라 async def로 선언해야 하고, 래핑된 코루틴은 await해야 한다는 것을 기억하자).
문제는 우리가 만든 데코레이터를 함수와 코루틴 모두에 적용하고 싶은 경우이다. 어쩌면 각각을 따로 지원하는 두 개의 데코레이터를 만드는 것이 가장 좋은 방법일 수 있다. 그러나 사용자에게 보다 간단한 인터페이스를 제공하려면 내부적으로 어떤 데코레이터를 사용해야 하는지 분기해주는 디스패처(dispatcher) 래퍼(wrapper)를 만들 수 있다. 마치 데코레이터를 위한 파사드(facade)를 만드는 것과 같다.
함수와 코루틴을 모두 지원하는 데코레이터를 만드는 것이 얼마나 어려운지에 대한 일반적인 결론은 없다. 왜냐하면 데코레이터 자체의 로직에 따라 달라지기 때문이다. 예를 들어, 다음 코드는 사용자가 입력한 파라미터를 무시하고 고정된 값을 사용하기 때문에 일반 함수와 코루틴 모두에 대해서 잘 동작한다.
X, Y = 1, 2
def decorator(callable):
"""고정된 X와 Y 값으로 <callable>을 호출하는 데코레이터"""
@wraps(callable)
def wrapped():
return callable(X, Y)
return wrapped
@decorator
def my_function(x, y):
return x + y
@decorator
async def my_coroutine(x, y):
return x + y
하지만 코루틴에 대해 한 가지 주의할 것이 있다. 데코레이터는 호출 가능한(callable) 인자를 받아서 파라미터와 함께 그것을 호출하는데, 코루틴을 받았을 경우에도 await 없이 호출한다는 점이다. 이렇게 하면 코루틴 객체(이벤트 루프에 전달할 작업)가 생성되지만 작업이 끝날 때까지 기다리지는 않는다. 즉 나중에 await my_coroutine()를 호출하는 곳에서 결과를 알 수 있다. 즉, 지금은 전달받은 코루틴을 다른 코루틴으로 변경할 필요가 없어서 문제가 없었지만 일반적으로는 전달받은 코루틴에 대한 처리를 하는 것을 권장한다.
하지만 이것 역시 우리가 실제로 필요한 요건에 따라 달라질 수 있다. 만약 timing 함수를 만든다고 하면 코루틴을 전달받은 경우 실행된 시간을 측정하기 위해 해당 객체가 종료될 때까지 await 해야 한다. 즉, 래핑하는 객체 또한 코루틴이어야 한다.
다음 코드는 데코레이터의 파라미터 형태에 따라서 어떻게 호출 방식을 결정하는지 보여준다.
import inspect
def timing(callable):
"""함수 또는 코루틴의 실행 시간을 측정하는 데코레이터"""
@wraps(callable)
def wrapped(*args, **kwargs):
start = time.time()
result = callable(*args, **kwargs)
latency = time.time() - start
return {"result": result, "latency": latency}
@wraps(callable)
async def wrapped_coro(*args, **kwargs):
start = time.time()
result = await callable(*args, **kwargs)
latency = time.time() - start
return {"result": result, "latency": latency}
if inspect.iscoroutinefunction(callable):
return wrapped_coro
return wrapped
두 번째 래퍼는 코루틴을 위해 필요하다. 만약 이 부분이 없다면 두 가지 문제가 발생한다. 첫 째, (await 없이) 코루틴을 호출하면 실제로는 작업이 끝나길 기다린 것이 아니므로 정확한 결과가 아니다. 그리고 더 나쁜 것은 반환하는 딕셔너리에서 result 키에 대한 값은 실제 결과 값이 아니라 코루틴이라는 것이다. 결과적으로 나중에 await 없이 result 키에 대한 값을 사용하려고 하면 에러가 발생한다.
TIP: 일반적으로 데코레이터에서 받은 꾸며진 객체(decorted object)는 같은 종류의 객체로 교체해야 한다. 즉, 함수를 받았다면 함수를 반환하고, 코루틴을 받았다면 코루틴을 반환해야 한다.
마지막으로 파이썬에 추가된 최근의 개선 사항에 대해 알아보자. 이는 구문 상의 일부 제약 사항을 해결해준다.
데코레이터를 위한 확장 구문
파이썬 3.9의 PEP-614(https://www.python.org/dev/peps/pep-0614/)에서 보다 일반적인 문법을 지원하는 새로운 데코레이터 문법이 추가되었다. 이전에는 @를 사용할 수 있는 범위가 제한적이어서 모든 함수 표현식(experssion)에 적용할 수 없었다.
이제 이러한 제약이 해제되어 보다 복잡한 표현식을 데코레이터에 사용할 수 있다. 덕분에 코드 라인을 줄일 수 있게 되었으나, 언제나 그렇듯 너무 복잡한 기능을 축약하여 가독성을 떨어뜨리지 않도록 주의하자.
예를 들어, 함수에서 호출된 파라미터의 로그를 남기는 데코레이터를 만든다고 가정해보자. 이런 경우 내부 항수의 정의를 데코레이터 호출 시 두 개의 람다(lambda) 표현식으로 대체할 수 있다.
def _log(f, *args, **kwargs):
print(f"호출: {f.__name__}({args}, {kwargs})")
return f(*args, **kwargs)
@(lambda f: lambda *args, **kwargs: _log(f, *args, **kwargs))
def my_function(x, y):
return x + y
>>> my_function(1, 2)
호출: my_function((1, 2), {})
3
PEP 문서에서는 이 기능이 유용하게 쓰을 수 있는 몇 가지 경우를 소개하고 있다. 예를 들면 다른 표현식을 평가(eval)하기 위해 no-op 함수(역주: no-op 함수: no-operation 함수. 특별한 작업을 하지 않지만 명시적으로 아무 작업을 하지 않는다고 표현하거나 다른 문장을 실행하는 등의 용도로 사용됨)를 단순화한다거나 eval 함수를 호출하지 않도록 할 수 있다.
권장하는 것은 가독성을 해치지 않느 범위 내에서 최대한 간결한 문장을 선택하자. 데코레이터 표현을 읽기 어렵다면 두 개 이상의 함수로 나눠서 알기 쉽게 분리하도록 하자.
데코레이터 활용 우수 사례
데코레이터가 사용되는 예제는 수 없이 많이 있지만 가장 관련성이 높은 몇 가지만 소개한다.
-
파라미터 변환
: 파라미터가 어떻게 처리되는지 세부사항을 숨기면서 함수의 서명을 변경하는 경우에 사용한다. 명확한 의도를 가지고 사용할 경우에만 유용하기 때문에 주의해서 사용해야 한다. 다시 말해 기존의 다소 복잡한 함수에 대해 데코레이터를 사용해서 명시적으로 좋은 서명을 제공하는 경우라면 클린 코드를 달성하기 위한 좋은 방법이다. 반면에 데코레이터를 잘못 사용해 함수의 서명이 실수로 변경된 경우는 피해야만 하는 상황이다. 이 장의 끝 부분에서 이를 어떻게 보완할 수 있는지 논의한다. -
코드 추적
: 파라미터와 함께 함수의 실행 경로를 로깅하려는 경우. 어쩌면 이미 여러 함수에서 제공하는 추적(tracing) 기능에 익숙할지도 모른다. 이 기능은 종종 데코레이터 방식으로 제공된다. 이것은 기존 코드를 건드리지 않고 외부의 기능을 통합할 수 있는 강력한 추상화이자 좋은 인터페이스이다. 관련 기능은 나만의 로깅/추적 기능을 데코레이터로 구현하는 데 참고가 될 수 있다. -
파라미터 유효성 검사
: 데코레이터는 파라미터의 값이나 데이터 타입이 유효한지 투명하게 검사하는 데 사용될 수 있다. 데코레이터를 사용하면 계약에 의한 디자인을 따르면서 추상화의 전제조건을 강요하도록 할 수 있다. -
재시도 로직 구현
: 이전 섹션에서 살펴본 것과 같은 방법으로 구현이 가능하다. -
일부 반복 작업을 데코레이터르 이동하여 클래스 단순화
: 이는 DRY 원칙과 관련이 있으며, 이번 장의 끝에서 다시 다룰 것이다.
함수 서명 변경
객체 지향 설계에서는 때때로 상호 작용해야 하는 객체 간의 다른 인터페이스를 가지는 경우가 있다. 레거시 코드에 복잡한 서명(많은 파라미터, 보일러플레이트 코드 등)으로 정의된 함수가 많이 있다고 해보자. 많은 함수를 변경한다는 것은 대규모 리팩토링을 의미하므로 이러한 함수와 상호 작용할 수 있는 더 깨끗한 인터페이스가 있다면 좋을 것이다.
데코레이터를 사용하여 변경 사항을 최소화 할 수 있다. 만약에 프레임워크에 이러한 내용을 염두에 두었다면 기존 코드와 통신할 때 데코레이터를 어댑터로 사용할 수 있다.
프레임워크에서 다음과 같은 함수를 호출하는 경우를 상상해보자.
def resolver_function(root, args, context, info): ...
이제 이 함수를 여러 곳에서 사용하고 있어서, 모든 파라미터의 처리 부분을 캡슐화하고 우리 애플리케이션에 알맞은 동작으로 변환하는 추상화를 하는 것이 좋겠다고 결정을 내린 상황이다.
실제 코드를 살펴보면 첫 번째 줄에서 동일한 객체를 반복해서 생성하는 보일러플레이트 코드가 있고, 나머지 코드는 오직 이러한 도메인 객체와 교류하고 있다.
def resolver_function(root, args, context, info):
helper = DomainObject(root, args, context, info)
...
helper.process()
이 예제에서는 데코레이터의 함수의 서명을 변경하여 해당 도메인 객체가 직접 전달되는 것처럼 할 수 있다. (위 코드에서 보면 helper 객체가 직접 전달되는 것처럼 가정할 수 있다.) 이 경우 데코레이터는 원래의 파라미터를 가로채서 도메인 객체를 만들고, 데코레이팅된 함수에 helper 객체를 전달하고 있다. 이제 원래의 함수는 이미 초기화된 helper 객체를 다진 것처럼 서명을 변경할 수 있다.
다음과 같은 형태로 변경 가능하다.
@DomainArgs
def resolver_function(helper):
...
helper.process()
domain_args 데코레이터는 다음과 같이 구현할 수 있다.
def DomainArgs(f):
@wraps(f)
def wrapped(root, args, context, info):
helper = DomainObject(root, args, context, info)
return f(helper)
return wrapped
이렇게 하면 기존의 함수 서명을 변경하지 않고도 새로운 도메인 객체를 사용할 수 있다. 이것은 데코레이터를 사용하여 기존의 함수 서명을 변경하는 좋은 예이다.
파라미터 유효성 검사
전에 언급한 것처럼 데코레이터를 사용하여 파라미터의 유효성 검사를 할 수 있다. 심지어 DbC(Design by Contract)의 원칙에 따라 사전조건 또는 사후조건을 강제할 수도 있다. 따라서 일반적으로 파라미터를 다룰 때 데코레이터를 많이 사용한다.
특히 유사한 객체를 반복적으로 생성하거나 추상화를 위해 유사한 변형을 반복한느 경우가 있다. 이런 경우 단순히 데코레이터를 만들어 사용하면 이 작업을 쉽게 처리할 수 있다.
코드 추적
이 섹셕에서 말하는 추적(tracing)이란 다음과 같은 시나리오에서 사용하려는 것으로 모니터링 하고자 하는 함수의 실행과 관련한 것이다.
- 함수의 실행 경로 추적(예를 들면 실행 함수 로깅)
- 함수 지표 모니터링(예를 들어 CPU 사용률이나 메모리 사용량 등)
- 함수의 실행 시간 측정
- 언제 함수가 실행되거 전달된 파라미터는 무엇인지 로깅
데코레이터의 활용 - 흔한 실수 피하기
파이썬 데코레이터는 훌륭한 기능이지만 잘못 사용했을 경우 발생하는 문제에 있어서는 예외가 아니다. 이번 섹션에서는 데코레이터를 사용할 때 주의해야 하는 몇 가지 주요 사항을 살펴본다.
래핑된 원본 객체의 데이터 보존
원본 함수의 일부 프로퍼티나 속송을 유지하지 않아 원하지 않는 부작용을 유발하는 경우가 있다. 이를 설명하기 위해 함수가 실행될 때 로그를 남기는 데코레이터를 사용한다.
# decorator_wraps_1.py
def trace_decorator(function):
def wrapped(*args, **kwargs):
logger.info("함수 %s가 호출되었습니다", function.__qualname__)
return function(*args, **kwargs)
return wrapped
위의 데코레이터를 사용한 함수가 있다고 가정해보자. 처음에는 원본 함수의 정의와 비교해 함수가 전혀 수정되지 않은 것처럼 보일 것이다.
@trace_decorator
def process_account(account_id: str):
"""id별 계정 처리"""
logger.info("계정 처리 시작: %s", account_id)
...
그러나 이 데코레이터는 원본 함수의 일부 속성을 변경한다. 원래 데코레이터는 기존 함수의 어떤 것도 변경하지 않아야 하지만 이번 코드는 어떤 결함으로 인해 함수명과 docstring을 변경해버렸다.
이 함수의 help를 확인해보자.
>>> help(process_account)
Help on function wrapped in module decorator_wraps_1:
wrapped(*args, **kwargs)
그리고 어떻게 호출되는지 확인해보자.
>>> process_account.__qualname__
'trace_decorator.<locals>.wrapped'
뿐만 아니라, 원래 함수의 어노테이션 정보도 없어졌다.
>>> process_account.__annotations__
{}
데코레이터가 원본 함수를 wrapped라 불리우는 새로운 함수로 대체했기 때문에 이런 문제가 발생한다. 만약 이 데코레이터는 다른 이름의 함수에 적용하더라도 결국은 wrapped라는 이름만 출력될 것이다. 이렇게 되면 개별 함수에 대해서 로깅을 하거나 다른 속성을 활용하고 싶을 때에도 원래의 함수를 알 수 없으므로 디버깅이 어려워지는 문제가 있다.
또 다른 문제는 (docstring 또한 wrapped 함수의 docstring으로 덮어씌워졌기 때문에) docstring에 테스트 코드를 작성한 경우 기존 테스트 정보도 없어진다는 점이다. doctest 모듈로 코드를 호출해도 docstring의 테스트가 호출되지 않는다. (역주: doctest 모듈은 docstring 안에 파이썬 세션 형태의 텍스트를 검색하여 해당 세션과 같은 결과가 나오는지 비교해주는 테스트 모듈이다.)
이것을 수정하는 것은 간단하다. 래핑된 함수, 즉 wrapped 함수에 @wraps 데코레이터를 추가하면 실제로는 function 파라미터 함수를 래핑한 것이라고 알려주는 것이다.
# decorator_wraps_2.py
def trace_decorator(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("함수 %s가 호출되었습니다", function.__qualname__)
return function(*args, **kwargs)
return wrapped
이제 다시 help를 호출해보자.
>>> from decorator_wraps_2 import process_account
>>> help(process_account)
Help on function process_account in module decorator_wraps_2:
process_account(account_id: str)
id별 계정 처리
이제 __qualname__
속성(역주: 파이썬 객체에는 미리 정의된 특별한 속성들이 있는데 그 중 하나이다. __name__
속성은 함수의 이름을 반환하지만, PEP3155 파이썬 3.3에 추가된 __qualname__
속성은 클래스의 정규화된 이름(fully qualified name)을 반환한다. A라는 클래스 안에 myfunc라는 메서드가 있는 경우 __name__
은 myfunc이지만, __qualname__
은 A myfunc이어서 보다 자세한 정보를 얻을 수 있다.)도 원래 함수의 이름을 출력한다.
>>> process_account.__qualname__
'process_account'
그러나 가장 중요한 것은 docstring에 포함된 단위 테스트 기능이 복구되었다는 것이다! 또한 wraps 데코레이터를 사용함으로써 __wrapped__
속성을 통해 원본 함수에서 접근할 수 있게 되었다. (역주: __wrapped__
속성은 원본 함수를 가리키는 속성인데 @wraps를 사용해야 속성이 생성된다.) 또한 상용 환경에서 사용하면 안 되겠지만, 단위 테스트에서 수정하지 않은 원본 함수의 기능을 점검할 때에도 유용하게 사용할 수 있게 되었다. 일반적으로 다음과 같은 템플릿에 맞추어 functools.wraps를 추가하면 된다.
def decorator(original_function):
@wraps(original_function)
def decorated_function(*args, **kwargs):
# 데코레이터에 의한 수정 작업 ...
return original_function(*args, **kwargs)
return decorated_function
TIP: 데코레이터를 작성할 때는 항상 functools.wraps를 사용하자.
데코레이터 부작용 처리
데코레이터 함수 구현 시 부작용이 발생하지 않도록 하는 방법에 대해서 알아보자. (일부러) 이런 부작용을 활용하는 경우도 있지만, 어떻게 하는 것이 좋을지 고민이 된다면 앞으로 설명할 이유로 인해 부작용이 발생하지 않도록 하는 것이 좋다. 데코레이터 함수를 구현할 때 지켜야할 단 하나의 조건은 구현 함수 가장 안쪽에 위치해야 한다는 것이다. 그렇게 하지 않으면 임포팅과 관련된 문제가 발생할 수 있다. 그럼에도 불구하고 때로는 부작용이 필요한(심지어 의도적인) 경우도 있고, 반대의 경우도 있다.
이러한 두 가지 예제를 모두 살펴볼 것이다. 확실하지 않다면 래핑된 함수가 호출되기 직전까지 부작용을 최대한 지연하도록 주의해야 한다.
다음으로 래핑된 함수 바깥에 추가 로직을 구현하는 것이 왜 좋지 않은지 살펴볼 것이다.
데코레이터 부작용의 잘못된 처리
함수의 실행과 실행 시간을 로깅하는 데코레이터를 생각해보자.
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 함수 실행 시간: %.2f초", 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_effects_1 import process_with_delay
INFO:<function process_with_delay at 0x...> 함수 실행
함수를 임포트만 했을 뿐인데 데코레이터 함수가 실행되었다. 뭔가 잘못되고 있음을 알 수 있다. 실제 함수를 호출하지 않았으므로 로그가 남지 않아야 한다.
이제 함수를 실행하고 실행하는 데 걸리는 시간을 확인하면 어떻게 도리까? 같은 함수를 여러번 호출했으므로 비슷한 수행시간이 나와야 한다.
>>> main()
...
INFO:<function process_with_delay at 0x...> 함수 실행 시간: 8.67s
>>> main()
...
INFO:<function process_with_delay at 0x...> 함수 실행 시간: 13.01s
>>> main()
...
INFO:<function process_with_delay at 0x...> 함수 실행 시간: 17.35s
동일한 기능인데 실행할 때마다 오래 걸린다! 이 지점에서 이미 명백한 문제가 있음을 알 수 있을 것이다.
데코레이터의 문법을 떠올려 보자. @traced_function_wrong은 실제로 다음을 의미한다.
process_with_delay = traced_function_wrong(process_with_delay)
이 문장은 모듈을 임포트할 때 실행된다. 따라서 함수에 설정된 start_time은 모듈을 처음 임포트할 때의 시간이 된다. 함수를 연속적으로 호출하면 함수의 실행시간으로 기록되는 것이 아니라 모듈을 임포트할 때의 시간으로 기록된다.
다행히도 이것을 수정하는 것은 매우 간단하다. 실행을 지연하기 위해 래핑된 함수 내부로 코드를 이동하면 된다.
def traced_function_right(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("%s 함수 실행", function)
start_time = time.time()
result = function(*args, **kwargs)
logger.info("%s 함수 실행 시간: %.2f초", function, time.time() - start_time)
return result
return wrapped
데코레이터의 동작이 기대와 다른 경우 결과는 훨씬 더 비참할 수 있다. 예를 들어 이벤트 발생시 로그를 남기고 외부 서비스로 전송하려는 경우 임포트 전에 어디로 보낼지 적절한 설정을 하지 않으면 전송에 실패하게 된다. 데코레이팅 사용 전에 설정을 했다는 것을 보장할 수는 없다. 설사 보장할 수 있다고 해도 데코레이터의 설정을 사용자 측에서 하는 것은 좋은 습관이 아니다. 데코레이터에서 파일을 읽는 중 또는 설정 파일을 파싱하는 중에 발생하는 다른 부작용에 대해서도 마찬가지이다.
데코레이터 부작용의 활용
때로는 이러한 부작용을 의도적으로 사용하여 실제 실행이 가능한 시점까지 기다리지 않는 경우도 있다.
데코레이터의 부작용을 활용하는 대표적인 예로 모듈의 공용 레지스트리에 객체를 등록하는 경우가 있다.
예를 들어 이전 이벤트 시스템에서 일부 이벤트만 사용하려는 경우를 살펴보자. 이런 경우가 이벤트 계층 구조의 중간에 가상의 클래스를 만들고 일부 파생 클래스에 대해서만 이벤트를 처리하도록 할 수 있다.
각 클래스마다 처리 여부를 플래그(flag)로 표시하는 대신 데코레이터를 사용해 명시적으로 레지스트리에 등록할 수 있다.
여러 이벤트 정보가 있는데 사용자의 활동과 관련된 이벤트만 처리할 예정이라고 해보자. 여기서는 UserLoginEvent와 UserLogoutEvent만 처리한다.
EVENTS_REGISTRY는 = {}
def register_event(event_cls):
"""모듈에서 접근 가능하도록 이벤트 클래스를 레지스트리에 등록"""
EVENTS_REGISTRY는[event_cls.__name__] = event_cls
return event_cls
class Event:
"""기본 이벤트 클래스"""
class UserEvent(Event):
TYPE = "user"
@register_event
class UserLoginEvent(UserEvent):
"""사용자가 시스템에 접근했을 때 발생하는 이벤트"""
@register_event
class UserLogoutEvent(UserEvent):
"""사용자가 시스템에서 로그아웃했을 때 발생하는 이벤트"""
위의 코드를 살펴보면 처음에 EVENTS_REGISTRY는 비어있는 것처럼 보이지만 이 모듈의 일부를 임포트하면 register_event 데코레이터로 지정한 클래스로 채워지게 된다.
>>> from decorator_side_effects_2 import EVENTS_REGISTRY
>>> EVENTS_REGISTRY
{'UserLoginEvent': <class 'decorator_side_effects_2.UserLoginEvent'>, 'UserLogoutEvent': <class 'decorator_side_effects_2.UserLogoutEvent'>}
위 코드만 봐서는 이해하기 어렵고 오해할 수 있다. 왜냐하면 EVENTS_REGISTRY는 런타임 중에 모듈을 임포트한 직후에야 최종 값을 가지므로 코드만 봐서는 어떤 값이 될지 쉽게 예측하기 어렵다.
이런 동작 방식이 문제가 되는 경우도 있지만 어떤 경우에는 이 패턴이 필요한 경우가 있다. 사실 많은 웹 프레임워크나 널리 알려진 라이브러리들은 이 원리로 객체를 노출하거나 활용하고 있다. 즉, 유사한 기능을 구현하려고 한다면 먼저 이러한 위험에 대해 정확히 인지하고 있어야 한다. 대부분의 경우 다른 방법을 사용하는 것을 권장한다.
이 예제에서는 데코레이터가 래핑된 객체나 동작 방식을 변경하지 않고 원본 함수를 그대로 반환한 것도 사실이다. 그러나 여기서 중요한 점은 래핑된 객체를 일부 수정하거나 래핑된 객체를 수정하는 또다른 내부 함수를 정의헀다면, 결과 객체를 외부에 노출하는 코드가 있어야 한다는 점이다.
“외부”라는 단어에 주목하자. 이전까지는 꼭 필요한 개념이 아니었지만 이제 결과 객체가 같은 클로저에 있지 않고 외부 스코프에 있으며 그렇기 때문에 이제 런타임까지 지연되지 않고 바로 실행이 된다.
어느 곳에나 동작하는 데코레이터 만들기
데코레이터는 여러 시나이로에 적용될 수 있다. 예를 들어 같은 데코레이터를 함수나 클래스, 메서드 또는 정적 메서드 등 여러 곳에 재사용하려는 경우이다.
보통 데코레이터를 만들면 장식하고 싶은 첫 번째 유형의 객체만을 지원하려고 생각하게 된다. 그러나 같은 데코레이터를 다른 유형에 적용하려고 하면 오류가 발생한다. 전형적인 예로 함수에 적용된 데코레이터를 클래스의 메서드에 적용하려고 하면 오류가 발생한다. 메서드에 대한 데코레이터를 디자인한 다음 유사한 메서드 또는 클래스 메서드에 적용하려는 경우에도 마찬가지이다.
데코레이터를 만들 때는 일반적으로 재사용을 고려하여 함수뿐 아니라 메서드에서도 동작하길 바란다.
*args와 **kwargs를 사용하여 데코레이터를 정의하면 모든 경우에 적용할 수 있다. 그러나 다음 두 가지 이유로 원래 함수의 서명과 비슷하게 데코레이터를 정의하는 것이 좋을 때가 있다.
- 원래의 함수의 모양과 비슷하기 때문에 읽기가 쉽다.
- 파라미터를 받아서 뭔가를 하려면 *args와 **kwargs를 사용하는 것이 더 복잡하다.
파라미터를 받아서 특정 객체를 생성하는 경우가 많다고 생각해보자. 예를 들어 문자열을 받아서 드라이버 객체를 초기화하는 경우이다. 이런 경우 파라미터를 변환해주는 데코레이터를 만들어 중복을 제거할 수 있다.
다음 예제에서 DBDriver 객체는 연결 문자열을 받아서 데이터베이스에 연결하고 DB 연산을 수행하는 객체이다. 메서드는 DB 정보 문자열을 받아서 DBDriver 객체를 생성한다. 데코레이터는 이러한 변환은 자동화하여 문자열을 받아 DBDriver 객체를 생성하고 함수에 전달한다. 따라서 마치 객체를 직접 받은 것처럼 가정할 수 있다.
from functools import wraps
from log import logger
class DBDriver:
def __init__(self, dbstring: str) -> None:
self.dbstring = dbstring
def execute(self, query: str) -> str:
return f"query {query} at {self.dbstring}"
def inject_db_driver(function):
"""파라미터로 받은 데이터베이스 dns 문자열을
사용하여 DBDriver 인스턴스 생성"""
@wraps(function)
def wrapped(dbstring):
return function(DBDriver(dbstring))
return wrapped
@inject_db_driver
def run_query(driver):
return driver.execute("SELECT * FROM table")
이렇게 문자열을 전달하면 DBDriver 객체를 반환하므로 예상대로 동작한다.
>>> run_query("SELECT * FROM table")
'query SELECT * FROM table at <__main__.DBDriver object at 0x...>'
하지만 이제 같은 기능을 하는 데코레이터를 클래스 메서드에서 재사용하고 싶다면 어떻게 될까?
class DataHandler:
@inject_db_driver
def run_query(self, driver):
return driver.execute(self.__class__.__name__)
아래처럼 실행하면 동작하지 않는다.
>>> DataHandler().run_query("test_fails")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
...
TypeError: wrapped() missing 1 required positional argument: 'dbstring'
무엇이 문제일까? 클래스의 메서드에는 self라는 변수가 있다. 이것은 클래스의 인스턴스를 가리키는데, 이것은 데코레이터에 전달되지 않는다. 이것은 데코레이터가 함수에 전달되는 파라미터를 잘못 처리하고 있음을 의미한다.
이 문제를 해결하려면 메서드와 함수에 대해서 동일하게 동작하는 데코레이터를 만들어야 한다. 디스크립터 프로토콜을 구현한 데코레이터 객체를 만든다.
데코레이터를 클래스 객체로 구현하고 __get__
메서드를 구현한 디스크립터 객체로 만들면 된다.
from functools import wraps
from types import MethodType
class inject_db_driver:
"""문자열을 DBDriver 객체로 변환하여 래핑된 함수에 전달"""
def __init__(self, function):
self.function = function
wraps(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))
지금은 호출할 수 있는 객체를 메서드에 다시 바인딩한다는 정도만 알면 된다. 이것은 데코레이터가 클래스의 메서드에도 적용될 수 있도록 해준다. 즉 함수를 객체에 바인딩하고 데코레이터를 새로운 호출 가능 객체로 다시 생성한다.
데코레이터와 클린 코드
상속보다 컴포지션(Composition)
일반적으로 상속보다는 컴포지션이 더 좋은 선택이다. 왜냐하면 상속은 코드의 결합도를 높게 만들어서 몇 가지 문제를 수반하기 때문이다.
GoF의 디자인 패턴 (Design Patterns: Elements of Reusable Object-Oriented Software - DESIG01) 책의 내용은 대부분 다음 생각에 기반한다.
클래스 상속보다는 컴포지션(Favor object composition over class inheritance)
`
특별한 프레임워크를 가정해보자. 이 프레임워크는 다음과 같은 기능을 제공한다.
- 모든 속성이 “resolve_” 접두어를 가지고 있다.
- 그러나 우리가 가지고 있는 도메인 객체는 “resolve_” 접두어가 없는 변수명을 가지고 있다.
이러한 상황에서 x라는 변수에 대해서 “resolve_x”를 만드는 것처럼 일일이 모든 변수에 대해서 “resolve_” 접두어를 붙이는 것은 매우 지루한 작업이다. 첫 번째로 떠오르는 아이디어는 __getattr__
매직 메서드를 구현한 믹스인(mixin) 부모 클래스를 만드는 것이다. 이렇게 하면 모든 변수에 대해서 “resolve_” 접두어를 붙일 수 있다.
class BaseResolverMixin:
def __getattr__(self, attr: str):
if attr.startswith("resolve_"):
*_, actual_attr = attr.partition("resolve_")
else:
actual_attr = attr
try:
return self.__dict__[actual_attr]
except KeyError as e:
raise AttributeError from e
@dataclass
class Customer(BaseResolverMixin):
customer_id: str
name: str
address: str
위 트릭은 잘 동작한다. 그러나 더 좋은 방법이 없을까?
메서드 이름 변환 작업을 직접 담당하는 데코레이터를 만들어볼 수 있다.
from dataclasses import dataclass
def _resolver_method(self, attr: str):
"""__getattr__ 매직 메서드를 대신할 속성 결정(resolution) 메서드"""
if attr.startswith("resolve_"):
*_, actual_attr = attr.partition("resolve_")
else:
actual_attr = attr
try:
return self.__dict__[actual_attr]
except KeyError as e:
raise AttributeError from e
def with_resolver(cls):
"""사용자 정의 결정 메서드를 __getattr__에 할당"""
cls.__getattr__ = _resolver_method
return cls
@dataclass
@with_resolver
class Customer:
customer_id: str
name: str
address: str
두 버전 모두 다음과 같이 잘 동작한다.
>>> customer = Customer("1", "name", "address")
>>> customer.resolve_customer_id
'1'
>>> customer.resolve_name
'name'
>>> customer.resolve_address
'address'
with_resolver 데코레이터를 살펴보면 원래의 __getattr__
과 유사한 서명을 가진 독립 함수를 결정(resolve) 메서드로 _resolver_method를 만들었다. 그래서 _resolver_method의 첫 번째 파라미터도 의도적으로 self로 사용하여 원래의 __getattr__
메서드가 함수로 변경되었음을 표시했다.
나머지 코드는 간단하다. with_resolver 데코레이터는 파마리터로 받은 클래스에 대해서 새로 만든 _resolver_method 메서드를 __getattr__
결정 메서드로 등록했다. 이제 더 이상 상속을 사용하지 않고도 동일한 기능을 구현할 수 있다.
데코레이터와 DRY 원칙
DRY(Don’t Repeat Yourself) 원칙은 코드의 중복을 피하라는 원칙이다. 이 원칙은 코드의 중복을 피하고 코드의 유지보수성을 높이기 위해 사용된다. 데코레이터는 이 원칙을 따르는 좋은 방법이다.
하지만 실질적으로 코드 사용량을 줄일 수 있다는 확실한 믿음이 있어야 한다. 다음과 같은 사항을 고려했을 경우에만 데코레이터를 사용하는 것을 권장한다.
- 처음부터 데코레이터를 만들지 않는다. 패턴이 생기고 데코레이터에 대한 추상화가 명확해지면 그때 리팩토링한다.
- 데코레이터가 적어도 3회 이상 필요한 경우에만 구현한다.
- 데코레이터 코드를 최소한으로 유지한다.
데코레이터와 관심사의 분리
코드 재사용의 핵심은 응집력이 있는 컴포넌트를 만드는 것이다. 즉, 최소한의 책임을 자겨서 오직 한 가지 일만 해야 하며, 그 일을 잘해야 한다. 컴포넌트가 작을수록 재사용성이 높아진다.
다음과 같이 특정 함수의 실행을 추적하는 데코레이터는 생성한다. 이전 예제와 동일하다.
def trace_decorator(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("함수 %s가 호출되었습니다", function.__qualname__)
start_time = time.time()
result = function(*args, **kwargs)
logger.info("함수 %s가 실행된 시간: %.2f초", function.__qualname__, time.time() - start_time)
return result
return wrapped
이 데코레이터는 동작에 문제가 있다. 하나 이상의 작업을 수행하고 있다. 특정 함수가 방금 호출되었음을 로깅하고 함수가 실행된 시간을 측정하고 있다. 이것은 데코레이터가 너무 많은 일을 하고 있다는 것을 의미한다.
이것은 좀 더 작은 컴포넌트로 분리할 수 있다. 데코레이터는 함수의 실행을 추적하는 것만을 담당하고, 시간을 측정하는 것은 다른 데코레이터로 분리할 수 있다.
def log_execution(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("함수 %s가 호출되었습니다", function.__qualname__)
return function(*args, **kwargs)
return wrapped
def measure_time(function):
@wraps(function)
def wrapped(*args, **kwargs):
start_time = time.time()
result = function(*args, **kwargs)
logger.info("함수 %s가 실행된 시간: %.2f초", function.__qualname__, time.time() - start_time)
return result
return wrapped
동일한 기능을 다음과 같이 조합하여 달성할 수 있다.
@measure_time
@log_execution
def operation():
...
데코레이터가 적용되는 순서도 중요하다.
TIP: 데코레이터에 하나 이상의 책임을 두면 안 된다. SRP(Single Responsibility Principle)를 데코레이터에도 따르는 것이 좋다.
좋은 데코레이터 분석
좋은 데코레이터는 다음과 같은 특징을 가진다.
- 캡슐화와 관심사의 분리: 좋은 데코레이터는 실제로 하는 일과 데코레이팅하는 일의 책임을 명확히 구분해야 한다. 어설프게 추상화를 하면 안 된다. 즉 데코레이터의 클라이언트는 내부에서 어떻게 구현했는지 전혀 알 수 없는 블랙박스로서 동작해야 한다.
- 독립성: 데코레이터가 하는 일은 독립적이어야 하며 데코레이팅되는 객체와 최대한 분리되어야 한다.
- 재사용성: 데코레이터는 하나의 함수 인스턴스에만 적용되는 게 아니라 여러 유형에 적용 가능한 형태가 바람직하다. 왜냐하면 하나의 함수에만 적용된다면 데코레이터가 아니라 함수로 대신할 수도 있기 때문이다. 충분히 범용적이어야 한다.
Celery 프로젝트에서 데코레이터의 좋은 예를 볼 수 있다.
@app.task
def my_task():
...
이것이 좋은 데코레이터인 이유 중 하나는 캡슐화가 매우 잘되어 있기 때문이다. 라이브러리 사용자는 함수 본문을 정의하기만 하면 데코레이터가 이를 자동으로 처리한다. 데코레이터가 하는 일은 사용자에게 전혀 드러나지 않는다.
Previous
‘4. SOLID 원칙’ -> Previous