일급 함수 디자인 패턴
디자인 패턴은 어떤 언어를 사용하는가에 따라 적용될 수도, 그렇지 않을 수도 있다. 특히 일급 함수를 지원하는 언어에서는 여러 패턴에 대해 다시 생각해 볼 수 있다. 패턴에 참여하는 일부 클래스의 객체를 간단한 함수로 교체하면 획일적으로 반복되는 코드의 상당 부분을 줄일 수 있다는 것이다.
이 장에서는 함수 객체를 이용해서 전략 패턴을 리팩토링하고, 비슷한 방법으로 명령 패턴을 단순화하는 방법에 대해 알아본다.
먼저 전략 패턴이 어떤 것인지 알아본다.
전략패턴이란?
일련의 알고리즘을 정의하고 각각을 하나의 클래스 안에 넣어서 교체하기 쉽게 만든다. 전략을 이용하면 사용하는 클라이언트에 따라 알고리즘을 독립적으로 변경할 수 있다.
즉, 특정한 기능(콘텍스트)에 대하여 클라이언트(객체)가 할 수 있는 여러 구체적인 행위를 클래스로 만들어 정의하되, 콘텍스트와 구체적인 행위 사이에 전략이라는 공통된 인터페이스를 갖는 패턴이다.
이를 통해 콘텍스트 코드의 변화 없이 새롭게 기능을 추가하거나 변경할 때, 그에 해당하는 구체적인 전략 클래스를 추가하여 객체 지향 원칙 중 하나인 개방 폐쇄 원칙을 지킬 수 있다. (코드의 유지 보수에 용이하다.)
- 개방 폐쇄 원칙 : 확장에는 열려 있으며 수정에는 닫혀 있다. -> 기존 모듈에 기능을 추가할 수 있으며, 수정 시에는 기존 코드의 변화가 없다.
구체적인 예를 통해서 살펴본다.
온라인 상점이 다음과 같은 할인 규칙을 갖는다고 가정한다.
- 충성도 포인트가 1000점 이상인 고객은 전체 주문에 대해 5% 할인을 적용한다.
- 하나의 주문에서 20개 이상의 동일 상품을 구입하면 해당 상품에 대해 10% 할인을 적용한다.
- 서로 다른 상품을 10종류 이상 주문하면 전체 주문에 대해 7% 할인을 적용한다.
하나의 주문에는 하나의 할인 규칙만 적용된다고 가정한다.
이를 파이썬 코드로 구현해보면 다음과 같다.
from abc import ABC, abstractmethod
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: # 콘텍스트
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.discount(self)
return self.total() - discount
def __repr__(self):
fmt = "<Order total: {:2f} due: {:2f}>"
return fmt.format(self.total(), self.due())
class Promotion(ABC): # 전략: 추상 베이스 클래스
@abstractmethod
def discount(self, order):
"""할인액을 구체적인 숫자로 반환한다."""
class FidelityPromo(Promotion):
"""충성도 포인트가 1000점 이상인 고객에게 전체 5% 할인 적용"""
def discount(self, order):
return order.total() * 0.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() * 0.1
return discount
class LargeOrderPromo(Promotion):
"""10종류 이상의 상품을 구입하면 전체 7% 할인 적용"""
def discount(self, order):
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * 0.07
return 0
joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), LineItem('apple', 10, 1.5), LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, FidelityPromo()))
<Order total: 42.000000 due: 42.000000>
print(Order(ann, cart, FidelityPromo()))
<Order total: 42.000000 due: 39.900000>
banana_cart = [LineItem('banana', 30, .5), LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, BulkItemPromo()))
<Order total: 30.000000 due: 28.500000>
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, LargeOrderPromo()))
<Order total: 10.000000 due: 9.300000>
print(Order(joe, cart, LargeOrderPromo()))
<Order total: 42.000000 due: 42.000000>
여기서 Order 클래스는 콘텍스트, Promotion 은 전략, 각각의 promotion 은 구체적인 전략이다.
각 구체적인 전략(FidelityPromo, BulkItemPromo, LargeOrderPromo) 는 객체 속성을 갖지 않는다. 따라서 이를 일반 함수로 변경할 수 있다.
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: # 콘텍스트
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(self)
return self.total() - discount
def __repr__(self):
fmt = "<Order total: {:2f} due: {:2f}>"
return fmt.format(self.total(), self.due())
def fidelity_promo(order):
"""충성도 포인트가 1000점 이상인 고객에게 전체 5% 할인 적용"""
return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0
def bulk_item_promo(order):
"""20개 이상의 동일 상품을 구입하면 10% 할인 적용"""
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * 0.1
return discount
def large_order_promo(order):
"""10종류 이상의 상품을 구입하면 전체 7% 할인 적용"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * 0.07
return 0
joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), LineItem('apple', 10, 1.5), LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, fidelity_promo))
<Order total: 42.000000 due: 42.000000>
print(Order(ann, cart, fidelity_promo))
<Order total: 42.000000 due: 39.900000>
banana_cart = [LineItem('banana', 30, .5), LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, bulk_item_promo))
<Order total: 30.000000 due: 28.500000>
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, large_order_promo))
<Order total: 10.000000 due: 9.300000>
print(Order(joe, cart, large_order_promo))
<Order total: 42.000000 due: 42.000000>
ABC 를 상속 받은 Promotion 추상 클래스가 없어졌다. 각 구체적인 전략은 일반 함수로 대체 되었다.
따라서 Order 객체마다 할인 전략 객체를 만들 필요가 없다. 함수로 바로 사용할 수 있기 때문이다.
구체적인 전략 객체가 콘텍스트에 대한 어떠한 상태값을 가지고 있다면, 전략 패턴과 플라이웨이트 패턴(여러 콘텍스트에서 공유할 수 있는 공유 객체 활용)을 혼합하여 사용해야 한다. 하지만 단순히 콘텍스트에서 오는 데이터 만을 처리하는 경우라면 굳이 추상 클래스(전략)에서 정의된 메서드 하나만을 위한 클래스(구체적인 전략)를 구현하는 것 보다는 일반 함수를 만드는 것이 코드 가독성이나 속도 측면에서 더 나을 수 있다.
함수는 사용자 정의 클래스보다 가볍고 파이썬이 모듈을 컴파일할 때 단 한번만 생성되므로 공유 객체가 따로 필요하지 않다. 함수 자체가 공유 객체이다.
전략 패턴에 이어서, 이번에는 주어진 객체에 대해 가장 좋은 할인 전략을 선택하는 '메타 전략' 을 만든다고 하면 어떻게 해야 할까
promos = [fidelity_promo, bulk_item_promo, large_order_promo]
def best_promo(order):
"""최대로 할인받을 금액을 반환한다."""
return max(promo(order) for promo in promos)
앞서 언급되었듯이, 함수는 일급 객체이므로 다음과 같은 특징을 갖는다.
- 데이터 구조체의 변수나 요소에 할당할 수 있다.
- 함수 인수로 전달할 수 있다.
위 코드는 가독성이 좋고 잘 작동하지만, 새로운 할인 전략을 추가하려면 함수를 코딩하고 이 함수를 promos 리스트에 추가해야 한다. 혹은 새로운 할인 함수를 Order 객체에 인수로 전달해서 작동시킬 수 있지만, 이 때 best_promo() 는 새로운 할인 함수를 고려하지 않는다.
이를 해결하기 위해 두 가지 방법을 사용할 수 있다.
첫 번재는 모듈을 활용하는 방법이다. 파이썬 모듈도 일급 객체로서, 모듈을 다루는 여러 함수가 표준 라이브러리에서 제공된다. 파이썬 문서에서는 globals() 내장 함수를 다음과 같이 설명한다.
globals()
현재 전역 심벌 테이블을 나타내는 딕셔너리 객체를 반환한다. 이 딕셔너리는 언제나 현재 모듈에 대한 내용을 담고 있다. (함수나 메서드 안에서 호출할 때는 함수를 호출한 모듈이 아니라 함수가 정의된 모듈을 나타낸다.)
promos = [
globals()[name]
for name in globals()
if name.endswith("_promo") and name != "best_promo"
]
def best_promo(order):
"""최대로 할인받을 금액을 반환한다."""
return max(promo(order) for promo in promos)
무한 재귀 호출을 피하기 위해 best_promo 는 제외한 _promo 로 끝나는 함수들만 리스트에 담았다. best_promo 의 내용은 어떤 것도 변경되지 않았다.
혹은 할인 함수만을 담은 별도의 모듈을 만들어서 모듈 내부를 조사하는 방식으로도 접근할 수 있다.
promos = [func for name, func in inspect.getmembers(promotions, inspect.isfunction)]
inspect.getmembers() 함수는 불리언형 함수를 조건식으로 하여 inspect.isfunction 을 통해 모듈 내부의 함수만 걸러냈다.
다만, 이는 promotions.py 라는 별도의 모듈에 할인 함수만이 담겨 있다는 점을 전제로 한다. 따라서 다른 함수가 promotions 모듈에 들어간다면 에러가 발생할 것이다.
함수를 인수로 전달하는 기법을 사용하면 명령 디자인 패턴도 단순하게 구현이 가능하다.
명령 디자인 패턴은 연산을 실행하는 객체(호출자)와 연산을 구현하는 객체(수신자)를 분리하는 것을 목적으로 하는 디자인 패턴의 종류이다. 명령 객체를 수신자와 호출자 사이에 놓고, 명령은 execute() 라는 단 하나의 메서드로 인터페이스를 구현한다. execute() 메서드는 원하는 연산을 수행하기 위해 수신자가 가지고 있는 메서드를 호출한다.
이런 방식을 사용하면, 호출자는 수신자의 인터페이스를 알 필요가 없으며, 명령의 서브 클래스를 통해 서로 다른 수신자를 추가할 수 있다.
class MacroCommand:
"""명령 리스트를 실행하는 명령"""
def __init__(self, commands):
self.commands = list(commands)
def __call__(self):
for command in self.commands:
command()
여러 명령을 리스트로 만들고, __call__() 메서드를 통해서 command.execute() 를 호출하는 대신 command() 를 호출하면 된다.
정리
사용하는 언어에 따라서 디자인 패턴은 변화할 수 있다. 특히 일급 함수를 가진 파이썬은 일급 함수의 특징을 활용하여 보다 간단하게 구현할 수 있다. 전략 패턴에 대하여 구체적인 전략을 함수로 구현하고, 추상 클래스인 전략을 없앤 보다 간결한 패턴을 만들 수 있다. 디자인 패턴을 흉내 내기 보다는 파이썬에서 함수나 콜러블 객체를 이용하여 더욱 자연스럽게 구현할 수 있는 경우가 많이 있다.
이미지 출처 :
'python' 카테고리의 다른 글
Fluent Python (챕터 7) (0) | 2021.11.06 |
---|---|
Architecture Patterns with Python(4장) (0) | 2021.10.31 |
Architecture Patterns with Python(3장) (0) | 2021.10.23 |
Fluent Python (챕터 5) (0) | 2021.10.22 |
Architecture Patterns with Python(2장) (0) | 2021.10.15 |