실전 속의 디자인 패턴

각 패턴은 생성(creational), 구조(structural), 행동(behavioral) 패턴 중의 하나로 분류된다. 일부 패턴은 파이썬 내부에서 자체적으로 구현되어 있으므로 보이지 않은 채로도 적절히 적용될 수 있다.

생성 패턴

생성 패턴은 객체를 인스턴스화 할 때의 복잡성을 최대한 추상화하기 위한 것이다. 객체 초기화를 위한 파라미터의 결정, 초기화에 필요한 관련 객체 준비 등의 모든 관련 작업을 단순화 한다. 이를 통해 더 간단한 인터페이스를 제공할 수 있고 사용자는 보다 안전하게 객체를 생성할 수 있다.

팩토리

파이썬의 핵심 기능 중 하나는 모든 것이 객체라는 것이며 따라서 모두 똑같이 취급될 수 있다. 이러한 이유로 파이썬에서는 팩토리 패턴이 별로 필요하지 않다. 간단히 객체를 생성할 수 있는 함수를 만들 수 있으며, 생성하려는 클래스를 파라미터로 전달할 수도 있다.

싱글턴과 공유상태

싱글턴 패턴은 파이썬에 의해 완전히 추상회되지 않은 패턴이다. 대부분의 경우 이 패턴은 실제로 필요하지 않거나 나쁜 선택이다. 싱글턴은 전역 변수의 한 형태이며 단위 테스트가 어렵다.

일반적으로 싱글턴은 가능하면 사용하지 않는 것이 좋다. 꼭 필요하다면 파이썬에서 가장 쉬운 방법은 모듈을 사용하는 것이다. 모듈에 객체를 생성하면 모듈을 임포트한 모든 곳에서 사용할 수 있다.

공유 상태

하나의 인스턴스만 갖는 싱글턴을 사용하는 것보다는 여러 인스턴스에서 사용할 수 있도록 데이터를 복제하는 것이 좋다.

모노 스테이트 패턴의 주요 개념은 싱글턴인지 아닌지에 상관없이 일반 객체처럼 많은 인스턴스를 만들 수 있어야 한다는 것이다.

따라서 이 패턴을 사용하는 것이 사용하기 편할 뿐 아니라 에러 발생 가능성이 적기 때문에 더 좋은 선택이다.

class GitFetcher:
    _current_tag = None
 
    def __init__(self, tag):
        self.current_tag = tag
 
    @property
    def current_tag(self):
        if self._current_tag is None:
            raise AttributeError("tag가 초기화되지 않음")
        return self._current_tag
 
    @current_tag.setter
    def current_tag(self, new_tag):
        self.__class__._current_tag = new_tag
 
    def pull(self):
        logger.info("%s에서 풀", self.current_tag)
        return self.current_tag

위 예제에서 다른 버전을 가진 GitFetcher 인스턴스를 만들어 보면 모두가 같은 최신 버전tag를 공유하고 있음을 쉽게 확인할 수 있다.

>>> f1 = GitFetcher(0.1)
>>> f2 = GitFetcher(0.2)
>>> f1.current_tag = 0.3
>>> f2.pull()
0.3
 
>>> f1.pull()
0.3

더 많은 속성이 필요하거나 공유 속성을 좀 더 캡슐화하고 싶다면 디스크립터를 사용할 수 있다.

class SharedAttribute:
    def __init__(self, initial_value=None):
        self.value = initial_value
        self._name = None
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        if self.value is None:
            raise AttributeError(f"{self._name} was never set")
        return self.value
 
    def __set__(self, instance, new_value):
        self.value = new_value
 
    def __set_name__(self, owner, name):
        self._name = name
 
class GitFetcher:
 
    current_tag = SharedAttribute()
    current_branch = SharedAttribute()
 
    def __init__(self, tag, branch=None):
        self.current_tag = tag
        self.current_branch = branch
 
    def pull(self):
        logger.info("%s에서 풀", self.current_tag)
        return self.current_tag

borg 패턴

이전의 솔루션은 대부분 잘 작동하지만 꼭 싱글턴을 사용해야하는 경우 최후의 더 나은 대안이 있다. 주요 개넘은 같은 클래스의 모든 인스턴스가 하나의 상태를 공유하는 것이다.

class BaseFetcher:
    def __init__(self, source):
        self.source = source
 
 
class TagFetcher(BaseFetcher):
    _attributes = {}
 
    def __init__(self, source):
        self.__dict__ = self.__class__._attributes # 인스턴스에 클래스변수 할당
        super().__init__(source)
 
    def pull(self):
        logger.info("%s 태그에서 풀", self.source)
        return f"Tag = {self.source}"
 
 
class BranchFetcher(BaseFetcher):
    _attributes = {}
 
    def __init__(self, source):
        self.__dict__ = self.__class__._attributes # 인스턴스에 클래스변수 할당
        super().__init__(source)
 
    def pull(self):
        logger.info("%s 브랜치에서 풀", self.source)
        return f"Branch = {self.source}"

예제에서 한 인스턴스에서 딕셔너리를 업데이트하면 모든 객체에 동일하게 업데이트 된다. 이런 타입의 새로운 객체에 대해서는 같은 딕셔너리를 사용할 것이므로 사전은 공통적으로 지속 업데이트 될 것이다.

구조 패턴

구조(Structural) 패턴은 인터페이스의 복잡성을 늘리지 않으면서 기능을 확장하여 객체, 인터페이스를 만들어야 하는 상황에 유용하다. 이러한 패턴의 가장 큰 장점은 향상된 기능을 깔끔하게 구현할 수 있다는 것이다.

어댑터 패턴

래퍼(wrapper)라고도 불리는 이 패턴은 호환되지 않은 두 개 이상의 객체에 대한 인터페이스를 동시에 사용할 수 있게 한다. 여기에는 두 가지 방법이 있다.

첫 번째 방법은 사용하려는 클래스를 상속받는 클래스를 만드는 것이다. 상속받은 클래스는 기존 메서드와 호환될 수 있도록 래핑하는 메서드를 가진다.

class UserSource(UsernameLookup):
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.search(user_namespace)
 
    @staticmethod
    def _adapt_arguments(user_id, username):
        return f"{user_id}:{username}"

기존의 객체들이 fetch()를 공통으로 사용하고 있을 때 한 객체가 search()를 지원할 경우 이를 래핑하는 새로은 fetch()를 만든다.

상속을 통해 구현할 경우 얼마나 많은 외부 라이브러리를 가져올지 정확히 알기 어려움으로 강한 결합을 만들고 융통성을 떨어트린다. 따라서 더 나은 방법은 컴포지션 패턴을 사용하는 것이다.

class UserSource:
    def __init__(self, username_lookup: UsernameLookup) -> None:
        self.username_lookup = username_lookup
 
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.username_lookup.search(user_namespace)
 
    @staticmethod
    def _adapt_arguments(user_id, username):
        return f"{user_id}:{username}"

객체에 기존에 상속하던 객체의 인스턴스를 제공하는 방식으로 구현한다.

컴포지트

컴포지트 패턴은 기본 객체와 기본객체들을 포함하는 컨테이너 객체를 같은 방법으로 사용할 수 있도록 해 준다.

class Product:
    def __init__(self, name, price):
        self._name = name
        self._price = price
 
    @property
    def price(self):
        return self._price
 
class ProductBundle:
    def __init__(
        self,
        name,
        perc_discount,
        *products: Iterable[Union[Product, "ProductBundle"]]
    ) -> None:
        self._name = name
        self._perc_discount = perc_discount
        self._products = products
 
    @property
    def price(self):
        total = sum(p.price for p in self._products)
        return total * (1 - self._perc_discount)

위 예제에서 ProductBundle 객체는 Product 또는 ProductBundle을 하위 객체로 가질 수 있다. 전체 가격을 계산하기 위해서는 하위 상품이 없을 때까지 계속 상품의 가격을 확인해야 한다.

데코레이터

이 패턴을 사용하면 상속없이도 객체의 기능을 동적으로 확장할 수 있다.

class DictQuery:
    def __init__(self, **kwargs):
        self._raw_query = kwargs
 
    def render(self) -> dict:
        return self._raw_query
 
 
class QueryEnhancer:
    def __init__(self, query: DictQuery):
        self.decorated = query
 
    def render(self):
        return self.decorated.render()
 
 
class RemoveEmpty(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v for k, v in original.items() if v}
 
 
class CaseInsensitive(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v.lower() for k, v in original.items()}
>>> original = DictQuery(key="value", empty="", none=None, upper="UPPERCASE", title="Title")
>>> new_query = CaseInsensitive(RemoveEmpty(original))
>>> original.render()
{'key': 'value', 'empty': '', 'none': None, 'upper': 'UPPERCASE', 'title': 'Title'}
 
>>> new_query.render()
{'key': 'value', 'upper': 'uppercase', 'title': 'title'}

위 예제에서 데코레이터 패턴을 사용하여 동일한 인터페이스를 가지며 장식된 객체를 만든다.

파이썬은 동적인 특성을 가지고 있다. 각각의 데코레이션 단계를 함수로 정의하여 기본 데코레이터 객체에 전달하는 방법으로 데코레이팅을 수행할 수 있다.

class DictQuery:
    def __init__(self, **kwargs):
        self._raw_query = kwargs
 
    def render(self) -> dict:
        return self._raw_query
 
 
class QueryEnhancer:
    def __init__(
        self,
        query: DictQuery,
        *decorators: Iterable[Callable[[Dict[str, str]], Dict[str, str]]]
    ) -> None:
        self._decorated = query
        self._decorators = decorators
 
    def render(self):
        current_result = self._decorated.render()
        for deco in self._decorators:
            current_result = deco(current_result)
        return current_result
 
 
def remove_empty(original: dict) -> dict:
    return {k: v for k, v in original.items() if v}
 
def case_insensitive(original: dict) -> dict:
    return {k: v.lower() for k, v in original.items()}
>>> query = DictQuery(foo="bar", empty="", none=None, upper="UPPERCASE", title="Title")
>>> QueryEnhancer(query, remove_empty, case_insensitive).render()
{'foo': 'bar', 'upper': 'uppercase', 'title': 'title'}

파사드

파사드(Facade)는 객체간 상호작용을 단순화하려는 많은 상황에서 유용하다. 이 패턴은 여러 객체가 다대다 관계를 이루며 상호작용하는 경우에 사용된다.

파사드는 허브, 단일 참조점의 역할을 한다 외부 오브젝트는 파사드와만 대화하고 파사드에서 적절히 요청을 전달한다. 외부 오브젝트의 입장에서는 파사드 내부의 모든 내용이 완전히 불투명해야 한다.

이 패턴은 객체의 결합력을 낮춰주며 인터페이스의 개수를 줄이고 보다 더 나은 캡슐화를 지원한다. 이 패턴을 사용하여 API를 제공하면 사용자가 노출된 기능을 쉽게 사용할 수 있다. 또한 기능만 노출하고 나머지는 인터페이스 뒤에 숨김으로써 세부 코드의 리팩토링이 자유로워진다.

행동패턴

행동 패턴은 객체의 협력, 통신, 런타임중 인터페이스에 대한 문제를 해결하는 것을 목표로 한다.

책임 연쇄 패턴

책임 연쇄 패턴은 요청을 처리할 수 있는 기회를 하나 이상의 객체에게 부여함으로써 요청 객체와 처리 객체의 결합도를 없앤다.

아래 예제에서 이벤트들은 로그 라인을 처리할 수 없는 경우 후계자 객체에게 전달하고 이러한 과정을 반복한다.

import re
 
class Event:
    pattern = None
 
    def __init__(self, next_event=None):
        self.successor = next_event
 
    def process(self, logline: str):
        if self.can_process(logline):
            return self._process(logline)
 
        if self.successor is not None:
            return self.successor.process(logline)
 
    def _process(self, logline: str) -> dict:
        parsed_data = self._parse_data(logline)
        return {
            "type": self.__class__.__name__,
            "id": parsed_data["id"],
            "value": parsed_data["value"],
        }
 
    @classmethod
    def can_process(cls, logline: str) -> bool:
        return cls.pattern.match(logline) is not None
 
    @classmethod
    def _parse_data(cls, logline: str) -> dict:
        return cls.pattern.match(logline).groupdict()
 
class LoginEvent(Event):
    pattern = re.compile(r"(?P<id>\\d+):\\s+login\\s+(?P<value>\\S+)")
 
class LogoutEvent(Event):
    pattern = re.compile(r"(?P<id>\\d+):\\s+logout\\s+(?P<value>\\S+)")
 
class SessionEvent(Event):
    pattern = re.compile(r"(?P<id>\\d+):\\s+log(in|out)\\s+(?P<value>\\S+)")
>>> chain = LogoutEvent(LoginEvent())
>>> chain.process("567: login user")
{'type': 'LoginEvent', 'id': '567', 'value': 'user'}

이 솔루션은 충분히 유연하며 모든 조건들이 상호배타적이다.

템플릿 메서드 패턴

이 패턴은 코드의 재사용성을 높여주고 객체를 보다 유연하게 하여 다형성을 유지하면서도 코드를 쉽게 수정할 수 있다.

주요 개념은 어떤 행위를 정의할 때 특정한 형태의 클래스 계층구조를 만드는 것이다. 공통 로직을 부모 클래스의 public 메서드로 구현하고 그 안에서 서브클래스에서 구현하는 private 메서드를 호출하는 것이다.

이 패턴을 사용하면 다형성을 쉽게 보존할 수 있으므로 디자인이 유연해진다. 구현을 변경해야 할 경우 하위 클래스의 private메서드를 오버라이드하기만 하면 된다.

커맨드 패턴

커맨드 패턴은 수행해야 할 작업을 요청한 순간부터 실제 실행 시까지 분리할 수 있는 기능을 제공한다.

데이터베이스와 상호작용하는 라이브러이에서 이러한 패턴을 찾아볼 수 있다. psycopg2에서는 execute() 메서드를 호출하면 객체의 내부 표현이 변경되지만 실제로 실행되지는 않는다. fetchall() 메서드를 호출할 때 비로소 데이터가 조회되고 커서에서 사용 가능한 상태가 된다.

이 패턴을 따르게 하는 가장 간단한 방법은 실행될 명령의 파라미터들을 저장하는 객체를 만드는 것이다. 그리고 파라미터들과 상호작용 할 수 있는 메서드를 제공하는 객체를 만들어야 한다. 마지막으로 실제로 작업을 수행할 객체를 만들어야 한다.

상태 패턴

상태 패턴은 구체화를 도와준다. 상태에 따른 행동을 수행할 경우 상태를 열거형이나 상수가 아닌 객체로 표현한다.

import abc
 
from log import logger
 
class InvalidTransitionError(Exception):
    """도달 불가능한 상태에서 전이할 때 발생하는 예외"""
 
 
class MergeRequestState(abc.ABC):
    def __init__(self, merge_request):
        self._merge_request = merge_request
 
    @abc.abstractmethod
    def open(self):
        pass
 
    @abc.abstractmethod
    def close(self):
        pass
 
    @abc.abstractmethod
    def merge(self):
        pass
 
    def __str__(self):
        return self.__class__.__name__
 
 
class Open(MergeRequestState):
    def open(self):
        self._merge_request.approvals = 0
 
    def close(self):
        self._merge_request.approvals = 0
        self._merge_request.state = Closed
 
    def merge(self):
        logger.info("머지 %s", self._merge_request)
        logger.info("브랜치 삭제 %s", self._merge_request.source_branch)
        self._merge_request.state = Merged
 
 
class Closed(MergeRequestState):
    def open(self):
        logger.info("종료된 머지 리퀘스트 %s 재오픈", self._merge_request)
        self._merge_request.state = Open
 
    def close(self):
        pass
 
    def merge(self):
        raise InvalidTransitionError("종료한 요청을 머지할 수 없음")
 
 
class Merged(MergeRequestState):
    def open(self):
        raise InvalidTransitionError("이미 머지 완료됨")
 
    def close(self):
        raise InvalidTransitionError("이미 머지 완료됨")
 
    def merge(self):
        """이미 머지 완료됨"""
 
 
class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state = None
        self.approvals = 0
        self.state = Open
 
    @property
    def state(self):
        return self._state
 
    @state.setter
    def state(self, new_state_cls):
        self._state = new_state_cls(self)
 
    def open(self):
        return self.state.open()
 
    def close(self):
        return self.state.close()
 
    def merge(self):
        return self.state.merge()
 
    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}"

이 예제에서 상태 전이는 state 객체에 위임된다.

MergeRequest가 모든 처리를 state 객체에 위임했기 때문에 항상 self.state.open()과 같은 형태로 호출된다.

__getattr__매직 메서드를 사용하면 반복적 코드를 제거할 수 있다.

class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state: MergeRequestState
        self.approvals = 0
        self.state = Open
 
    @property
    def state(self):
        return self._state
 
    @state.setter
    def state(self, new_state_cls):
        self._state = new_state_cls(self)
 
    def __getattr__(self, method):
        return getattr(self.state, method)
 
    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}"

Null 객체 패턴

Null 객체 패턴의 원칙은 함수나 메서드는 일관된 타입을 반환해야 한다는 것이다. 이것이 보장된다면 클라이언트는 다형성을 가진 메서드에서 반환되는 객체에 대해 null 체크를 하지 않고 바로 사용할 수 있다.

함수는 일관성이 있는 타입의 객체를 반환해야 한다. 어떠한 경우에도 None을 반환하면 안 된다. None은 일어난 일에 대해 아무것도 설명해주지 않는다. 때문에 반환된 객체에 대해 메서드를 호출하는 호출자는 AttributeError을 발생시키게 된다.

비어있는 상태의 객체를 나타내는 null 객체는 사용자가 원래 기대하던 것과 동일한 메서드를 가지고 있어야 하며 아무 일도 수행하지 않아야 한다.