[python cleancode] 9. 일반적인 디자인 패턴

햄도·2021년 4월 18일
0

Python Cleancode

목록 보기
9/9

출처

파이썬 클린코드를 읽으며 정리한 내용입니다.

  • 디자인 패턴: 개발 중 자주 발생하는 일반적인 문제들을 어떻게 추상화하여 해결할 수 있는지 소개
  • 어떻게 클린코드에 기여할 수 있는지의 관점에서 디자인 패턴을 살펴보자.
  • 파이썬의 동적인 특성으로 인해 디자인 패턴을 적용할 때 어떤 점을 주의해야 하는지 살펴보자.

파이썬에 디자인 패턴 적용 시 고려사항

  • 파이썬에서 클린 코드를 달성하기 위해서는 어떤 패턴들이 있고 어떻게 구현할 수 있는지 파악해야 한다.
    • 예를 들어 반복은 파이썬에 깊숙히 묻혀있는 개념이기 때문에 이터레이션 패턴을 직접 구현하려고 시도하는 것은 파이써닉하지 않다.
  • 애플리케이션에서 자주 사용되는 패턴들을 어떻게 파이썬스럽게 구현할 수 있는지 알아보자.

실전 속의 디자인 패턴

  • GoF에서는 각 패턴을 생성, 구조, 행동 패턴 중 하나로 분류한다.
  • 모든 것을 살펴보려는 것은 아니니 다음 두 가지를 염두에 두자.
    1. 일부 패턴은 파이썬 내부에서 자체적으로 구현되어 있으므로 보이지 않은 채로도 적절히 적용될 수 있다.
    2. 모든 패턴이 똑같이 일반적인 것은 아니다. 빈번하게 언급되는 것도 있고, 아닌 것도 있다.
  • 디자인 패턴은 발견되는 것이다. 강제로 디자인 패턴을 적용하지 말자.

생성(creational) 패턴

  • 생성 패턴은 객체를 인스턴스화 할 때의 복잡성을 최대한 추상화하기 위한 것
  • 객체 초기화를 위한 파라미터를 결정하거나 초기화에 필요한 관련 객체를 준비하는 것 등 모든 관련 작업을 단순화
  • 이를 통해 더 간단한 인터페이스를 제공하고, 사용자가 안전하게 객체 생성 가능
  • 객체 생성을 위한 다섯 가지 패턴 중 주로 싱글턴 패턴을 피하기 위한 방법을 살펴보자.

팩토리

  • 파이썬에서는 모든 것이 객체이기 때문에 타입에 상관없이 모두 파라미터나 할당 등에 사용될 수 있다.
  • 이때문에 파이썬에서는 팩토리 패턴 없이도 간단히 객체들을 생성하는 함수를 만들고, 생성하려는 클래스를 파라미터로 전달할 수 있다.
    • 객체 생성하는 부분을 캡슐화해 의존성을 낮추기 위해 팩토리 패턴을 사용하는데, 파이썬은 클래스를 파라미터로 전달하는 등의 방법으로 팩토리 패턴 대체 가능

싱글턴과 공유상태

  • 싱글턴 패턴은 파이썬에 의해 완전히 추상화되지 않은 패턴이다.
  • 대부분의 경우 이 패턴은 실제로 필요하지 않거나 나쁜 선택
  • 단위 테스트가 어려우며, 예측하기도 어렵다.
  • 꼭 필요하다면 모듈에 객체를 생성해 해당 모듈을 임포트한 곳에서 사용하자.
  • 모듈은 이미 싱글톤이기 때문에 여러번 임포트하더라도 sys.modules에 로딩되는 것은 한개다.
  • 공유 상태
    • 싱글톤을 사용하는것보다 여러 인스턴스에서 사용할 수 있도록 데이터를 복제하는 것이 좋다.
    • 모노 스테이트 패턴의 주요 개념은 싱글턴인지 아닌지에 상관없이 일반 객체처럼 많은 인스턴스를 만들 수 있어야 한다는 것이다.
    • 이 패턴은 투명하게 정보를 동기화하기 때문에 사용자가 내부 동작을 신경쓸 필요가 없다.
    • 이 패턴이 싱글톤보다는 좋은 선택이다.
# git 저장소에서 최신 태그의 코드를 가져오는 객체 예시
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):
        print("%s에서 풀" % self.current_tag)
        return self.current_tag
  • 위 예시에서 GitFetcher의 인스턴스는 여러 개 있을 수 있으며, 어떤 클라이언트에서 pull 요청을 하든 tag라는 공통 속성을 참조한다.
  • tag는 언제든지 새 버전으로 업데이트될 수 있고, fetch요청을 하면 모든 인스턴스에서 해당 버전을 참조해야 한다.
f1 = GitFetcher(0.1)
f2 = GitFetcher(0.2)
f1.current_tag = 0.3
f2.pull()
>>> 
0.3에서 풀

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
  • 코드는 더 길어지지만, 책임을 캡슐화하고 코드를 분리하여 각각이 응집력을 갖게 되므로 단일 책임 원칙을 준수할 수 있게 된다.
    • GitFetcher 클래스는 git 관리에만 집중하면 되고, tag 등 인스턴스 간 공유되는 속성을 관리하는 것은 SharedAttribute가 책임진다.
  • 또한 디스크립터를 사용함으로써 재사용성도 높아진다. 현재 branch를 여러 인스턴스가 공유하는 등 공유하는 속성이 늘어난다면 새로운 클래스 속성만 추가하면 된다.
  • 이렇게 구현하면 모든 클래스를 테스트할 필요 없이 디스크립터 객체에 대해서만 테스트하면 되기 때문에, 단위 테스트에서도 이점을 가져갈 수 있다.
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):
        print("%s에서 풀" % self.current_tag)
        return self.current_tag
  • 이 경우 3회 반복의 법칙을 참고하여 디스크립터가 필요할지 판단하자.
  • borg 패턴
    • 꼭 싱글턴을 사용해야 하는 경우 최후의 대안
    • 실제로는 모노 스테이트 패턴으로, 파이썬에서 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):
        print("%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):
        print("%s 브랜치에서 풀" % self.source)
        return f"Branch = {self.source}"
  • 위 예시에서 하나는 Git 태그에 기반을 두어 동작하고, 하나는 브랜치를 기반으로 동작한다.
  • borg 로직을 구현하기 위해 각 클래스의 속성을 저장할 사전을 클래스 속성으로 지정하고, 객체를 초기화할 때 모든 객체에서 이 사전을 참조하도록 했다.
  • 이렇게 하면, 사전은 mutable 객체이므로 한 곳에서 사전을 업데이트하면 모든 객체에 동일하게 업데이트된다.
  • 기본 클래스에 사전과 관련된 로직을 추가하면 다른 클래스의 객체에도 의도치 않게 영향을 미칠 수 있으므로 주의하자.
class SharedAllMixin:
    def __init__(self, *args, **kwargs):
        try:
            self.__class__._attributes
        except AttributeError:
            self.__class__._attributes = {}

        self.__dict__ = self.__class__._attributes
        super().__init__(*args, **kwargs)

class BaseFetcher:
    def __init__(self, source):
        self.source = source

class TagFetcher(SharedAllMixin, BaseFetcher):
    def pull(self):
        print("%s 태그에서 풀" % self.source)
        return f"Tag = {self.source}"

class BranchFetcher(SharedAllMixin, BaseFetcher):
    def pull(self):
        print("%s 브랜치에서 풀" % self.source)
        return f"Branch = {self.source}"
  • 위와 같이 믹스인 클래스에서 사전을 만들도록 분리하면 상속에도 문제가 없다.
  • 빌더
    • 이 패턴은 객체의 복잡한 초기화를 추상화한다.
    • 디스크립터와 마찬가지로 여러 사용자가 사용하는 api 등을 노출하는 경우에만 구현해야 한다.
    • 필요로 하는 모든 객체를 직접 생성해주는 하나의 복잡한 객체를 만든다.
    • 사용자가 필요로 하는 모든 보조 객체를 직접 생성하여 메인 객체에 전달하는 것이 아니라, 한 번에 모든 것을 처리해주는 추상화를 한다.
    • 빌더 객체는 사용자 인터페이스를 제공하고, 사용자는 최종 객체에 대한 모든 정보를 해당 인터페이스에 파라미터로 전달하는 형태

구조(structural) 패턴

  • 인터페이스를 복잡하게 하지 않으면서도 기능을 확장하여 더 강력한 인터페이스 또는 객체를 만들어야 하는 상황에 유용
  • 여러개의 객체를 조합하거나 인터페이스를 조합해서 향상된 기능을 깔끔하게 구현할 수 있다.

어댑터 패턴

  • 이 패턴은 호환되지 않는 두 개 이상의 객체에 대한 인터페이스를 동시에 사용할 수 있게 한다.
  • 개발하다보면 원래 사용하던 인터페이스와 호환되지 않는 객체를 사용해야 할 수도 있다. 이 경우 새로운 객체를 수용할 수 있는 인터페이스를 개발해야 한다. 이것은 두 가지 방법으로 구현할 수 있다.
    1. 사용하려는 클래스를 상속받는 클래스를 만든다.
    2. 컴포지션을 사용한다. 즉 사용하려는 클래스를 새로운 클래스의 private 속성으로 추가한다.
  • 1번 방법은 기존 클래스가 다른 클래스에서 파생된 클래스인 경우, 다중 상속을 하게되기 때문에 결합을 강하게 만들고 융통성을 떨어뜨린다.
  • 2번 방법은 필요한 파라미터를 인스턴스에 전달하기만 하면 되기 때문에 더 낫다.

컴포지트(composite)

  • 기본 객체와, 기본 객체를 묶어서 사용하는 컨테이너 객체를 구분 없이 동일하게 사용하고 싶을 때 사용한다.
  • 기본 객체는 리프 노드이고, 컨테이너 객체는 중간 노드라고 할 수 있다.
from typing import Iterable, Union
# 상품 클래스와, 상품 여러개로 구성된 번들 클래스의 예시
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 클래스의 price에 접근하면 기존 Product 객체의 price를 이용해 포함된 제품의 모든 가격을 합산해 할인율을 적용한다.
  • 이렇게 구현하여 완벽히 호환되도록 만들지 않고도 price를 구할 수 있다.

데코레이터 패턴

  • 파이썬에서 제공하는 데코레이터와는 전혀 다른 개념
  • 상속을 하지 않고도 객체의 기능을 동적으로 확장할 수 있다.
# 전달된 파라미터를 사용해 쿼리에 사용할 수 있는 사전 형태의 객체 반환
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()}
  • QueryEnhancer를 상속받은 클래스들은 공통 인터페이스를 가지고 있으므로 상호 교환할 수 있다.
  • 이 객체들은 데코레이팅된 객체를 받아 변환한 후 수정된 버전을 반환한다.
original = DictQuery(key="value", empty="", none=None, upper="UPPER", title="Title")
new_query = CaseInsensitive(RemoveEmpty(original))
original.render()
>>> {'key': 'value', 'empty': '', 'none': None, 'upper': 'UPPER', 'title': 'Title'}
new_query.render()
>>> {'key': 'value', 'upper': 'upper', 'title': 'title'}
  • 파이썬의 동적인 특성을 활용해 각각의 데코레이션 단계를 함수로 정의한 후 기본 데코레이터 객체에 전달할 수도 있다.
from typing import Callable, Dict

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):
    return {k: v for k, v in original.items() if v}

def case_insensitive(original):
    return {k: v.lower() for k, v in original.items()}
query = DictQuery(foo="bar", empty="", none=None, upper="UPPER", title="Title")
QueryEnhancer(query, remove_empty, case_insensitive).render()
>>> {'foo': 'bar', 'upper': 'upper', 'title': 'title'}
  • 이 예제에서는 함수 기반의 접근법이 더 쉽게 이해될 수 있지만, 입력 데이터에 따라 복잡한 형태로 데코레이팅을 하는 경우 객체 지향적인 방식을 사용하는 것이 좋다.

파사드

  • 파사드는 여러 객체가 다대다 관계를 이루며 상호작용하는 경우에 좋다.
  • 각각의 객체에 대한 모든 연결을 만드는 대신 파사드 역할을 하는 중간 객체를 만든다. 이 때 외부 오브젝트 입장에서는 파사드 내부의 모든 내용이 완전히 불투명해야 한다.
  • 이 패턴을 사용하면 객체의 결합력을 낮춰줄뿐만 아니라 인터페이스의 개수를 줄이고, 더 나은 캡슐화를 지원해 간단한 디자인을 유도한다.
  • 보다 더 나은 API 설계를 위해서도 유용하다.
  • 파사드는 패키지에도 적용할 수 있다. 예시로 __init__.py를 이용해 사용자에게 노출해야 하는 임포트 가능한 외부용 레이아웃과 내부용 레이아웃을 구분하는 경우가 있다. 이렇게 하면 사용자에게 단일 진입점을 제공하며, 패키지의 나머지 파일들을 마음껏 리팩토링하거나 재정렬할 수 있다.

행동(behavioral) 패턴

  • 객체가 어떻게 협력해야하는지, 어떻게 통신해야 하는지, 런타임 중 인터페이스는 어떤 형태여야 하는지에 대한 문제 해결이 목표
  • 정적으로는 상속을 통해, 동적으로는 컴포지션을 통해 해결

책임 연쇄 패턴

  • 앞서 다루었던 이벤트 시스템을 다시 살펴보자.
  • 텍스트 파일이나 http 애플리케이션 서버에서 발생한 로그와 시스템 이벤트 정보를 파싱, 클라이언트가 사용하기 편리한 형태로 데이터 추출
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+)")
  • 이제 event 객체들을 만들고 처리할 특정 순서로 정렬하면 된다.
  • 객체마다 동일한 논리의 process() 메서드를 가지기 때문에 제공한 데이터에서 정보를 추출하려고 시도하고, 처리할 수 없으면 다음 이벤트에게 전달한다.
  • 로그인 이벤트는 아래와 같이 처리할 수 있다.
chain = LogoutEvent(LoginEvent())
chain.process("567: login user")
>>> {'type': 'LoginEvent', 'id': '567', 'value': 'user'}
# 원래 코드

class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data

    # 객체를 만들지 않고 클래스의 메소드 사용
    # 인스턴스 속성을 변화시키지 않는 경우
    @staticmethod
    def meets_condition(event_data: dict):
        return False

class UnknownEvent(Event):
    """데이터만으로 식별할 수 없는 이벤트"""

class LoginEvent(Event):
    """로그인 사용자에 의한 이벤트"""
    @staticmethod
    def meets_condition(event_data: dict):
        return(
            event_data["before"]["session"] == 0
            and event_data["after"]["session"] == 1
        )

class LogoutEvent(Event):
    """로그아웃 사용자에 의한 이벤트"""
    @staticmethod
    def meets_condition(event_data: dict):
        return(
            event_data["before"]["session"] == 1
            and event_data["after"]["session"] == 0
        )

class SystemMonitor:
    """시스템에서 발생한 이벤트 분류"""

    def __init__(self, event_data):
        self.event_data = event_data

    def identify_event(self):
        for event_cls in Event.__subclasses__():
            try:
                if event_cls.meets_condition(self.event_data):
                    return event_cls(self.event_data)
            except KeyError:
                continue

        return UnknownEvent(self.event_data)
  • 이 솔루션은 충분히 좋지만, 사용자가 런타임 중 subclasses 순서와는 다르게 우선순위를 변경하고 싶다면 어떻게 해야 할까?
  • 메서드 하나에 의존하는 것보다 여러 객체에 패턴을 적용하는 것이 훨씬 유연하다.

템플릿 메서드 패턴

  • 어떤 행위를 정의할 때 특정 형태의 클래스 계층구조를 만든다.
  • 계층구조를 이루는 모든 클래스들은 공통 템플릿을 공유하며, 템플릿의 특정요소만 변경할 수 있다.
  • 공통적인 로직을 부모 클래스의 public 메서드로 구현하고, 그 안에서 내부의 다른 private 메서드들을 호출하도록 한다.
  • 이렇게 하면 템플릿에 있는 기본 공통 로직은 모두 재사용된다.
  • 책임 연쇄 패턴에서 구현한 클래스가 예시
  • 새로운 이벤트 클래스에서 기능을 확장하려면 파생클래스에서 정규식으로 속성을 재정의하기만 하면 되고, 나머지 로직은 템플릿 메서드에 따라서 재정의된다.
  • 파싱 방법을 변경해야 하는 경우 private 메서드만 오버라이드해주면 되며, 반환 값의 타입이 동일하다면 하위 호환성 또한 자연스럽게 유지된다.
  • 자연스럽게 리스코프 치환 원칙과 개방/폐쇄 원칙도 준수하게 된다.
  • 코드의 재사용성을 높여주고 객체를 유연하게 하며 코드를 쉽게 수정할 수 있다.
  • 자신만의 라이브러리나 프레임워크를 만들 때에도 이런 패턴이 유용하다. 사용자가 하위 클래스를 만들고 특정 private 메서드를 오버라이드하기만 하면 하휘 호환성이 유지되는 새로운 행동을 정의할 수 있다.

커맨드

  • 커맨드 패턴은 수행해야 할 작업을 요청할 순간부터 실제 실행 시까지 분리할 수 있는 기능을 제공한다. 또한 클라이언트가 발행한 원래 요청을 수신자와 분리할 수도 있고, 수신자는 다른 객체일 수 있다.
  • __call__() 매직 메서드를 구현하여 호출 가능한 객체를 생성할 수 있다. 따라서 일단 객체를 초기화하고 나중에 호출할 수 있다.
    • 이것이 유일한 요구사항이라면 클로저를 이용해도 되지만, 커맨드 패턴으로 그 이상의 기능까지 확장할 수 있다.
    • 커맨드는 정의 이후에 일부 파라미터를 변경하거나 옵션을 추가할 수 있다는 것이 주요 개념
  • 예시로 psycopg2나 SQLAlchemy 등이 있다. 이러한 라이브러리에서는 쿼리가 여러 단계를 거쳐 정의되며, 쿼리 결과를 원한다고 명시적으로 결정하기 전까지는 쿼리 객체를 수정할 수 있다.
  • 이러한 구조를 따르도록 하는 간단한 방법은 실행될 명령의 파라미터를 저장하는 객체(command)와, 상호작용할 수 있는 메서드를 제공하는 객체(invoker)를 만드는 것이다. 선택적으로 invoke 객체에 요청을 감시하기 위한 로그를 추가할 수 있으며, 마지막으로 실제 작업을 수행하는 객체(receiver)를 만들어야 한다.
  • 실제 작업하는 메서드는 단순히 __call__()을 사용할 수도 있고, 사용자 정의 메서드를 사용할 수도 있다.

상태 패턴

  • 상태 패턴은 구체화를 도와주는 대표적인 소프트웨어 디자인 패턴이다.
  • 이전 장에서 나왔던 머지 리퀘스트를 나타내는 객체의 상태들은 그저 특정 상태를 나타내는 문자열이었으므로 열거형을 사용했다.
    • 그런데 만약 상태에 따라 다른 행동을 수행해야만 했다면? 행동은 객체가 해야 하는 일이기 때문에 상태는 객체가 되어야 한다.
    • 규칙 추가 가정
      • open -> closed 상태로 갈 때에는 모든 승인 제거
      • 머지 리퀘스트가 방금 open되었다면 승인 개수는 무조건 0개
      • 머지 리퀘스트가 머지될때는 소스 브랜치 삭제
      • closed 상태의 머지 리퀘스트를 머지하는 등의 동작은 불가능
    • 위의 로직을 모두 MergeRequest 클래스에 넣으면 하나의 클래스가 너무 많은 책임을 갖게 된다. 그리고 많은 메서드와 많은 수의 if문이 있는 클래스가 될 것이다. 이 경우 어떤 코드가 어떤 비즈니스 로직을 나타내는지 구별하기 어렵다.
    • 따라서 상태별로 작은 객체를 만들어 각 객체가 적은 책임을 갖게 하는 것이 좋다.
import abc

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):
        print("%s 머지" % self._merge_request)
        print("%s 브랜치 삭제" % self._merge_request.source_branch)
        self._merge_request.state = Merged

class Closed(MergeRequestState):
    def open(self):
        print("종료된 머지 리퀘스트 %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):
        pass

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}"
  • 위 예시에서 MergeRequest 객체는 상태를 저장하는 _state 속성을 가지며 해당 속성을 통해 최종 MergeRequest 상태를 알 수 있다.
  • Merge에서 상태 전이를 하려면 더블 디스패치, 즉 두 번의 메서드 호출이 필요하다.
  • 추상 기본 클래스가 꼭 필요한 것은 아니지만, 다루는 대상의 종류를 더 명확하게 하며 모든 하위 상태 객체가 인터페이스의 모든 메서드를 구현하도록 강제한다.
    • 모든 메서드를 구현하고 싶지 않다면 추상 클래스가 아닌 간단한 일반 클래스를 사용하여 관련 메서드를 비워두는 것이 좋아보인다.
  • MergeRequest와 MergeRequestState는 1:1 관계이며, 상태 전이가 되면 이전 객체는 가비지 컬렉션의 대상이 되어야 한다. 1:1 관계를 유지하기 어렵다면 약한 참조를 사용할 수도 있다.
mr = MergeRequest("develop", "master")
mr.open()
mr.approvals
>>> 0
mr.approvals = 3
mr.close()
mr.approvals
>>> 0
mr.open()
>>> 종료된 머지 리퀘스트 master: develop 재오픈
mr.merge()
>>> 
master: develop 머지
develop 브랜치 삭제
mr.close()
>>> 
InvalidTransitionErrorTraceback (most recent call last)

<ipython-input-17-7333a1258b38> in <module>
----> 1 mr.close()

<ipython-input-6-f01e7f739763> in close(self)
     77
     78     def close(self):
---> 79         return self.state.close()
     80
     81     def merge(self):

<ipython-input-6-f01e7f739763> in close(self)
     52
     53     def close(self):
---> 54         raise InvalidTransitionError("이미 머지 완료됨")
     55
     56     def merge(self):

InvalidTransitionError: 이미 머지 완료됨
  • 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)

    @property
    def status(self):
        return str(self.state)

    def __getattr__(self, method):
        return getattr(self.state, method)

    def __str__(self):
        return f"{self.target_branch}: {self.source_branch}"

Null 객체 패턴

  • 함수나 메서드는 일관된 타입을 반환해야 한다. 이것이 보장된다면 클라이언트는 다형성을 가진 메서드에서 반환되는 객체에 대해 null 체크를 하지 않고도 바로 사용할 수 있다.
  • 객체가 반드시 인터페이스를 준수할 필요가 없다는 사실은 메서드의 반환 값에 대해 더 주의해야 한다는 것을 의미한다.
  • 메서드에서는 일관성이 있는 타입의 객체를 반환해야 한다. 사용자 정의 객체를 반환하는 메소드에서 None을 반환해야 한다면, 예외를 발생시키거나 비어있는 상태를 나타내는 객체를 만들어 반환해야 한다. 어떤 경우에도 None을 반환하면 안된다.
    • None은 방금 일어난 일에 대해 어떤것도 설명해주지 않으며, 호출자는 특별한 공지가 없으면 아무 생각 없이 반환 객체에 대해 메서드를 호출할 것이다. 그럼 AttirbuteError가 발생한다.
  • 이 구조를 사용하면 런타임 시 오류를 피할 수 있으며 객체를 유용하게 활용할 수 있다. 그 상태에 왜 도달했는지 로그를 추가하거나, 어떤 파라미터가 사용되었는지 확인하는 작업을 추가해 테스트와 디버깅에 도움이 될 수 있다.
  • 파이썬의 매직 메서드를 잘 활용하면 일반적인 null객체를 생성할 수도 있지만, 이정도까지 일반화하는 것은 좋지 않다. 두 가지 이유가 있다.
    • 도메인의 특성을 나타내는 의미가 없어진다.
    • 원래의 인터페이스를 따르지 않게 된다.

디자인 패턴에 대한 최종 정리

디자인에 대한 패턴의 영향성

  • 디자인 패턴은 그 자체로 좋다거나 나쁜 것은 아니다. 그보다는 어떻게 구현하느냐의 문제이다.
  • 현재의 요구사항을 처리하며 미래에 변화를 수용할 수 있을 정도로 유연한 디자인을 하자.
  • 디자인 패턴이 솔루션을 잘못된 방향으로 인도하지 않도록, 처음에는 일반적인 도메인 문제로 가정하고 올바르게 추상화해 디자인하자.

모델의 이름

  • 디자인 패턴을 적용한 경우 코드에서 이름을 직접 사용할 필요는 없다. 디자인 패턴의 이름을 클래스에 추가하면 원래 클래스의 의미를 잃을 수 있다.
  • 최상의 디자인은 사용자에게 디자인 패턴이 완전히 투명해지는 것이다. 사용자는 디자인에 대해 고민할 필요가 없어야 한다.
  • 디자인 패턴의 가장 큰 장점은 깨끗한 디자인이 아니라 어휘의 확장이다. 패턴은 의사소통의 도구로 사용되며 이름만으로 디자인 의도를 쉽게 표현할 수 있다.
profile
developer hamdoe

0개의 댓글