파이썬의 데코레이터
데코레이터는 데코레이터 이후에 나오는 것을 데코레이터의 첫 파라미터로 하고 데코레이터의 결과 값을 반환한다.
함수 데코레이터
함수에 데코레이터를 사용하면 어떤 종류의 로직이라도 적용할 수 있다.
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 데코레이터는 파라미터가 필요 없으므로 어떤 함수애도 쉽게 적용할 수 있다.
@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": "**redacted**",
"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()위 예제는 LoginEvent의 데이터에 대해 직렬화를 수행한다. 로그인 이벤트에 직접 매핑할 클래스를 선언하여 사용했다.
이 방법은 시스템을 확장할수록 다음과 같은 문제가 발생한다.
- 클래스가 너무 많아진다 : 이벤트 클래스와 직렬화 클래스가 1 : 1로 매핑되어 있으므로 직렬화 클래스가 많아지게 된다.
- 충분히 유연하지 않다 : 만약
password를 가진 다른 클래스에서도 이 필드를 숨기고자 한다면 함수로 분리한 후 여러 클래스에서 호출해야 한다. 이는 코드를 충분히 재사용했다고 볼 수 없다. - 표준화 :
serialize()메서드는 모든 이벤트 클래스에 있어야만 한다. 믹스인을 사용해 다른 클래스로 분리할 수 있지만 상속을 제대로 사용했다고 볼 수 없다.
다른 방법은 이벤트 인스턴스와 변형 함수를 필터로 받아서 동적으로 객체를 만드는 것이다.
def hide_field(field) -> str:
return "**redacted**"
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=str.lower,
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데코레이터를 사용하면 다른 클래스의 코드를 확인하지 않고도 각 필드가 어떻게 처리되는지 쉽게 알 수 있다.
파이썬 3.7 이상의 버전에서의 데코레이터를 사용하면 init함수의 템플릿화된 단순 코드를 작성하지 않고도 앞의 예제를 보다 간단하게 작성할 수 있다.
from dataclasses import dataclass
from datetime import datetime
@Serialization(
username=str.lower,
password=hide_field,
ip=show_original,
timestamp=format_time,
)
@dataclass
class LoginEvent:
username: str
password: str
ip: str
timestamp: datetime데코레이터에 인자 전달
파라미터를 갖는 데코레이터를 구현하는 일반적인 방법중 첫 번째는 간접 참조(indirection)를 통해 새로운 레벨의 중첩 함수를 만들어 데코레이터의 모든 것을 한 단게 더 깊게 만드는 것이다. 두 번째 방법은 데코레이터를 위한 클래스를 만드는 것이다. 일반적으로 두 번째 방법이 가독성이 더 좋다.
중첩 함수의 데코레이터
데코레이터에 파라미터를 전달하기 위해서는 최소 세 단계의 중첩 함수가 필요하다.
여기에서 첫 번째 함수는 파라미터를 받아 내부 함수에 전달한다. 두 번째 함수는 데코레이터가 될 함수이다. 세 번째는 데코레이팅의 결과를 반환하는 함수이다.
파라미터와 기본 값을 가지는 새로운 데코레이터 구현은 다음과 같다
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.warning(
"retrying %s due to %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 run_with_custom_parameters(task):
return task.run()데코레이터 객체
중첩 함수 방식의 파라미터 전달은 세 단계의 중첩된 함수가 필요하다.
이것을 보다 깔끔하게 구현하기 위해 클래스를 사용하여 데코레이터를 정의할 수 있다. 이 경우 __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(self.retries_limit):
try:
return operation(*args, **kwargs)
except self.allowed_exceptions as e:
logger.info(
"retrying %s due to %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()데코레이터 활용 우수 사례
- 파라미터 변환 : 더 좋은 API를 노출하기 위해 함수의 시그니처를 변경하는 경우 이 때 파라미터가 어떻게 처리되고 변환되는지를 캡슐화하여 숨길 수 있다.
- 코드 추적 : 파라미터와 함께 함수의 실행을 로깅하려는 경우
- 파라미터 유효성 검사
- 재시도 로직 구현
- 일부 반복 작업을 데코레이터로 이동하여 클래스 단순화
파라미터 변환
데코레이터를 사용하여 파라미터의 유효성을 검사할 수 있다. DbC의 원칙에 따라 사전조건 또는 사후조건을 강제할 수도 있다. 따라서 일반적으로 파라미터를 다룰 때 데코레이터를 자주 사용한다.
특히 유사한 객체를 반복적으로 생성하거나 추상화를 위해 유사한 변형을 반복하는 경우 데코레이터를 사용하면 작업을 쉽게 처리할 수 있다.
코드 추적
- 실제 함수의 실행 경로 추적 (실행 함수 로깅)
- 함수 지표 모니터링 (CPU 사용량, 메모리 사용량 등)
- 함수의 실행 시간 측정
- 함수의 실행 시각, 파라미터 종류 로깅)
데코레이터의 활용 - 흔한 실수 피하기
래핑된 원본 객체의 데이터 보존
데코레이터를 함수에 적용할 때 원본 함수의 일부 프로퍼티 또는 속성을 유지하지 않아 원하지 않는 부작용을 유발하는 경우가 있다.
def trace_decorator(function):
def wrapped(*args, **kwargs):
logger.info("running %s", function.__qualname__)
return function(*args, **kwargs)
return wrapped위 데코레이터를 사용한 함수의 경우 __qualname__ 속성을 사용해 함수의 이름을 출력 할 경우 원본 함수의 이름이 아닌 새로운 함수의 이름을 출력하게 된다.
@trace_decorator
def process_account(account_id):
"""Id별 계정 처리"""
logger.info("processing account %s", account_id)
...>>> print(process_account.__qualname__)
'trace_decorator.<locals>.wrapped'이 데코레이터를 이름이 다른 여러 함수에 적용하더라도 wrapped라는 이름만 출력하게 된다. 이렇게 되면 개별 함수를 확인하고 싶은 경우에 실제 함수를 알 수 없으므로 오히려 디버깅이 더 어려워지는 문제가 생긴다.
또 다른 문제는 이러한 함수에 테스트와 함께 docstring을 작성한 경우 데코레이터에 의해 덮어써진다는 점이다.
이 것을 수정하기 위해서는 래핑된 함수 wrapped 함수에 @wraps 데코레이터를 적용하여 function 파라미터 함수를 래핑한 것이라고 명시해 주어야 한다.
def trace_decorator(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("running %s", function.__qualname__)
return function(*args, **kwargs)
return wrapped이렇게 코드를 변경하면 함수의 이름이 보존되며, wraps 데코레이터의 __wrapped__를 사용해 수정되지 않은 원본 함수에도 접근할 수 있다. 일반적인 데코레이터의 경우 아래의 구조에 따라 functiongools.wraps를 추가하면 된다.
def decorator(original_function):
@wraps(original_function)
def decorated_function(*args, **kwargs):
# 데코레이터에 의한 수정 작업 ...
return original_function(*args, **kwargs)
return decorated_function데코레이터 부작용 처리
데코레이터 부작용의 잘못된 처리
def traced_function_wrong(function):
logger.debug("%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위의 예제는 함수의 실행과 실행 시간을 로깅하는 데코레이터이다. 여기에서 start_time는 모듈을 처음 임포트할 때의 시간이 된다. 때문에 함수가 실제로 호출될 때가 아니라 잘못된 시점에 기록되게 된다.
start_time = time.time() 코드를 래핑된 함수 내부로 이동시켜 실행을 지연시키면 문제는 해결된다.
def traced_function(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("%s 함수 실행", function)
start_time = time.time()
result = function(*args, **kwargs)
logger.info(
"함수 %s의 실행시간: %.2fs", function, time.time() - start_time
)
return result
return 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):
"""사용자가 시스템에서 나갈 때 발생하는 이벤트"""위 코드에서 처음에 EVENTS_REGISTRY는 비어있는 것처럼 보이지만 이 모듈을 임포트하면 register_event 가 지정된 클래스들로 채워지게 된다. EVENTS_REGISTRY는 모듈을 임포트한 직후에 최종 값을 가지므로 코드만 봐서는 값을 쉽게 예측하기 어렵다.
이러한 동작 방식이 문제가 되는 경우도 있지만 이 패턴이 필요한 경우도 존재한다.
어느 곳에서나 동작하는 데코레이터 만들기
함수에 사용될 데코레이터를 클래스의 메서드에 사용하거나 또는 메서드에 대한 데코레이터를 다른 유사한 메서드에 적용하려는 경우 오류가 발생할 수 있다.
데코레이터를 만들 때에는 일반적으로 재사용을 고려하여 함수뿐만 아니라 메서드에서도 동작하기를 바란다.
*args와 **kwargs 시그니처를 사용하여 데코레이터를 정의하면 모든 경우에 사용할 수 있다. 하지만 다음 이유들로 원래 함수의 시그니처와 비슷하게 데코레이터를 정의하는 것이 좋을 때가 있다.
- 원래의 함수와 모양이 비슷하기 때문에 읽기가 쉽다.
- 파라미터를 받아 작업할 시
*args와**kwargs를 사용하는 것이 불편하다.
from functools import wraps
class DBDriver:
def __init__(self, dbstring):
self.dbstring = dbstring
def execute(self, query):
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("test_function")함수에 문자열을 전달하면 DBDriver인스턴스를 반환하므로 예상한 것처럼 동작한다.
하지만 이 데코레이터를 클래스 메서드에 적용할 경우 동작하지 않는다.
class DataHandler:
@inject_db_driver
def run_query(self, driver):
return driver.execute(self.__class__.__name__)클래스의 메서드는 self라는 특수한 변수를 항상 첫 번째 파라미터로 받는다.
따라서 하나의 파라미터만 받도록 설계된 이 데코레이터는 연결 문자열 자리에 self 를 전달하고, 두 번째 파라미터에는 아무것도 전달하지 않아 에러가 발생한다.
이 문제의 해결책은 데코레이터를 클래스 객체로 구성하고 __get__ 메서드를 구현한 디스크립터 객체를 만드는 것이다.
from functools import wraps
from types import MethodType
class inject_db_driver:
"""문자열을 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
return self.__class__(MethodType(self.function, instance))데코레이터와 DRY 원칙
데코레이터를 사용하면 특정 로직을 분리된 컴포넌트로 추상화할 수 있다. 이것의 가장 큰 장점은 여러 객체에 데코레이터를 적용하여 코드를 재사용할 수 있다는 것이다. 이것은 특정 기능을 한번만 정의하기 때문에 DRY(Don’t Repeat Yourself) 원칙을 잘 따른다.
코드 재사용을 위해서 데코레이터를 사용할 때에는 실질적으로 코드 사용량을 줄일 수 있다는 확신이 있어야 한다.
모든 데코레이터, 특히 신중하게 설계되지 않은 데코레이터는 코드의 복잡성을 증가시킨다. 따라서 그다지 재사용할 필요가 없을 경우 별개의 함수나 작은 클래스로도 충분한 경우가 있다.
다음과 같은 사항을 고려했을 경우에만 데코레이터 사용을 하는 것이 좋다.
- 처음부터 데코레이터를 만들지 않는다. 패턴이 생기고 데코레이터에 대한 추상화가 명확해지면 그 때 리팩토링을 한다.
- 데코레이터가 적어도 3회 이상 필요한 경우에만 구현한다. 재사용 가능한 컴포넌트를 만드는 것은 일반 컴포넌트를 만드는 것보다 세 배나 더 어렵다.
- 데코레이터 코드를 최소한으로 유지한다.
데코레이터와 관심사의 분리
코드 재사용의 핵심은 응집력이 있는 컴포넌트를 만드는 것이다. 최소한의 책임을 가져서 오직 한 가지 일만 해야 하며, 그 일을 잘 해야 한다. 컴포넌트가 작을수록 재사용성이 높아진다. 또한 결합과 종속성을 유발하고 소프트웨어의 유연성을 떨어뜨리는 추가 동작이 필요 없이 여러 상황에서 쓰일 수 있다.
def traced_function(function):
@functools.wraps(function)
def wrapped(*args, **kwargs):
logger.info("%s 함수 실행", function.__qualname__)
start_time = time.time()
result = function(*args, **kwargs)
logger.info(
"함수 %s 처리 소요시간 %.2fs",
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(*kwargs, **kwargs)
return wrapped
def measure_time(function):
@wraps(function)
def wrapped(*args, **kwargs):
start_time = time.time()
result = function(*args, **kwargs)
logger.info("함수 %s 처리 소요시간 %.2fs", function.__qualname__,
time.time() - start_time)
return result
return wrapped동일한 기능을 다음과 같이 조합하여 달성할 수 있다.
@measure_time
@log_execution
def operation():
....데코레이터에 하나 이상의 책임을 두면 안 된다. SRP는 데코레이터에도 적용된다.
좋은 데코레이터 분석
좋은 데코레이터가 갖추어야 할 특성
- 캡슐화와 관심사의 분리 : 실제로 하는 일과 데코레이팅 하는 일의 책임을 명확히 구분해야 한다. 데코레이터의 클라이언트는 내부에서 어떻게 구현했는지 전혀 알 수 없는 블랙박스처럼 동작해야 한다.
- 독립성 : 데코레이터가 하는 일은 독립적이어야 하며 데코레이팅 되는 객체와 최대한 분리되어야 한다.
- 재사용성 : 데코레이터는 하나의 여러 유형에 적용 가능한 형태가 바람직하다. 하나의 함수에만 적용된다면 데코레이터가 아닌 함수로 대체할 수 있다.