[ Programming > Design Pattern ]
[디자인 패턴] Decorator 패턴
[디자인 패턴] Decorator 패턴
Decorator 패턴은 디자인 패턴에서 구조 패턴에 해당한다. 구조 패턴은 클래스나 객체를 조합하여 더 크고 복잡한 구조를 만들거나 기존 구조를 확장하는데 사용한다. 그리고 데코레이터 패턴은 클래스의 기능을 추가하기 위해서 다른 객체를 덧붙이는 패턴으로, 객체의 결합을 통해 동적으로 유연하게 확장할 수 있는 패턴이다.
데코레이터 패턴은 기존에 구현되어있는 클래스를 수정하지 않고, 기능을 추가할 수 있는 설계 패턴(OCP)으로 기능 확장이 필요할 때 동적인 기능이 필요할 때 subclass로의 상속 대안으로 사용할 수 있는 패턴이다.
1. Decorator 패턴 적용
이번엔 Decorator 패턴을 토핑추가가 가능하며, 토핑별로 가격이 달라지는 요구사항을 구현하는 것을 바탕으로 설명하겠습니다.
요구 사항
음료는 에스프레소, 블랙 커피, 밀크 커피가 있다.
토핑은 두유, 우유, 휘핑크림이 있다.
토핑 추가시 가격도 추가이 추가된다.
1.1 Decorator 패턴 없이 구현

Beverage Class
public class Beverage { private Milk milk; private Soy soy; ... public String getDescription() {} public Integer cost() {} public void setMilk() {} public Boolean hasMilk() {} public Boolean hasSoy() {} public Boolean hasWhip() {} ...}
Beverage 상속받는 에스프레소
public class Espresso extends Beverage { public String getDescription() { String description = "에스프레소 음료"; String prefix = ""; if(this.hasMilk()) { prefix = prefix+"우유, "; } ... Boolean hasCondiment = prefix.length()>0; description = hasCondiment? prefix + description : description; return description; } public Integer cost() { Integer price = 3000; if(this.hasMilk()) { price += 300; } ... return price }}
데코레이터 패턴없이 단순히 상속을 통한 Subclass로 구현하면 위와 같이 나온다.
장점
구현이 쉽다.
단점
토핑의 종류가 많아지면 각각 음료의 서브클래스에서 가격 함수 변경 필요(물론 가격을 field로 저장하여 쓰면 해결)
특정 음료에 특정 토핑 추가가 불가하면 모두 상세 구현 해야한다.
컴파일시 행동이 완전히 결정된다.
1.2 Decorator 패턴 적용 구현

위 그림은 Decorator 패턴을 적용했을 때 그림이다. 음료 인터페이스를 구현하는 첨가물 클래스를 만들고, 두유, 우유, 휘핑크림은 이 첨가물 클래스를 구현하는데, 음료 필드를 별도로 가져야한다.
각 구상 첨가물들은 모두 음료 인터페이스를 구현한 첨가물 인터페이스(추상 클래스, 클래스)를 상속받고 있다. 이는 행동의 상속을 위한 것이 아니라 형식을 맞추기 위한 상속임을 분명히 해야한다. 그리고 음료 인터페이스의 객체를 구상 첨가물에서 구성으로 가져 행위를 연결하였다.
CondimentDecorator (첨가물)
abstract class CondimentDecorator implements Beverage { protected Beverage decoratedBeverage; // 꾸며지는 음료 public CondimentDecorator (Beverage decoratedBeverage) { this.decoratedBeverage = decoratedBeverage; }}
휘핑 크림 (첨가물 구현)
class Whip extends CondimentDecorator { private discount = 100; public Whip (Beverage decoratedBeverage) { super(decoratedBeverage); } public Integer cost() { return this.decoratedBeverage.cost() + calculateCost(); } public Integer calculateCost() { return 500 - getDiscount(); } public String getDescription() { return this.decoratedBeverage.getDescription() + "휘핑"; } private Integer getDiscount() { return discount; }}
설명 정렬 (첨가물 구현)
class PrettyDescription extends CondimentDecorator { public PrettyDescription (Beverage decoratedBeverage) { super(decoratedBeverage); } public Integer cost() { return this.decoratedBeverage.cost(); } public String getDescription() { return sortDescription(this.decoratedBeverage.getDescription()); } private String sortDescription(String description) { String prettyDescription = 정렬(description); return prettyDescription; }}
에스프레소 (음료 구현)
public class Espresso implements Beverage { public String getDescription() { return "에스프레소"; } public Integer cost() { return 3000; }}
public class CoffeeStore { public static void main(String args[]) { Beverage beverage = new Espresso(); beverage = new Whip(beverage); // 휘핑크림 추가, espresso.cost() + whip.cost() = 3000+ 400 = 3400; beverage = new Whip(beverage); // 휘핑크림 추가, 3400+ 400 = 3800;, description = "에스프레소 휘핑 휘핑" beverage = new Whip(beverage); // 휘핑크림 추가, 3800+ 400 = 4200;, description = "에스프레소 휘핑 휘핑 휘핑" beverage = new PrettyDescription(beverage); // 설명을 깔끔하게 정렬, description = "트리플 휘핑 에스프레소"; }}
위 코드가 데코레이터 패턴을 적용했을 때 사용하는 코드다. 기존 음료 코드는 무수히 많은 if문을 통해 분기하여 가격과 설명을 계산했지만, 이제는 더 간단하게 내부 구현에 의존하지 않고 기능을 확장할 수 있다. 그래서 OCP를 더 잘지키게 된 셈이다. 그런데 데코레이터 패턴은 이렇게 단독으로 쓰기 힘들다. 왜냐하면 실제 비지니스에서는 순서에 의존이 있고 더 로직이 복잡할 것이기 때문이다. 예를 들면 위 코드에서 PrettyDescription이라는 클래스를 만들었는데 이 순서가 항상 맨 뒤에 있어야 하는 것처럼, 휘핑크림은 항상 마지막 부분에서 추가되도록 규칙을 정했다면 그 규칙을 따라야 한다.
1.3 Decorator 패턴 한계과 보완
데코레이터 패턴은 단독으로 쓰이기 보다는, 이를 보조하는 수단으로 팩토리, 빌더 패턴등을 함께 사용한다. 왜냐하면 위와 같이 순서에 따라 작동이 달라지거나, 토핑의 추가를 취소해야 하는 문제가 있기 때문이다.
아래는 간단한 빌더 패턴으로 토핑 추가,취소가 가능하도록 만든 것이다. 여기에 우선순위를 추가하여 정렬하도록 한다던가 하는 등의 기능을 추가하면 된다.
public class CoffeeBuilder { private List<Function<Coffee, Coffee>> steps = new ArrayList<>(); public void addMilk() { steps.add((c) => new WithMilk(c)); } public void addWhip() { steps.add((c) => new WithWhip(c)); } public void undo() { steps.pop(); } public Coffee build(){ Coffee coffee = new BasicCoffee(); for (Function<Coffee, Coffee> step : steps) { coffee = step.apply(coffee); } return coffee; }}
2. 프론트엔드의 Decorator 패턴 (고차 컴포넌트, Hoc)
프론트엔드에서도 데코레이터 패턴을 HOC(고차 컴포넌트) 만들때 사용한다. 예를 들면 특정 컴포넌트가 띄어질 때 로그를 찍는다던가, 어떤 권한을 체크하여 접근을 제한하는 기능을 만들때 유용하다.
// withAuthorization.tsxfunction withAuthorization<P extends object>(WrappedComponent: React.ComponentType<P>) { return function (wrappedComponentProps: P) { const {userRole} = useAuth(); const isAuthorized = userRole === "ADMIN"; if (!isAuthorized) return <div>접근 권한이 없습니다.</div>; return <WrappedComponent {...wrappedComponentProps} />; };}// withErrorBoundary.tsxfunction withErrorBoundary<P>(WrappedComponent: React.ComponentType<P>) { return class ErrorBoundary extends Component<P, State> { constructor(props: P) { super(props); this.state = {hasError: false}; } static getDerivedStateFromError() { return {hasError: true}; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.error("ErrorBoundary caught an error", error, errorInfo); } render() { if (this.state.hasError) { return <FallbackComponent />; } return <WrappedComponent {...this.props} />; } };}// SecretDashBoard.tsxconst SecretDashBoard = () => { return <div>대쉬보드</div>;};export default withErrorBoundary(withAuthorization(SecretDashBoard));// App.tsxfunction App() { return <SecretDashBoard />; // 권한 없으면 다른 화면 뜸. 에러가 뜨면 에러 컴포넌트 뜸.}
위 코드가 권한SecretDashBoard에 ErrorBoundary와 Auth Boundary가 함꼐 적용된 코드이다. 어떤 컴포넌트라도 쉽게 권한 설정이 가능하다. 물론 말했던데로 적용 순서에 따라 결과가 완전히 달라질 수 있다. (에러와 권한이 모두 충족되지 못할 경우)
마무리
이렇게 디자인 패턴은 프론트와 백엔드 모두에서 알게 모르게 적용된다. (물로 프론트는 약간 억지스러운 면이 있다) 하지만 그와 관계없이 디자인 패턴을 공부해야 더 좋은 코드와 설계가 가능한 것 같다. 완전히 마음대로 상황에 따라 응용이 가능하도록 노력해야겠다.
여러분들도 화이팅!!