CHAPTER 03.데코레이터 패턴
읽은 책 정리/Head Firstr Design Pattern

CHAPTER 03.데코레이터 패턴

데코레이터 패턴 이해를 위한 간단한 문제 제안

  • 2022년 스타버즈 커피는 커피 브랜드를 대표하는 브랜드로 성장해있습니다.
  • 1971년 스타버즈는 지금처럼 회사가 커질줄 몰랐고 메뉴도 지금처럼 많지 않았습니다.
  • 사업을 시작할 무렵에는 아래와 같은 설계 구조로 이뤄져 있습니다.

  • Beverage는 음료를 나타내는 추상클래스입니다. 매장에서 판매되는 모든 음료는 이 클래스의 서브클래스가 됩니다.
  • Beverage의 cost()메소드는 추상 메소드입니다. 서브클레스에서는 이 메소드를 구현해야합니다.
  • Beverage의 description이라는 인스턴스변수는 각 서브클래스에서 설정되며 커피에 대한 설명 정보가 문자열로 저장됩니다. description이라는 변수 정보는 getDescription() 메소드를 통해 정보를 가져올 수 있습니다.
  • 위 구조는 고객이 커피를 주문할 때 우유, 두유, 자바칩 같은 토핑 메뉴가 추가되야하는 상황을 맞이하면서 아래처럼 클래스가 폭팔적으로 많아지는 현상을 만나게 됩니다.
    • 그림을 자세히 보시면 HosueBlendWithxxxx 파일의 종류가 여러가지인걸 확인할 수 있습니다.

문제 해결 시도

  • 문제 해결을 위해 인스턴스 변수와 슈퍼클래스를 사용해서 문제를 해결해보겠습니다.
  • milk,soy,mocha,whip은 각 첨가물이 사용되었는지를 의미하는 Boolean 값입니다.
  • cost()를 추상 클래스로 정의하지않고 구현하겠습니다.
  • 이렇게 되도 서브클래스는 cost() 메소드를 오버라이드 해야겠지만 기본 음료 가격에 추가 비용을 합친 가격을 리턴할 수 있습니다.
  • 그러나 이런 구조는 아래와 같은 문제를 가지고 있습니다.
    • 첨가물 가격이 바뀔 때마다 키존 코드를 수정해야 합니다.
    • 첨가물의 종류가 많아지면 새로운 메소드를 추가해야하고 슈퍼클래스의 cost() 메소드도 수정해야합니다.
    • 새로운 음료가 출시될수도 있는데 첨가물이 있으면 안되는 경우가 있을수 있습니다. 사용하지 않는 메소드나 변수를 상속받는것은 매우 위험합니다.
  • 이를 통해 Beverage 음료는 변경하지 않고 확장할 수 있는 새로운 해결 방법이 필요합니다.
  • 확장에는 열려있고 변경에는 닫혀있다는것이 모순적인 표현이지만 직접 코드를 수정하지 않고도 코드를 확장할 수 있는 기법은 존재합니다.
    • 확장에는 열려있고 변경에는 닫혀있는 데코레이터 패턴에 대해서 살펴보도록 하겠습니다.

데코레이터 패턴

데코레이터 패턴이란?

  • 객체에 추가 요소를 동적으로 더할 수 있는 패턴입니다. 데코레이터를 사용하면 서브클래스를 만들 때보다 훨씬 유용하게 기능을 확장할 수 있습니다.
  • 아래 UML을 통해 데코레이터 패턴 설계를 살펴보도록 하겠습니다.

  • 각각의 Component는 직접 쓰일 수도 있고 데코레이터에 감싸여 쓰일 수도 있습니다.
  • 각 Decorator 안에는 Component 객체가 들어있습니다. 즉 Decorator안에는 Component 변수가 존재합니다.
    • 여기서 재귀적인 성격을 눈치채신다면 좋을거 같습니다.
    • Decorator는 자신이 장식할 구성 요소와 같은 인터페이스 또는 추상클래스를 구현합니다.
  • ConcreteComponent에 새로운 행동을 동적으로 추가합니다.
  • 추가적으로 ConcreteDecorartorA,B 처럼 새로운 메소드를 추가하여 기능들을 추가할 수도 있습니다.
  • 이러한 Decorator 패턴의 설계 방식을 사용해서 스타버즈 회사의 문제를 해결해보도록 하겠습니다.

데코레이터 패턴을 사용한 문제 해결

  • 위에 설명했던 데코레이터 패턴의 Component의 개념이 Beverage 클래스입니다. Beverage 클래스는 음료를 나타내기 때문에 어떠한 첨가물(Decorator)로 감싸질 수 있어야 하기 때문에 추상 클래스로 설정되기에 적합하며 Component의 개념으로 설정되기에 적합합니다.
  • 이를 통해 HouseBlend, DarkRoast 등 커피 종류마다의 구성 클래스를 만들게 됩니다.
  • 첨가물(Milk, Mocha, Soy 등)들은 CondimentDecorator의 상속을 통해서 구현되게 됩니다.
    • 이러한 구현 방식은 CondimentDecorator 클래스가 Beverage 클래스를 상속하여 동일한 상태를 유지시켜 어떠한 첨가물도 Beverage에 첨가물(Decorator)를 적용시킬수 있도록 하기 위함입니다.
    • 이때 주의할 것은 행동을 상속받기 위해 적용시키는것이 아니라 형식을 맞추기 위해 적용한 것이라는것을 이해해야합니다.
💡 여기서 Component는 추상 클래스여야만 하냐라는 의문이 생길 수 있습니다. 반드시 Component를 추상 클래스로 선언할 필요는 없습니다. 인터페이스로 설정해도 되지만 예제를 진행할 때 Beverage의 초기 클래스가 추상 클래스로 선언되었다는 가정으로 진행되었기 때문에 현재 Beverage 클래스가 추상 클래스인것입니다.

구현

  • 백문이 불여일타. 코드를 통해 구현을 진행해 보겠습니다.
package com.example.designpattern.decorator.starbuzz;

public abstract class Beverage {
	String description = "제목 없음";
  
	public String getDescription() {
		return description;
	}
 
	public abstract double cost();
}
  • Beverage는 추상 클래스이며 두 가지 메소드를 가지고 있습니다.
  • getDescription()은 이미 구현되어있지만 cost()는 서브클래스에서 구현해야 합니다.
package com.example.designpattern.decorator.starbuzz;

public abstract class CondimentDecorator extends Beverage {
	Beverage beverage;
	public abstract String getDescription();
}
  • 각 데코레이터가 감쌀 음료를 나타내는 Beverage를 인스턴스 변수로 선언합니다.
  • 모든 CondimentDecorator(첨가물 데코레이터)는 getDescription() 메소드를 새로 구현하도록 하였습니다.
    • 이는 추후 실행 결과에서 음료에 대한 정보를 출력할 때 재귀적인 호출로 인해 모든 첨가물 정보를 출력하기 위해서 입니다.
    • 이해가 안간다면 밑으로 글을 읽으면서 실행 결과를 확인하면 이해가 갈것입니다.
package com.example.designpattern.decorator.starbuzz;

public class Espresso extends Beverage {
  
	public Espresso() {
		description = "에스프레소";
	}
  
	public double cost() {
		return 1.99;
	}
}
package com.example.designpattern.decorator.starbuzz;

public class HouseBlend extends Beverage {
	public HouseBlend() {
		description = "하우스 블랜드 커피";
	}
 
	public double cost() {
		return .89;
	}
}
  • 여기서는 실제 음료를 구현합니다.
    • 빠른 예제 진행을 위해 두 가지 음료만 구현하도록 하겠습니다.
  • 음료에 대한 정보를 가져올 수 있는 getter는 Beverage에 구현되어있고 cost() 메소드만 재정의해주면 됩니다.
    • cost 또한 재귀함수 개념을 통해 누적값을 더하여 가격을 출력할 것입니다.
  • 여기까지 구현했다면 **추상 구성 요소(Beverage), 구상 구성 요소(HouseBlend, Espresso), 추상 데코레이터(CondimentDecorator)**까지 구현한것입니다. 지금부터는 구상 데코레이터를 구현하도록 하겠습니다.
package com.example.designpattern.decorator.starbuzz;

public class Mocha extends CondimentDecorator {
	public Mocha(Beverage beverage) {
		this.beverage = beverage;
	}
 
	public String getDescription() {
		return beverage.getDescription() + ", 모카";
	}
 
	public double cost() {
		return .20 + beverage.cost();
	}
}
  • Mocha는 데코레이터라 CondimentDecorator를 확장합니다.
  • 첨가물은 Beverage를 통해 기본 생성이 됩니다.
    • 이를 통해 감싸는 Beverage에 대한 메소드를 호출하여 값을 받아오는 재귀 방식으로 기존 Beverage에 대한 수정 없이 기능을 확장할 수 있습니다.
package com.example.designpattern.decorator.starbuzz;

public class StarbuzzCoffee {
 
	public static void main(String args[]) {
		Beverage beverage = new Espresso();
		System.out.println(beverage.getDescription() 
				+ " $" + beverage.cost());
 
		Beverage beverage2 = new DarkRoast();
		beverage2 = new Mocha(beverage2);
		beverage2 = new Mocha(beverage2);
		beverage2 = new Whip(beverage2);
		System.out.println(beverage2.getDescription() 
				+ " $" + beverage2.cost());
 
		Beverage beverage3 = new HouseBlend();
		beverage3 = new Soy(beverage3);
		beverage3 = new Mocha(beverage3);
		beverage3 = new Whip(beverage3);
		System.out.println(beverage3.getDescription() 
				+ " $" + beverage3.cost());
	}
}
  • Main문을 실행하여 직접 확인해보겠습니다.
  • 먼저 아무런 데코레이터를 적용하지 않은 에스프레소를 출력합니다.
  • 이후 DarkRost에는 Mocha 데코레이터 2회, whip 데코레이터를 적용합니다.
  • 추가로 HousBlend에는 Soy, Mocha, Whip 데코레이터를 적용해봅니다.
  • 이해가 가지 않는다면 아래의 구조로 호출되는 그림 구조를 읽어보면 도움이 됩니다.

실행결과

정리

  • 이러한 데코레이터 패턴에도 단점이 있습니다. 구상 구성 요소와 추상 Decorator가 어떤것인지를 알아야 한다는 점입니다.
    • 간단히 말에 사용자가 어떤 객체에 어떤 Decorator를 적용해야할 지를 알아야 한다는 의미입니다.
    • 지금은 예제라 간단하지만 수십개의 클래스를 아무것도 모른 상태에서 만난 개발자는 말문이 막힐 것입니다.
    • 그럼에도 불구하고 OCP의 원칙을 잘 지킬수 있는 패턴임인것은 반박의 여지가 없습니다.
  • 데코레이터 패턴을 사용하면 OCP 원칙을 지켜서 구현할 수 있습니다.
  • 구성과 임으로 실행 중에 새로운 행동을 추가할수 있습니다.
  • 데코레이터는 자기가 감싸고 있는 메소드를 호출하여 새로운 기능을 더하여 행동을 확장합니다.(ex.재귀적인 호출)
  • 구성 요소의 클라이언트는 데코레이터의 존재를 알 수 없는 구조입니다. 클라이언트가 구성 요소의 구체적인 형식에 의존하는 경우는 예외입니다.