데코레이터 패턴(Decorator Pattern)으로 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만들 때보다 훨씬 유연하게 기능을 확장할 수 있습니다.
커피 전문점의 주문 시스템을 예로, 데코레이터 패턴을 단계 별로 설명드리겠습니다.
1단계. 간단한 주문 시스템
먼저, 가장 간단한 구현입니다.
Beverage 추상 클래스가 있고, 매장에서 판매되는 모든 음료는 이 클래스의 서브 클래스가 됩니다.
그리고, Beverage 추상 클래스에는 getDescription(), getCost(), getName()이라는 추상 메서드가 있습니다.
그래서, 모든 음료는 세 개의 메서드를 모두 구현해야합니다.
getDescription()은 “가장 훌륭한 다크 로스트 커피”와 같은 음료 설명을 반환하는 메서드이고,
getCost()는 음료의 가격을 반환하는 메서드,
getName()은 음료의 이름을 반환하는 메서드입니다.
하지만, 이 시스템에서는 각 음료에 휘핑 크림, 모카, 두유 등의 첨가물을 추가할 수 없습니다.
2단계. 첨가물 추가가 가능한 주문 시스템
그래서 첨가물을 추가할 수 있도록 모든 경우의 수에 대해 클래스를 만들면 아래와 같은 구조가 됩니다.
이렇게 구현할 경우, 매우 복잡한 구조가 만들어집니다.
만약, 새로운 첨가물이 추가되는 경우, 더 많은 클래스가 만들어져야 해요.
일단은 클래스가 너무 많기 때문에 인스턴스 변수와 슈퍼 클래스 상속을 써서 첨가물을 관리하도록 해보겠습니다.
(단, 아직 첨가물은 우유, 두유, 휘핑크림, 모카만 가능하다고 가정합니다.)
그럼, Beverage 클래스가 이러한 구조로 변합니다.
멤버변수로 첨가물의 첨가 여부를 나타내는 milk, soy, whip, mocha가 추가되고, has첨가물(), set첨가물() 메서드가 추가되었습니다.
또한, Beverage 클래스의 getCost() 메서드는 추상 메서드로 정의하지 않고, 구현합니다.
즉, Beverage 클래스의 getCost()는 첨가물의 가격을 계산하고, 서브클래스의 getCost()에서는 Beverage 클래스가 반환한 첨가물의 가격에 더하여 커피 값을 더한 값을 반환하면 됩니다.
Beverage 추상 클래스의 코드는 이렇습니다.
public abstract class Beverage {
boolean milk;
boolean soy;
boolean mocha;
boolean whip;
public Beverage(){
this.milk = false;
this.soy = false;
this.mocha = false;
this.whip = false;
}
public abstract String getDescription();
public abstract String getName();
public Double getCost(){
double condimentCost = 0;
if(hasMilk()){
condimentCost += 0.25;
}
if(hasSoy()){
condimentCost += 0.1;
}
if(hasWhip()){
condimentCost += 0.15;
}
if(hasMocha()){
condimentCost += 0.05;
}
return condimentCost;
}
public boolean hasMocha(){return milk;}
public boolean hasWhip(){return whip;}
public boolean hasSoy(){return soy;}
public boolean hasMilk(){return milk;}
public void setMilk(boolean milk) {this.milk = milk;}
public void setSoy(boolean soy) {this.soy = soy;}
public void setMocha(boolean mocha) {this.mocha = mocha;}
public void setWhip(boolean whip) {this.whip = whip;}
public void 기타_메서드들(){}
}
이렇게 구현하는 경우, 앞서 모든 경우의 클래스를 만들었을 때 보다 클래스의 수가 현저히 적습니다.
class DarkRoast{
public int getCost(){
return super.getCost() + 0.33;
}
}
그렇다면, 위 구현은 어떤 문제점이 있을까요??
- 첨가물 가격이 바뀔 때마다 기존 코드를 수정해야합니다.
- 첨가물의 종류가 많아지면 새로운 메소드를 추가해야 하고, 슈퍼 클래스의 getCost() 메서드도 고쳐야 합니다.
- 새로운 음료가 출시될 수도 있습니다. 그 중에는 특정 첨가물이 들어가면 안 되는 음료도 있을 것입니다.
- 예를 들어,
아이스 티는 여전히 hasWhip()을 상속 받게 됩니다. - 즉, 일부 서브 클래스에 적합하지 않은 행동이 추가되었습니다.
Duck추상 클래스에 fly()를 추가하면서 서브 클래스인 RubberDuck이 fly() 상속받은 것과 같은 문제입니다.
- 예를 들어,
더블 모카를 주문할 수 없습니다.
상속이 강력하기는 하나,
상속을 사용한다고 해서 무조건 유연하거나 관리하기 쉬운 디자인이 만들어지지는 않습니다.
서브클래스를 만드는 방식으로 행동을 상속 받으면, 그 행동은 컴파일 시점에 완전히 결정됩니다.
게다가 모든 서브 클래스에서 똑같은 행동을 상속 받아야 합니다.
3단계. 데코레이터 패턴을 이용한 주문 시스템
하지만, 구성, 위임으로 객체의 행동을 확장하면 실행 중에 동적으로 행동을 설정할 수 있습니다.
따라서, 객체를 동적으로 구성하면, 기존 코드를 고치는 대신 새로운 코드를 만들어서 기능을 추가할 수 있습니다.
기존 코드는 건드리지 않기 때문에 코드 수정에 따른 버그나 의도하지 않은 부작용을 원천봉쇄 할 수 있습니다.
OCP(Open-Closed Principle)는 정말 중요한 디자인 원칙 중 하나입니다.
디자인 원칙
클래스는확장에는 열려 있어야 하지만,변경에는 닫혀 있어야 한다.
우리의 목표는 기존 코드를 건드리지 않고 확장으로 새로운 행동을 추가하는 것입니다.
이후 배울 데코레이터 패턴도 OCP 원칙을 준수합니다.
하지만, 무조건 OCP를 적용한다면, 필요 이상으로 복잡하고 이해하기 힘든 코드를 만들게 되는 부작용이 발생할 수 있으니 주의해야합니다.
❓ 확장에는 열려 있고, 변경에는 닫혀 있다고요? 어떻게 2가지 조건을 동시에 만족할 수 있죠?
✋ 코드를 변경하지 않아도 시스템을 확장하게 해주는 기발한 객체지향 기법은 많습니다.
옵저버 패턴에서도, 옵저버를 새로 추가하면 subject에 코드를 추가하지 않으면서도 얼마든지 확장할 수 있습니다.
❓ 모든 부분에서 OCP를 준수하려면 어떻게 해야 하나요?
✋ 보통 그렇게 하는 것은 불가능합니다. OCP를 준수하는 객체지향 디자인을 만드려면 적지 않은 시간과 노력이 필요합니다.
디자인의 모든 부분을 깔끔하게 정돈할 만큼 여유가 있는 상황도 흔치 않습니다. (게다가 그렇게 할 필요가 없습니다.)
OCP를 지키다 보면 새로운 단계의 추상화가 필요한 경우가 종종 있는데, 추상화를 하다보면 코드가 복잡해집니다.
그래서 가장 바뀔 가능성이 높은 부분을 중점적으로 살펴보고 OCP를 적용하는 방법이 가장 좋습니다.
❓ 바뀌는 부분 중에서 OCP를 적용할만큼 중요한 부분을 어떻게 골라낼 수 있죠?
✋ 객체지향 시스템 디자인 경험과 지금 건드리고 있는 분야의 지식이 많다면 쉽게 구분할 수 있습니다.
여러 디자인을 살펴보면 바뀌는 부분 가운데 중요한 부분을 골라내는 안목이 높아집니다.
2단계에서는 문제점이 발견되었습니다.
이번 단계에서는 음료를 첨가물로 장식(decorate) 해보겠습니다.
먼저, 데코레이터의 구조를 먼저 살펴보면 아래와 같습니다.
주문 시스템에서 Component는 Beverage를 의미하고, Decorator는 첨가물을 의미합니다.
ConcreteComponentA/B는 DarkRoast와 같은 음료를 의미하고,
ConcreateDecoratorA/B는 두유, 휘핑크림과 같은 첨가물을 의미합니다.
중요한 점
- Component는 직접 쓰일 수도 있고, Decorator에 감싸여 쓰일 수도 있다.
- Decorator는 자신이 장식할 구성 요소(ex. DarkRoast)와 같은
인터페이스또는추상 클래스를 구현한다. - Decorator는 자신이 장식할 Component 객체를 갖는다.
- 각 ConcreteComponent는 Component를, 각 ConcreteDecorator는 Decorator를 확장한다.
- 데코레이터가 새로운 메서드를 추가할 수도 있습니다. 하지만, 일반적으로 새로운 메소드를 추가하는 대신 Component에 있던 메서드를 별도의 작업으로 처리해서 새로운 기능을 추가합니다.
일단은 이러한 구조라는 것만 봐주시면 됩니다.
다음으로, 주문 시스템에 Decorator 패턴을 적용한 모습을 보겠습니다.
Beverage는 Component에 대응되고, BeverageCondiment는 Decorator에 대응됩니다.
BeverageCondiment는 자신이 장식할 Beverage를 필드로 갖고 있어요.
아 그래서 도대체 어떻게 동작하는건데~?
어떤 고객이 모카와 휘핑크림을 추가한 다크 로스트 커피를 주문한다면, 다음과 같이 장식할 수 있습니다.
DarkRoast객체를 가져온다.Mocha객체로 장식한다.Whip객체로 장식한다.getCost()메서드로 첨가물의 가격을 계산한다.
그림으로 다시 살펴보겠습니다.
DarkRoast객체에서 시작합니다.
DarkRoast는 Beverage로부터 상속받으므로, 음료의 가격을 계산하는 메서드를 갖고 있습니다.
2. 고객이 모카를 추가했으니까 Mocha 객체를 만들고, 그 객체로 DarkRoast를 감쌉니다.
여기서 Mocha 객체는 BeverageCondiment의 서브클래스이면서, Beverage의 서브클래스입니다.
따라서, Mocha에도 getCost() 메서드가 있습니다.
3. 고객이 휘핑크림도 추가했으니까 Whip 데코레이터로 Mocha를 감쌉니다.
Whip도 마찬가지로, cost() 메서드를 갖고 있습니다.
4. 가격을 계산합니다.
가격을 구할 때는 가장 바깥쪽에 있는 데코레이터인 Whip의 getCost()를 호출하면 됩니다.
그러면 Whip은 그 객체가 장식하고 있는 객체에게 가격 계산을 위임합니다.
가격이 구해지고 나면, 계산된 가격에 휘핑크림의 가격을 더한 다음 그 결과값을 리턴합니다.
코드로 보면 아래와 같습니다.
class Whip implements BeverageCondiment{
private final Beverage beverage;
public int getCost(){
return beverage.getCost() + .33;
}
}
Beverage beverage = new Whip(new Mocha(new DarkRoast()));
System.out.println(beverage.getCost()) // 1.29
지금까지 배운 내용을 한 번 정리해보겠습니다.
- 데코레이터의 슈퍼 클래스는 자신이 장식하고 있는 객체의 슈퍼 클래스와 같다.
- 한 객체를 여러 개의 데코레이터로 감쌀 수 있다.
- 데코레이터는 자신이 감싸고 있는 객체와 같은 슈퍼클래스를 갖고 있기에 원래 객체(싸여있는 객체)가 들어갈 자리에 데코레이터 객체를 넣어도 상관없다.
- 데코레이터는 자신이 장식하고 있는 객체에게 어떤 행동을 위임하는 일 말고도 추가 작업을 수행할 수 있다.
- 객체는 언제든지 감쌀 수 있으므로 실행 중에 필요한 데코레이터를 마음대로 적용할 수 있다.
❓ CondimentDecorator에서 Beverage 클래스를 확장하고 있는데, 그럼 상속 아니야..?
✋ 상속을 사용하고 있는 것은 맞습니다.
하지만, 형식을 맞추기 위해 사용한 것이지, 행동을 물려받기 위해 상속을 사용한 것은 아니에요.
어떤 구성 요소를 가지고 데코레이터를 만들 때, 새로운 행동을 추가합니다.
슈퍼 클래스로부터 행동을 상속 받아서 얻는 것이 아니라 객체를 구성해서 새로운 행동을 얻는 거죠.
만약 상속만 써야 했다면, 행동이 컴파일시에 정적으로 결정되어 버리지만,
객체 구성을 이용하고 있기 때문에 실행 중에 데코레이터를 조합해서 사용할 수 있기 때문에,
새로운 첨가물이 추가되어도 유연성을 잃지 않을 수 있어요.
❓ 구성 요소의 형식만 상속하면 되는 거라면 Beverage 클래스를 왜 인터페이스로 만들지 않고 추상 클래스로 만든 건가요?
✋ Beverage 클래스를 받았을 때부터 추상 클래스였기 때문이에요.
사실 인터페이스를 쓰면 되지만, 기존 코드를 고치는 일은 될 수 있으면 피하는 게 좋으니까 추상 클래스를 써도 되는 상황이라면,
그냥 추상 클래스만 가지고 작업을 하는 게 나을 수도 있어요.
데코레이터 패턴을 적용한 코드는 아래 깃헙 주소에서 확인하실 수 있습니다!
GitHub - rlfalsgh95/design-pattern
Contribute to rlfalsgh95/design-pattern development by creating an account on GitHub.
github.com
데코레이터의 적용된 예 : 자바 I/O
java.io는 데코레이터 패턴을 바탕으로 만들어졌어요.
파일에서 데이터를 읽어오는 스트림에 기능을 더하는 데코레이터를 사용하는 객체는 보통 다음과 같은 형식으로 구성됩니다.
FileInputStream을 데코레이터로 장식할 예정입니다.
자바 I/O 라이브러리는 FileInputStream, StringBufferInputStream 등 다양한 구성 요소를 제공합니다.
이는 모두 바이트를 읽어들이는 구성 요소 역할을 합니다.
BufferedInputStream은 구상 데코레이터입니다.
FileInputStream에 입력을 미리 읽어서 더 빠르게 처리할 수 있게 해주는 버퍼링 기능을 더해 주는 역할을 합니다.
ZipInputStream도 구상 데코레이터입니다.
zip 파일에서 데이터를 읽어올 때, 그 속에 들어있는 항목을 읽는 기능을 더해주죠.
java.io 패키지의 구조는 아래와 같아요.
굉장히 복잡하죠.
InputStream만 떼어서 한 번 보겠습니다.
어디서 많이 본 구조 아닌가요?
여기서 InputStream은 Component 역할을, FileInputStream은 Decorator의 역할을 수행합니다.
구조가 단순히 이러한 모양을 갖고 있다고 데코레이터 패턴은 아니에요.
구상 데코레이터인 FileInputStream과 BufferedInputStream의 코드를 보겠습니다.
FileInputStream은 InputStream이면서, InputStream을 필드로 갖고 있습니다.
BufferedInputStream은 생성자 파라미터로 InputStream을 받아서 FileInputStream을 초기화 하고 있습니다.
BufferedInputStream는 필드로 갖고 있는 in의 기능에 더해서 버퍼링 기능을 확장한 클래스라 보시면 되겠습니다.
그럼 이 InputStream은 어떻게 사용할까요?
알고리즘 문제를 풀 때, 버퍼링 기능을 이용하여 Scanner보다 더 빠르게 입력 받기 위해 아래와 같은 방식을 사용해보신 분들도 많을 거에요.
저도 문제를 풀 때는 몰랐지만, 데코레이터 패턴이라고 하더라구요.
InputStreamReader input = new InputStreamReader(new BufferedInputStream(System.in));
Reader/Writer 스트림도 InputStream과 거의 똑같이 디자인 되어 있습니다.
자바 I/O를 보면 잡다한 클래스가 많고, 복잡하다는 것을 발견할 수 있는데요, 이것이 데코레이터 패턴의 단점 중 하나입니다.
자 그럼 직접 입력 데코레이터를 만들어볼게요!
입력 스트림에 있는 대문자를 전부 소문자로 바꿔주는 데코레이터를 만들어 볼게요.
public class LowerCaseInputStream extends FilterInputStream {
public LowerCaseInputStream(InputStream in){
super(in);
}
// ! 입력 스트림으로부터 다음 byte를 읽는 메서드, 더이상 읽을 byte가 없으면 -1을 반환한다.
@Override
public int read() throws IOException {
int c = in.read();
return (c == -1 ? c : Character.toLowerCase((char)c));
}
// ! 이 입력 스트림에서 최대 len byte의 데이터를 byte 배열로 읽습니다.
// ! 버퍼로 읽은 총 바이트 수 또는 스트림 끝에 도달하여 더 이상 데이터가 없는 경우 -1을 반환한다.
@Override
public int read(byte[] b, int off, int len) throws IOException {
int result = in.read(b, off, len);
for(int i = off; i < off + result; i++){
b[i] = (byte) Character.toLowerCase((char)b[i]);
}
return result;
}
}
read() 메서드는 입력 스트림으로부터 다음 byte를 읽는 메서드에요.
이 메서드 안에서 필드로 갖고 있는 In의 read() 메서드를 호출한 다음,
그 결과를 소문자로 바꿔줌으로써, 소문자 변환 기능을 확장하였습니다.
아래 test.txt 파일을 읽어서 소문자로 바꾸는 코드를 작성해볼게요.
hello. This is ethan, who announced the Decorate Pattern.
class InputTest {
@Test
void test(){
int c;
StringBuilder stringBuilder = new StringBuilder();
try(InputStream in = new LowerCaseInputStream(new BufferedInputStream(new FileInputStream("./src/test/resources/test.txt")))){
while((c = in.read()) > 0){
stringBuilder.append((char) c);
}
}catch (Exception e) {
e.printStackTrace();
}
Assertions.assertEquals(stringBuilder.toString(), "hello. this is minho gil, who announced the decorate pattern.");
}
}
FileInputStream는 파일에서 byte를 읽고, BufferdInputStream은 버퍼링 기능을 확장하였고,
마지막으로 LowerCaseInputStream은 소문자로 변환하는 기능을 확장하였어요.
결국, 파일에서 읽은 byte를 소문자로 변환하여 가져올 수 있었어요!
이외에도 다른 요구사항이 추가되어도, 데코레이터를 추가함으로써, 기존 코드를 수정하지 않고도 기능을 확장할 수 있습니다.
더블 모카는 어떻게 만들 수 있을까?
class BeverageTest {
@Test
@DisplayName("더블 모카 다크 로스트의 가격은 1.05이다.")
void test1(){
Beverage beverage = new Mocha(new Mocha(new DarkRoast()));
assertEquals(beverage.getCost(), 1.05);
}
}
장/단점?
Pros
- 런타임에 동작을 수정할 수 있다.
- 데코레이터는 상속보다 유연한 방식으로 기능을 추가할 수 있습니다. 즉, 데코레이터는 객체 간의 결합도를 낮추며, 객체 지향 설계의 원칙 중 하나인 개방-폐쇄 원칙(OCP)을 따릅니다.
- 높은 유연성을 갖고 있어, 유지보수가 용이하다. (서브 클래스를 이용하여 기능을 확장할 수 있다.)
- 기존 클래스를 수정하지 않고도 새로운 기능을 추가할 수 있습니다. 이는 코드의 유지보수와 확장성을 높여줍니다.
cons
- 데코레이터 패턴을 사용하면, 클래스 계층 구조가 매우 복잡해질 수 있습니다. 이는 설계 과정에서 클래스 계층 구조를 잘 고려해야 함을 의미합니다.
- 데코레이터 패턴을 구현하는 과정에서, 많은 중복 코드가 발생할 수 있습니다. 이는 코드 가독성을 떨어뜨릴 수 있습니다.
- 데코레이터 패턴이 중첩되어 사용될 경우, 코드가 복잡해질 수 있습니다. 이는 디버깅과 유지보수에 어려움을 더할 수 있습니다.
출처
- Head First Design Pattern Chapter. 3
- https://neillmorgan.wordpress.com/2010/02/07/decorator-pattern-pros-and-cons/
'스터디' 카테고리의 다른 글
싱글턴 패턴 (Singleton Pattern)이란? (2) | 2023.04.04 |
---|---|
22.11.15 SSAFY 스터디 CS 발표 - 디자인 패턴 (전략 패턴) (0) | 2022.12.01 |
22.11.8 SSAFY 스터디 CS 발표 - 디자인 패턴 (템플릿 메서드 패턴) (0) | 2022.11.07 |
22.10.11 SSAFY 스터디 CS 발표 - 컴퓨터 구조, 기억장치 (0) | 2022.10.11 |
22.09.22 SSAFY 스터디 CS 발표 - 컴퓨터 구조 (0) | 2022.09.22 |