[헤드 퍼스트 디자인 패턴] 03. 데코레이터 패턴

akim·2023년 5월 1일
0
post-thumbnail

"상속맨, 디자인에 눈을 뜨다"
3장의 제목을 저렇게 지어도 좋을 것 같군요. 3장에서는 상속을 남용하는 사례를 살펴보고 객체 작성이라는 형식으로 실행 중에 클래스를 꾸미는(데코레이션하는)방법을 배웁니다. 데코레이터 패턴을 배우면 기존 클래스 코드를 바꾸지 않고도 객체에 새로운 임무를 추가할 수 있습니다.

1. 초대형 커피 전문점, 스타버즈

이 커피 전문점의 주문 시스템 클래스는 다음과 같이 구성되어 있다.

  • Beverage 는 음료를 나타내는 추상 클래스이며 매장에서 판매되는 모든 음료는 이 클래스의 서브 클래스가 된다.
  • cost() 메소드는 추상 메소드다. 서브 클래스에서 이 메소드를 구현해서 새로 정의해야 한다.
  • description 이라는 인스턴스 변수는 각 서브 클래스에서 설정되며, 여기에는 '가장 훌륭한 다크 로스트 커피' 같은 음료 설명이 저장된다.
  • getDescription() 메소드를 호출해서 description 변수에 저장된 내용을 확인할 수 있다.

고객은 커피를 주문할 때 우유나 두유, 초콜릿(모카)을 추가하고 그 위에 휘핑크림을 얹기도 한다. 각각을 추가할 때마다 커피 가격이 올라가야 하기에 주문 시스템을 구현할 때 이런 점을 모두 고려해야 한다.

이때 모든 주문에 대한 서브 클래스를 만들어서 cost()를 구현해주면..

이러한 대참사가 일어나게 된다!

만약 우유 가격이 인상되면 어떻게 해야 할까? 캐러멜이 새로 추가된다면 어떻게 해야 할까?

클래스 관리 문제를 생각해 볼 때, 지금까지 우리가 배웠던 디자인 원칙 가운데 지켜지지 않고 있는 것이 있는 것이다.

그렇다면 Beverage 클래스에 우유, 두유, 초콜릿, 휘핑크림 첨가 여부를 보여줄 수 있는 인스턴스 변수를 추가하고, 각 첨가물의 Boolean 값을 알아내거나 설정하는 게터/세터 메소드를 추가해준 뒤 cost() 를 추상 메소드로 정의하지 않고 구현하면 어떨까?

슈퍼클래스에 있는 cost() 가 첨가물의 가격을 계산하고, 서브 클래스에서 이 메소드를 오버라이드 할 때 그 기능을 확장해서 특정 음료의 가격을 더하면 될 것이다.

분명 클래스 개수는 줄어들겠지만, 여기에도 문제점이 있다.

  • 첨가물 가격이 바뀔 때마다 기존 코드를 수정해야 한다.
  • 첨가물의 종류가 많아지면 새로운 메소드를 추가해야 하고, 슈퍼클래스의 cost() 메소드도 고쳐야 한다.
  • 특정 첨가물이 들어가면 안 되는 새로운 음료가 출시될 경우, 여전히 특정 메소드를 상속받기 때문에 문제가 생긴다.
  • 모카 추가를 한 번이 아니라 두 번 하게 된다면 방법이 없다.

위와 같이, 상속을 한다고 해서 무조건 유연하거나 관리하기 쉬운 디자인은 아니라는 것을 알 수 있다.

여기서 새로운 디자인 원칙을 찾을 수 있다.


새로운 디자인 원칙, OCP

OCP(Open-Closed Principle)
클래스에는 확장에는 열려 있어야 하지만 변경에는 닫혀 있어야 한다.

클래스를 확장하고 원하는 행동을 마음대로 추가해도 되지만, 기존 코드를 건드리면 안된다는 것이다. 이렇게 하면 새로운 기능을 추가할 때 급변하는 주변 환경에 잘 적응하는 유연하고 튼튼한 디자인을 만들 수 있게 된다.

그런데 코드를 수정하지 않고 코드를 확장해야 한다니 모순처럼 보인다. 과연 어떻게 이게 가능한 것일까?


2. 데코레이터 패턴 살펴보기

다른 방법으로 커피 전문점 시스템에 접근해보자.

일단 특정 음료에서 시작해서 첨가물로 그 음료를 장식(decorate)해보자. 예를 들어 어떤 고객이 모카와 휘핑크림을 추가한 다크 로스트 커피를 주문한다면 다음과 같이 장식할 수 있다.

  1. DarkRoast 객체를 가져온다.
  2. Mocha 객체로 장식한다.
  3. Whip 객체로 장식한다.
  4. cost() 메소드를 호출한다. 이때 첨가물의 가격을 계산하는 일은 해당 객체에게 위임한다.

여기서 객체를 어떻게 장식 할 수 있을까? 그리고 어떤 식으로 위임 할 수 있을까?


주문 시스템에 데코레이터 패턴 적용하기

DarkRoast 객체에서 시작한다.

DarkRoastBeverage 로부터 상속받으므로 음료의 가격을 계산하는 메소드르 가지고 있다.

고객이 모카를 주문했으니 Mocha 객체를 만들고 그 객체로 DarkRoast 를 감싼다.

이때 Mocha 객체는 데코레이터다. 객체의 형식은 객체가 장식하고 있는 객체를 반영하는데, 이 경우에는 Beverage 가 된다. 여기서 반영한다는 것은 '같은 형식을 갖는다' 는 의미이다.

고객이 휘핑크림도 추가했으니 Whip 데코레이터를 만들어 Mocha 를 감싼다.

whip 도 데코레이터이므로 DarkRoast 의 형식을 만영한다.
MochaWhip 에 싸여 있는 DarkRoast 는 여전히 Beverage 객체이기에 cost() 메소드 호출을 비롯한 일들을 할 수 있다.

가격을 구할 때는 가장 바깥쪽에 있는 데코레이터부터 차례로 호출해 들어간 다음 다시 차례로 리턴해오면 된다.

  1. 가장 바깥쪽에 있는 데코레이터인 Whipcost() 를 호출한다.
  2. WhipMochacost() 메소드를 호출한다.
  3. Mocha 는 다시 DarkRoastcost() 를 호출한다.
  4. DarkRoast 는 가격(99센트)을 리턴한다.
  5. MochaDarkRoast 로부터 리턴받은 가격에 모카 값(20센트)을 더해서 총 1.19 달러를 리턴한다.
  6. WhipMocha 로부터 받은 가격에 휘핑 크림 값(10센트)를 다시 추가해서 최종 결과인 1.29 달러를 리턴한다.


데코레이터 패턴의 정의

데코레이터 패턴으로 객체에 추가 요소를 동적으로 더할 수 있다. 데코레이터를 사용하면 서브 클래스를 만들 때보다 훨씬 유연하게 기능을 확장할 수 있다.

  • 데코레이터의 슈퍼클래스는 자신이 장식하고 있는 객체의 슈퍼클래스와 같다.

  • 한 객체를 여러 개의 데코레이터로 감쌀 수 있다.

  • 데코레이터는 자신이 감싸고 있는 객체와 같은 슈퍼클래스를 가지고 있기에 원래 객체(싸여 있는 객체)가 들어갈 자리에 데코레이터 객체를 넣어도 상관없다.

  • 데코레이터는 자신이 장식하고 있는 객체에게 어떤 행동을 위임하는 일 말고도 추가 작업을 수행할 수 있다.

  • 객체는 언제든지 감쌀 수 있으므로 실행 중에 필요한 데코레이터를 마음대로 적용할 수 있다.

이제 데코레이터가 무엇이고 어떻게 쓰이는지 이론적으로는 이해가 되지만 코드로는 막상 어떻게 구현해야 하는지 감이 잡히지 않는다. 클래스 다이어그램을 통해 구현 방법에 대해 생각해보도록 하자.

  • 각 데코레이터 안에는 Component 객체가 들어있다. 즉, 데코레이터에는 구성 요소의 레퍼런스를 포함한 인스턴스 변수가 있다.

  • ConcreteDecorator 에는 데코레이터가 감싸고 있는 Component 객체용 인스턴스 변수가 있다.

  • 데코레이터가 ConcreteDecorator 에 새로운 메소드를 추가할 수도 있지만, 일반적으로는 새로운 메소드를 추가하는 대신 Component에 원래 있던 메소드를 (호출하기 전이나 호출한 후에) 별도의 작업으로 처리해서 새로운 기능을 추가한다.


Beverage 클래스를 데코레이터 패턴을 적용해 구현하게 되면 아래와 같이 표현할 수 있다.

그런데 이 클래스 다이어그램을 보면 CondimnetDecorator 에서 Beverage 클래스를 확장하고 있다. 이러면 그냥 상속 이지 않을까?

상속이 맞다. 다만 데코레이터 형식이 그 데코레이터로 감싸는 객체의 형식과 같다는 점이 중요한 것이다. 즉, 데코레이터 패턴에서는 상속으로 행동을 물려받는 것이 아니라 상속을 사용해서 형식을 맞추는 것이다.


그렇다면 행동은 어디에서 올까?

어떤 구성요소를 가지고 데코레이터를 만들 때 새로운 행동을 추가하는 것이다. 새로운 행동은 슈퍼클래스로부터 행동을 상속받아서 얻는 것이 아니고 객체를 구성해서 얻는다. Milk, Mocha, Soy, Whip 과 같은 각 데코레이터는 cost()getDescription() 을 구현해야 하는 셈이다.


그런데 구성 요소의 형식만 사용하면 되는 거라면 Beverage 클래스를 인터페이스로 만들어도 되는 게 아닐까?

원래 데코레이터 패턴에서는 특정한 추상 구성 요소를 지정할 필요가 없다. 그래서 사실 인터페이스를 쓰면 되긴 하지만 기존 코드를 고치는 일은 될 수 있으면 피하는 것이 좋으므로 기존에 추상 클래스를 사용하던 본 커피 전문점의 환경에 맞게 추상 클래스만 가지고 작업을 한 것이다.


이렇게 형식과 행동을 분리함으로써 행동을 컴파일시에 정적으로 결정해버리지 않고, 실행 중에 데코레이터를 마음대로 조합해서 사용할 수 있도록 한다는 것이 데코레이터 패턴의 큰 장점이다.


3. 커피 주문 시스템 구현하기

그럼 이제 학습한 디자인 패턴을 가지고 실제 코드를 작성해보자.


우선 스타버즈 커피에서 만든 Beverage 클래스부터 살펴보자.

public abstract class Beverage { //Beverage 는 추상 클래스이며, getDescription() 과 cost() 라는 2개의 메소드를 가진다.

	String description = "Unknown Beverage";
  
	public String getDescription() { //getDescription() 은 이미 구현되어 있지만, cost() 는 서브클래스에서 구현해야 한다.
		return description;
	}
 
	public abstract double cost();
}

첨가물(condiment)을 나타내는 추상 클래스(데코레이터 클래스)는 아래와 같다.

public abstract class CondimentDecorator extends Beverage { // Beverage 객체가 들어갈 자리에 들어갈 수 있어야 하므로 Beverage 클래스를 확장한다.
	
    // 각 데코레이터가 감쌀 음료를 나타내는 Beverage 객체를 여기에서 지정한다.
	// 음료를 지정할 때는 데코레이터에서 어떤 음료든 감쌀 수 있도록 Beverage 슈퍼클래스 유형을 사용한다.
	Beverage beverage;
    
    // 모든 첨가물 데코레이터에 getDescription() 메소드를 새로 구현하도록 추상 메소드로 선언한다.
	public abstract String getDescription();
}

그럼 이제 실제 음료를 구현해보자. 음료를 설명하는 문자열을 설정하고, cost() 메소드를 구현하면 된다.

public class Espresso extends Beverage { // Beverage 클래스를 확장한다.
  
	public Espresso() {
		description = "Espresso"; // 클래스 생성자 부분에서 description 변수값을 설정한다.
	}
  
	public double cost() {
		return 1.99; // 이 클래스는 첨가물 가격을 걱정할 필요 없이 그냥 에스프레소 가격인 1.99 달러만 리턴하면 된다.
	}
}

지금까지 추상 구성 요소인 Beverage 와 구상 구성 요소인 Espresso , 그리고 추상 데코레이터인 CondimentDecorator 까지 만들었다. 이제는 구상 데코레이터(첨가물)를 구현하면 된다.

public class Mocha extends CondimentDecorator { // Mochasms 데코레이터이므로 CondimentDecorator를 확장한다.

	 // Mocha 인스턴스에는 Beverage의 레퍼런스가 들어있으며, 아래 2가지가 필요하다.
     // 1. 감싸고자 하는 음료를 저장하는 인스턴스 변수
     // 2. 인스턴스 변수를 감싸고자 하는 객체로 설정하는 생성자
    public Mocha(Beverage beverage) 
		this.beverage = beverage;
	}
 
 	// 장식하고 있는 객체에 작업을 위임하고 그 결과에 첨가물을 더한 값을 리턴한다.
	public String getDescription() 
		return beverage.getDescription() + ", Mocha"; 
	}
 
 	// 마찬가지로 장식하고 있는 객체에 가격을 구하는 작업을 위임하여 음료 값을 구한 다음, 모카 가격을 더하여 리턴한다.
	public double cost() {
		return .20 + beverage.cost();
	}
}

이제 데코레이터 패턴으로 구현해낸 시스템이 잘 작동하는지 주문 코드를 작성해보자.

public class StarbuzzCoffee {
 
	public static void main(String args[]) {
		Beverage beverage = new Espresso(); // 아무것도 넣지 않은 에스프레소를 주문하고 그 음료 설명과 가격을 출력한다.
		System.out.println(beverage.getDescription() 
				+ " $" + beverage.cost());
 
		Beverage beverage2 = new DarkRoast(); // DarkRoast 객체를 만들고
		beverage2 = new Mocha(beverage2); // Mocha로 감싼다.
		beverage2 = new Mocha(beverage2); // Mocha 샷을 하나 더 추가하고
		beverage2 = new Whip(beverage2); // Whip 으로 감싼다.
		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());
	}
}

구상 구성 요소 vs 추상 구성 요소

위와 같은 작업을 통해 드디어 코드가 완성되었다.

그런데 이 코드를 그대로 쓰게 되면 구상 구성 요소로 할인과 같은 추가 작업을 할 때 문제가 생길 수 있다. 일단 HouseBlend 를 데코레이터로 감싸게 되면 그 커피가 하우스 블래드인지 다크 로스트인지 알 수 없기 때문이다.

이처럼 구상 구성 요소 로 작업을 처리하는 코드에 데코레이터 패턴을 적용하면 코드가 제대로 작동하지 않는다. 반대로 추상 구성 요소 로 돌아가는 코드에는 데코레이터 패턴을 적용해야만 제대로 된 결과를 얻을 수 있다.

그렇기에 구상 구성 요소로 돌아가는 코드를 만들어야 한다면 데코레이터 패턴 사용을 다시한번 생각해봐야 한다.


4. 오늘의 디자인 원칙

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


본 포스팅에 쓰인 이미지와 내용의 모든 출처는 책 '헤드 퍼스트 디자인 패턴' 에 있습니다.

profile
학교 다니는 개발자

0개의 댓글