본문 바로가기

python

파이썬 클린 코드 - 9장 (일반적인 디자인 패턴)

1. 실전 속의 디자인 패턴

GoF 에서 소개하는 23개의 디자인 패턴을 기준으로 하며, 각 패턴은 생성(creational), 구조(structural), 행동(behavioral) 패턴 중의 하나로 분류된다. 일부 패턴은 파이썬 내부에서 자체적으로 구현되어 있으므로 보이지 않은 채로도 적용될 수 있다. 또한 모든 패턴이 똑같이 일반적이지는 않다. 몇 개는 매우 빈번하게 언급되는 반면 다른 것들은 특별한 상황에서만 사용되는 것도 있다.

 

애플리케이션의 솔루션에 강제로 디자인 패턴을 적용해서는 안되며, 패턴이 출현할 때까지는 솔루션을 진화시키고 리팩토링하고 개선해야 한다.

따라서 디자인 패턴은 발명되는 것이 아니라 발견되는 것이다. 코드에 반복적으로 같은 내용이 출현할 때 비로소 일반적이고 추상화된 클래스, 객체 또는 컴포넌트의 패턴이 발견되는 것이다.

 

1-1. 생성(creational)패턴

생성 패턴은 객체를 인스턴스화 할 때의 복잡성을 최대한 추상화하기 위한 것이다. 객체 초기화를 위한 파라미터를 결정하거나 초기화에 필요한 관련 객체를 준비하는 것 등의 모든 관련 작업을 단순화하려는 것이다.

 

# 팩토리

파이썬의 핵심 기능 중 하나는 모든 것이 객체라는 것이며 따라서 모두 똑같이 취급될 수 있다는 것이다. 즉, 클래스나 함수 또는 사용자 정의 객체 각각의 역할이 특별히 구분되어 있지 않다. 이들은 모두 파라미터나 할당 등에 사용될 수 있다.

이러한 이유로 파이썬에서는 팩토리 패턴이 별로 필요하지 않다. 간단히 객체들을 생성할 수 있는 함수를 만들 수 있으며, 생성하려는 클래스를 파라미터로 전달할 수도 있다.

 

# 싱글턴과 공유 상태(monostate)

싱글턴 패턴은 파이썬에 의해 완전히 추상화되지 않는 패턴이다. 사실 대부분의 경우 이 패턴은 실제로 필요하지 않거나 좋지 않은 선택이다. 싱글턴은 객체 지향 소프트웨어를 위한 전역 변수의 한 형태이며 이는 나쁜 습관이다. 싱글턴은 단위 테스트가 어렵다. 어떤 객체에 의해서 언제든지 수정될 수 있다는 사실은 예측하기 어렵다는 뜻이고, 부작용이 큰 문제를 일으킬 수도 있다.

 

일반적으로 싱글턴은 가능하면 사용하지 않는 것이 좋다. 파이썬에서 이를 해결하는 가장 쉬운 방법은 모듈을 사용하는 것이다. 모듈에 객체를 생성할 수 있으며, 모듈을 임포트한 모든 곳에서 사용할 수 있다. 파이썬에서 모듈은 이미 싱글턴이라는 것을 의미한다. 즉, 여러 번 임포트 하더라도 sys.modules 에 로딩되는 것은 항상 한 개다.

 

공유상태

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

모노 스테이트 패턴의 주요 개념은 싱글턴인지 아닌지에 상관없이 일반 객체처럼 많은 인스턴스를 만들 수 있어야 한다는 것이다. 이 패턴의 장점은 완전히 투명한 방법으로 정보를 동기화하기 때문에 사용자는 내부 동작을 신경쓰지 않아도 된다는 점이다.

 

얼마나 많은 정보를 동기화해야 하는지 여부에 따라 다양한 수준으로 이 패턴을 적용할 수 있다.

 

가장 간단한 형태로 모든 인스턴스에 하나의 속성만 공유될 필요가 있다고 해보자. 이 경우 단지 클래스 변수를 사용하는 것처럼 쉽게 구현할 수 있지만 속성의 값을 업데이트하고 검색하는 올바른 인터페이스를 제공해야만 한다.

 

Git 저장소에서 최신 태그의 코드를 가져오는 객체가 있다고 가정해본다. 이 객체의 인스턴스는 여러 개 있을 수 있으며 어떤 클라이언트에서든 코드 가져오기 요청을 하면 tag 라는 공통의 속성을 참조할 것이다. tag 는 언제든지 새 버전으로 업데이트될 수 있으며, fetch 요청을 하면 기존의 인스턴스 뿐만 아니라 새로운 인스턴스에서도 해당 버전을 참조해야 한다.

 

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 was never set")
        return self._current_tag

    @current_tag.setter
    def current_tag(self, new_tag):
        self.__class__._current_tag = new_tag

    def pull(self):
        logger.info("pulling from %s", self.current_tag)
        return self.current_tag

 

다음과 같이 다른 버전을 가진 여러 GitFetcher 인스턴스를 만들어 보면 모두가 같은 최신 버전을 공유하고 있음을 쉽게 확인할 수 있다.

 

>>> 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("pulling from %s", self.current_tag)
        return self.current_tag

 

디스크립터를 사용함으로써 재사용성이 높아지고 DRY 원칙을 준수할 수 있다.

 

동일한 로직을 현재 태그 기준이 아니라 현재 브랜치를 기준으로 적용하고 싶다면 다른 코드는 그대로 두고 새로운 클래스 속성만 추가하면 된다.

 

# 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):
        logger.info("pulling from tag %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("pulling from branch %s", self.source)
        return f"Branch = {self.source}"

 

이전 예제의 객체를 각각 태그와 브랜치를 기반으로 동작하도록 나눴다. 두 객체 모두 초기화된 메서드를 공유하는 기본 클래스를 가진다. borg 로직을 구현하기 위해서는 약간의 수정이 필요하다. 속성을 저장할 사전을 클래스 속성으로 지정하고, 객체를 초기화할 때 모든 객체에서 바로 이 동일한 사전을 참조하도록 해야 한다.

 

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):
        logger.info("pulling from tag %s", self.source)
        return f"Tag = {self.source}"


class BranchFetcher(SharedAllMixin, BaseFetcher):
    def pull(self):
        logger.info("pulling from branch %s", self.source)
        return f"Branch = {self.source}"

 

각각의 클래스에서 믹스인 클래스를 사용해 사전을 만든다. 만약 사전이 없는 경우에는 초기화를 한다. 나머지는 동일한 로직이다. 이렇게 구현하면 상속에도 문제가 없으므로 보다 실용적인 대안이 될 수 있다.

 

빌더

빌더 패턴은 필요로 하는 모든 객체를 직접 생성해주는 하나의 복잡한 객체를 만들어야 한다는 것이다. 사용자가 필요로 하는 모든 보조 객체를 직접 생성하여 메인 객체에 전달하는 것이 아니라, 한 번에 모든 것을 처리해주는 추상화를 해야 한다는 것이다.

 

1-2. 구조(structural) 패턴

구조 패턴은 인터페이스를 복잡하게 하지 않으면서도 기능을 확장하여 더 강력한 인터페이스 또는 객체를 만들어야 하는 상황에 유용하다.

이 패턴의 큰 장점은 향상된 기능을 깔끔하게 구현할 수 있다는 것이다. 여러 개의 객체를 조합하거나 작고 응집력 높은 인터페이스들을 조합하기만 하면 된다.

 

# 어댑터 패턴

어댑터 패턴은 호환되지 않는 두 개 이상의 객체에 대한 인터페이스를 동시에 사용할 수 있게 한다.

호환되지 않는 서로 다른 객체를 수용할 수 있는 새로운 인터페이스를 개발한다. 이것은 두 가지 방법으로 구현할 수 있다.

 

첫 번째는 상속을 이용하는 것이다.

 

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 라는 다른 메서드를 지원하는 객체가 있을 때, search 메서드를 래핑하는 새로운 fetch 메서드를 만든다.

 

다만, 상속은 강한 결합을 만들고 융통성을 떨어뜨린다. 개념적으로 상속은 is a 관계에 한정해서 적용하는 것이 바람직하다. 

 

보다 나은 방법은 컴포지션을 사용하는 것이다.

 

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}"

 

객체에 기존에 상속받고자 했던 객체의 인스턴스를 제공하기만 하면 된다.

 

# 컴포지트(composite)

프로그램에서 사용하는 객체는 내부적으로 또 다른 여러 객체를 사용해서 작업한다. 잘 정의된 로직을 가진 기본 객체도 있고, 이러한 기본 객체들을 묶어서 사용하는 컨테이너 객체도 있다. 문제는 이러한 기본 객체와 컨테이너 객체를 특별한 구분 없이 동일하게 사용하길 원하는 경우에 발생한다.

 

예를 들어 여러 상품을 보유하고 있는 온라인 매장을 보자. 여러 상품을 그룹지어 한 번에 패키지로 구매하면 할인을 해준다. 상품에는 개별 가격이 있는데 패키지 상품은 할인율을 감안하여 가격이 계산되어야 한다. 여기서의 상품이란 상품 1개를 말하는 것일 수도 있고 여러 상품을 묶은 패키지 상품이 될 수도 있다. 이러한 상품의 묶음을 나타내는 객체를 만들 것이고, 전체 가격을 확인하는 기능을 위임할 것이다. 전체 가격을 확인하려면 다음과 같이 하위 상품이 없을 때까지 계속 상품의 가격을 확인하면 된다.

 

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)

 

컴포지트 객체는 기본 객체이든 컨테이너 객체이든 상관없이 해당 요청을 처리할수 있을 때까지 계속 전달한다. 

 

# 데코레이터

데코레이터 패턴은 상속을 하지 않고도 객체의 기능을 동적으로 확장할 수 있게 한다.

다음 예제는 전달된 파라미터를 사용해서 쿼리에 사용할 수 있는 사전 형태의 객체를 반환한다. 예를 들어 elasticsearch 에 사용하기 위한 쿼리 같은 객체를 만드는 것이다.

 

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 를 상속받은 클래스들은 공통된 인터페이스를 가지고 있으므로 상호 교환이 가능하다. 이 객체는 데코레이팅된 객체를 수신하도록 설계되었다. 값을 받고 수정된 버전으로 반환한다.

 

만약 False 로 평가되는 값을 모두 지우고 쿼리에 알맞게 정규화를 하려면 다음과 같이 하면 된다.

 

>>> 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'}

 

혹은 각각의 데코레이션 단계를 함수로 정의한 다음 기본 데코레이터 객체(QueryEnhancer) 에 전달할 수도 있다.

 

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)

파사드는 객체 간 상호 작용을 단순화하려는 많은 상황에서 유용하다. 패턴은 여러 객체가 다대다 관계를 이루며 상호작용하는 경우에 사용된다. 각각의 객체에 대한 모든 연결을 만드는 대신 파사드 역할을 하는 중간 객체를 만드는 것이다.

 

파사드는 허브 또는 단일 참조점(single point of reference) 의 역할을 한다. 외부 프로젝트 입장에서는 파사드 내부의 모든 내용이 완전히 불투명해야 한다.

이 패턴을 사용하면 객체의 결합력을 낮춰주는 확실한 장점 외에도 인터페이스의 개수를 줄이고 보다 나은 캡슐화를 지원할 수 있게 되므로 보다 간단한 디자인을 유도하는 장점이 있다.

 

이 패턴은 API 설계를 위해서도 사용할 수 있다. 단일 인터페이스를 제공하면 단일 진리점(single point of truth) 또는 코드의 진입점(entry point for code) 역할을 하여 사용자가 노출된 기능을 쉽게 사용할 수 있다. 뿐만 아니라 기능만 노출하고 나머지 모든 것은 인터페이스 뒤에 숨김으로써 세부 코드는 원하는 만큼 리팩토링을 해도 된다.

 

파사드는 클래스나 객체에 한정된 것이 아니라 패키지에도 적용된다. 사용자에게 노출해야 하는 임포트 가능한 외부용 레이아웃과 직접 임포트해서는 안되는 내부용 레이아웃을 구분하는 것이다.

__init__.py 파일이 모듈의 루트로서 파사드와 같은 역할을 한다.

 

1-3. 행동(Behavioral) 패턴

행동 패턴은 객체가 어떻게 협력해야하는지, 어떻게 통신해야하는지, 런타임 중에 인터페이스는 어떤 형태여야 하는지에 대한 문제를 해결하는 것을 목표로 한다.

 

# 책임 연쇄 패턴

앞선 장에서 다루었던 이벤트 시스템 예제를 다시 살펴본다. 이 시스템은 텍스트 파일이나 HTTP 애플리케이션 서버에서 발생한 로그와 시스템 이벤트 정보를 파싱한다. 그리고 클라이언트에서 사용하기 편리한 형태로 데이터를 추출해준다.

 

여기에서는 후계자(successor) 라는 개념이 추가 되었다. 이 후계자는 현재 이벤트가 로그 라인을 처리할 수 없는 경우에 대비한 다음 이벤트 객체이다. 이벤트를 연결하고 각 이벤트는 데이터를 처리하려고 시도한다. 직접 처리가 가능한 경우 결과를 반환하고, 그렇지 않으면 후계자에게 전달하고 이러한 과정을 반복한다.

 

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'}

 

 

# 템플릿 메서드 패턴

템플릿 메서드 패턴(template method pattern) 을 적절히 구현하면 중요한 이점을 얻을 수 있다. 코드 재사용성을 높여주고 객체를 보다 유연하게 하여 다형성을 유지하면서도 코드를 쉽게 수정할 수 있다는 점이다.

 

주요 개념은 어떤 행위를 정의할 때 특정한 형태의 클래스 계층구조를 만드는 것이다. 계층구조를 이루는 모든 클래스들은 공통된 템플릿을 공유하며 템플릿의 특정 요소만 변경할 수도 있다. 그런 다음 공통적인 로직을 부모 클래스의 public 메서드로 구현하고 그 안에서 내부의 다른 private 메서드들을 호출하는 것이다.

이전 섹션의 책임 연쇄 패턴에서 이미 이 패턴을 사용했다. Event 에서 파생된 클래스는 오직 특정 패턴 하나만 구현하였다. 나머지 공통적인 논리는 Event 클래스의 템플릿 메서드에 있다. process 이벤트는 공통적인 로직을 가지며 can_process() 와 _process() 라는 보조 메서드가 있다.

 

이러한 추가 메서드들은 클래스 속성에 의존한다. 따라서 새로운 타입에서 기능을 확장하려면 단지 파생클래스에서 정규식으로 속성을 재정의하기만 하면 된다. 나머지 로직은 템플릿 메서드에 따라서 재정의된다.

 

# 커맨드

커맨드 패턴(command pattern) 은 수행해야 할 작업을 요청한 순간부터 실제 실행 시까지 분리할 수 있는 기능을 제공한다. 또한 클라이언트가 발행한 원래 요청을 수신자와 분리할 수도 있다. 

 

예를 들어 psycopg2(PostgresSQL 클라이언트 라이브러리) 에서는 DB 와 연결을 하고 커서를 얻고, 이 커서를 통해 SQL 문을 실행할 수 있다. execute 메서드를 호출하면 객체의 내부 표현이 변경되지만 아직 실제로 실행되지는 않는다. fetchall() 또는 유사한 메서드를 호출할 때에 비로소 데이터가 조회되고 커서에서 사용 가능한 상태가 된다.

 

SQLAlchemy 에서도 마찬가지다. 쿼리는 여러 단계를 거쳐 정의되며 쿼리 결과를 원한다고 명시적으로 결정하기 전까지는 쿼리 객체와 상호 작용할 수 있다. 쿼리와 상호 작용하는 메서드들을 호출하면 query 객체의 내부 속성을 변경하고 self 자체를 반환할 뿐이다.

 

위와 같은 구조를 따르도록 하는 가장 간단한 방법은 실행될 명령의 파라미터들을 저장하하는 객체(command) 를 따로 만드는 것이다. 그리고 명령에 필요한 파라미터를 활용하여 상호 작용할 수 있는 메서드를 제공하는 객체(invoker) 를 만들어야 한다. 마지막으로 작업을 수행할 객체(receiver) 를 만들어야 한다.

 

# 상태 패턴

상태(state) 패턴은 구체화(reification) 을 도와주는 패턴이다. 이 패턴을 통해 도메인 문제의 개념을 부수적인 가치에서 명시적인 객체로 전환시킬 수 있다.

어떤 행동을 해야 하거나 상태에 따라 다른 행동을 수행해야 한다면, 상태를 단순한 열거형이 아닌 객체로 표현해야 한다.

 

모든 논리를 단일 장소, 즉 단일 클래스에 넣으면 하나의 클래스가 너무 많은 책임을 갖게 되므로 좋은 디자인이 아니다. 

따라서 상태별로 작은 객체를 만들어 각각의 객체가 적은 책임을 갖게 하는 것이 좋다. 먼저 표현하고자 하는 각 종류의 상태를 만들고, 각 객체의 메서드에 앞서 설명한 규칙에 따라 전이 로직을 작성한다.

 

8장에서 살펴본 머지 리퀘스트 관련 예제를 수정해본다.

MergeRequest 객체는 상태를 저장하는 _state 속성을 가지며 해당 속성을 통해 최종 MergeRequest 의 상태를 알 수 있다.

 

class InvalidTransitionError(Exception):
    """Raised when trying to move to a target state from an unreachable source
    state.
    """


class MergeRequestState(abc.ABC):
    def __init__(self, merge_request):
        self._merge_request = merge_request

    @abc.abstractmethod
    def open(self):
        ...

    @abc.abstractmethod
    def close(self):
        ...

    @abc.abstractmethod
    def merge(self):
        ...

    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("merging %s", self._merge_request)
        logger.info("deleting branch %s", self._merge_request.source_branch)
        self._merge_request.state = Merged


class Closed(MergeRequestState):
    def open(self):
        logger.info("reopening closed merge request %s", self._merge_request)
        self._merge_request.state = Open

    def close(self):
        """Current state."""

    def merge(self):
        raise InvalidTransitionError("can't merge a closed request")


class Merged(MergeRequestState):
    def open(self):
        raise InvalidTransitionError("already merged request")

    def close(self):
        raise InvalidTransitionError("already merged request")

    def merge(self):
        """Current state."""


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}"

 

>>> mr = MergeRequest("develop", "master")
>>> mr.open()
>>> mr.approvals
0
>>> mr.approvals = 3
>>> mr.close()
>>> mr.approvals
0
>>> mr.open()
INFO: reopening closed merge request master:develop
>>> mr.merge()
INFO: merging master:develop
INFO: deleting branch develop
>>> mr.close()
Traceback (most recent call last):
...
InvalidTransitionError: already merged request

 

상태 전이는 state 객체에 위임되며 state 는 항상 MergeRequest 를 가리키게 된다. state 가 가리키는 객체는 ABC 의 하위 클래스 중 하나이다. 이들은 모두 동일한 메시지에 대해서 적절한 처리를 한 다음 MergeRequset 를 다음 상태로 전이시킨다.

 

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}"

 

사용자는 MergeRequest 가 가지고 있지 않은 모든 것을 state 속성이 가지고 있다는 것을 알 수 있다. init 메서드에서 _state 의 타입 어노테이션은 _state 가 MergeRequestState 타입의 객체임을 알려주고 있다. 따라서 MergeRequestState 를 살펴보게 되며 open(), close(), merge() 메서드를 안전하게 사용할 수 있음을 알 수 있게 된다.

 

2. Null 객체 패턴

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

 

어떤 경우에도 None 을 반환하면 안 된다. None 이라는 문구는 발생한 일에 대해 어떠한 설명도 해주지 않으며 특별한 공지가 없으면 호출자는 아무 생각 없이 반환 객체에 대해 메서드를 호출하고 결국 AttributeError 가 발생한다.

 

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

이 구조를 사용하면 런타임 시 오류를 피할 수 있을 뿐만 아니래 객체를 유용하게 활용할 수도 있다. 코드를 테스트하기가 쉬워지며 디버깅하에 도움이 될 수 있다.