Programming/Design patterns

[Design patterns] Decorator Pattern (데코레이터 패턴)

Jonghyuk Baek 2022. 10. 18. 00:46

크리스마스 트리에는 얼마든 장식을 추가할 수가 있다. 아무리 많이 추가한들 여전히 트리이다.

Introduction

오늘 소개할 내용은 데코레이터 (Decorator) 패턴으로, 그 기능을 요약하자면 다음과 같다.

 

클래스 내용을 수정하지 않으면서 동적으로 오브젝트에 기능을 추가할 수 있다.

 

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

 


Motivation

전체 클래스 오브젝트의 동작을 수정하는 대신 특정 오브젝트에만 어떤 기능을 동적으로 추가해야 할 때가 있다. 즉, 기존에 작성된 클래스 코드를 전혀 건드리지 않으면서도 새로운 기능을 자유롭게 얼마든지 추가할 수 있으며, 기능이 추가되어도 외부에서는 기존 오브젝트와 동일하게 상호작용 할 수 있어야 한다.

 

코드 수정 없이 기능을 추가하기 위해 기존 클래스를 서브클래싱 할 수 있지만, 이 경우 기능의 추가가 정적으로 이루어진다는 단점이 있다 (기능의 추가가 서브클래스 코드의 형태로 고정됨).

 

데코레이터 패턴은 상속 없이 다른 오브젝트를 동일 인터페이스로 한번 감싼 후 추가적인 기능을 수행하는 도구로, 서브클래스보다 훨씬 유동적인 기능의 확장을 보장한다.

 

Applicability

데코레이터 패턴은 다음과 같은 상황에서 유용하다.

  • 개별 오브젝트에 새로운 기능을 동적으로, 투명하게 (transparent, 인터페이스를 수정하지 않음) 추가하면서 기존의 오브젝트 동작은 수정하고 싶지 않을 때 사용할 수 있다.
  • 서브클래스를 이용한 기능의 확장이 곤란할 때 사용할 수 있다. 너무 많은 수의 서브클래스가 만들어지는 결과를 낳거나 클래스 정의가 숨겨져 있는 경우, 서브클래싱 자체가 불가능한 경우가 포함된다.

 

Structure

클래시컬한 데코레이터 패턴 구조는 위와 같다.

 

위 그림에서 마름모로 시작하는 화살표는, base 쪽의 클래스 오브젝트가 head 쪽의 클래스 오브젝트를 가지고 있다는 의미이다. 삼각형이 붙은 선은 상속 관계를 나타내며, 검은 박스는 example code snippet이다.

 

  • Component: 기능이 추가되어야 하는 오브젝트의 인터페이스를 정의하는 추상 클래스이다.
  • ConcreteComponent: 기능이 추가되어야 하는 오브젝트를 정의한다.
  • Decorator: Component 오브젝트에 대한 레퍼런스를 유지하면서 Component 클래스의 인터페이스를 따르는 클래스이다.
  • ConcreteDecorator: Component에 동작을 추가한다.

 

Consequences

데코레이터 패턴을 사용하면 다음과 같은 효과를 얻을 수 있다.

  • 기존의 static한 상속에 비해 더 유연하게 기존 클래스에 동작을 추가할 수 있다. 런타임 중에도 얼마든지 추가 기능을 붙이거나 다시 떼어내는 게 가능하며, 이는 기능을 재분리하는 것이 어렵고 기능을 덧붙일 때마다 새 클래스가 만들어져야 하는 단순 상속의 특성과 대비된다.
  • 또한 데코레이팅 한번 한 후에도 해당 클래스는 기존의 Component 클래스를 상속하고 있으며, 인터페이스도 Component와 동일하기 때문에 (transparency) 다시 다른 데코레이터에서 이걸 받아 데코레이팅 하는 것이 가능하다 (데코레이터의 중첩).
  • 많은 기능이 점진적으로 추가되어야 할 때 좋다. 이를 하나하나 상속으로 처리하다 보면 기본 기능을 담당하던 하던 클래스가 클래스 계층 구조의 저어어어 위쪽에 위치하게 되는데, 데코레이터를 이용하면 간단한 클래스들의 조합으로 동일한 기능을 수행할 수 있다. 또한, 이전에 한 번도 써 보지 않은 추가 기능의 조합을 만들어 내는 것도 편하다.
  • 데코레이터가 Component를 흉내내고는 있지만, 사실 동일한 오브젝트는 아니기 때문에 주의해야 한다.
  • 데코레이터를 사용하다 보면 기능이 조금씩 다른 많은 오브젝트가 생기기 쉬운데, 전체 시스템을 이해하기 어렵게 만들 수 있다.

 

Implementation

from abc import ABC, abstractmethod

class Shape:
	"""Component abstract class."""
	
	@abstracmethod
	def __str__(self):
		return ""

class Circle(Shape):
	"""Concrete component class."""
	
	def __init__(self, raidus):
		self.radius = radius

	def __str__(self):
		return f"A circle with radius {self.radius}"

class Rectangle(Shape):
	"""Another concrete component class."""

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

	def __str__(self):
		return f"A rectangle with side {self.side}"

class TransparentShape(Shape):
	"""Concrete decorator class."""
	
	def __init__(self, shape: Shape, alpha: float):
		self.shape = shape
		self.alpha = alpha

	def __str__(self):
		return f"{self.shape} has alpha {self.alpha}"

class ColoredShape(Shape):
	"""Another concrete decorator class."""
	
	def __init__(self, shape: Shape, color: Tuple[int]):
		self.shape = shape
		self.color = color

	def __str__(self):
		return f"{self.shape} with color {self.color}"

위의 class diagram 이미지를 참고하면서 보면 좋다. Abstarct decorator class는 생략했으며, 더 보기에 깔끔한 배치를 위해 클래스 다이어그램을 임의로 마개조했다. Udemy: Design Patterns in Python 의 소스코드를 대부분 참고했다.

TransparentShape와 ColoredShape 모두 Shape를 상속받는다. TransparentShape와 ColoredShape 데코레이터는 기존의 Shape 클래스의 str 메서드의 기능을 확장하되, 단순 서브클래싱을 통한 기능의 확장과는 달리 각각의 데코레이터를 중첩해 사용할 수 있도록 되어 있다. 아래의 예시를 보자.

 

if __name__ == '__main__':
    rectangle = Rectangle(10)
    print(rectangle)

    rectangle_red = ColoredShape(rectangle, (255, 0, 0))
    print(rectangle_red)

    rectangle_red_transparent = TransparentShape(rectangle_red, 0.5)
    print(rectangle_red_transparent)

    print(isinstance(rectangle_red_transparent, Shape))
    print(isinstance(rectangle_red_transparent, Rectangle))
    print(isinstance(rectangle_red_transparent, ColoredShape))
    print(isinstance(rectangle_red_transparent, TransparentShape))

>>>
A rectangle with side 10
A rectangle with side 10 with color (255, 0, 0)
A rectangle with side 10 with color (255, 0, 0) has alpha 0.5
True
False
False
True

Rectangle 오브젝트를 만든 후 순차적으로 color와 transparency 관련 기능을 추가할 수 있다. 코드 작성 시 color와 transparency를 동시에 적용할 것을 따로 고려하지 않은 상황에서도, 두 기능이 함께 더해진 rectangle_red_transparent 오브젝트를 만들어 낼 수 있다.

다만 주의할 점은, 후반 4개의 isinstance 결과에서 볼 수 있듯이 기능이 아무리 중첩되어도 해당 오브젝트는 가장 마지막으로 덧씌운 데코레이터와 그것이 상속하는 가장 위의 추상 클래스로만 인식된다는 것이다. 즉, 만약 Rectangle 클래스에서 추가 메서드를 통한 동작을 정의했다 해도 rectangle_rec_transparent는 해당 동작을 전혀 이용할 수 없다.

 

Notes

  • 얼마든지 중첩해 사용할 수 있다는 장점을 위해서는 모든 데코레이터가 component의 인터페이스를 따르고 있어야 한다.
  • 데코레이터 추상 클래스를 항상 분리해 작성해야 할 필요는 없다.
  • ConcreteComponent들과 Decorator 모두 Component를 상속받아야 하기 때문에, Component 추상 클래스는 되도록 인터페이스만 정의해 주는 느낌으로 가볍게 작성하는 것이 좋다. 무거운 기능들은 서브클래스 내부에서 처리하는 것이 좋으며, 그렇지 않은 경우 서브클래스들이 사실상 불필요한 기능까지 담당해야 할 수도 있다.
  • 데코레이터 패턴은 오브젝트의 동작을 바깥에서 한번 감싸면서 기능을 조금씩 더해가는 느낌이라면, Strategy 패턴은 아예 오브젝트의 내부 동작 자체를 교체하는 데에 주안점을 두고 있다. Component 클래스가 처음부터 너무 무겁게 짜여 있다면 이 쪽이 대안이 될 수 있다.

 

Related Patterns

  • Adapter 패턴과는 달리 인터페이스를 전혀 변경하지 않는다.
  • 한 개의 component를 가지는 Composite 패턴으로 볼 수 있지만, 오브젝트를 묶는 용도로 쓰는 것이 아니라 동작을 추가하기 위해 쓰는 패턴이다.
  • 데코레이터 패턴과 다르게 Strategy 패턴은 오브젝트 외부가 아닌 내부 동작을 변경한다.

 

Functional Decorator in Python

사실 데코레이터 패턴은 파이썬 언어의 기본 기능 중 하나로 마주할 수 있는데, 우리가 흔히 보는 파이썬의 데코레이터 도구가 그것이다.

어떤 함수를 또다른 동일한 인자 목록을 가지는 함수로 한번 wrapping 하면서 함수 동작 앞뒤로 추가적인 기능을 덧붙이는 것 또한 데코레이터 패턴에 해당되는데 (함수 또한 오브젝트이다), 이를 쉽게 사용할 수 있도록 도와주는 빌트인 기능이 바로 파이썬의 functional decorator 기능이다. 디자인 패턴이 프로그래밍 언어의 문법으로 아예 내장된 사례 라 볼 수 있다. 이전 logger 글에서 소개한 소스 코드의 예시를 보자.

import logging
logging.basicConfig()

def logged(func, *args, **kwargs):
    logger = logging.getLogger()

    def new_func(*args, **kwargs):
        logger.debug("calling {} with args {} and kwargs {}".format(
            func.__name__, args, kwargs))
        return func(*args, **kwargs)

    return new_func

@logged
def bar(*args, **kwargs):
    print("I am inside bar.")
    print(args, kwargs)

def foo(*args, **kwargs):
    print("I am inside foo.")
    print(args, kwargs)

>>> logging.getLogger().setLevel(logging.DEBUG)

>>> bar(1, 2, 3, foo=4)
I am inside bar.
(1, 2, 3) {'foo': 4}
DEBUG:root:calling bar with args (1, 2, 3) and kwargs {'foo': 4}

>>> logged(foo)(1, 2, 3, bar=4)
I am inside foo.
(1, 2, 3) {'bar': 4}
DEBUG:root:calling foo with args (1, 2, 3) and kwargs {'bar': 4}

logged(foo)는 foo 단독 호출에서와 동일한 방식으로 호출할 수 있으며, foo 자체의 동작은 변경하지 않은채로 함수 로깅이라는 추가적인 동작을 더해주고 있다. 여러 functional decorator가 존재할 때 이들을 순서대로 중첩해 사용하는 것도 가능하다. 앞에서 언급한 데코레이터 패턴의 동작 특성들과 동일하다는 것을 알 수 있다.

파이썬 문법은 이런 데코레이팅 과정을 예시 코드의 bar 정의 부분의 @logged 의 형태로 사용할 수 있도록 해 준다. 예시 코드의 아래 명령어 출력 파트에서 logged(foo)와 데코레이터가 붙은 bar의 출력 내용이 동일한 것을 확인하자.

 


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

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/

 

 

반응형