[ python ] 05.데코레이터를 사용한 코드 개선_(1)

박찬영·2024년 4월 5일

파이썬 클린 코드

목록 보기
13/19
post-thumbnail

파이썬 클린 코드

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

개요

이 장에서는 데코레이터와 그 동작 원리 그리고 어떻게 구현할 수 있는지에 대해서 공부한다.
이 장의 목표는 다음과 같다.

  • 파이썬에서 데코레이터가 동작하는 방식을 이해한다.
  • 함수와 클래스에 적용되는 데코레이터를 구현하는 방법을 배운다.
  • 일반적인 실수를 피하여 데코레이터를 효과적으로 구현하는 방법을 배운다.
  • 데코레이터를 활용한 코드 중복을 회피
  • 데코레이터를 활용한 관심사의 분리
  • 좋은 데코레이터 사례
  • 데코레이터가 좋은 선택이 될 수 있는 일반적인 상황, 관용구, 패턴

파이썬 데코레이터

파이썬에서 데코레이터는 처음에 기존 함수와 메서드의 기능을 쉽게 수정하기 위한 수단으로 소개되었다.
데코레이터가 없을 때에는 classmethodstaticmethod 같은 함수를 사용해서 기존 메서드의 정의를 변형하고 있었다. 이는 추가적인 코드가 필요하고, 기존 함수의 정의를 별도 문장에서 변경해야 하는 불편함이 있었다.

즉 함수를 변형하고 싶을 때마다 변형을 담당하는 함수를 호출하고, 기존 함수와 같은 이름으로 변환 결과를 다시 저장하는 방식으로 진행했다.

예를 들어 original 이라는 함수가 있고, modifier라는 사용자 정의 변환 함수가 있다고 하면, 다음과 같이 원본 함수를 변경할 수 있다.

def original(...):
	...
original = modifier(original)

함수를 동일한 이름으로 다시 할당하기 때문에, 혼란스럽고 오류가 발생하기 쉽다. 이러한 이유로 새로운 구문이 추가되었다.

@modifier
def original(...):
	...

즉 데코레이터는 데코레이터 이후에 나오는 것을 데코레이터의 첫 번째 파라미터로 하고 데코레이터의 결과 값을 반환하게 하는 문법적 기능이다.

데코레이터의 구문은 독자가 한 곳에서 함수의 전체 정의를 찾을 수 있기 때문에 가독성을 크게 향상 시킨다.

일반적으로 데코레이터 구문을 사용하지 않고 기존 함수를 다시 할당하는 방식은 피하도록 하자. 특히, 함수를 재할당하는 코드가 원래 함수가 정의된 곳에서 멀리 떨어진 경우 코드를 읽기가 어려워진다.

위 예제에서 modifier는 파이썬 용어로 데코레이터라고 하고 original을 데코레이팅된 함수 또는 래핑된(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("%s 재시도", operation.__qualname__)
                    last_raised = e
            raise last_raised
        return wrapped

retry 데코레이터는 파라미터가 필요 없으므로 어떤 함수에도 쉽게 적용할 수 있다.

@retry
def run_operation(task):
	"""실행중 예외가 발생할 것으로 예상되는 특정 작업을 실행"""
    return task.run()

run_operation위에 있는 @retry는 실제로 파이썬에서 run_operation = retry(run_operation)을 실행하게 해주는 문법적 기능이다. 위 예제에서는 timeout같은 예외가 발생할 경우 여러 번 호출을 반복하는 retry로직을 데코레이터로 구현해봤다.

클래스 데코레이터

클래스 데코레이터를 사용할 때 다음과 같은 일반적인 장점이 존재한다.

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

예제

예제를 통해 클래스 데코레이터의 장점을 살펴보자

모니터링 플랫폼을 위한 이벤트 시스템은 각 이벤트의 데이터를 변환하여 외부 시스템으로 보내야한다. 그러나 각 이벤트 유형은 데이터 전송 방법에 특별한 점이 있을 수 있다. 특히 로그인 이벤트에는 자격 증명과 같은 중요한 정보를 숨겨야 한다. timestamp와 같은 필드는 특별한 포맷으로 표시하기 때문에 변환이 필요할 수도 있다.
이러한 요구 사항을 준수하기 위한 가장 간단한 방법은 각 이벤트마다 직렬화 방법을 정의한 클래스를 만드는 것이다.

class LoginEventSerializer:
  def __init__(self, event):
    self.event = event

  def serialize(self) -> dict:
    return {
        "username": self.event.username,
        "password": "**sensitive information**",
        "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() 메서드는 모든 이벤트 클래스에 있어야만 한다. 비록 믹스인을 사용해 다른 클래스로 분리할 수 있지만 상속을 제대로 사용했다고 볼 수 없다.

위 구현과 다른 방법은 이벤트 인스턴스와 변형 함수를 필터로 받아서 동적으로 객체를 만드는 것이다. 필터를 이벤트 인스턴스의 필드들에 적용해 직렬화하는 것이다. 각 필드를 변형할 함수를 만든 다음 이들을 조합해 직렬화 객체를 만든다.

from datetime import datetime

def hide_field(field)->str:
  return "**sensitive information**"

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.serialier = 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 Login_Event:
  username: str
  password: str
  ip : str
  timestamp:datetime

데코레이터를 사용하면 다른 클래스의 코드를 확인하지 않고도 각 필드가 어떻게 처리되는지 쉽게 알 수 있다. 클래스 데코레이터에 전달된 인수를 읽는 것만으로도 username과 ip는 수정되지 않고 password 필드는 숨겨지고, timestamp는 포매팅된다는 것을 알 수 있다.

다른 유형의 데코레이터

데코레이터 @구문이 실제로 무엇을 의미하는지 알았으므로 데코레이터가 단지 함수나 메서드, 클래스에만 적용되지 않는다는 것도 알 수 있다. 사실 제너레이터나 코루틴, 심지어 이미 데코레이트된 객체도 데코레이트 가능하다. 즉 데코레이터는 스택 형태로 쌓일 수 있다.

앞 예제는 데코레이터가 어떻게 연결될 수 있는지를 보여주는 예시이다. 먼저 클래스를 정의하고 @dataclass를 적용하여 속성의 컨테이너 역할을 하는 데이터 클래스로 변환한다. 그런 다음 @Serialization에서 serialize()메서드가 추가된 새로운 클래스를 반환한다.

고급 데코레이터

이번 섹션에서는 코드를 보다 깔끔하게 해주는 데코레이터의 고급 사용법에 대해서 알아보려고 한다.
데코레이터를 사용하여 관심사를 더 작은 기능으로 분리하고 코드를 재사용할 수 있다는 것을 알게 되었다. 그러나 이를 효율적으로 하려면 데코레이터에 파라미터를 추가할 수 있어야 한다.

데코레이터 인자 전달

파라미터를 갖는 데코레이터를 구현하는 방법은 여러 가지가 있지만 가장 일반적인 방법을 살펴볼 것이다.

첫 번째는 간접참조를 통해 새로운 레벨의 중첩 함수를 만들어 데코레이터의 모든 것을 한 단계 더 깊게 만드는 것이다.

두 번째 방법은 데코레이터를 위한 클래스를 만드는 것이다.

일반적으로 두 번째 방법이 가독성이 더 좋다. 왜냐하면 세 단계 이상 중첩된 클로저 함수보다 객체가 이해하기 쉽기 때문이다. 그러나 완벽을 기하기 위해 두 가지 모두를 살펴보려고 한다.

중첩 함수를 사용한 데코레이터

크게 보면 데코레이터는 함수를 파라미터로 받아서 함수를 반환하는 함수이다. 데코레이터에 파라미터를 추가하려면 다른 수준의 간접 참조가 필요하다. 첫 번째 함수는 파라미터를 받아서 내부 함수에 전달한다. 두 번째 함수는 데코레이터가 될 함수다. 세 번째는 데코레이팅의 결과를 반환하는 함수이다. 즉, 최소 세 단계의 중첩 함수가 필요하다.

앞선 예제에서 살펴봤던 재시도 기능을 살펴보려고 한다. 이전에는 재시도 횟수가 데코레이터 안에 고정되어 있었지만 이번에는 인스턴스마다 재시도 횟수를 지정하려고 하며 파라미터에 기본 값도 추가할 것이다. 이렇게 하려면 함수를 한 단계 더 추가해야 한다. 먼저 파라미터에 대한 것과 그리고 데코레이터 자체에 대한 것이다.

코드는 다음과 같은 형태가 된다.

@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[Sequence[Exception]] = 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_exception as e:
          logger.warning(
              "%s 재시도, 원인: %s",
              operation.__qualname__, e
          )
          last_raised = e
        raise last_raised
      return wrapped
    return retry

# decorator 사용
@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_retries_limit(task):
  return task.run()

@with_retry(
    retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError)
)
def run_with_custom_parameters(task):
  return task.run()

이와 같이 파라미터를 갖는 데코레이터를 구현하려고 할 때 중첩 함수를 사용하여 구현하는 방법을 가장 먼저 생각할 수 있다. 이 방법은 대부분의 경우에 잘 작동하지만, 이미 확인한 것처럼 새로운 함수가 추가될 때마다 들여쓰기가 추가되어 너무 많은 중첩 함수가 필요할 수 있다. 또한 함수는 상태를 저장하지 않기 때문에 객체가 하는 것처럼 내부 데이터를 관리하기가 어렵다.

데코레이터 객체

이번에는 두 번째 방법으로 언급했던 클래스를 사용하여 데코레이터를 정의하는 방법을 알아본다. 이 경우 __init__ 메서드에 파라미터를 전달한 다음 __call__이라는 매직 메서드에서 데코레이터의 로직을 구현하면 된다.

from typing import Optional
_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(self.retries_limit):
        try:
          return operation(*args, **kwargs)
        except self.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_with_custom_retries_limit(task):
  return task.run()

먼저 @연산 전에 전달된 파라미터를 사용해 데코레이터 객체를 생성한다. 데코레이터 객체는 __init__ 메서드에서 정해진 로직에 따라 초기화를 진행한다. 그 다음 @ 연산이 호출된다. 데코레이터 객체는 run_with_custom_retries_limit 함수를 래핑하여 __call__ 매직 메서드를 호출한다.
__call__ 매직 메서드는 앞의 데코레이터에서 하던 것처럼 원본 함수를 래핑하여 우리가 원하는 로직이 적용된 새로운 함수를 반환한다.

기본값을 가진 데코레이터

기본값을 사용하려면 다음과 같이 호출하면 된다.

@retry()
def my_function()

기본값을 호출할 때에도 괄호가 존재한다.

데코레이터의 파라미터는 기본값을 가지고 있는지에 관계없이 키워드 전용으로 하는 것이 좋다. 데코레이터를 적용할 때 각 값이 하는 일에 대한 컨텍스트 정보가 많지 않고, 위치 파라미터를 사용하면 변수의 의미를 명확히 알 수 없으므로, 보다 많은 정보를 담고 있는 키워드 파라미터를 사용하는 것이 좋다.

또는 데코레이터가 단일 위치 전용 파라미터를 갖도록 정의하면 명시적으로 호출 방식을 표현할 수 있다.

def retry(operation, /):...

데코레이터 활용 우수 사례

이번에는 데코레이터 활용의 우수 사례를 살펴보자. 일반적으로 데코레이터가 좋은 선택이 될 수 있는 경우들이다.

  • 파라미터 변환
    파라미터가 어떻게 처리되는지 세부사항을 숨기면서 함수의 서명을 변경하는 경우에 사용한다. 명확한 의도를 가지고 사용할 경우에만 유용하기 때문에 주의해서 사용해야 한다. 다시 말해 기존의 다소 복잡한 함수에 대해 데코레이터를 사용해서 명시적으로 좋은 서명을 제공하는 경우라면 클린 코드를 달성하기 위한 좋은 방법이다. 반면에 데코레이터를 잘못 사용해 함수의 서명이 실수로 변경된 경우는 피해야만 하는 상황이다.

  • 코드 추적
    파라미터와 함께 함수의 실행 경로를 로깅하려는 경우, 어쩌면 이미 여러 함수에서 제공하는 추적 기능에 익숙할지도 모른다. 이 기능은 종종 데코레이터 방식으로 제공된다. 이것은 기존 코드를 건드리지 않고 외부의 기능을 통합할 수 있는 강력한 추상화이자 좋은 인터페이스이다. 관련 기능들은 나만의 로깅/추적 기능을 데코레이터로 구현하는 데 참고가 될 수 있다.

  • 파라미터 유효성 검사
    데코레이터는 파라미터의 값이나 데이터 타입이 유효한지 투명하게 검사하는 데 사용될 수 있다. 데코레이터를 사용하면 계약에 의한 디자인을 따르면서 추상화의 전제조건을 강요하도록 할 수 있다.

  • 재시도 로직 구현
    이전 섹션에서 살펴본 것과 같은 방법으로 구현이 가능하다.

  • 일부 반복 작업을 데코레이터로 이동하여 클래스 단순화
    이는 DRY 원칙과 관련이 있다.

함수 서명 변경 예시

def resolver_function(root, args, contxt, info):
	helper = DomainObject(root, args, context, info)
    ...
    helper.process()

이 예제에서는 데코레이터에서 함수의 서명을 변경하여 해당 도메인 객체가 직접 전달되는 것처럼 할 수 있다.(helper 객체가 직접 전달되는 것처럼 만들 수 있다.)

이 경우 원래의 파라미터를 가로채서 도메인 객체를 만들고, 데코레이팅된 함수에 helper 객체를 전달하고 있다. 이제 원래의 함수는 이미 초기화된 helper 객체를 가진 것처럼 서명을 변경할 수 있다.

@DomainArgs
def resolver_function(helper):
	helper.process()

마치며

이번에는 데코레이터를 사용해서 코드를 더 간결하게 만드는 방법들에 대해서 살펴봤다. 아직은 데코레이터에 대한 이해가 완벽하지는 않지만, 데코레이터로 정의한 로직을 객체에 다양하게 사용할 수 있음을 배웠다.

profile
안녕하세요 박찬영입니다.

0개의 댓글