본문 바로가기

Fundamental/Design Pattern

[디자인 패턴] 3. 데코레이터 패턴(Decorator Pattern)

 

 

데코레이터 패턴(Decorator Pattern)
실행 중에 객체를 직접 수정하지 않고 동적으로 기능을 확장시킬 수 있는 패턴이다.

 


 

커피 전문점인 스타버즈 커피는 주문 시스템을 가지고 있었는데, 주문 시스템의 클래스는 아래와 같이 구성되어 있었습니다.

 

 

 

그런데 점점 사업이 확장되면서 수많은 커피 종류와 더불어 휘핑 크림, 우유 등의 첨가물을 추가한 커피를 출시하게 되니, 같은 커피여도 첨가물 종류나 갯수마다 다른 가격을 책정해야 했고, 결국 커피마다, 첨가물에 따라 다르게 완성된 커피 클래스들을 별도로 만들게 되어 수많은 클래스들이 생겨나게 되어 클래스 관리가 너무 어렵게 되었습니다.

 

그래서 스타버즈는 상속을 사용해서 이 문제를 해결해보려 합니다.

 

 

 

이제 각 서브 클래스(HouseBlend, ...)들이 첨가물이 있는지를 확인해서 가격을 계산할 수 있게 되었습니다. Beverage 클래스의 cost()는 첨가물의 가격을 계산하고, 서브 클래스에서 cost()메소드를 오버라이드할 때 그 기능을 확장해서 특정 음료의 가격을 더합니다. 각 서브 클래스의 cost()는 음료 가격을 계산하고, Beverage 클래스의 cost()를 호출해서 첨가물 가격을 더합니다.

 

괜찮아 보였지만 이번엔 또 다른 문제들이 생겨나기 시작했습니다..

 

  • 첨가물 가격이 바뀔 때마다 기존 코드를 수정해야 합니다.
  • 첨가물의 종류가 많아질수록 새로운 메소드를 추가해야 하고, Beverage 클래스의 cost 메소드도 수정해야 합니다.
  • 새로 출시된 음료에는 특정 첨가물이 들어가면 안 되는 음료도 있습니다. 휘핑 크림이 들어가면 안 되는데, hasWhip()과 같은 메서드도 함께 상속받게 될 것입니다.

 

이제 데코레이터 패턴을 사용해서 위와 같은 문제를 해결해봅시다!

 

 

 

처음엔 음료 객체인 DarkRoast를 생성합니다. 다음으로 음료에 더할 첨가물 객체를 계속해서 감쌉니다. 마지막으로 가격을 구할 땐 가장 바깥에 위치한 Whip의 cost()를 호출하면 완성된 음료의 가격을 구할 수 있습니다.

 

위와 같은 구조를 클래스 다이어그램으로 보면 아래와 같습니다.

 

 

 

위 클래스 다이어그램을 직접 구현해봅시다.

우선 추상 클래스로 Beverage, CondimentDecorator를 만듭니다.

 

abstract class Beverage {
    String description = "제목 없음";

    public String getDescription() {
        return description;
    }

    // cost 함수는 서브 클래스에서 구현해야 합니다.
    public abstract double cost();
}

abstract class CondimentDecorator extends Beverage {
    // 각 데코레이터가 감쌀 음료를 나타내는 Beverage 객체를 여기에서 지정합니다.
    // 음료를 지정할 때는 데코레이터에서 어떤 음료든 감쌀 수 있도록 Beverage 슈퍼 클래스 유형을 사용합니다.
    Beverage beverage;

    // 모든 첨가물 데코레이터에 getDescription() 메소드를 새로 구현하도록 만들 계획입니다.
    // 그래서 추상 메소드로 선언했습니다.
    public abstract String getDescription();
}

 

 

다음으로, 각각의 구현체들을 만듭니다.

 

class DarkRoast extends Beverage {
    public DarkRoast(){
        description = "다크로스트";
    }

    public double cost() {
        return 1.99;
    }
}

class Mocha extends CondimentDecorator {
    public Mocha(Beverage beverage) {
        this.beverage = beverage;
    }

    @Override
    public String getDescription() {
        return beverage.getDescription() + ", 모카";
    }

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

class Whip extends CondimentDecorator {
    public Whip(Beverage beverage) {
        this.beverage = beverage;
    }

    @Override
    public String getDescription() {
        return this.beverage.getDescription() + ", 휘핑크림";
    }

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

 

 

마지막으로 모카와 휘핑 크림이 첨가된 다크로스트 커피를 만들어서 설명과 가격을 출력해봅시다!

 

public static void main(String[] args) {
    DarkRoast darkRoast = new DarkRoast();
    Mocha darkRoastWithMocha = new Mocha(darkRoast);
    Whip darkRoastWithMochaAndWhip = new Whip(darkRoastWithMocha);

    System.out.println(darkRoastWithMochaAndWhip.getDescription());
    System.out.println(darkRoastWithMochaAndWhip.cost());
}

 

 

이제 첨가물을 추가할 때 기존의 코드를 수정하지 않고, 각각의 커피 클래스들을 만들지 않아도, 다양한 커피를 만들 수 있게 되었습니다.

이렇게 데코레이터 패턴을 사용하면 새로운 기능을 쉽게 추가할 수 있지만 아래와 같은 단점도 존재합니다.

 

  1. 다크로스트를 첨가물 데코레이터로 감싸면, 이 객체가 다크로스트인지 모르게 됨
  2. 데코레이터 객체들이 많아질수록 관리해야 할 객체가 많아지므로 코딩을 실수할 가능성이 커짐

그렇기 때문에, 감싸져 있는 어느 한 객체의 기능을 동적으로 변경해야 하거나, 그 객체로 어떤 작업을 별도로 수행해야 하는 경우엔 이 패턴 사용을 지양해야 합니다.

 

Java에선 I/O 클래스가 이러한 데코레이터 패턴으로 구성되어 있습니다. 파일에서 읽은 스트림에 기능을 더하는 데코레이터를 사용하는 객체는 보통 아래와 같은 형식으로 구성됩니다.

 

데코레이터 패턴을 사용하면 나만의 기능을 추가하거나 기존 기능을 override해서 수정도 가능해요

 


데코레이터 패턴에서 배울 수 있는 객체지향 원칙

  • 바뀌는 부분은 캡슐화한다.
  • 상속보다는 구성을 활용한다.
  • 구현보다는 인터페이스에 맞춰서 프로그래밍한다.
  • 상호작용하는 객체 사이에서는 가능하면 느슨한 결합을 사용해야 한다.
  • 클래스는 확장에는 열려 있어야 하지만 변경에는 닫혀 있어야 한다(OCP).

 

데코레이터 패턴(Decorator Pattern)
실행 중에 객체를 직접 수정하지 않고 동적으로 기능을 확장시킬 수 있는 패턴이다.