해쟈스토리

데코레이터를 사용한 코드 개선 본문

파이썬/클린코드

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

해쟈 2024. 9. 6. 13:16
728x90
반응형
  • 클래스 데코레이터는 코드 재사용과 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"),
		}

@dataclass
class LoginEvent:
	SERIALIZER = LoginEventSerializer

	username: str
	password: str
	ip: str
	timestamp: datetime

	def serializer(self) -> dict:
		return self.SERIALIZER(self).serialize()

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

  • 클래스가 너무 많아진다
    이벤트 클래스와 직렬화 클래스가 1:1로 매핑되어 있으므로 직렬화 클래스가 점점 많아지게 된다.

    이벤트 클래스의 필드(username, password등...)이 LoginEvent와 다른 어떤 이벤트 클래스가 생성되면, 직렬화 클래스를 그대로 가져다 쓸 수가 없다.  cmdEvent라는 클래스가 만들어지고 필드에 cmd라는게 추가되면, 직렬화 클래스의 serialize 메소드에서 return하는 dict가 cmd 필드를 가지지 않기 때문에 직력화 클래스도 만들어줘야한다.(LoginEventSerializer는 cmd라는 필드가 없다)

  • 유연하지 않다.
    만약 password를 가진 다른 클래스에서도 이 필드를 숨기려면 함수로 분리한 다음 여러 클래스에서 호출해야한다. 
def mask_password(data):
    if 'password' in data:
        data['password'] = "**민감한 정보 삭제**"
    return data

class LoginEventSerializer:
    def serialize(self):
        data = {
            "username": self.event.username,
            "password": self.event.password,
            # ...
        }
        return mask_password(data)

class PasswordChangeEventSerializer:
    def serialize(self):
        data = {
            "old_password": self.event.old_password,
            "new_password": self.event.new_password,
            # ...
        }
        return mask_password(data)

 

  • 표준화
    serialize() 메서드는 모든 이벤트 클래스에 있어야만 한다. 비록 믹스인을 사용해 다른 클래스로 분리할 수 있지만 상속을 제대로 사용했다고 볼 수 없다.

    Base 이벤트클래스를 만들고 serialize 메소드를 추상메소드로 만들어서 상속을 통해 구현할 수는 있다.
    인터페이스를 강제하는 것은 때때로 바람직하지 않을 수 있다. 특히 다중 상속이 필요한 상황에서는 복잡성이 증가할 수 있다.이러한 문제들은 시스템이 커지고 복잡해질수록 더욱 두드러진다. 따라서 더 유연하고 확장 가능한 설계 패턴을 고려해야 할 수 있다.

    예를 들어, 전략 패턴이나 데코레이터 패턴 등을 활용하여 이러한 문제들을 해결할 수 있다.

 

또 다른 방법은 이벤트 인스턴스와 변형함수를 필터로 받아서 동적으로 객체를 만드는 것이다.

아래 코드와 같이 각 필드를 변형할 함수(hide_field, format_time, show_original)를 만든 다음 

@Serialization(
usernames=show_original,
password=hide_field,
ip=show_original,
timestamp=format_time,
)
에서와 같이 필터를 넘긴다. 데코레이터를 하면 __init__가 실행되면서 전달된 키워드 인자들이 transformations 딕셔너리로 받아진다.
(**transformation은 키워드 인자 언패킹 문법으로 함수나 클래스 생성자에 전달된 키워드 인자들을 딕셔너리로 받는다.)

__init__의 내용을 보면 EventSerializer의 인스턴스를 생성한다. 이때 받은 딕셔너리를 넘긴다.

데코레이터로 Serialization 클래스를 사용했으므로 __call__메소드가 자동으로 호출된다.

event_class.serialize = serialize_method 

 
위 코드를 보면 event_class에 serialize라는 메소드를 생성 후 Serianlizae_method를 연결해준다.
event_class는 뒤에 나오는 데이터 클래스 LoginEvent가 된다. 즉, 해당 클래스에 serialize라는 메소드를 생성 후 Serianlizae_method를 연결해준다.

.

 

from datetime imposrt 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(
	usernames=show_original, 
	password=hide_field,
	ip=show_original,
	timestamp=format_time,
)
@dataclass
class LoginEvent:
	username: str
	password: str
	ip: str
	timestamp: datatime

그래서 위의 코드를 실제 사용하기 위해서는 아래와 같이 진행된다.

# 1. LoginEvent 인스턴스 생성
login_event = LoginEvent(
	username="user123".
	password="password",
	ip="192.168.1.1",
	timestamp=datetime.now()
)

# 2. serialize 메소드 호출
serialized_data = login_event.serialize()

# 3. 결과 출력
print(serialized_data)


####### 출력 결과
{
	'username': 'user123',
	'password': '**민감한 정보 삭제**',
	'ip': '192.168.1.1',
	'timestamp' : 2024-09-06 15:00'
}

데코레이터를 사용하면 다른 클래스의 코드를 확인하지 않고도 각 필들가 어떻게 처리되는지 쉽게 알 수 있다. 

클래스 데코레이터에 전달된 인수를 읽는 것만으로도 username과 ip는 수정 되지 않고, password 필드는 숨겨지고, timestamp는 포매팅 된다는 것을 알 수 있다.

 

 

고급 데코레이터

데코레이터를 사용하여 관심사를 더 작은 기능으로 분리하고 코드를 재사용할 수 있다는 것을 알게 되었다. 그러나 이를 효룰적으로 하려면 데코레이터에 파라미터를 추가할 수 있어야 한다.

데코레이터를 어떻게 하면 잘 사용할 수 있는지 보자.

 

데코레이터에 인자 전달

파라미터를 전달받아 로직을 추상화한다면 더욱 강력하게 데코레이터를 쓸 수 있다.

파라미터를 갖는 데코레이터를 구현하는 방법은 여러가지가 있다. 

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

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

 

일반적으로 두번째 방법이 가독성이 더 좋다. 왜냐하면 세 단계 이상 중첩된 클로저 함수보다 객체가 이해하기 쉽기 때문이다.

 

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

크게보면 데코레이터는 함수를 파라미터로 받아서 함수를 반환하는 함수이다. 함수형 프로그래밍에서 함수를 받아서 함수를 반환하는 함수를 고차함수라고 한다.

첫번째 함수는 파라미터를 받아서 내부 함수에 전달한다. 두번째 함수는 데코레이터가 될 함수다. 세번째는 데코레이팅의 결과를 반환하는 함수다.

 

def decorator_with_arguments(arg1, arg2):
    def real_decorator(func):
        def wrapper(*args, **kwargs):
            print(f"Decorator arguments: {arg1}, {arg2}")
            return func(*args, **kwargs)
        return wrapper
    return real_decorator

@decorator_with_arguments("hello", "world")
def my_function():
    print("Inside my_function")


my_function()
# 출력:
# Decorator arguments: hello, world
# Inside my_function

\@decorater_with_arguments("hello", "world")는 사실상 아래 코드와 같다.

my_function = decorator_with_arguments("hello", "world")

\@wraps 를 사용하는 이유

주요 목적은 데코레이터가 적용된 함수의 메타데이터를 보존하는 것

1. 메타데이터 보존:

   데코레이터를 사용하면 원래 함수가 새로운 함수로 대체된다. 이 과정에서 원래 함수의 이름, 문서열(docstring), 인자 목록 등의 메타데이터가 손실될 수 있다. `@wraps`는 이러한 메타데이터를 새 함수에 복사한다.
   아래 코드에서 예를 들면 run_operation함수에 데코레이터를 씌웠는데 이때 새로운 함수 wrapped로 대체되게 됨. run_operateion이 실행되는 것이지만 데코레이터에 의해 wrapped 함수의 메타데이터를 가지게 되는데 이를 방지할 수 있다.

<@wraps 사용시>

from functools import wraps

def with_retry():
    def retry(operation):
        @wraps(operation)
        def wrapped(*args, **kwargs):
            print("Retry logic here")
            return operation(*args, **kwargs)
        return wrapped
    return retry

@with_retry()
def run_operation():
    """This is run_operation's docstring."""
    print("Running operation")

print(run_operation.__name__)  # 출력: run_operation
print(run_operation.__doc__)   # 출력: This is run_operation's docstring.

<@wrapz 미사용시>

def with_retry():
    def retry(operation):
        def wrapped(*args, **kwargs):
            print("Retry logic here")
            return operation(*args, **kwargs)
        return wrapped
    return retry

@with_retry()
def run_operation():
    """This is run_operation's docstring."""
    print("Running operation")

print(run_operation.__name__)  # 출력: wrapped
print(run_operation.__doc__)   # 출력: None

2. 디버깅 및 내부 검사 지원:

   `@wraps`를 사용하지 않으면, 디버거나 내부 검사 도구가 원래 함수 대신 래퍼 함수를 보게 된다. 이는 디버깅을 어렵게 만들 수 있다.

 

3. 문서화 도구 지원:

   많은 문서화 도구들은 함수의 메타데이터를 사용한다. `@wraps`를 사용하지 않으면 이러한 도구들이 제대로 작동하지 않을 수 있다.

 

4. 일관성 유지:

   함수의 `__name__`, `__doc__`, `__module__` 등의 속성이 원래 함수와 일치하게 된다. 이는 코드의 일관성을 유지하는 데 도움이 된다.

 

따라서 `@wraps`를 사용하는 것은 좋은 관행이며, 특히 라이브러리나 프레임워크를 개발할 때 매우 중요하다. 이는 코드의 일관성을 유지하고, 디버깅과 문서화를 용이하게 만들어 준다.

 

def with_retry(retries_limit: int = _DEFAULT_RETRIES_LIMIT, allowed_exceptions: Optional[Sequence[Exception]] = None,):
	aloowed_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 allowd_exceptions ae 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=4, allowed_exceptions(ZeroDivisionError, AttributeError)
def reun_wint_custom_parameters(task):
	return task.run()

 

이 방법은 대부분의 경우 잘 동작하지만, 새로운 함수가 추가될 때마다 들여쓰기가 추가되어 너무 많은 중첩 함수가 필요할 수 있다.???

 

 

 

1. 복잡한 로직: 데코레이터 내부에 여러 단계의 로직이 필요한 경우.

2. 여러 데코레이터의 중첩: 여러 데코레이터를 함께 사용할 때 발생할 수 있는 복잡성.

3. 조건부 로직: 파라미터에 따라 다른 동작을 수행해야 하는 경우.

 

또한 함수는 상태를 저장하지 않기 때문에 객체가 아는 것처럼 내부 데이터를 관리하기가 어렵다.

 


파라미터를 받는 데코레이터는 함수로 구현하는 것보다는 클래스로 구현하는 것이 더 깔끔하다.

_DEFULT_RETRIES_LIMIT = 3
class WithRrtry:
	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):
		@wrapps(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

 


코루틴을 위한 데코레이터

파이썬은 거의 모든 것이 객체이기 때문에 거의 모든 것을 데코레이터로 꾸밀 수 있다. 코루틴도 마찬가지이다.

우리가 만든 데코레이터를 함수와 코루틴 모두에 적용하고 싶은 경우가 있다. 어쩌면 각각을 따로 지원하는 두개의 데코레이터를 만드는 것이 가장 좋은 방법일 수 있다.

그러나 사용자에게 보다 간단한 인터페이스를 제공하려면 내부적으로 어떤 데코레이터를 사용해야 하는지 분기해주는 디스패처 래퍼를 만들 수 있다. 마치 데코레이터를 위한 파사드를 만드는 것과 같다.

함수와 코루틴을 모두 지원하는 데코레이터를 만드는 것이 얼마나 어려운지에 대한 일반적인 결론은 없다. 데코레이터 자체의 로직에 따라 달라지기 때문이다.

 

코루틴을 위한 데코레이터인 경우 wrapper객체도 코루틴이어야한다.

무슨말인지 코드로 보자

X, Y = 1, 2

def decorator(callable):
	"""고정된 X와 Y값으로 <callable> 호출 """
	
	@wraps(callable)
	def wrapped():
		return callable(X,Y)
	return wrapped

@decorator
def func(x, y):
	return x + y

@decorator
async def coro(x, y):
	return x + y

위 예제를 보면 coro 코루틴에서 async를 하지만 await로 기다리는 부분이 없다. 

 

💡 일반 적으로 데코레이터에서 받은 꾸며진 객체는 같은 종류의 객체로 교체해야 한다. 
즉, 함수를 받았다면 함수로, 코루틴을 받았다면 코루틴으로 교체한다.

 

 

데코레이터를 위한 확장 구문

데코레이터의 내부 함수 정릐를 데코레이터 호출 시 람다 표현식으로 대체할 수 있다.

def _log(f, *args, **kwargs):
	print(f"함수 이름: {f.__qualname__!r}, 파라미터: {args=} 와 {kwargs=}")
	return f(*args, **kwargs)

@(lambda f: lambda *args, **kwargs: _log(f, *args, **kwargs))
def func(x):
	return x + 1

>> func(3)
함수 이름: 'func', 파라미터: args=(3,) 와 kwargs={}

 

데코레이터 활용 우수 사례

함수 서명 변경

레거시 코드에 복잡한 서명으로 정의된 함수가 있다. 클린코드 측면에서 서명이 복잡한 것은 좋지 않아 리펙토링을 하고 싶지만 여러 곳에서 이미 이 더러운? 함수를 사용하고 있어서 모든 파라미터 부분을 캡슐화하소 우리 애플리케이션에 알맞은 동작으로 변환하는 추상화를 하는 것이 좋겠다고 결정했다.

기존의 코드가 객체를 받아서 많은 파라미터로 분해하여 사용하고 있다면, 기존 코드를 모두 리펙토링 하는 것 보다는 데코레이터를 중간 레이어로서 사용하는 것이 좋은 선택일 수 있다.

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


# 데코레이터로 서명 변경
@DomainArgs
def resolver_func(helper):
	helper.process()
	...

omainArgs는 resolver_func를 감싼 새로운 함수를 반환한다. 이 함수는 root, args, context, info를 받는다. 

이 인자를 사용해 DomainObject 인스턴스를 생성한다. 생성된 DomainObject 입스턴스를 원래의 resolver_func에 전달한다.

DomainArgs 데코레이터 코드는 다음과 같다.

 

class DomainObject:
	def __init__(self, root, args, context, info):
		self.root = root
		self.args = args
		self.context = context
		self.info = info

	def process(self):
		pass

def DomainArgs(resolver_func):
	@wraps(resolver_func)
	def wrapper(root, args, context, info):
		helper = DomainObject(root, args, context, info)
		return resolver_func(helper)
	return wrapper

 

파라미터 유효성 검사

from functools import wraps
from typing import Dict, Any, Callable

def validate_parameters(**validators: Dict[str, Callable[[Any], bool]]):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # 함수 시그니처에서 파라미터 이름 가져오기
            import inspect
            param_names = inspect.signature(func).parameters.keys()
            
            # 위치 인자를 키워드 인자로 변환
            all_args = dict(zip(param_names, args))
            all_args.update(kwargs)
            
            for param, validator in validators.items():
                if param in all_args:
                    value = all_args[param]
                    if not validator(value):
                        raise ValueError(f"Invalid value for parameter '{param}': {value}")
            
            return func(*args, **kwargs)
        return wrapper
    return decorator

# 유효성 검사 함수들
def is_positive(x):
    return isinstance(x, (int, float)) and x > 0

def is_in_range(start, end):
    return lambda x: start <= x <= end

def is_string(x):
    return isinstance(x, str)

# 데코레이터 사용 예시
@validate_parameters(
    age=is_positive,
    name=is_string,
    score=is_in_range(0, 100)
)
def register_student(name: str, age: int, score: float):
    print(f"Registering student: {name}, Age: {age}, Score: {score}")

# 테스트
try:
    register_student("Alice", 20, 85.5)  # 유효한 입력
    register_student("Bob", -5, 110)  # 유효하지 않은 입력
except ValueError as e:
    print(f"Validation Error: {e}")

위치인자를 키워드 인자로 변환함으로써 일관성을 유지했다.

모든 인자에 대해 유효성 검사를 수행해야하는데 만약 키워드 인자가 있는 함수를 데코레이터로 감싸면 내부 동작이 달라지니까 모든 인자를 키워드 인자로 바꿔서 키워드인자가 있든 없든 관계없이 동일하게 동작하도록 하게 했다.

 

코드 추적

  • 함수의 실행 경로 추적(예를 들어 실행 함수 로깅)
  • 함수 지표 모니터링(예를 들어 CPU 사용률이나 메모리 사용량 등)
  • 함수의 실행 시간 측정
  • 언제 함수가 실행되고 전달된 파라미터는 무엇인지 로깅

데코레이터의 활용 - 흔한 실수 피하기

래핑된 원본 객체의 데이터 보존

데코레이터를 함수에 적용할 때 가장 많이 실수하는 것 중에 하나는 원본 함수의 일부 프로퍼티나 또는 속성을 유지하지 않아 원하지 않는 부작용을 유발한다는 것이다.

계속된 예제에서 @wraps 가 있었다. 이것을 하는 이유가 래핑된 원본 객체의 데이터를 보존하기 위함이다.

@trace_decorator
def process_account(account_id: str):
	"""id별 계정 처리"""
	logger.info("계정 처리", account_id)
	...

위와 같은 trace_decorator가 있다.  이는 process_account를 감쌌다. 이때 process_account에 대해 help메소드를 실행하면 docstring이 그대로 나올거 같지만 trace_decorator의 docstring이 나오게 된다.

또한 어떤 이름으로 함수가 호출됬는지 알기 위해 __qualname__속성을 보면 trace_decorator.<locals>.wrapped 로 나오게 된다. 

(__name__ 속성은 함수의 이름을 반환하지만 __qualname__ 속성은 클래스의 정규화된 이름을 반환한다. A클래스에 myfunc메소드가 있을 경우, A.myfunc을 얻는다)

이렇게 되면 개별 함수에 대해서 로깅을 하거나 다른 속성을 활용하고 싶을 때에도 원래의 함수를 알 수 없으므로 디버깅이 어려워지는 문제가 있다.

 

또한 docstring이 제대로 나와야 doctest를 진행할 수 있는데 @wraps를 안해주면 doctest를 할 수 없다.

Doctest란

Doctest는 docstring(문서화 문자열) 내에 예제 코드와 예상 출력을 작성하는 방식으로 테스트를 구성한다.
Python 인터프리터 세션을 시뮬레이션하는 형태로 작성된다.

 

장점:
   - 문서화와 테스트를 동시에 할 수 있어 효율적이다
   - 예제가 항상 최신 상태로 유지된다 (테스트가 실패하면 알 수 있으므로).
   - 별도의 테스트 파일 없이 코드 내에 테스트를 포함할 수 있다.

 

실행 방법:
   - 코드 내에서: `doctest.testmod()`를 호출하여 실행
   - 명령줄에서: `python -m doctest your_file.py`

 

Doctest는 간단한 함수나 모듈의 테스트에 매우 유용하며, 문서화와 테스트를 동시에 할 수 있어 코드의 품질과 유지보수성을 높이는 데 도움을 준다.

그러나 복잡한 테스트 시나리오나 대규모 테스트 스위트에는 unittest나 pytest 같은 더 강력한 테스트 프레임워크를 사용하는 것이 좋다.

 

def add(a, b):
    """
    두 수를 더합니다.

    >>> add(2, 3)
    5
    >>> add(-1, 1)
    0
    >>> add(0, 0)
    0
    """
    return a + b

if __name__ == "__main__":
    import doctest
    doctest.testmod()

이를 방지하고자 데코레이터의 내부 함수에 @wraps를 붙이는 것이다.

728x90
반응형