[python cleancode] 5. 데코레이터를 사용한 코드 개선

햄도·2021년 4월 11일
1

Python Cleancode

목록 보기
5/9

출처

파이썬 클린코드를 읽으며 정리한 내용입니다.

  • 데코레이터의 정의, 동작, 구현과 실제 디자인 개선 사례
  • 앞서 나온 소프트웨어 디자인에 데코레이터가 어떻게 도움이 될 수 있는지

파이썬의 데코레이터

  • 함수와 메서드의 기능을 쉽게 수정하기 위한 수단으로 고안
def original():
    pass

def modifier(org):
    pass

original = modifier(original)
  • 이러한 방식은 혼란스럽고, 오류가 발생하기 쉽다.
  • 위 예시는 아래와 같이 작성하면 된다.
@modifier
def original():
    pass
  • 데코레이터는 데코레이터 이후에 나오는 것을 첫 번째 파라미터로 하고, 데코레이터의 결과 값을 반환한다.
  • 함수뿐만 아니라 메서드, 제너레이터, 클레스에도 데코레이터를 적용할 수 있다.

함수 데코레이터

  • 데코레이터를 사용하는 가장 간단한 방법
  • 함수에 데코레이터를 사용하면?
    • 파라미터 유효성, 사전조건 검사
    • 기능 전체를 새롭게 정의
    • 서명 변경
    • 원래 함수 결과 캐시
# 특정 예외에 대해서 일정 횟수 재시도하는 데코레이터 예시
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()

클래스 데코레이터

  • 함수뿐만 아니라 클래스에도 데코레이터를 사용할 수 있다. 이 경우 데코레이터 함수의 파라미터는 클래스가 된다.
  • 클래스 데코레이터는 남용할 경우 코드를 더 복잡하게 만들 수 있지만, 일단은 장점을 보자.
  • 클래스 데코레이터의 장점
    • 코드 재사용과 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()
  • 이 방법은 처음엔 잘 동작하지만, 시스템을 확장할수록 다음과 같은 문제가 발생한다.
    • 클래스가 점점 많아진다.
    • 한 클래스에 만들어진 함수를 다른곳에서 재사용하기 어렵다.
    • 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 filed, transformation in
            self.serialization_fields.items()
        }
    
class Serialization:
    def __init__(self, **transformations):
        self.serializer = EventSerializer(transformations)
    
    # 인자로 받은 event_class에 serialize를 주입
    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
  • 이렇게 데코레이터를 사용하면 다른 클래스의 코드를 확인하지 않고도 각 필드가 어떻게 처리되는지 쉽게 알 수 있다.
  • 추가로 클래스의 속성을 정의하는 것만으로 init 메소드의 로직을 구현해주는 클레스 데코레이터도 있다. python 3.7 이상에서 @dataclass를 이용해 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

다른 유형의 데코레이터

  • 데코레이터는 함수, 메서드, 클래스뿐만 아니라 제너레이터, 코루틴, 심지어 이미 데코레이트된 객체에도 사용할 수 있다.

  • 앞의 예시에 나온것처럼, 데코레이터는 스택 형태로 쌓일 수 있다.

  • 데코레이터가 코루틴으로 사용되는 것도 좋은 예시이다. 새로 생성된 제너레이터에 데이터를 보내기 전 next()를 호출하는 작업은 잊어버리기 쉬운데, 이 경우 제너레이터를 파라미터로 받아 next()를 호출한 후 제너레이터를 반환하는 데코레이터를 만들면 쉽게 해결된다.

    • 코루틴이란? cooperative routine를 의미하는데 서로 협력하는 루틴이라는 뜻. 즉, 메인 루틴과 서브 루틴처럼 종속된 관계가 아니라 서로 대등한 관계이며 특정 시점에 상대방의 코드 실행.

      https://dojang.io/pluginfile.php/13976/mod_page/content/3/041002.png

데코레이터에 인자 전달

  • 데코레이터가 파라미터를 전달받아 로직을 추상화할 수 있다면 더욱 강력해질것이다.
  • 파라미터를 갖는 데코레이터는 다음 두 가지 방법으로 구현할 수 있다.
    1. 간접 참조를 통해 새로운 레벨의 중첩 함수를 만들어 데코레이터를 한 단계 깊게 만든다.
    2. 데코레이터를 위한 클래스를 만든다.
  • 두 번째 방법이 일반적으로 가독성이 더 좋다.

중첩 함수의 데코레이터

  • 파라미터를 데코레이터에 전달하려면 세 단계의 중첩 함수가 필요하다.

    • 첫 번째 함수: 파라미터를 받아서 내부 함수에 전달
    • 두 번째 함수: 데코레이터가 될 함수
    • 세 번째 함수: 데코레이팅 결과 반환 함수
  • @retry(arg1, arg2, ...) 이 구문은 다음과 같다. <original_function> = retry(arg1, arg2, ...)(<original_function>)

    • 이런 구조라서 먼저 첫 번째 함수로 arg를 받아 저장해두고, 리턴되는 두 번째 함수로 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.info("retrying %s due to %s", operation, e)
                    last_raised = e
            raise last_raised
        return wrapped
    return retry

데코레이터 객체

  • 세 단계의 중첩함수보다 깔끔하게 구현하려면 클래스를 사용할 수 있다.
  • __init__() 메소드에 파라미터를 전달하고, __call__()에서 데코레이터 로직을 구현하면 된다.
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(retries_limit):
                try:
                    return operation(*args, **kwargs)
                except allowed_exceptions as e:
                    logger.info("retrying %s due to %s", operation, e)
                    last_raised = e
            raise last_raised
        return wrapped
  • 파이썬 구문은 다음과 같이 처리된다.
    1. @ 연산 전 전달된 파라미터를 사용해 데코레이터 객체를 생성한다.
    2. 데코레이터 객체는 __init__에서 정해진 로직에 따라 초기화를 진행한다.
    3. @ 연산이 호출되면, 데코레이터 객체는 run_with_custom_retries_limit 함수를 래핑해 __call__메서드를 호출한다.
    4. __call__ 은 원본 함수를 래핑하여 새로운 함수를 반환한다.

데코레이터 활용 우수 사례

  • 어떤 경우에 데코레이터가 좋은 선택이 될 수 있을까?
    • 파라미터 변환: 파라미터가 어떻게 처리되고 변환되는지 캡슐화하여 숨길 수 있다.
    • 코드 추적: 파라미터와 함께 함수의 실행을 로깅하려는 경우
    • 파라미터 유효성 검사
    • 재시도 로직 구현
    • 일부 반복 작업을 데코레이터로 이동하여 클래스 단순화

파라미터 변환

  • 데코레이터를 이용하여 파라미터의 유효성을 검사하거나, DbC의 원칙을 따라 사전조건, 사후조건을 강제할 수 있다.
  • 특히 유사한 객체를 반복적으로 생성하거나, 추상화를 위해 유사한 변형을 반복하는 경우 사용하면 좋다.

코드 추적

  • 여기서 말하는 추적이란 다음과 같은 시나리오에서 사용된다.
    • 실제 함수의 실행 경로 추적
    • CPU 사용률 등 함수 지표 모니터링
    • 함수의 실행 시간 측정
    • 언제 함수가 실행되고, 전달된 파라미터는 무엇인지 로깅

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

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

  • 데코레이터를 함수에 적용할 때 많이 실수하는 것 중 하나는 원본 함수의 일부 프로퍼티나 속성을 유지하지 않는 것이다.
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):
    """id별 계정 처리"""
    logger.info("%s 계정 처리", account_id)
help(process_account)
>>> 
Help on function wrapped in module __main__:

wrapped(*args, **kwargs)
print(process_account.__name__)
>>> wrapped
print(process_account.__qualname__)
>>> trace_decorator.<locals>.wrapped
  • 데코레이터가 원본 함수를 wrapped로 변경했기 때문에, 원본 함수의 이름을 확인할 수 없고 docstring도 사라진다.
  • 이것을 수정하려면, 간단히 wrapped 함수에 @wraps 데코레이터를 적용해주면 된다.
  • 이렇게 하면 function 파라미터 함수를 래핑한 것이라고 알려줄 수 있다.
from functools import wraps
def trace_decorator(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("%s 실행", function.__qualname__)
        return function(*args, *kwargs)
    return wrapped
@trace_decorator
def process_account(account_id):
    """id별 계정 처리"""
    logger.info("%s 계정 처리", account_id)
help(process_account)
>>> 
Help on function process_account in module __main__:

process_account(account_id)
    id별 계정 처리
print(process_account.__name__)
>>> process_account
print(process_account.__qualname__)
>>> process_account
process_account.__wrapped__
>>> <function __main__.process_account(account_id)>

데코레이터 부작용 처리

데코레이터 부작용의 잘못된 처리

  • 데코레이터 바깥에 로직을 넣는 경우, 부작용이 발생할 수 있다.
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
  • 위와 같은 함수는 언뜻보기엔 문제가 없어보이지만, 모듈 임포트 시 wrapped에 들어가있지 않은 두 줄이 실행된다.
  • 따라서 시도때도 없이 로그가 찍히고, start_time은 모듈 임포트 시의 시간으로 고정된다.
  • 이를 해결하기 위해서는 다음과 같이 모든 로직을 wrapped 안으로 넣어주면 된다.

데코레이터 부작용의 활용

  • 이러한 부작용을 활용할 수 있는 케이스도 있다.
  • 대표적인 예시는 모듈의 공용 레지스트리에 객체를 등록하는 경우이다.
  • 앞서 나왔던 이벤트 시스템에서, 레지스트리에 등록된 일부 이벤트만 사용하려는 경우를 고려해보자. 이 경우 클래스마다 플래그 표시를 하는 대신 데코레이터를 사용해 명시적으로 표시할 수 있다.
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):
    """사용자가 시스템에서 나갈 때 발생"""
  • 이 모듈을 임포트하면 register_event 데코레이터를 사용한 클래스가 EVENT_REGISTRY에 들어가게 된다.
  • 위 코드만 봐서는 이해하기 어렵지만, 이런 패턴이 필요한 경우도 있다.
  • 위와 같이 데코레이터가 래핑된 객체를 변경하지 않는 경우에도 꼭 객체를 외부에 노출하는 코드가 있어야 한다.

어느 곳에서나 동작하는 데코레이터 만들기

  • 데코레이터를 만들면 한 데코레이터를 함수나 클래스, 메서드 또는 정적 메서드 등 여러 곳에 재사용하고 싶어질 수 있다.
  • *args**kwargs 서명을 사용하여 데코레이터를 정의하면 모든 경우에 사용할 수 있다. 하지만 가독성 측면에서나 사용성 측면에서나 원래 함수의 서명과 비슷하게 데코레이터를 정의하는 게 더 좋을 수 있다.
  • 파라미터를 받아 특정 객체를 생성하는 경우가 많으면, 파라미터를 객체로 변환해주는 데코레이터를 만들어 중복을 제거할 수 있다.
import logging
from functools import wraps

logger = logging.getLogger(__name__)

class DBDriver:
    def __init__(self, dbstring):
        self.dbstring = dbstring
    
    def execute(self, query):
        return f"{self.dbstring}에서 쿼리 {query} 실행"
    
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("test_function")
run_query("test_db")
>>> 'test_db에서 쿼리 test_function 실행'
  • 위와 같이 데코레이터를 함수에 사용하는 경우는 잘 동작한다.
  • 하지만 같은 데코레이터를 클래스 메서드에 적용하면 인자에 self가 있어 동작하지 않는다.
class DataHandler:
    @inject_db_driver
    def run_query(self, driver):
        return driver.execute(self.__class__.__name__)
  • 이를 해결하기 위해서는 디스크립터 프로토콜을 구현한 데코레이터 객체를 만들 수 있다.
class inject_db_driver:
    """데이터베이스 dns 문자열을 받아 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
        # instance에 function을 동적으로 bind하여 리턴한 결과를 __class__로 다시 래핑
				# 이것을 사용하려는 클래스에 클래스 변수로 넣어주면 호출될 때 __get__호출
				return self.__class__(MethodType(self.function, instance))
			
  • 디스크립터에 대해서는 호출한 객체를 메서드에 다시 바인딩한다는 정도만 알면 된다.
  • 함수는 __get__메서드를 사용하지 않기 때문에 여전히 잘 동작한다.

데코레이터와 DRY 원칙

  • 데코레이터를 사용하여 특정 로직을 분리된 컴포넌트로 추상화하는 방법은 코드를 재사용할 수 있도록 한다. 이것은 DRY 원칙을 잘 따른다.
  • 하지만 데코레이터는 코드의 복잡성을 증가시키기 때문에 복잡성이 가치가 있을 때에만 사용해야 한다.
  • 따라서 다음과 같은 경우에 데코레이터 사용을 고려하자.
    • 처음부터 데코레이터를 만들지 않는다. 패턴이 생기고 어떻게 추상화할지가 명확해지면 그 때 리팩토링을 한다.
    • 데코레이터가 적어도 3회 이상 필요한 경우에만 구현한다.
    • 데코레이터 코드를 최소한으로 유지한다.

데코레이터와 관심사의 분리

  • SRP는 데코레이터에도 적용된다. 데코레이터 또한 오직 한 가지 일만 해야 하며, 그 일을 잘 해야 한다.

좋은 데코레이터 분석

  • 훌륭한 데코레이터가 갖춰야 할 특성은 다음과 같다.
    • 캡슐화와 관심사의 분리: 실제로 하는 일과 데코레이팅 하는 일의 책임을 명확히 구분해야 한다.
    • 독립성: 데코레이터가 하는 일은 독립적이어야 하고, 데코레이팅되는 객체와 최대한 분리되어야 한다.
    • 재사용성: 여러 유형에 적용되는 형태가 바람직하다.
  • 잘 쓰인 데코레이터 예시
    • Celery의 @app.task: 이 데코레이터는 많은 로직과 코드를 래핑하지만 그 중 어떤것도 래핑하는 함수와 관련이 없다. 또한 아무도 데코레이터가 하는 일을 살펴볼 필요가 없다.
    • 웹 프레임워크의 @route: 이것을 이용해 데코레이팅된 함수는 url로 등록된다. 이 또한 래핑하는 함수와 관련이 없으며, 함수를 매퍼에 등록하여 url에 연결하거나 원래 함수 서명을 변경하여 http 요청 객체를 수신하게 해준다. 이를 통해 더 깔끔한 인터페이스를 제공한다.
  • 예시를 통해 알 수 있듯이, 데코레이터는 깔끔한 프로그래밍 인터페이스를 정의하는 훌륭한 방법이다.

요약

  • 데코레이터는 강력한 도구.
  • 함수를 위한 데코레이터를 만들 때에는 원래 함수의 서명과 일치하게 만드는 것이 좋다.
  • 데코레이터는 코드를 재사용하여 DRY 원칙을 다르는 데에 유용하지만, 복잡성이 존재하기 때문에 신중하게 사용해야 한다.
  • 깔끔한 인터페이스를 만들기 위해 사용해도 좋다.
profile
developer hamdoe

0개의 댓글