Programming/Design patterns

[Design patterns] Composite Pattern (컴퍼짓 패턴)

Jonghyuk Baek 2022. 10. 12. 00:05

부분은 전체와 같이 취급된다.

Introduction

GoF 디자인 패턴 중 structural pattern (구조 패턴) 들을 하나씩 다루고 있는데, 이제 한 절반쯤 왔다. 이번에 소개할 내용은 Composite 패턴으로, 한줄로 요약하자면 다음과 같다.

오브젝트를 트리 구조로 구성해 계층 구조를 표현하는 동시에, 개별 오브젝트와 오브젝트의 composition을 동일한 방식으로 다룰 수 있게 한다.

본 글의 많은 부분은 에릭 감마의 GoF Design Pattern 서적에서 참고했고, 파이썬에 맞추어 아주 살짝씩 변경한 부분이 있다.

 


Motivation

단순한 기능을 하는 작은 오브젝트를 모아 큰 오브젝트를 구성하고, 이들을 다시 동일한 방식으로 모아 더 큰 단위의 오브젝트를 구성해야 하는 경우가 있다.

예를 들어, Point-Line-Polygon-Polygons 와 같이 폴리곤의 집합을 가장 기본적인 요소부터 순차적으로 표현해야 할 때라던가, Powerpoint 의 grouping 기능과 같이 어떤 컴포넌트들을 한 묶음으로 묶을 수 있고 이들을 다시 더 큰 묶음으로 묶을 수 있는 기능이 필요한 때가 있다. 그리고 각 컴포넌트나 묶음에 대해서는 계층과 상관 없이 translation, rotation, resize 와 같은 기능이 동일하게 제공되어야 한다.

이런 기능을 구현하기 위해 primitive 오브젝트들의 집합을 다루기 위한 container 클래스를 따로 구현하는 접근을 취할 수 있지만, 이럴 경우 오브젝트의 집합과 오브젝트 각각에 대해 동일한 작업을 수행해야 하는 경우에도 클래스의 차이로 인해 그것들을 쓰는 쪽에서는 각각을 구별해 개별적인 처리를 해 주어야 하고, 이는 전체 시스템을 복잡하게 만든다.

Composite 패턴은 오브젝트 자체와 그것들의 composition을 동일하게 취급함으로써, 클라이언트가 클래스 종류를 구분할 필요가 없는 재귀적인 composition을 가능하게 한다.

여기서 핵심은, 모든 구체화 클래스의 기본이 되는 추상 클래스를 작성할 때에 이 클래스가 primitive 오브젝트와 그것들의 composition을 모두 표현할 수 있도록 설계되어야 한다는 것이다.

 

Applicability

Composite 패턴은 다음과 같은 상황에서 유용하다.

  • 오브젝트의 부분-집합 (part-whole) 구조를 표현해야 할 때 사용할 수 있다.
  • 개별 오브젝트와 오브젝트의 묶음을 동일한 방식으로 다루고 싶을 때 사용할 수 있다.

 

Structure

위 그림에서 마름모로 시작해 검은 점으로 끝나는 화살표는, base 쪽의 클래스 오브젝트가 head 쪽의 클래스 오브젝트 리스트를 가지고 있다는 의미이다.

삼각형이 붙은 선은 상속 관계를 나타내며, 검은 박스는 example code snippet이다.

 

  • Component:
    • Composition 내 오브젝트들의 기본 인터페이스를 정의한다.
    • 모든 클래스에서 공통으로 사용할 인터페이스의 기능을 구현한다.
    • Child component에 접근하고 관리하기 위한 인터페이스를 정의한다.
    • (optional) Parent component에 접근하기 위한 인터페이스를 정의한다.
  • Leaf:
    • Composition 내의 leaf 오브젝트를 나타낸다. Leaf는 children을 가질 수 없다.
    • Composition 내에서 간단한 primitive 오브젝트의 동작을 정의한다.
  • Composite:
    • Children을 가지는 component의 동작을 정의한다.
    • Child component를 저장한다.
    • Child 의 접근과 관리에 관련된 인터페이스를 구현한다.
  • Client:
    • Component 인터페이스를 이용해 composition 내의 오브젝트를 다룬다.

 

실제로 오브젝트를 생성했을 때 오브젝트 간의 관계는 위와 같은 형태로 나타난다. 클라이언트에서 operation을 호출했을 때 대상이 Leaf 였다면 operation이 즉시 호출되고, 대상이 Composite 였다면 child component에 대해 재귀적으로 operation 메서드를 호출하게 된다. 물론 이 과정에 추가적인 동작을 추가할 수도 있다.

양 쪽 모두 client 쪽에서 대상 오브젝트의 계층 정보에 대해서는 전혀 알 필요가 없다.

 

Consequences

  • Primitive 오브젝트는 더 복잡한 오브젝트로 묶일 수 있으며, 이것은 다시 다른 primitive 오브젝트나 composite 오브젝트와 함께 더 큰 오브젝트로 묶여 클래스 계층을 이룰 수 있다. 필요에 따라 얼마든지 compose 가 가능하며, primitive 오브젝트를 받기를 기대하는 클라이언트에 composite 오브젝트를 제공해도 문제 없이 작동한다.
  • 클라이언트 쪽에서는 primitive 오브젝트와 composite 오브젝트의 구분 없이 기능을 사용하므로, 클라이언트 쪽의 구현이 간단해진다.
  • Component의 확장이 쉽다. 기존 코드의 수정 없이 새로운 종류의 composite 서브클래스를 바로 계층 구조에 포함시킬 수 있다.
  • 위 특성에 의한 장점과는 반대로, 너무 general 한 구조를 가지다 보니 정적으로 (static) composite 내에 포함될 component의 타입을 제한하는 것이 쉽지 않다. 따라서 덧붙일 수 있는 타입에 제한을 두고 싶다면 런타임 중에 타입 체크를 진행해야 한다….. 만 대부분의 타입이 런타임 중에 결정되는 파이썬의 동적 특성 덕분에 일반적인 파이썬 언어 사용 케이스에서는 크게 문제가 되지 않는다.

 

Implementation

from abc import abstractmethod

class Drawing:
    """Component class"""

    @abstractmethod
    def translate(self, x, y):
        pass

    @abstractmethod
    def resize(self, x_factor, y_factor):
        pass

    def get_child(self):
        return []

    def add(self, drawing):
        raise NotImplementedError

    def remove(self, idx):
        raise NotImplementedError

class Rectangle(Drawing):
    """Leaf class"""

    def __init__(self, center_x, center_y, w, h):
        self.center_x = center_x
        self.center_y = center_y
        self.w = w
        self.h = h

    def translate(self, x, y):
        self.center_x += x
        self.center_y += y

    def resize(self, x_factor, y_factor):
        self.w /= x_factor
        self.h /= y_factor

class Ellipse(Drawing):
    """Another leaf class"""

    def __init__(self, center_x, center_y, r_x, r_y):
        self.center_x = center_x
        self.center_y = center_y
        self.r_x = r_x
        self.r_y = r_y

    def translate(self, x, y):
        self.center_x += x
        self.center_y += y

    def resize(self, x_factor, y_factor):
        self.r_x /= x_factor
        self.r_y /= y_factor

class Drawings(Drawing):
    """Composite class"""

    def __init__(self, drawings=None):
        self.drawings = []
        if drawings is not None:
            self.drawings = drawings

    def translate(self, x, y):
        for drawing in self.drawings:
            drawing.translate(x, y)

    def resize(self, x_factor, y_factor):
        for drawing in self.drawings:
            drawing.resize(x_factor, y_factor)

    def get_child(self):
        print(
            f"Child of {self.__class__.__name__} class object: "
            f"{[d.__class__.__name__ for d in self.drawings]}"
        )
        return self.drawings

    def add(self, drawing):
        self.drawings.append(drawing)

    def remove(self, idx):
        self.drawings.pop(idx)

 

위 예시를 클래스 다이어그램으로 나타내면 아래와 같다. 실행시킬 수 없는 메서드는 생략했다.

Drawing 클래스가 Component 클래스에 해당하며, Drawings 클래스가 Composite 클래스에 해당한다. Drawing 클래스는 개별 leaf와 composite 클래스에 필요한 메서드를 모두 가지고 있지만, leaf에 해당하는 Rectangle, Ellipse에서는 일부 메서드만을 사용할 수 있다.

Drawings 클래스는 모든 메서드를 사용 가능하며, Drawing 오브젝트의 리스트를 클래스 내부에 가지고 있다. Drawings에서 resize, translate와 같은 메서드를 실행시키면 하위 Drawing 클래스들에 대해 재귀적으로 operation을 실행시키게 된다.

아래 코드를 통해 실제 이용 시 어떻게 동작하는 지 보자.

if __name__ == "__main__":
    # 1. Add rect leaf to drawings composite.
    rect = Rectangle(5, 5, 3, 3)
    drawings = Drawings()
    drawings.add(rect)
    drawings.get_child()

    # 2-1. Add ellipse leaf to another drawings composite.
    ellipse = Ellipse(3, 3, 6, 6)
    more_drawings = Drawings()
    more_drawings.add(ellipse)

    # 2-2. Add drawings composite to original drawings composite.
    drawings.add(more_drawings)
    drawings.get_child()

    # 3. Call some methods of leaf class.
    print(rect.__class__.__name__, rect.__dict__)
    rect.resize(0.5, 0.5)
    rect.translate(3, 3)
    print(rect.__class__.__name__, rect.__dict__)

    # 4. Recursivey call some methods like leaf class.
    print(drawings.drawings[0].__class__.__name__, drawings.drawings[0].__dict__)
    drawings.resize(0.5, 0.5)
    drawings.translate(3, 3)
    print(drawings.drawings[0].__class__.__name__, drawings.drawings[0].__dict__)

>>>
# 1
Child of Drawings class object: ['Rectangle']

# 2
Child of Drawings class object: ['Rectangle', 'Drawings']

# 3
Rectangle {'center_x': 5, 'center_y': 5, 'w': 3, 'h': 3}
Rectangle {'center_x': 8, 'center_y': 8, 'w': 6.0, 'h': 6.0}

# 4
Rectangle {'center_x': 8, 'center_y': 8, 'w': 6.0, 'h': 6.0}
Rectangle {'center_x': 11, 'center_y': 11, 'w': 12.0, 'h': 12.0}

(1) Drawings composite 오브젝트를 생성하고, Rectangle 오브젝트를 child로 추가했다. get_child() 메서드에서 출력되는 내용을 통해 Rectangle이 child로 잘 추가된 것을 확인할 수 있다.

(2) 이후에는 새로운 composite 오브젝트를 생성한 후 (more_drawings), 여기에 Ellipse 오브젝트를 하나 추가해 주었다. 그러고 나서, 이것을 기존의 drawings 오브젝트의 child로 추가했다.

즉, drawings 오브젝트는 Rectangle 하나와 Drawings 오브젝트 하나를 child로 가지고 있으며, 하위 Drawings 오브젝트는 다시 child로 Ellipse 오브젝트를 가지고 있다. 오브젝트 다이어그램으로 나타내면 아래와 같다.

(3) 여기서 drawings 오브젝트가 가지고 있는 leaf 클래스 오브젝트인 rect 에 대해 resize와 translate 메서드를 호출했다. 출력 결과를 통해 잘 작동한 것을 확인할 수 있다.

(4) 이번에는 drawings 오브젝트 자체에 대해서 resize와 translate 메서드를 동일한 방식으로 호출했다. 이후 하위의 leaf 오브젝트 (rect) 의 어트리뷰트를 까 보았을 때, 정상적으로 resize, translate 오퍼레이션이 적용된 것을 확인할 수 있다.

 

Notes

  • Parent reference를 위한 도구Component 클래스에 정의해 놓으면 트리 탐색과 계층 구조 관리가 쉬워진다. 여기서 child가 parent reference를 가지면 해당 parent에서는 바로 그 child 오브젝트를 레퍼런스로 가지도록 항상 유지하는 것이 관건인데, Composite 클래스에서 child에 대한 add, remove 메서드가 불릴 때 항상 해당 child의 parent 레퍼런스를 수정하도록 작성할 수 있다.

 

  • Component 클래스 인터페이스 중 Leaf 클래스에서는 절대 쓰이지 않는 메서드들이 존재하게 된다. 이런 메서드들에 대한 디폴트 구현은 child가 존재하지 않는 Composite 오브젝트라 가정하고 내용을 작성하는 식으로 회피할 수 있다. 예를 들면, get_child 메서드가 호출되면 빈 리스트가 반환되는 식이다. 실제의 구현은 해당 메서드가 비로소 필요해지는 Composite 클래스에서 구현하면 된다.

 

  • Child를 관리하기 위한 메서드는 가장 상위 클래스인 Component 클래스에 정의되어 있다. 그렇다면 실제 구현 내용의 선언은 똑같이 Component 클래스에 위치해야 할까, 혹은 그 기능을 사용하는 Composite 클래스에 위치해야 할까?
    • Component 클래스에 구현할 경우 모든 구성 요소를 동일한 방식으로 취급할 수 있어 명확성이 보장되지만 child가 존재하지 않는 각 leaf에 대해 무의미한 동작 시도를 반복하게 될 수 있다.
    • Composite 클래스에 구현할 경우 Leaf에 대해서 child를 다루기 위한 메서드를 호출하는 것을 방지해 안정성을 높일 수 있다. 하지만 leaf와 composite가 서로 다른 인터페이스를 가지게 되면서 코드의 명확성이 떨어질 수 있다 (원문은 transparency인데, 이 맥락에서 뭐라 옮겨야 할 지 모르겠다).
    위의 방식과 유사하게, Component 클래스에서부터 선언을 해 놓되 디폴트 동작은 에러 레이즈 등으로 실패하도록 작성하는 것이 해결책이 될 수 있다. Child를 다루기 위한 메서드의 기본 동작은 기본적으로 실패하도록 작성해 놓고, Composite 클래스에서만 내용을 재정의 함으로써 leaf 클래스에서의 child 관련 동작을 방지할 수 있다.

 

  • Component 리스트 (children 리스트) 는 Composite 클래스에서 정의하는 것이 좋다. 만약 베이스인 Component 클래스에서부터 리스트가 존재하게 되면, 각각의 leaf 오브젝트들이 사용하지 않을 불필요한 child 리스트를 가지고 있게 된다.

 

  • Pytorch의 ConcatDataset 클래스가 정확히 Composite 패턴을 따른다. 이와 관련해서는 아래 내용을 참고하자.
class Dataset(Generic[T_co]):
    r"""An abstract class representing a :class:`Dataset`."""

    def __getitem__(self, index) -> T_co:
        raise NotImplementedError

    def __add__(self, other: 'Dataset[T_co]') -> 'ConcatDataset[T_co]':
        return ConcatDataset([self, other])


class ConcatDataset(Dataset[T_co]):
    r"""Dataset as a concatenation of multiple datasets."""

    datasets: List[Dataset[T_co]]
    cumulative_sizes: List[int]

    @staticmethod
    def cumsum(sequence):
        ...
        return r

    def __init__(self, datasets: Iterable[Dataset]) -> None:
        ...

    def __len__(self):
        return self.cumulative_sizes[-1]

    def __getitem__(self, idx):
        ...
        return self.datasets[dataset_idx][sample_idx]

    @property
    def cummulative_sizes(self):
        ...
        return self.cumulative_sizes

가시성을 위해 소스 코드에서 인터페이스 부분만을 남겨두었다. ConcatDataset은 Dataset 클래스를 상속받으며, 내부 프로퍼티로 Dataset의 list를 가지고 있다. Child dataset을 다루기 위한 cumsum(), cummulative_sizes() 와 같은 메서드가 추가되어 있으며, 기존의 Dataset을 다루기 위한 인터페이스를 그대로 사용할 수 있다.

위 소스를 클래스 다이어그램으로 나타내면 아래와 같다. ConcatDataset은 얼마든지 다시 더 큰 ConcatDataset에 의해 묶일 수 있다. 또한 하위로 들어갈 Dataset의 세부적인 타입에도 영향을 받지 않는다.

 

Related Patterns

  • Chain of Responsibility 패턴을 위해 child-parent 링크가 사용되기도 한다.
  • Component 클래스를 부모로 가지도록 하는 Decorator 패턴이 Composite 패턴과 동시에 적용될 수 있다 (파이썬 데코레이터가 아닌 디자인 패턴으로써의 데코레이터).
  • Flyweight 패턴을 쓰면 컴포넌트를 공유할 수 있지만, parent 레퍼런스 기능을 잃게 된다.
  • Composite 트리 구조를 훑기 위해 Iterator 패턴을 적용할 수 있다.
  • Visitor 패턴을 이용하면 기존 계층과 인터페이스를 수정하지 않고 많은 수의 element로 이루어진 계층 구조에 새로운 연산을 추가해 줄 수 있다.

 


아래는 참고할 만한 페이지들이다.

References

Design Patterns 15 Years Later: An Interview with Erich Gamma, Richard Helm, and Ralph Johnson

https://www.informit.com/articles/article.aspx?p=1404056

Udemy: Design Patterns in Python

https://www.udemy.com/course/design-patterns-python/

 

 

반응형