[디자인 패턴] 3. the Decorator Pattern

StandingAsh·2024년 10월 17일

참고: Head First Design Patterns

가정


커피숍 사업을 위한 프로그램을 개발중이다. 음료 객체를 위한 구조를 설계해보자.

abstract class Beverage {
	private description; // 멤버 변수
    
	getDescription();
    cost();
}

위와 같이 추상클래스 Beverage를 만들고, 모든 음료들은 이를 상속하도록 만들어보자.

class HouseBlend extends Beverage { cost(); }
class DarkRoast extends Beverage { cost(); }
class Decaf extends Beverage { cost(); }
class Espresso extends Beverage { cost(); }
...

모든 Beveragecost()를 구현해 음료의 가격을 리턴한다.

자, 얼핏 보기에는 Java가 제공하는 추상클래스를 용도에 맞게 잘 사용하고 있는 듯 하다. 음료들의 공통된 멤버와 메소드를 추출하여 추상클래스로 묶고, 각 음료마다 이를 상속하여 구현. 이 방식으로 그 어떤 음료든 구현할 수 있을 것 같다. 그렇지 않은가?

문제점


위에서 설계한 구조대로라면, 음료마다 Beverage를 상속해서 구현해줘야 한다. 여기까지는 그럴 수 있어 보인다.
그런데, 옵션에 대한 관리가 전혀 되지 않는다. 그 말은, 모든 옵션의 경우의 수 만큼 클래스를 만들어야 한다.

대부분 커피숍들은 위 예시처럼 서너 종류의 음료만 팔지 않는다. 일례로 커피 프렌차이즈 '메가커피'의 경우 커피 메뉴만 17가지이다. 논커피 음료(혹은 Beverage)의 경우 14가지, 차는 11가지. 여기에 5가지의 주스, 11가지의 에이드, 스무디 & 프라페는 무려 20가지나 된다. 총 78가지 음료가 된다.

여기에 , 아이스 옵션까지 추가한다면, 물론 일부 음료는 핫, 아이스 중 하나만 선택 가능하지만, 그래도 우리의 Beverage 개수는 109가지로 늘어난다.

class AmericanoHot extends Beverage {}
class AmericanoIced extends Beverage {}
class AndSoMuchMore ....

109가지로 끝이라면 참 좋겠다만, 음료 옵션은 사이즈, 토핑, 원두 종류, 디카페인, 샷 추가 아샷추, 우유 종류, 제로 슈거 등 정말 수도 없이 많다.

class AmericanoHotSmall extends Beverage {}
class AmericanoHotSmallDoubleShot extends Beverage {}
class AmericanoHotSmallWIthIcedTeaShot extends Beverage {}
class AmericanoHotSmallDecaf extends Beverage {}

class AmericanoHotRegular extends Beverage {}
class AmericanoHotRegularDoubleShot extends Beverage {}
...
  • 단순히 3가지 사이즈에 음료마다 평균적으로 6가지 옵션 경우의 수가 발생한다고 계산해봐도 109 x 3 x 6 ... 무려 2000가지에 근접한다.

이것만 문제인가? 만약, 우유 가격이 오른다면? 우유가 들어가는 모든 음료를 하나하나 찾아서 cost()를 수정해줘야 한다.
즉, 유지보수 측면에서도 최악이다.

해결책?

메뉴 자체가 많은 것은 그렇다 치고, 각 옵션의 경우의 수 마다 클래스를 만드는 것정말 바보같은 짓이다.
그렇다면, 옵션 여부를 인스턴스 변수로 만들고 상속시켜서 관리하는 것은 어떨까?

abstract class Beverage {
	private String description;
    private boolean milk;
    private boolean soy;
    private boolean mocha;
    private boolean whip;
    
    public boolean hasMilk();
    public boolean hasSoy();
    public boolean hasMocha();
    public boolean hasWhip();
}

이런식으로 Beverage를 만듦으로써 모든 경우의 수의 클래스를 만드는 불상사는 피하게 됐다. 그치만, 이걸로는 뭔가 석연찮다. 가령, 새로운 옵션이 추가되기라도 한다면 필연적으로 Beverage의 코드를 뜯어 고쳐야 한다. 이는 모든 서브클래스에게 영향이 갈 것이고, 결코 우리가 원하는 방향이 아니다.

  • 더 좋은 방법이 없을까?

OCP (the Open - Closed Principle)


※ [디자인 패턴] 2. the Observer Pattern 보러 가기

우리는 이전 게시글에서 OCP에 대해 간략히 다루었다.

  • 코드를 수정하여야 하는 상황은 최소화하고, 코드의 확장에 있어서는 유연하도록 프로그래밍하라.

기능을 확장해야 할 때, 기존 코드를 수정하는 대신 새로운 코드를 추가해서 구현할 수 있도록 설계를 해야 한다는 의미를 담고있다. 이는 객체지향 프로그래밍에서 가장 중요한 디자인 원칙 중 하나이다.

대부분의 카페는 메뉴의 변동이 잦다. 인기 없는 메뉴가 사라지고 신메뉴가 등장하기도 하지만, 짧은 주기로 이벤트 메뉴나 시즌 메뉴를 출시하기도 한다. 즉, 우리의 프로그램은 OCP가 중요한 케이스이다.

다만, OCP가 프로그램의 모든 부분에서 필요한 것은 아니다. OCP의 남용은 코드가 과도하게 복잡하거나 난해해지는 결과를 초래할 수 있다. 따라서, 설계 단계에서 OCP를 적용할 영역을 신중하게 골라야한다.

데코레이터 패턴


Decorate[동사] : 장식하다, 꾸미다

Decorate의 사전적 정의이다. 이것이 정확히 우리가 할 일이다. 우리는 Beverage 하나를 잡고, 런타임에 고객이 원하는 메뉴의 재료(Condiment)로 음료(Beverage)를 장식할 것이다.

  • 즉, 이 모든 과정이 동적으로 이루어진다.

예를 들어, 고객이 'Dark Roast with Mocha and Whip'을 주문했다고 가정해보자.
우리는 DarkRoast 객체를 Mocha 객체로 장식, 그 다음엔 Whip 객체로 장식한 후 각 객체의 cost() 메소드를 호출해 최종 가격을 산출한다.

클래스 다이어그램

Component라고 부르는 최초의 추상 타입이 있다. 우리 프로그램에서는 Beverage가 컴포넌트에 해당한다. 컴포넌트는 추상클래스가 될 수도 있고, Java에서는 인터페이스가 될 수도 있다.

ConcreteComponent는 컴포넌트의 구현체이다. 이 객체가 바로 데코레이터에 의해 장식될 객체이다.

Decorator는 장식 할 컴포넌트와 같은 인터페이스(혹은 추상클래스)를 구현한다. 데코레이터는 컴포넌트에 대해 HAS-A 관계이다. 즉, 데코레이터의 구현체 ConcreteDecorator는 컴포넌트를 참조할 인스턴스 변수를 갖는다.

적용

우선, 컴포넌트와 데코레이터의 뼈대는 아래와 같이 구현할 수 있겠다. Beverage가 컴포넌트, 음료에 추가될 옵션인 CondimentDecoratorBeverage를 장식해 줄 것이다.

abstract class Beverage {
	String description = Unknown Beverage";
    
	public String getDescription();    
    public int cost();
}
abstract class CondimentDecorator extends Beverage {
	public String getDescription() { return description; }
}
  • Beverage의 코드는 데코레이터 패턴을 적용하기 전과 차이가 없다. OCP에 따라 코드의 수정은 최소화!

다음으로, ConcreteComponent에 해당하는 음료 클래스들을 만들어보자.

class Americano extends Beverage {
	public Americano() {
    	description = "Americano";
    }
    
    public int cost() { return 1500; }
}

class Frappuccino extends Beverage {
	public Frappuccino() {
    	description = "Frappuccino";
    }
    
    public int cost() { return 3000; }
}
...

생성자를 통해 Beverage에게서 물려받은 description을 음료 설명에 알맞게 초기화해준다. 그 후, 음료 값을 리턴하도록 cost() 함수를 구현해주면 끝이다.

마지막으로, ConcreteDecorator을 만들어보자.

class JavaChip extends CondimentDecorator {
	Beverage beverage;
    
    public Mocha(Beverage beverage) {
    	this.beverage = beverage;
    }
    
    public String getDescription() {
    	return beverage.getDescription() + " + Java Chip";
    }
    
    public int cost() {
    	return beverage.cost() + 500;
    }
}

장식해 줄 음료를 생성자를 통해 넘겨 받는 식으로 구현하였다. getDestription()은 넘겨 받은 음료의 .getDescription()을 호출하고, 거기에 옵션 내용을 덧붙인다. 마찬가지로, cost()도 장식 대상의 가격을 꺼내와서 자기 자신의 가격을 더해준다.

정리


데코레이터 패턴을 적용하여 구현한 음료들이 실제로 어떻게 장식되는지 간단히 살펴보자.

Beverage beverage = new Frappuccino();
beverage = new Mocha(beverage);
beverage = new JavaChip(beverage);
beverage = new Whip(beverage);

먼저 Frappuccino에 해당하는 Beverage를 생성했다. 그리고는 Mocha라는 데코레이터로 Frappuccino감싸버리고는, 이를 다시 원본 Beverage에 대입한다. 고객의 옵션 요구사항마다 이 작업을 반복한다. 최종적으로 beverageWhip(JavaChip(Mocha(Frappuccino()))라고 할 수 있다.

  • 그렇다면, 마지막 Whip() 장식까지 받은 beveragecost() 메소드를 호출한다면 어떻게 될까?

순서대로 Frappuccinocost()가 먼저 호출되고, 그 리턴값이 Mochacost()에 더해지고, 또 JavaChip에 더해지고 마지막에 Whip에 더해진 값을 리턴한다. 즉, 프라푸치노 가격에 모카, 자바칩, 휘핑 옵션이 더해진 가격을 얻을 수 있다. 옵션에 따라 데코레이터가 원본을 겹겹이 감싸주면서 우리는 더이상

Beverage beverage = new FrappucccinoWithMochaWithJavaChipWithWhip()

같은 해괴망측한 코드를 쓰지 않아도 훌륭히 "프라푸치노에 모카, 자바칩, 휘핑 추가" 객체를 다룰 수 있게 되었다.
다만, 짐작했겠지만 매번 component = new Decorator(component)을 하는 방식은 좋은 방법은 아니다.

  • 후에 배울 팩토리 패턴, 빌더 패턴에서 데코레이터를 이용하는 더 좋은 방법을 배울 것이다.

더 나아가...


Java가 제공하는 I/O 클래스들도 사실은 데코레이터 패턴으로 설계되어있다!

  • InputStream 클래스 다이어그램을 한번 살펴보자.

InputStream이 컴포넌트, FilterInputStream이 데코레이터라는 점을 빠르게 알아챘다면 우리가 만든 커피숍 프로그램이랑 크게 다르지 않은 구조라는 점을 느꼈을 것이다.

  • 가벼운 예시로, 대문자를 소문자로 변환해주는 InputStream을 구현해보자.
public class LowerCaseInputStream extends FilterInputStream {
    public LowerCaseInputStream(InputStream in) {
        super(in);
    }
 
    public int read() throws IOException {
        int c = super.read();
        return (c == -1 ? c : Character.toLowerCase((char)c));
    }
        
    public int read(byte[] b, int offset, int len) throws IOException {
        int result = super.read(b, offset, len);
        for (int i = offset; i < offset + result; i++)
            b[i] = (byte)Character.toLowerCase((char)b[i]);
        return result;
    }
 }

FilterInputStream을 상속받고 read() 메소드를 오버라이딩하였다. 커피숍의 Mocha 또는 Whip같은 역할을 할 것이다.

int c;
try {
	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();
}

위 코드에서 볼 수 있듯, FileInputStreamBufferedInputStream으로 장식되고, 마지막엔 우리가 구현한 LowerCaseInputStream으로 장식되었다. 사실상 우리가 Beverage를 다루었던 방법과 동일한 것을 알 수 있다.

우리는 이제 데코레이터 패턴을 배웠으므로, 앞으로 InputStream을 더 잘 장식해가면서 사용할 수 있게 되었다.

profile
우당탕탕 백엔드 생존기

0개의 댓글