본문 바로가기

Python

[디자인패턴] 파이썬에서 Decorator Pattern (데코레이터 패턴) 구현하기

이 글은 decorator(@기호와 함께 쓰는)라고 하는 파이썬의 기능에 관한 것이 아니라 "데코레이터" 및 "위임"라는 디자인 패턴에 관한 글이다.

 

이전 글에 이어서 이번에는 위임을 사용하는 데코레이터 패턴에 대해 정리해보고자 한다.

참고) 공부중에 나는 “파이썬의 데코레이터가 데코레이터 패턴을 구현한 것과 동일한 것이 아닌가?” 라는 의문점이 있었는데 이 글을 보고 차이점을 이해하였다.

 

  데코레이션(decoration)이란?

  • 객체에 기능을 추가하고는 싶지만 해당 객체의 클래스를 확장하지 않을 때 사용
  • 기본 클래스에 영향을 주지 않고 필요할 때 동작을 동적으로 추가
  • 객체 지향 설계의 단일 책임 원칙(SRP)을 준수를 유지하는 데 필요
  • 위임과 마찬가지로 데코레이터 패턴은 상속으로 문제를 해결할 수 있지만 실제로 디자인 측면에서 이치에 맞지 않거나 실행 불가능한 경우에 자주 사용

 

  예시 - HR 시스템

간단한 인사 시스템을 예시로 들어보자.

생성할 클래스는 다음과 같다.

  1. 직원용 클래스
  2. 연도별(YTD) 급여 보고서용 클래스

이 보고서에는 연초부터 지금까지 직원이 받은 급여가 나열된다.

from datetime import datetime

class Employee:
  def __init__(self, name, salary):
    self.name = name
    self.salary = salary

class SalaryYTDReport:
  def __init__(self, employees):
    self.employees = employees
    self.data = []

  def prep_data(self):
    self.data = []
    for employee in self.employees:
      emp_sal_dict = {'Employee': employee.name, 'YTD Salary': self.calculate_YTD_salary(employee.salary)}
      self.data.append(emp_sal_dict)

  def calculate_YTD_salary(self, salary):
    current_month = datetime.today().month
    ytd_salary = (salary/12) * current_month
    ytd_salary = round(ytd_salary, 2)
    return ytd_salary

SalaryYTDReport 는 직원 사전 배열을 생성하며, 우리의 목표는 이 정보를 잘 보여주는 것이다. 정보를 보여주는 데에는 터미널, 웹 페이지, 스프레드 시트 등 다양한 옵션이 있지만 이러한 다양한 형태로 데이터를 표시하는 기능은 이 클래스의 책임이라고 볼 수 없다.

이 클래스는 이름 그대로 데이터를 수집하고 보고서와 같은 형식으로 데이터를 구조화하는 역할만을 해야한다.

 

대안

만약 상속이 유일한 대안이라면, 보고서 표시 메서드를 포함하는 Reporting 클래스를 생성하여 SalaryYTDReport 가 상속받도록 하는 것이 적절하다고 생각될 것이다. 또는 데이터의 형식만SalaryYTDReport 에서 지정해주고, 이를 상속하는 Reporting 클래스를 만들어야 한다고 생각할 수도 있다.

 

문제점

이러한 방법은 소규모 또는 중간 규모의 운영에 적합할 수 있다. 하지만 회사에 월 단위 급여가 아닌 시간당으로 일하는 직원이 있을 수 있다. 여러 부서에서 각자의 보고서 표시 방법을 구현하고 싶을 경우, 이 코드는 활용될 수 없다. 이런 경우 데코레이터를 도입해야한다.

 

  Report 데코레이터

SalaryYTD클래스의 SRP를 유지하려면 이 클래스의 유일한 책임은 YTD 급여 데이터를 보고하는 것뿐이어야하며, 이 클래스에 기능을 추가하려면 클래스 이름을 변경해야 한다. 물론 이게 나쁜 것은 아니지만, 현재 클래스가 하고 있는 기능보다 더 많은 기능을 계속해서 추가하는 것은 바람직하지 않다.

보고서 클래스(여기서는 SalaryYTDReport 클래스)에 기본적으로 보고하는 기능을 추가하는 클래스를 만들어보자. 우리는 이 보고서가 웹 페이지에서 HTML로만 표시된다는 점만 알고있지만, 중간에서 컨트롤하는 레이어는 보고서 데이터로 다른 작업을 수행한다고 가정한다. 이 경우 SalaryYTDReport 클래스의 기능을 유지하면서 보고서를 HTML로 표시하는 메서드를 제공하는 클래스가 필요하다.

이때 위임에 대해 배운 내용이 유용하게 쓰일 수 있다. 보고서를 HTML로 표시하는 기능을 추가하면서 SalaryYTDReport의 기본 기능을 위임하는 데코레이터 클래스를 만들 것이다. 즉, HTML 서식 지정 기능으로 SalaryYTDReport 클래스를 장식(decorate)할 것이다.

이때 이전 글에서와 같은 위임 접근 방식을 사용하지만 매우 중요한 차이점이 있다. SalaryYTDReport 클래스에서 prep_data()data에 모두 접근하고자 한다. 1부에서 사용한 접근 방식의 문제점은 속성(attribute)이 아닌 클래스 메서드에서만 작동한다는 것이다. 다시 말해, 1부에서 했던 것과 똑같은 방식으로 이 문제에 접근하면 report.data에서 오류가 발생한다.

이 문제를 해결하기 위해 파이썬에 내장된 __dict__를 사용하여 속성을 파악하고, 기존의 dir 접근 방식을 사용하여 메서드를 파악하도록 한다. 그런 다음 __getattr__ 재작성을 조금만 수정하도록 한다.

아래와 같이 메서드의 경우는 메서드를 반환하고 호출하고, 속성일 경우는 요청된 속성을 반환하기만 하면 된다.

class HTMLReportDecorator:
  def __init__(self, report):
    self.html_report = []
    self.report = report
    self.report_methods = [f for f in dir(SalaryYTDReport) if not f.startswith('_')]
    self.report_attributes = [a for a in report.__dict__.keys()]

  def __getattr__(self, func):
    if func in self.report_methods:
      def method(*args):
        return getattr(self.report, func)(*args)
      return method
    elif func in self.report_attributes:
      return getattr(self.report, func)
    else:
      raise AttributeError

  def report_data(self):
    self.html_report = []
    self.prep_data()
    for row in self.data:
      name = f"<b>{row['Employee']}</b>"
      ytd = f"<i>{row['YTD Salary']}</i>"
      html_row = f"{name}: {ytd}<br />"
      self.html_report.append(html_row)

이제 SalaryYTDReport 객체를 생성하고, 이 객체를 사용하여 HTMLReportDecorator의 인스턴스를 인스턴스화하여 장식한 다음, SalaryYTDReport를 인스턴스화할 때 사용한 것과 동일한 변수에 할당한다.

이런식으로하면 데코레이트된 객체를 몇 가지 추가 기능이 있는 베이스 객체처럼 사용할 수 있다. 이 객체는 기본 클래스 자체(이 경우 SalaryYTDReport)와 똑같이 작동하면서 데코 클래스의 기능을 가진다.

>>> from report import *
>>> emp1 = Employee('Bob', 100000)
>>> emp2 = Employee('Jan', 150000)
>>> emp3 = Employee('Erik', 30)
>>> report = SalaryYTDReport([emp1, emp2, emp3])
>>> report = HTMLReportDecorator(report)
>>> # 베이스 객체 SalaryYTDReport 처럼 사용:
>>> for employee in report.employees:
...   print(employee.name)
Bob
Jan
Erik
>>> # Show it has the decorator's functionality too:
>>> report.report_data() # 리포트 생성
>>> report.html_report # 리포트 표현
['<b>Bob</b>: <i>66666.67</i><br />', '<b>Jan</b>: <i>100000.0</i><br />', '<b>Erik</b>: <i>20.0</i><br />']

 

  예시 2: 로깅 메서드

앞의 예시는 클래스를 다시 작성하지 않고도 기본적으로 클래스에 기능을 추가하는 데코레이터 패턴의 매우 기본적인 구현을 보여준다. 이번에는 조금 더 복잡한 데코레이터 메서드의 사용 사례를 구현해 보도록 한다.

어떤 이유로 메서드의 시작과 끝을 로깅하고 싶다고 가정해 보자. 이는 향후 사용자 행동을 분석하거나 성능 인사이트를 수집하는 등 다양한 이유로 수행될 수 있다. 후자를 위한 솔루션을 구현해 보자.

모든 메서드 호출의 시간을 기록하는 SalaryYTDReport의 데코레이터를 작성할 것이다.

import time
...
class PerformanceLogReportDecorator:
  def __init__(self, report):
    self.report = report
    self.report_methods = [f for f in dir(SalaryYTDReport) if not f.startswith('_')]
    self.report_attributes = [a for a in report.__dict__.keys()]

  def __getattr__(self, func):
    if func in self.report_methods:
      def method(*args):
        return self.log(func, *args)
      return method
    elif func in self.report_attributes:
      return getattr(self.report, func)
    else:
      raise AttributeError

  def log(self, func, *args):
    start = datetime.now()
    getattr(self.report, func)(*args)
    time.sleep(1) # 로깅이 실제로 일어나고있음을 보여주기 위한 용도
    end = datetime.now()
    microseconds = (end-start).microseconds
    print(f"{func} ran in {microseconds} microseconds")

보고서 클래스와 마찬가지로 사용하되, PerformanceLogReportDecorator 클래스로 장식하기 때문에 로깅이라는 보너스 기능을 얻을 수 있다.

>>> from report import *
>>> emp1 = Employee('Bob', 100000)
>>> emp2 = Employee('Jan', 150000)
>>> emp3 = Employee('Erik', 30)
>>> report = SalaryYTDReport([emp1, emp2, emp3])
>>> report = PerformanceLogReportDecorator(report)
>>> report.prep_data()
prep_data ran in 709 microseconds

실제 사용할 상황에서는 콘솔에 출력하지 않고 로그 파일이나 데이터베이스에 기록하게 될 것이다.

 

  결론

대부분의 디자인 패턴과 마찬가지로, 문제는 구현에 있는 것이 아니라 언제 사용해야 하는지 인식하는 데 있는 것 같다.