[python] 일급 함수 디자인 패턴

요즘 [전문가를 위한 파이썬] 기술서적으로 파이썬 스터디를 하고 있습니다. 발표 회차에 다가와서...이번 포스트에서는 함수 객체를 이용해 Strategy 패턴을 리팩토링하는 내용을 다루겠습니다.

일급 함수란?

파이썬 함수는 일급 객체입니다. 일급 객체는 다음 작업을 수행할 수 있는 프로그램 개체입니다.

- 런타임에 생성할 수 있다

- 데이터 구조체의 변수나 요소에 할당할 수 있다

- 함수 결과로 반환할 수 있다

 

전략(Strategy) 패턴 리팩토링

  • Strategy 패턴?
    • 모든 프로그램은 문제를 해결하기 특정 알고리즘이 구현되어 있다. Strategy 패턴에서는 그 알고리즘을 구현한 부분을 모두 교환할 수 있다. 즉 알고리즘을 빈틈없이 교체해서 같은 문제를 다른 방법으로도 쉽게 해결할 수 있도록 도와주는 패턴이다.
    • 알고리즘을 정의하고 각각을 하나의 클래스 안에 넣어서 교체하기 쉽게 만든다. 전략을 이용하면 사용하는 클라이언트에 따라 알고리즘을 독립적으로 변경할 수 있다.
from abc import ABC, abstractclassmethod
from collections import namedtuple

Customer = namedtuple('Customer', 'name fidelity')

class LineItem:

    def __init__(self, product, quantity, price):
        self.product = product
        self.quantity = quantity
        self.price = price

    def total(self):
        return self.price * self.quantity
    
class Order: # context
    
    def __init__(self, customer, cart, promotion=None):
        self.customer = customer
        self.cart = list(cart)
        self.promotion = promotion

    def total(self):
        if not hasattr(self, '__total'):
            self.__total = sum(item.total() for item in self.cart)
        return self.__total

    def due(self):
        if self.promotion is None:
            discount = 0
        else:
            discount = self.promotion
        return self.total() - discount.discount(self)
    
    def __repr__(self):
        return f'<Order total: {self.total()} due: {self.due()}>'
    
class Promotion(ABC): # strategy: abstractclassmethod

    @abstractclassmethod
    def discount(self, order):
        """할인액을 구체적인 숫자로 반환한다"""
    
class FidelityPromo(Promotion): # 첫 번째 구체적인 전략
    """충성도 포인트가 1000점 이상인 고객에게 전체 5% 할인 적용"""

    def discount(self, order):
        return order.total() * .05 if order.customer.fidelity >= 1000 else 0
    
class BulkItemPromo(Promotion):
    """20개 이상 동일 상품 구입하면 10% 할인 적용"""
    def discount(self, order):
        discount = 0
        for item in order.cart:
            if item.quantity >= 20:
                discount += item.total() * .1
        return discount
    
class LargeOrderPromo(Promotion):
    """10종류 이상의 상품을 구입하면 전체 7% 할인 적용"""

    def discount(self, order):
        discount_items = {item.product for item in order.cart}
        if len(discount_items) >= 10:
            return order.total() * .07
        return 0

실행

hong = Customer('홍길동', 0) # 충성도 점수 0
kim = Customer('김철수', 1100) # 충성도 점수 1100
cart = [LineItem('banana', 4, 0.5),
        LineItem('apple', 10, 1.5),
        LineItem('watermellon', 5, 5.0)]

print('fidelity')
print(Order(hong, cart, fidelity_promo))
print(Order(kim, cart, fidelity_promo))
print(Order(kim, cart, best_promo))

print('bulk')
cart2 = [LineItem('banana', 30, .5),
         LineItem('apple', 10, 1.5)]

print(Order(hong, cart2, bulk_promo))
print(Order(kim, cart2, best_promo))

print('large')
cart3 = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(hong, cart3, large_order_promo))

---결과 출력---
<Order total: 42.0 due: 42.0>
<Order total: 42.0 due: 39.9>
<Order total: 42.0 due: 39.9>
bulk
<Order total: 30.0 due: 28.5>
large
<Order total: 10.0 due: 9.3>

 

함수지향 전략

각각의 구체적인 전략은 discount()라는 메서드 하나를 가진 클래스입니다. 전략 객체는 객체 속성을 가지고 있지 않습니다. 따라서 전략 객체가 일반 함수로 보이는 것이 맞습니다. 

다음은 첫번째 예시코드에서 promotion 추상 클래스 제거, 구체적인 전략 간단히 함수로 변경한 리팩토링입니다.

"""충성도 포인트가 1000점 이상인 고객에게 전체 5% 할인 적용"""
def fidelity_promo(order):
    return order.total() * .05 if order.customer.fidelity >= 1000 else 0
    
"""20개 이상 동일 상품 구입하면 10% 할인 적용"""
def bulk_promo(order):
    discount = 0
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * .1
    return discount
    
"""10종류 이상의 상품을 구입하면 전체 7% 할인 적용"""
def large_order_promo(order):
    discount_items = {item.product for item in order.cart}
    if len(discount_items) >= 10:
        return order.total() * .07
    return 0

위 리팩토링에서는 Order 객체마다 할인 전략 객체를 만들 필요가 없습니다. 전략 객체는 훌륭한 플라이웨이트가 되기도 합니다.

  • 플라이웨이트?
    • 플라이웨이트는 권투에서 가장 체중이 가벼운 체중을 나타냅니다. 뜻대로 이 디자인 패턴은 객체를 가볍게 하기 위한 방식입니다. 
      • 가볍다? 오브젝트는 컴퓨터 안에서 가상적으로 존재하는 것이며 여기서 말하는 무게는 '모미리의 사용량'을 의미합니다. 
      • 따라서 Flyweight 패턴은 인스턴스를 가능한 대로 공유시켜서 쓸데없이 생성하지 않도록 한다는 것입니다.
    • 새로운 콘텍스트에서 동일 전략 객체를 반복해서 석용할 때 새로 생성하는 비용을 줄이기 위해 플라이웨이트를 공유하면 더 유리합니다.

위 예시에서 Order 객체를 만들때 기존 전략 객체가 있으면 재사용할 수 있어서 이전 예시 코드에서 전략 패턴의 단점인 런타임 비용을 극복하기 위해 플라이웨이트 패턴을 사용하도록 권장합니다.

  • 구체적인 전략 객체가 속성을 가지고 있어서 더욱 복잡한 경우에는 모든 전략 패턴과 플라이웨이트 패턴을 혼합해서 사용해야합니다.
  • 구체적인 전략 객체가 속성 가지지 않고 단지 콘텍스트에서 오는 데이터를 처리하는 경우에는 일반 함수를 만드는 것이 훨씬 좋습니다.
    • 함수는 사용자 정의 클래스보다 훨씬 가볍고 파이썬이 모듈을 컴파일할때 단 한번만 생성되므로 플라이웨이트가 필요하지 않습니다.
    • 함수도 여러 콘텍스트에서 동시에 공유할 수 있는 공유 객체입니다.

최선의 전략 선택하기: 단순 접근

Order 객체에 대해 적용할 수 있는 가장 좋은 할인 전략을 선택하는 전략을 추가해봅니다.

# 함수가 일급 객체이기때문에 함수를 담고있는 데이터 구조체 구현도 가능합니다. 
promos = [fidelity_promo, bulk_promo, large_order_promo]
def best_promo(order):
    """최대로 할인 받을 금액을 반환"""
    return max(promo(order) for promo in promos)
    
print('fidelity')
print(Order(hong, cart, fidelity_promo))
print(Order(kim, cart, fidelity_promo))
print(Order(kim, cart, best_promo))

print('bulk')
cart2 = [LineItem('banana', 30, .5),
         LineItem('apple', 10, 1.5)]

print(Order(hong, cart2, bulk_promo))
print(Order(kim, cart2, best_promo))

일부 코드 중복이 존재합니다. 새로운 할인 전략이 추가되면 함수를 코딩하고 이를 promos 리스트에 추가해야점도 버그가 발생할 여지가 있습니다.

모듈에서 전략 찾기

파이썬 모듈도 일급 객체이며 모듈을 다루는 여러 함수가 표준 라이브러리에서 제공됩니다.

현재 모듈에 대한 내용을 담고있는 globals() 모듈을 사용하여 다른 *_promo() 함수를 찾아냅니다.

promos = [globals()[name] for name in glabas() if name.endwith('_promo') and name !='best_promo']
def best_promo(order):
    """최대로 할인 받을 금액을 반환"""
    return max(promo(order) for promo in promos)

 

마치며

전략 패턴에 대해 일급 함수를 이용하여 단순화할 수 있는 방안을 살펴봤습니다. 자바로 디자인패턴을 배울때보다 더 간단하게 구현할 수 있었던거 같습니다. 디자인 패턴의 경우 이론적 서술보다 직접 코드를 작성하고 어떤식으로 대체하는건지 그 과정을 확인하는 접근이 이해에 도움이 됩니다. 해당 챕터에서는 디자인 패턴에서 설명하는 strategy나 command 패턴을 흉내내는 것보다 파이썬에서 함수나 콜러블 객체를 이용하여 더 자연스럽게 콜백을 구현할 수 있는 경우가 많다고 알려주고 있습니다. 컴포넌트가 단일 메서드 인터페이스를 구현하며 그 메서드가 execute, run 등 일반적인 이름을 갖고 있는 디자인 패턴이나 API를 볼 수 있는데 여기서 API는 일급 함수나 기타 콜러블을 사용해서 파이썬에서 더욱 간결하게 구현할 수 있습니다. 

'CS' 카테고리의 다른 글

[fluent python]함수 데코레이터와 클로저  (0) 2024.04.14
utf8  (0) 2023.12.10