데코레이터 패턴(Decorator Pattern) ; 상속, 동적인 요건

객체에 추가적인 요건을 동적으로 추가한다. 데코레이터는 서브클래스를 만드는 것을 통해서
기능을 유연하게 확장할 수 있는 방법을 제공한다.

OCP(Open-Closed Principle)

클래스는 확장에 대해서는 열려 있고 코드 변경에는 닫혀있어야 한다는 객체지향 설계 원칙.

일반적으론 확장을 한다는 것은 뭔가 변경한다는 것인데, 코드 변경에는 닫혀있어야 한다는 것은 일종의 모순이다.
중복코드를 추상화를 통해 상속으로 해결한 경우, 추상속성이나 기능이 확장되어야 할 때 슈퍼클래스를 변경해야 하는 것은
일반적으로 코드 변경을 동반하게 된다.

반면, 데코레이터 패턴을 사용하면 이런 모순적인 상황에 대해 유연하게 대처할 수 있다.

책에서 나오는 예시 - 스타버즈

책에서는 한 대형 카페를 예시로 데코레이터 패턴을 설명하고 있다.
이 가게에서는 최초 Beverage 책임이 몰빵되어 있는 슈퍼슈퍼 클래스를 정의해서,
cost() 라는 추상클래스를 상속받는 모든 음료 구현체마다 각각 코드를 구현해야하기 때문에
슈퍼클래스에게 막중한 책임은 생겼지만, 그 책임을 각 서브 클래스마다 위임하게 되는 문제가 있었다.

하지만 데코레이터라는 일종의 Wrapping Class를 정의하고, 이를 활용하면서 해결하게 된다.

데코레이터를 적용한 해결

Beverage (최상위 슈퍼클래스)

1
2
3
4
5
6
7
8
9
public abstract class Beverage {
protected String description = "제목 없음";

public String getDescription() {
return description;
}

public abstract double cost();
}

Espresso (음료 클래스)

1
2
3
4
5
6
7
8
9
10
11
public class Espresso extends Beverage {

public Espresso() {
description = "에스프레소";
}

@Override
public double cost() {
return 1.99;
}
}

CondimentDecorator(첨가물 데코레이터 슈퍼클래스)

1
2
3
public abstract class CondimentDecorator extends Beverage {
public abstract String getDescription(); // 하위 첨가물 클래스에게 description 정의를 강제한다.
}

SteamMilk(첨가물 클래스)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SteamMilk extends CondimentDecorator {
private Beverage beverage;

public SteamMilk(Beverage beverage) {
this.beverage = beverage;
}

@Override
public String getDescription() {
return beverage.getDescription() + ", 스팀밀크";
}

@Override
public double cost() {
return .10 + beverage.cost();
}
}

테스트 샘플 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

/**
에스프레소 $1.99
다크 로스트 커피, 모카, 모카, 휘핑크림 $1.49
하우스 블렌드 커피, 두유, 모카, 휘핑크림 $1.34
*/
class DecoratorTest {

@Test
void 데코레이터테스트() throws Exception {
Beverage beverage = new Espresso(); // 1.99
System.out.println(beverage.getDescription() + " $" + beverage.cost());

Beverage beverage2 = new DarkRoast(); // 0.99
beverage2 = new Mocha(beverage2);
beverage2 = new Mocha(beverage2); // 모카 2번 추가 = 0.99 + 0.20 + 0.20
beverage2 = new Whip(beverage2); // 휘핑크림 추가 = = 0.99 + 0.20 + 0.20 + 0.10 = 1.49
System.out.println(beverage2.getDescription() + " $" + beverage2.cost());

Beverage beverage3 = new HouseBlend(); // 0.89
beverage3 = new Soy(beverage3); // 두유 추가 = 0.89 + 0.15
beverage3 = new Mocha(beverage3); // 모카 추가 = 0.89 + 0.15 + 0.20
beverage3 = new Whip(beverage3); // 휘핑크림 추가 = 0.89 + 0.15 + 0.20 + 0.10 = 1.34
System.out.println(beverage3.getDescription() + " $" + beverage3.cost());
}
}

Java I/O 에서의 데코레이터 패턴

vs

Java I/O 관련 클래스에서 이런 데코레이터 패턴을 주로 사용한다.
Input/Output Stream 클래스는 인터페이스를 제외하곤, 모두 단독으로 사용이 가능하다.
다만, 추가 Wrapping Class 로 감싸게 되면, 감싼 클래스의 부가적인 기능을 활용할 수 있게 된다.

이런 부분을 개인적으로 숟가락으로 땅을파는 일 vs 포크레인으로 땅파기 에 비유하곤 한다.
물론 숟가락으로도 땅을 팔 수 있다. 아주 오래걸릴 뿐…
반면 포크레인으론 쉽게 땅을 팔 수 있게 된다. 포크레인은 좋은 데코레이터 클래스와 같다.

1
2
3
FileInputStream fileInputStream = new FileInputStream("/test.txt"); // 단독으로도 사용 가능
BufferedInputStream bis = new BufferedInputStream(fileInputStream); // 버퍼 기능
LineNumberInputStream lnis = new LineNumberInputStream(bis); // 라인넘버 출력 기능 --> 현재 deprecated 됨

데코레이터의 장점

  • OCP 를 지킬 수 있도록 유연성 제공
  • 구성과 위임을 별도로 두면서 동적으로 확장할 수 있다.
  • 확장하는 클래스는 제한이 없다(몇개를 추가하던..)

데코레이터의 단점

  • 구성요소의 클라이언트 측에선 데코레이터의 존재를 알 수 없다.
  • 데코레이터를 필두로, 자잘한 객체들이 매우 많이 추가될 수 있다.

–> Java I/O를 생각해보면, 평소 InputStream 종류가 얼마나 많은 지 모르는 경우가 많다.
또한 래핑 순서도 꽤나 중요한 요소가 될 수 있는데, ‘어떤 순서로 래핑해야 되는 지’ 매우 혼란스러운 경우가 있다.

단점보완 - 팩토리 패턴, 빌드 패턴

팩토리나 빌드 패턴은 어떤 객체를 생성할 때 도움이 되는 패턴들이다.
데코레이터의 단점은 ‘클라이언트 측에서 래핑클래스에 대한 무지로 인한 생성 시 어려움’ 이었다.
팩토리나 빌드 패턴은 이런 점을 강제 혹은 자동으로 생성해 주기 때문에 데코레이터의 단점을 보완할 수 있다.

생각해보자

평소 OCP 에 대해서도 많은 의문을 가지고 있었다.
‘어떻게 확장엔 오픈하면서 코드 변경은 클로즈하게 개발을 할 수 있을까? 모순 아닌가?..’
데코레이터는 이런 문제에 대한 아주 좋은 답을 하고 있다. 평소 코드로 모든 것을 풀어내려고 했던 나에 대해 반성하게 되었고,
설계적인 공부를 게을리하면 안되겠다는 생각이 들었다.

물론 데코레이터 패턴만이 답은 아니다. 특히, 자바스크립트나 코틀린같은 언어로 넘어가게 되면,
데코레이터를 별도 클래스가 아니라 확장함수 형태로도 쉽게 기능을 개선할 수 있다.

데코레이터의 발전 과정이나 사용 방법을 언어마다 정리해 보는 것도 좋은 학습이 될 것 같다.

참고자료