티스토리 뷰

Books

파이썬 클린 코드 (5/10)

최성훈 2023. 1. 16. 00:09
반응형

5장. 데코레이터를 사용한 코드 개선


파이썬의 데코레이터

@modifier
def original(...):
	# ...
데코레이터는 데코레이터 이후에 나오는 것을 데코레이터의 첫 번째 파라미터로 하고 데코레이터의 결과 값을 반환하게 하는 문법적 설탕(syntax sugar)일 뿐이다.

... 예제에서 말하는 modifier는 파이썬 용어로 데코레이터라 하고, original을 데코레이팅된(decorated) 함수 또는 래핑된(wrapped) 객체라 한다.

함수 데코레이터

함수에 데코레이터를 사용하면 어떤 종류의 로직이라도 적용할 수 있다. 파라미터의 유효성을 검사하거나 사전조건을 검사하거나, 기능 전체를 새롭게 정의할 수도 있고, 서명을 변경할 수도 있고, 원래 함수의 결과를 캐시하는 등의 작업을 모두 할 수 있다.
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
def run_operation(task):
	"""실행 중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
    return task.run()

 

여기서 @retry 데코레이터는 실제로 파이썬에서 run_operation = retry(run_operation)과 동일하게 실행하게 해준다.

즉, run_operation() 함수가 실제로 실행되기 전 @retry 데코레이터의 로직이 실행되고, @retry 데코레이터의 파라미터로 run_operation() 함수가 넘어가는 것이다.

 

클래스 데코레이터

함수에 적용한 것처럼 클래스에도 데코레이터를 사용할 수 있다. (PEP-3129). 유일한 차이점은 데코레이터 함수의 파라미터로 함수가 아닌 클래스를 받는다는 것이다.

어떤 개발자들은 클래스 데코레이터가 복잡하고 가독성을 떨어뜨릴 수 있다고 말할 수 있다. 왜냐하면 클래스에서 정의한 속성과 메서드를 데코레이터 안에서 완전히 다른 용도로 변경할 수 있기 때문이다.

데코레이터를 남용할 경우 이것 또한 사실이다. 파이썬에서 보면 함수 데코레이터와 클래스 데코레이터는 다른 타입을 사용하는 것만 다를 뿐 차이점이 없다.
👉 클래스 데코레이터는 코드 재사용과 DRY 원칙의 모든 이점을 공유한다. 클래스 데코레이터를 사용하면 여러 클래스가 특정 인터페이스나 기준을 따르도록 강제할 수 있다. 여러 클래스에 적용할 검사를 데코레이터에서 한 번만 하면 된다.
👉 당장은 작고 간단한 클래스를 생성하고 나중에 데코레이터로 기능을 보강할 수 있다.
👉 어떤 클래스에 대해서는 유지보수 시 데코레이터를 사용해 기존 로직을 훨씬 쉽게 변경할 수 있다. 메타클래스와 같은 방법을 사용해 보다 복잡하게 만드는 것은 주로 권장되지 않는다.

 

나는 데코레이터가 DRY 원칙의 모든 이점을 공유한다는 점과, 가독성을 떨어뜨릴 수도 있다는 점에 모두 동의한다.

 

게다가 데코레이터의 목적에 맞게 정확하게 사용하지 않으면 래핑된 객체(또는 함수)는 기존의 동작과 다르게 의도치 않게 동작할 수도 있으므로, 주의해서 사용하는 게 좋다.

 

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()

 

위 LoginEventSerializer와 LoginEvent 클래스는 (1) password 필드를 숨기고, (2) timestamp 필드를 포매팅한다.

 

위 방법은 처음에는 잘 동작하지만, 시간이 지나면서 시스템을 확장할수록 다음과 같은 문제가 발생한다고 말한다.

 

👉 클래스가 너무 많아진다: 이벤트 클래스와 직렬화 클래스가 1:1로 맵핑되어 있으므로 직렬화 클래스가 점점 많아지게 된다.
👉 이러한 방법은 충분히 유연하지 않다: 만약 password를 가진 다른 클래스에서도 이 필드를 숨기려면 함수로 분리한 다음 여러 클래스에서 호출해야 한다. 이는 코드를 충분히 재사용했다고 볼 수가 없다.
👉 표준화: serialize() 메서드는 모든 이벤트 클래스에 있어야만 한다. 비록 믹스인을 사용해 다른 클래스로 분리할 수 있지만 상속을 제대로 사용했다고 볼 수 없다.

 

클래스 데코레이터의 대표적인 예를 살펴보자.

from datetime import datetime


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:
    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
)
class LoginEvent:
    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp

 

위 코드에서 첫 번째로 눈여겨봐야 할 점은 LoginEvent에서 SERIALIZER 속성이 사라지고, @Serialization 데코레이터가 추가된 것이다.

@Serialization 데코레이터를 통해서 LoginEvent의 각 필드는 어떤 함수를 통해 직렬화할 것인지 쉽게 알 수 있다.

 

데코레이터에 인자 전달 - (1) 중첩 함수의 데코레이터

크게 보면 데코레이터는 함수를 파라미터로 받아서 함수를 반환하는 함수이다. 이런 함수를 고차 함수(higher-order function)라고 부른다. 실제로는 데코레이터의 본문에 정의된 함수가 호출된다.
RETRIES_LIMIT = 3


class ControlledException(Exception):
    """도메인에서 발생하는 일반적인 예외"""


def with_retry(retries_limit=RETRIES_LIMIT, allowed_exceptions=None):  # 데코레이터의 파라미터를 받는 함수
    allowed_exception = 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 ControlledException as e:
                    logger.info("retrying %s", operation.__qualname__)
                    last_raised = e
            raise last_raised

        return wrapped

    return retry

 

책에서 말하는 것처럼 @with_retry 데코레이터는 retry라는 클로저를 반환한다. 즉, @with_retry 데코레이터는 결과적으로 함수를 반환하는 함수인 것이다.

 

이 데코레이터를 다른 함수에 적용한다면 다음과 같이 적용할 수 있다.

@with_retry()
# 또는 @with_retry(retries_limit=5)
# 또는 @with_retry(allowed_exceptions=(AttributeError,))
# 또는 @with_retry(retries_limit=5, allowed_exceptions=(ZeroDivisionError, AttributeError))
def run_operation(task):
    return task.run()

 

@with_retry 데코레이터는 내부적으로 run_operation() 함수를 호출하고, 이 로직은 wrapped() 클로저에서 구현돼 있다.

 

데코레이터에 인자 전달 - (2) 데코레이터 객체

RETRIES_LIMIT = 3


class ControlledException(Exception):
    """도메인에서 발생하는 일반적인 예외"""


class WithRetry:
    def __init__(self, retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
        self.retries_limit = retries_limit
        self.allowed_exceptions = allowed_exceptions

    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, e)
                    last_raised = e
            raise last_raised

        return wrapped

 

데코레이터 객체의 사용 방법은 이전 방법과 유사하다.

 

@WithRetry(retries_limit=5)
def run_operation(task):
    return task.run()

데코레이터와 DRY 원칙

모든 데코레이터, 특히 신중하게 설계되지 않은 데코레이터 코드는 코드의 복잡성을 증가시킨다. 어떤 사용자는 함수의 논리를 완전히 이해하기 위해 데코레이터의 경로를 따라가보길 원할 수 있다. 따라서 이 복잡성이 가치가 있어야 한다는 점을 기억하자. 그다지 재사용할 필요가 없을 경우 별개의 함수나 작은 클래스로도 충분한 경우가 있다.

 

데코레이터 작성 시 가장 중요한 내용이다.

 

우리가 데코레이터를 사용할 때, 그 내부 로직을 확인하지 않는 개발자는 거의 없을 것이다. (개발자는 데코레이터의 로직을 확인해야 하지만, 래핑 되는 함수의 입장에서는 데코레이터의 내부 로직을 알 필요가 없다.)

 

지금까지 예제에서 봤듯이 데코레이터를 작성하게 되면 함수의 depth가 증가하고, 데코레이터의 내부 로직을 이해하는데 시간이 필요하다. 따라서 내가 작성하는 데코레이터가 단순히 코드 재사용성을 높이기 위해서인지, 아니면 기존의 함수(또는 객체)의 기능을 확장하기 위함인지 확인이 필요하다.

 

개인적으로는 단순 코드 재사용성을 높여서 DRY 원칙을 따르기 위함이라면, 데코레이터 말고 별도의 함수나 클래스로 추출하는 것이 더 나은 방법이라고 생각한다. retry 기능과 같이 래핑 할 함수의 기능은 그대로 유지하되, 기능 확장이 필요한 경우에 데코레이터를 사용하는 것이 좋지 않을까?

 

책에서 말하듯이, 내가 작성한 데코레이터의 복잡성이 가치가 있어야 한다는 점을 명심하자.

 


좋은 데코레이터 분석

👉 캡슐화와 관심사의 분리: 좋은 데코레이터는 실제로 하는 일과 데코레이팅하는 일의 책임을 명확히 구분해야 한다. 어설프게 추상화를 하면 안 된다. 즉 데코레이터의 클라이언트는 내부에서 어떻게 구현했는지 전혀 알 수 없는 블랙박스 모드로 동작해야 한다.
👉 독립성: 데코레이터가 하는 일은 독립적이어야 하며 데코레이팅되는 객체와 최대한 분리되어야 한다.
👉 재사용성: 데코레이터는 하나의 함수 인스턴스에만 적용되는 것이 아니라 여러 유형에 적용 가능한 형태가 바람직하다. 왜냐하면 하나의 함수에만 적용된다면 데코레이터가 아니라 함수로 대신할 수도 있기 때문이다. 충분히 범용적이어야 한다.

 

아마 @retry 데코레이터가 위에서 말하는 좋은 데코레이터의 대표적인 예일 것이다.

 

위 특징과 @retry 데코레이터를 하나씩 비교해 보면, @retry 데코레이터는 위에서 말하는 모든 특징들을 만족한다는 것을 알 수 있다.

반응형

'Books' 카테고리의 다른 글

파이썬 클린 코드 (4/10)  (0) 2022.05.07
파이썬 클린 코드 (3/10)  (0) 2022.03.18
파이썬 클린 코드 (2/10)  (3) 2022.03.04
파이썬 클린 코드 (1/10)  (0) 2022.03.04
댓글