Decorator pattern은 객체의 기능을 동적으로 확장하고자 할 때, 또는 상속받을 클래스가 상속이 불가능할 때 사용되는 디자인 패턴이다. 게임의 예를 들면 맵을 돌아다니다가 일시적으로 공격력이 강화되거나, 속도가 빨라지거나, 방어력이 강화되는 아이템을 먹는 경우를 생각해 볼 수 있다.
이런 효과들은 중첩되어 적용도 가능하다. 또 익숙한 예로는 text editor를 생각해볼 수 있다. 볼드, 이탤릭, 칼라변경등 기본 텍스트를 꾸며주는 기능들이 이에 해당한다. 이역시 중복 적용이된다.
자바에서와 같이 스트림 입출력에서도 볼 수 있다. 스트림으로 읽어오는 데이터를 encoding/decoding 한다거나 압축을 할수도 있다. 또는 둘 다 적용도 가능하다.
이처럼 객체의 기능을 확장해 꾸며주는 방법은 상속을 통해서 가능하지만, 앞에서 언급했듯이 상속이 불가능할 수도 있고, 상속이 static한 확장이기 때문에 동적 확장에는 어울리지 않는다. 런타임으로 이런 동작이 가능한 한가지 방법은 aggregation을 이용하는 것이다.
aggregation이란, 상속관계(is) 대신에 객체를 레퍼런스 멤버로 들고있는걸 말한다. 각각이 개별적인 객체면서 parent에선 멤버인 child 객체를 사용할 수 있고, 관련 기능들을 위임하여 실행하게 된다.(use)
여기서 잠깐, 용어정리만 하자면, 비슷한 용어로 composition이 있는데 구현방법은 동일하나, composition은 parent없이는 child 객체가 의미가 없는 경우를 말한다.(own) stackexchange에서 본 예를들면 Text editor가 buffer를 가지고(own) file을 사용(use)한다. 차이를 명확히 알 수 있는 건, Text editor가 destroy되면 buffer도 같이 destroy되지만, file은 text editor의 life cycle과 무관하다.
그렇다면 decorator design pattern은 어떻게 구성해야할까? 일단 오리지널 Component와 Decorator 클래스를 생각해볼 수 있다. 기능의 확장이지만 포함관계는 역으로 decorator가 component를 멤버로 갖고, component의 메소드를 호출하며 그 전후로 추가 기능을 구현해주면 될거 같다. 변화가 생기는 부분은 decorator들이며, component와 커플링을 낮추기위해 인터페이스를 사용한다. 최종적으로는 다음과 같은 UML이 그려진다.

UML에서 속이 빈 마름모가 aggregation이고 검은색으로 칠한 마름모가 composition을 표현한다.
이제, 구현을 해보자. 구현은 앞에서 예를 들었던, 직관적으로 알기 쉬운 html text editor를 예로들어보겠다.
Kotlin
package decorator
interface IText{
fun getHtml(): String;
}
class PlainText(argText: String): IText{
private var text: String = argText
override fun getHtml(): String {
return text
}
}
Component에 해당하는 인터페이스와 concrete class를 만들었다. 생성한 텍스트의 html 형식을 가져오는 인터페이스를 만들었다.
package decorator
abstract class HTMLDecorator(argText: IText): IText{
private var textComponent: IText = argText
override fun getHtml(): String{
return textComponent.getHtml()
}
}
class BoldDecorator(argText: IText): HTMLDecorator(argText){
override fun getHtml(): String {
//return "<B>${super.getHtml()}</B>"
return "<B>${super.getHtml()}</B>"
}
}
class ItalicDecorator(argText: IText): HTMLDecorator(argText){
override fun getHtml(): String {
return "<I>${super.getHtml()}</I>"
}
}
데코레이터 역시, IText 인터페이스를 상속받는다. adapter pattern과 유사하게 느낄 수도 있겠다. abstract class에선 생성자로 IText 인터페이스를 갖는 객체의 레퍼런스를 저장한다. aggregation으로 표현된 부분의 구현이다. abstract class의 getHtml()에선 담아둔 레퍼런스의 getHtml()을 호출할 뿐이다.
abstract decorator class를 상속받은 BoldDecorator, ItalicDecorator에서 super.getHtml()로 가져온 텍스트의 앞뒤에 원하는 html tag로 꾸며서 값을 돌려주고 있다.
package decorator
fun main(args: Array<String>){
val plainText: IText = PlainText("Decorator Pattern!")
val decoratedText: IText = ItalicDecorator( BoldDecorator(plainText))
println(plainText.getHtml())
println(decoratedText.getHtml())
}
decorator의 사용방법은 생성자에 인자로 넘기는 것이다. component와 decorator모두 같은 인터페이스를 갖고 있으므로 어떠한 객체가 넘어가도 상관없다. 보다시피, BoldDecorator를 생성해서 ItalicDecorator에 넘겨주면, getHtml 호출이 연쇄적으로 작동하여 순서대로 모든 decorator가 적용된다.
decoratedText객체가 destory되어도 plainText에는 영향이 없으므로 aggregation 관계인 것을 확인할 수 있다.
Kotlin에서 언급하고 넘어갈게 있는데, delegation에 관련된 내용이다. 인터페이스가 방대한 경우, 작은 decoration을 위해 모든 인터페이스를 구현하는일은 부담이 된다. 이를 위해, ‘by‘라는 키워드를 써서 추상 메소드들이 이미 구현된 객체에게 위임할 수 있다. 언어레벨에서 delegation을 구현해 놨다고 보면 되겠다.
C#
namespace Decorator
{
interface IText
{
string getHtml();
}
public class PlainText : IText
{
private string text;
public PlainText(string argText)
{
text = argText;
}
public string getHtml()
{
return text;
}
}
}
Kotlin과 달리 인터페이스 상속시에 override를 쓰지 않는다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Decorator
{
abstract class HTMLDecorator : IText
{
private IText textComponent;
public HTMLDecorator(IText textObj)
{
textComponent = textObj;
}
public virtual string getHtml()
{
return textComponent.getHtml();
}
}
class BoldDecorator : HTMLDecorator
{
public BoldDecorator(IText textObj) : base(textObj) { }
public override string getHtml()
{
return "<B>" + base.getHtml() + "</B>";
}
}
class ItalicDecorator : HTMLDecorator
{
public ItalicDecorator(IText textObj) : base(textObj) { }
public override string getHtml()
{
return "<I>" + base.getHtml() + "</I>";
}
}
}
getHtml 메소드를 서브클래스가 override하기 위해서 virtual 키워드를 사용했다.
using UnityEngine;
namespace Decorator
{
public class HTMLClient : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
IText plainText = new PlainText("Decorator Pattern!");
IText decoratedText = new ItalicDecorator(new BoldDecorator(plainText));
Debug.Log(plainText.getHtml());
Debug.Log(decoratedText.getHtml());
}
}
}

의도하건 아니었지만 잼있는게 보이는데, html태그를 썼더니 실제로 bold와 italic이 적용되어 표시되는걸 볼 수 있다.
Python
파이썬에서 OOP적 디자인을 적용할 때마다 이게 맞는건지 갈등이 온다. OOP를 위해 만들어진 기분이 안들기 때문이다. 그래서 조금 방향을 틀어보고자 한다.
decorator는 파이썬 언어레벨에서 지원하고 있다. 이는 함수가 first-class object이기 때문이다. 간단하게만 얘기하면, 함수가 변수나 인자들과 동등한 레벨에 있고 구별하지 않는다는 얘기다. 그래서 함수를 인자로 넘기고 리턴값으로 돌려줄 수도 있다. 관련해서는 Real Python에서 잘 정리된 글이 있으니 참고하기 바란다.
이런 이유로 파이썬에서는 함수 안에 함수를 정의할 수도 있다. 그래서 다음처럼 사용이 가능하다.
def my_decorator(func):
def wrapper():
print("before func")
func()
print("after func")
return wrapper
def plain_func():
print("I'm here!")
decorated = my_decorator(plain_func)
decorated()
before func
I'm here!
after func
my_decorator는 인자로 함수를 받고, 이를 포함하는 wrapper function을 정의하여 그 함수를 리턴하고 있다. 사용방법도 함수 plain_func를 인자로 넘기고 리턴받은 함수를 변수에 할당했다가 함수처럼 사용한다. 코드에서 보이듯이 함수에서 괄호만 빼면 함수의 레퍼런스처럼 동작하고 있다.
OOP적인 디자인으로 python에서도 decorator pattern을 구현할 수 있지만, 유연한 언어적 특성으로 함수에 대한 decorator사용이 자연스럽다. 그렇지만, 사용이 좀 번거로운데 ‘pie syntax’라고 불리는 아주 간단한 방법을 제공한다.
@my_decorator
def plain_func2():
print("Who am I?")
plain_func2()
before func
Who am I?
after func
my_decorator의 정의는 앞에서와 같다. pie syntax란, ‘@’ 모양이 pie같고 ‘py’thon에서 따온 약간의 언어유희 명명으로 보인다. 아뭏튼 매우 단순하게 decorator를 사용할 수 있다. 자주사용하는 @dataclass도 사실은 클래스를 인자로 받는 decorator이다.
UML로 그려진 decorator pattern을 구현하는건 별로 어렵지 않겠지만, 이런 언어적 특성앞에 조금 회의감이 든다. 그래서 앞의 html 꾸미는 기능을 python decorator를 이용해 구현해 보겠다.
from typing import Callable
def bold_decorator(func: Callable[[str], str]) -> Callable[[str], str]:
def wrapper(text: str):
return f"<B>{func(text)}</B>"
return wrapper
def italic_decorator(func: Callable[[str], str]) -> Callable[[str], str]:
def wrapper(text: str):
return f"<I>{func(text)}</I>"
return wrapper
def plain_text(text: str) -> str:
return text
# static usage
@italic_decorator
@bold_decorator
def decorated_text(text: str) -> str:
return plain_text(text)
print(decorated_text("It's decorator!"))
# dynamic usage
dynamic_decorated_text = italic_decorator(bold_decorator(plain_text))
print(dynamic_decorated_text("It's decorator!"))
typing은 단지 어떤 함수형 인자를 넘기고 어떤 함수를 리턴하는지 이해를 돕기위한 것일 뿐이다. Callable은 함수타입을 표현하는 type annotation이다.
pie-syntax를 사용하면, 간편하긴 하지만 decorator가 숨겨지고 정적으로만 사용하게 된다. 대신에 decorator pattern에서 사용하듯 코드를 나열하면 dynamic하게 사용가능하다.
Conclusion
아마도 Adapter pattern과 유사성을 느꼈을 것이다. 차이점이라면, Adapter pattern은 서로 다른 인터페이스를 맞춰주는 wrapper가 되고 decorator는 동일한 인터페이스에 추가적인 일을 해주는 wrapper이다.
python의 예에서 보이듯이, 디자인 패턴은 꼭 지켜야할 룰이 아니라 하나의 모범답안일 뿐이다. 억지로 디자인 패턴이란 틀에 끼워 맞추려 하는게 최선은 아니라고 생각한다.