오늘은 데코레이터 패턴에 대해서 간략하게 알아보고
예시를 통해 적용 방식에 대해서 확실히 이해해보는 시간을 가져보겠습니다.
데코레이터 패턴이란, 상속을 피하고 프로그램의 실행중에 동적으로 클래스를 "꾸며"
서로 다른 기능을 만들어내는 패턴입니다.
이 패턴을 알고 나면, 원래 클래스의 코드를 전혀 바꾸지 않아도 해당 객체에 새로운 임무를 부여할 수 있습니다.
문제 상황에 대한 이해
예시 문제 상황은 이렇습니다.
스타벅스 내부 시스템을 만든다고 가정을 하고, 판매하는 음료의 가격을 매기는 시스템을 구성해 봅시다.
음료에 대해 표현하기 위해서, Beverage 라는 클래스를 만들고
Beverage에 들어가는 요소들을 반영하기 위해 Beverage 클래스의 Property로 그 재료를 넣었습니다.
그리고 나서, 각 음료는 Beverage에서 상속한 후 개별 음료 유형에 의존적인 private 메소드를 추가하고,
해당 메소드를 사용하여 cost 함수를 오버라이딩하는 식으로 구성을 하였습니다.
아 이렇게 하면, Beverage라는 공통된 특성을 유지한 채 개별 클래스로 구성하여 그 특성들을 표현할 수 있겠군요!
어느정도 만족스러워 하는 찰나, 이런 생각들이 듭니다.
모카 가격이 바뀌면 어쩌지? 모든 cost에 적용해둔 값을 변경해야하나?
만약 새로운 재료가 추가되어 Beverage 클래스에 정의를 하게 되면, 하위 클래스의 모든 Cost 메소드를 수정해야하나?
나중가서 차를 팔게 되면, Beverage에 있는 milk나 mocha와 같은 불필요한 요소들까지 같이 상속받는게 옳은걸까?
라고 생각하는 순간, 상속을 통해 구현을 했을 때의 단점들이 보이게 됩니다.
그리고 다시한번 디자인 원칙에 대해서 고민해보게 되죠
상속보다는 Composition(구성)을 사용하자
Decorator Pattern
데코레이터 패턴을 이용해서 문제를 해결하면, 커피에 들어가는 element가 추가되는 행위를
기본 Base Class (모카커피, 차 등..) 에서 관리하지 않고, 문제를 나누어 해결할 수 있게 됩니다.
다음과 같은 구조로 문제를 해결하게 되는데요
Beverage를 상속하는 Base Class 들과
Beverage를 상속한 CondimentDecorator를 상속하는 Decorator 들로 역할을 나눕니다.
Condiment는 재료라는 뜻으로, 이제 음료를 만들 때 추가되는 부가적인 요소들을 가리키는 말입니다.
Decoration을 한다는 것은, 개별 Decorator가 생성자로 호출이 될 때
인자로 Base Class 객체들을 넣어, 이를 감싸주어 하나의 층을 더 형성해주는 것입니다.
해당 Decorator가 Beverage가 갖는 메소드를 동일하게 가짐으로써
Decorator로 감싸져도, Beverage와 동일하게 행동을 할 수 있도록 설정해 주는 것입니다.
Decorator로 감쌈으로써, Beverage의 기능에 Decorator의 기능을 추가하여 로직을 구성할 수 있게 됩니다.
여기서는 문제 해결을 위해
상속을 통해 동일한 메소드를 전달한 것이 아니라
상속을 통해 그 메소드 형식을 동일하게 맞추었다고 생각하시면 될 것으로 보입니다.
Decorator 구조를 정리하면
- 데코레이터는 데코레이팅 하려는 Object와 동일한 SuperType을 가지며
- 1개 이상의 데코레이터를 Object로 감쌀 수 있으며
- 상속하는 부모가 동일하기 때문에, 데코레이팅 된 객체도 여전히 데코레이팅 할 수 있습니다.
- 개별 데코레이터는 각자의 역할만 수행하고, 나머지 역할을 감싼 Object에 위임합니다
- 객체를 런타임에 decorate 할 수 있습니다.
동적으로 Object에 추가적인 역할을 제공하며, 추가적인 기능을 위해 Subclass의 구현 대신 쉽게 적용할 수 있습니다.
코드 예시
Abstract Class
export default abstract class Beverage {
public description: string = "Unknown Beverage";
public getDescription() {
return this.description;
}
public abstract cost(): number;
}
Beverage 에 대한 추상 클래스 입니다.
꼭 추상클래스가 아니라 인터페이스여도 상관은 없지만
어짜피 description이 정의되지 않은 경우엔 저렇게 default 값을 넣을 건데,
하위 클래스에서 그걸 일일이 구현하게 둘 이유는 없다고 생각해서 저렇게 구성을 한 것입니다.
Beverage를 상속하는 클래스가 꼭 가져야할 메소드인 cost 함수가 abstract 로 정의되어 있습니다.
Derived Base Classes
import Beverage from "./abstract-class";
export default class ColdBrew extends Beverage {
public constructor() {
super();
this.description = "Cold Brew";
}
public cost(): number {
return 3000;
}
}
export default class Espresso extends Beverage {
public constructor() {
super();
this.description = "Espresso";
}
public cost(): number {
return 2500;
}
}
개별 음료를 구현한 구현체가 담겨있습니다.
여기서는 데코레이팅 될 녀석들을 클래스로 생성합니다.
Abstract Decorator
import Beverage from "../beverage/abstract-class";
export default abstract class CondimentDecorator extends Beverage {
public abstract getDescription(): string; // Re-Implement 를 요구함
}
개별 데코레이터의 구현의 뼈대가 될 Abstract Decorator 입니다
Beverage를 상속함으로써 동일하게 cost 함수가 구현되야 함을 명시하고
여기선 추가적으로 기존 Beverage 에서 구현된 Description 까지 손볼 수 있게 설정해줍니다.
Derived Decorators
import Beverage from "../beverage/abstract-class";
import CondimentDecorator from "./decorator";
export default class Ice extends CondimentDecorator {
public constructor(public readonly beverage: Beverage) {
super();
this.beverage = beverage;
}
public getDescription() {
return this.beverage.getDescription() + ` + 얼음`;
}
public cost(): number {
return this.beverage.cost();
}
}
export default class Milk extends CondimentDecorator {
public constructor(public readonly beverage: Beverage) {
super();
this.beverage = beverage;
}
public getDescription() {
return this.beverage.getDescription() + ` + 우유`;
}
public cost(): number {
return this.beverage.cost() + 500;
}
}
export default class Mocha extends CondimentDecorator {
public constructor(public readonly beverage: Beverage) {
super();
this.beverage = beverage;
}
public getDescription() {
return this.beverage.getDescription() + ` + 모카`;
}
public cost(): number {
return this.beverage.cost() + 500;
}
}
다양한 데코레이터들을 각 데코레이터의 특성에 맞추어 구현해줍니다.
각 데코레이터들은 서로 다른 기능을 가지지만, 동일한 형식으로 구성되어 있습니다.
Main 함수
const main = () => {
const espresso = new Espresso();
const espressoWithShot = new Mocha(espresso);
const espressoWithShotAndMilk = new Milk(espressoWithShot);
const espressoWithShotAndMilkAndIce = new Ice(espressoWithShotAndMilk);
console.log(espresso.getDescription(), espresso.cost());
console.log(espressoWithShot.getDescription(), espressoWithShot.cost());
console.log(espressoWithShotAndMilk.getDescription(), espressoWithShotAndMilk.cost());
console.log(espressoWithShotAndMilkAndIce.getDescription(), espressoWithShotAndMilkAndIce.cost());
};
구성된 예시를 보시면
처음 가장 기본적인 에스프레소에서
개별 데코레이터를 이용해 이전 버전의 에스프레소를 주입하고
그 결과들을 누적하여 최종 결과를 만들어 냅니다.
새로운 기능을 추가하는 과정에서
개별 객체가 구현의 변화가 필요 없이 그 기능과 형태를 확장 시킬 수 있었습니다.
이는 곧 객체지향 5원칙 중 Open-Closed 법칙을 충실히 지켰다고 볼 수 있습니다.
흠 이것도 나름 복잡하게 될 것 같은데..
라고 느끼셨을 수 있을 것 같습니다.
왜냐하면, 어짜피 개별 구성해야 할 Base Class가 여전히 많고
저런 데코레이팅을 일일이 해야 한다는 단점이 있습니다.
우유 100잔을 추가하려면.. (상상도 하고 싶지 않네요)
그런 부분의 문제를 해결하기 위해 Factory Pattern 이나 Builder Pattern을 사용하는데요
어떻게 해결하는지에 대해서는 다음 게시글에서 만나뵙도록 하겠습니다.
실제로 Java IO 도 저런 데코레이터 패턴을 이용해서 가장 기본 클래스인 FileInputStream의 기능에
다른 데코레이터들을 추가하여 확장시켜, 서로 다른 유형의 I/O에 대해 응답해주는 객체가 구현되어 있다고 합니다.
저런 부분에서 나타나는 단점은, 작은 여러 객체들이 너무 많이 정의되어 있어 혼란을 일으킬 수 있다는 점일 것으로 보입니다.
그렇지만, 이 패턴이 가진 장점에 대해 이해하고 OCP를 지키며 개발하는 하나의 방법을 배웠다고 생각하시면 좋을 것 같습니다.
Reference
'소프트웨어 > 디자인 패턴' 카테고리의 다른 글
[6] 싱글턴 패턴 (0) | 2020.11.05 |
---|---|
[5] 팩토리 메소드 패턴 (0) | 2020.10.29 |
[3] Strategy Pattern (2) | 2020.10.25 |
[2] Observer Pattern (0) | 2020.10.22 |
[1] Reactor Pattern (4) | 2020.10.19 |
댓글