본 포스트는 헤드퍼스트 디자인패턴을 읽고 정리한 글입니다. |
Ch3 데코레이터 패턴
Question: 커피 주문 시스템 구축
기존 주문 시스템 클래스를 개선하여 다양한 음료를 모두 포괄할 수 있는 주문 시스템을 만들어야 한다.
기존 주문 시스템
- cost: 음료 가격
- description: 음료 설명
요구사항
- 옵션이 존재한다.
- 샷 추가, 우유, 휘핑 크림 등을 선택할 수 있다.
- 옵션을 추가할 때마다 가격도 추가된다.
임시방편 문제 해결 1
각 옵션 유무에 따라 클래스를 모두 생성하고, cost 메서드에서 최종 가격을 계산한다.
Problem
특정 옵션의 가격이 인상되거나, 옵션 자체가 추가되면 모든 클래스를 일일이 확인 및 수정해야 한다.
지금까지 배운 4가지 디자인 원칙 중 어떤 것을 위반하고 있을까?
- 디자인 원칙 1, 달라지는 부분을 분리한다
- 디자인 원칙 3, 구성을 사용한다
임시방편 문제 해결 2
Baverage 추상 클래스에 Boolean
타입의 옵션 인스턴스 변수를 선언하고 상속을 통하여 관리한다.
Problem 2
옵션의 가격이 바뀔 때마다 코드를 수정해야 한다.
옵션의 종류가 많아지면 새로운 메서드를 추가하고 cost 메서드를 수정해야 한다.
음료 마다 옵션의 제약 사항이 존재한다면, 적합하지 않는 기능을 추가하게 된다.
디자인 원칙
다섯 번째, OCP(Open-Closed Principle)
클래스는 확장에는 열려있어야 하지만 변경에는 닫혀있어야 한다.
즉, 기존 코드의 변경 없이, 코드를 확장하는 것이다.
OCP 원칙을 모든 부분에서 준수하려고 하다보면
쓸 데 없이 시간을 낭비할 수 있으며, 필요 이상으로 복잡하고 이해하기 어려운 코드를 만들게 되는 부작용이 발생할 수 있다.
따라서, 디자인한 것 중에서 바뀔 가능성이 가장 높은 부분을 중점적으로 OCP 원칙을 적용하는 게 좋다.
데코레이터 패턴으로 OCP 원칙을 준수하는 방법을 알아보자.
데코레이터 패턴 (Decorator Pattern)
객체가 추가 요소를 동적으로 더할 수 있다. 데코레이터를 사용하면 서브클래스를 만들 때보다 훨씬 유연하게 기능을 확장할 수 있다.
장식(decorate)
샷 추가, 우유, 휘핑 크림 등의 옵션이 음료를 장식한다(decorate)고 생각해 보자.
한 손님이 샷 추가와 휘핑 크림을 추가한 라떼를 주문한다면 다음과 같이 장식할 수 있다.
- Latte 객체를 생성한다.
- Shot 객체로 장식한다.
- Shot 객체로 Latte를 감싼다.
- Whip 객체로 장식한다.
- Whip 객체로 Shot을 감싼다.
- cost() 메서드를 호출한다.
- 💡 이때 옵션의 가격을 계산하는 일은 해당 객체에게 위임한다.
- Whip의 cost 메서드 호출
- Whip은 Shot의 cost 메서드 호출
- Shot은 Latte의 cost 메서드 호출
- Latte는 본인 가격 반환
- Shot은 Latte에게 받은 가격에 샷 가격을 더해 반환
- Whip은 Shot에게 받은 가격에 휘핑 크림 가격을 더해 최종 반환
데코레이터 (decorator)
- 데코레이터의 슈퍼클래스는 자신이 장식하고 있는 객체의 슈퍼클래스와 같다.
- 한 객체를 여러 개의 데코레이터로 감쌀 수 있다.
- 데코레이턴는 자신이 감싸고 있는 객체와 같은 슈퍼클래스를 가지고 있기 때문에, 원래 객체(싸여 있는 객체)가 들어갈 자리에 데코레이터 객체를 넣을 수 있다.
- 데코레이터는 자신이 자식하고 있는 객체에게 특정 행동을 위임하는 일 말고도, 추가 작업을 수행할 수 있다. ⭐️
- 객체는 언제든지 감쌀 수 있으므로 실행 중에 필요한 데코레이터를 마음대로 적용할 수 있다.
데코레이터 패턴의 구조
Component (Interface or Abstract Class)
Component는 직접 쓰일 수도 있고, 데코레이터에 감싸여 쓰일 수도 있다.
Decorator (Abstract Class)
Decorator는 자신이 장식하고 있는 객체의 자리에 들어갈 수 있어야 한다.
따라서, Component 클래스를 확장하며, 각 데코레이터는 Component 객체를 포함한다.
- 어떤 Component든 감쌀 수 있도록, ConcreteDecorator의 슈퍼클래스인 Component를 사용한다.
Decorator는 자신이 장식하는 Component와 같은 인터페이스(또는 추상 클래스)를 구현한다.
ConcreteComponent
핵심 기능을 담당한다.
ConcreteComponent에 새로운 행동을 동적으로 추가할 수 있다.
ConcreteDecorator
추가 기능을 담당한다.
데코레이터가 감싸고 있는 Component 객체용 인스턴스 변수를 가지고 있다.
- Component의 기능과 Decorator의 기능을 모두 제공한다.
- Component의 상태를 확장할 수 있다.
- 상속보다는 구성을 활용한다의 세 번째 디자인 법칙을 지킨다.
- ConcreteDecorator를 추가하므로써, 새로운 기능을 추가할 수 있기 때문에 OCP 규칙을 지킬 수 있다.
데코레이터는 새로운 메서드를 추가할 수 있다.
- 하지만, 일반적으로 새로운 메서드를 추가하는 대신 구성 요소에 원래 있던 메서드를 별도의 작업으로 처리하여 새로운 기능을 추가한다.
데코레이터 패턴을 적용하여 커피 주문 시스템을 개선해 보자.
Problem Solve
기존 주문 시스템 클래스를 개선하여 다양한 음료를 모두 포괄할 수 있는 주문 시스템을 만들어야 한다.
기존 주문 시스템
- cost: 음료 가격
- description: 음료 설명
요구사항
- 옵션이 존재한다.
- 샷 추가, 우유, 휘핑 크림 등을 선택할 수 있다.
- 옵션을 추가할 때마다 가격도 추가된다.
Component와 Decorator
커피 주문 시스템에는 다양한 커피의 종류와 옵션이 존재한다.
각 커피는 고유한 특징과 가격을 가지며, 주문자는 커피에 자신만의(퍼스널) 옵션을 선택할 수 있다.
즉, 커피가 component, 옵션이 decorator가 되는 것이다.
커피와 옵션은 각각 추상 클래스를 가진다. 커피는 인터페이스를 사용할 수 있다.
라떼, 에스프레소, 디카페인 등 커피의 종류는 구상 구성 요소(ConcreteComponent)에 해당하며,
샷 추가, 우유, 휘핑 크림 등 옵션의 종류는 구상 데코레이터(ConcreteDecorator)에 해당한다.
구조도
실제 구현 코드
Beverage Abstract Class
public abstract class Beverage {
String description = "NOTHING";
public abstract double cost();
public String getDescription() {
return description;
}
}
OptionDecorator Abstract Class
public abstract class OptionDecorator extends Beverage {
Beverage beverage;
public abstract String getDescription();
Beverage 구상 클래스 - Latte
public class Latte extends Beverage {
public Latte() {
description = "Latte"; // Beverage 로부터 상속 받음
}
@Override
public double cost() {
return 2.99;
}
}
OptionDecorator 구상 클래스 - Shot
public class Shot extends OptionDecorator { // 추상 데코레이터를 확장, 구현 - OptionDecorator 에서는 Beverage 를 확장
public Shot(Beverage beverage) {
this.beverage = beverage;
}
@Override
public double cost() {
return beverage.cost() + .20; // 장식하고 있는 객체에 작업을 위임한 후, 자신의 가격을 더함
}
@Override
public String getDescription() {
return beverage.getDescription() + ", 1 shot"; // 장식하고 있는 객체에 작업을 위임한 후, 자신의 설명을 덧붙임
}
}
실행 결과
public class CoffeeOrderingSystem {
public static void main(String[] args) {
// 옵션을 추가하지 않은 에스프레소 주문
Beverage beverage = new Espresso();
System.out.println(beverage.getDescription() + " $" + beverage.cost());
// 샷 추가와 휘핑 크림을 추가한 라떼 주문
Beverage beverage2 = new Latte();
beverage2 = new Shot(beverage2); // Latte 를 Shot 으로 감싼다.
beverage2 = new Whip(beverage2); // Shot 을 Whip 으로 감싼다.
System.out.println(beverage2.getDescription() + " $" + beverage2.cost());
}
}
Question++: Beverage에 Size 추가
커피에 Tall, Grande, Venti 사이즈 개념을 도입하고, 사이즈 별로 가격을 다르게 받는다면 어떻게 해야 할까?
헤드퍼스트 답안
헤드퍼스트 디자인패턴에서 제공하는 코드의 구현은 다음과 같다.
Baverage Abstract Class
public abstract class Beverage {
public enum Size { TALL, GRANDE, VENTI };
Size size = Size.TALL;
String description = "Unknown Beverage";
public String getDescription() {
return description;
}
public void setSize(Size size) {
this.size = size;
}
public Size getSize() {
return this.size;
}
public abstract double cost();
}
CodimentDecorator Abstract Class
public abstract class CondimentDecorator extends Beverage {
public Beverage beverage;
public abstract String getDescription();
public Size getSize() {
return beverage.getSize();
}
}
CodimentDecorator 구상 클래스 - Soy
public class Soy extends CondimentDecorator {
public Soy(Beverage beverage) {
this.beverage = beverage;
}
public String getDescription() {
return beverage.getDescription() + ", Soy";
}
public double cost() {
double cost = beverage.cost();
if (beverage.getSize() == Size.TALL) {
cost += .10;
} else if (beverage.getSize() == Size.GRANDE) {
cost += .15;
} else if (beverage.getSize() == Size.VENTI) {
cost += .20;
}
return cost;
}
}
하지만..
위 코드에서는 Soy 데코레이터로 음료를 감싸는 상황에서만 사이즈 별 추가 금액이 계산되는 것을 알 수 있다.
그래서
내 나름대로 코드를 수정해보았다. 🙄
실제 코드 구현
Baverage Abstract Class
Size
구성 요소 자체가 3가지 사이즈를 가진다는 문제가 제시되었다.
따라서, Beverage 클래스에 enum을 추가하고, size 변수를 생성, getter/setter 메서드를 추가했다.
Cost
음료(Baverage의 구상 클래스)의 가격은 각 구상 클래스의 cost 메서드가 가지므로,
상위 클래스인 Beverage 추상 클래스의 cost 메서드에서는 해당 사이즈에 대한 가격을 반환한다.
Description
주문한 커피에 대한 description은 퍼스널 옵션을 모두 추가한 후(모든 데코레이터로 감싼 후), getDescription 메서드를 호출하여 확인할 수 있다.
따라서, Beverage 추상 클래스의 getDescription에 getSize 메서드를 포함하면 최종적으로 설정된 size를 포함한 description을 받을 수 있다.
public abstract class Beverage {
String description = "NOTHING";
public enum Size { TALL, GRANDE, VENTI };
Size size = Size.TALL;
public double cost() {
if (size == Size.GRANDE) return 0.5;
if (size == Size.VENTI) return 1.0;
return 0.0;
}
public String getDescription() {
return getSize() + " " + description;
}
public void setSize(Size size) { this.size = size; }
public Size getSize() { return this.size; }
}
Baverage 구상 클래스 - Latte
Beverage 추상 클래스의 cost 메서드에서는 해당 사이즈에 대한 가격을 반환하므로,
Baverage의 구상 클래스의 cost 메서드는 구상 클래스(super)의 cost 메서드 반환 값에 음료 자신의 가격을 추가하여 반환한다.
public class Latte extends Beverage {
public Latte() {
description = "Latte"; // Beverage 로부터 상속 받음
}
@Override
public double cost() {
return super.cost() + 2.90;
}
}
데코레이터 패턴의 장단점과 의문점
장점
1.
여러가지 종류가 존재하는 커피에 데코레이터를 감싸서 핵심 기능과 추가 기능을 분리할 수 있다.
2.
상속 대신 구성과 위임으로 유연하게 새로운 기능을 추가할 수 있다.
- 디자인 원칙 3, 상속보다 구성을 사용한다. 준수
단점
1.
위의 커피 주문 시스템 예시에서 구상 구성 요소(ConcreteComponent)로 특별 할인 같은 작업을 처리할 때 문제가 생길 수 있다.
데코레이터로 감싸고 나면, 구상 구성 요소를 통해 처리하는 작업에 대한 코드는 제대로 작동하지 않을 수 있다.
따라서, 구상 구성 요소로 작동하는 코드를 만들어야 할 때는 데코레이터 패턴의 사용을 재고해야 한다.
2.
관리해야할 객체가 늘어나 실수할 가능성이 높아진다.
실제로는 팩토리나 빌더 같은 다른 패턴으로 데코레이터를 만들고 사용한다.
이렇게 하면 데코레이터로 장식된 구성 요소는 캡슐화가 잘 되어 있게 된다.
3.
코드가 필요 이상으로 복잡하고 이해하기 어려워질 수 있다.
의문점
데코레이터가 감싸고 있는 다른 데코레이터를 알 수 있는가
같은 옵션을 두 번 선택했을 때 description에서 ‘샷 추가, 샷 추가’ 가 아닌 ‘2샷 추가’와 같이 출력하려면 데코레이터가 서로 어떤 작업을 하는지 알아야 한다.
하지만, 이렇게 되면 데코레이터 패턴이 만들어진 의도에 어긋난다.
최종적으로 만들어진 description을 파싱하여 재생성하는 등의 방법을 사용하여야 한다.
데코레이터가 적용된 예: Java I/O
자바 I/O는 데코레이터 패턴으로 만들어졌다.
구조도
Component
InputStream
ConcreteComponent
FilterInputStream을 제외한 FileInputStream, ByteArrayInputStream 등
Decorator
FilterInputStream
ConcreteDecorator
DataInputStream, BufferedInputStream 등
Question: Java I/O 활용하기
문제
입력 스트림에 있는 대문자를 전부 소문자로 바꿔주는 데코레이터 생성
Solve
FilterInputStream을 확장하여 read 메서드를 오바라이드한다.
public class LowerCaseInputStream extends FilterInputStream {
public LowerCaseInputStream(InputStream in) {
super(in);
}
@Override
public int read() throws IOException {
int c = in.read();
return (c == -1 ? c : Character.toLowerCase((char) c));
}
@Override
public int read(byte[] b, int offset, int len) throws IOException {
int result = in.read(b, offset, len);
for (int i = offset; i < offset+result; i++) {
b[i] = (byte) Character.toLowerCase((char) b[i]);
}
return result;
}
}
LowerCaseInputStream Test
public class InputTest {
public static void main(String[] args) throws IOException {
int c;
try {
/**
* 1. FileInputStream 을 만들고
* 2. BufferedInputStream 과
* 3. 새로 만든 LowerCaseInputStream 필터로 파일을 감싼다.
* */
InputStream in = new LowerCaseInputStream(
new BufferedInputStream(
new FileInputStream("test.txt")));
while ((c = in.read()) >= 0) {
System.out.print((char)c);
}
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
실행 결과
GitHub
전체 실습 코드는 아래 레포지터리의 ch3_DecoratorPattern 폴더에서 확인할 수 있습니다.