디자인패턴

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

hongyb 2024. 8. 6. 23:47

데코레이터 패턴과 새로운 객체지향 원칙을 알아보자

데코레이터 패턴

객체지향 원칙

바뀌는 부분은 캡슐화한다.

상속보다는 구성을 활용한다.

구현보다는 인터페이스에 맞춰서 프로그래밍한다.

상호작용하는 객체 사이에서는 가능하면 느슨한 결합을 사용해야 한다.

클래스는 확장에는 열려있어야 하지만 변경에는 닫혀 있어야 한다.

 

데코레이터 패턴

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

 

요구사항 및 문제점

커피전문점에서 HouseBlend, DarkRoast, Decaf와 같은 커피를 판다고 가정해 보자. 주문 시스템 클래스는 다음과 같다.

 

Beverage는 음료를 나타내는 추상 클래스이다. cost() 메서드는 추상 메서드이다. 서브 클래스에서 메서드를 구현해야 한다. description는 인스턴스 변수로 각 서브클래스에서 설정되며, 음료 설명이 저장된다. getDescription() 메서드를 호출해서 description 변수를 확인할 수 있다. 

 

고객은 커피를 주문할 때 우유, 두유, 모카, 휘핑크림와 같은 추가 메뉴 등을 얹기도 한다. 이때마다 주문 시스템을 어떻게 구현해야 할까? HouseBlendWithMilk, DarkRoastWithWhip, DecafWithMocha와 같이 모든 클래스를 구현해야 한다. 이는 너무 많은 클래스를 생성하게 된다. 

 

Beverage 클래스에 hasMilk(), setMilk, hasMocha, setMocha(), hasWhip(), setWhip()과 같은 메서드를 구현하는 아이디어로 문제를 해결할 수 있지만 첨가물 종류가 새로 생기거나, 첨가물 가격이 바뀔 때마다 기존 코드를 수정해야 하는 문제가 발생한다. 또한 추가 메뉴를 2번 추가하는 것을 구현하기에 복잡해진다.(ex. 모카 2번 추가)

 

이 문제를 어떻게 해결해야 할까?

 

해결 아이디어

이 문제를 해결하기 위해 OCP(Open-Closed Principle)이라는 디자인 원칙을 알아야 한다. '클래스는 확장에는 열려 있어야 하지만 변경에는 닫혀 있어야 한다.'는 원칙이다. 모순 처럼 보이지만 코드를 수정하지 않고 코드를 확장할 수 있게 하는 기법이 있다. 이번 시간에 데코레이터 패턴으로 OCP를 준수하는 방법을 알아보도록 하겠다.

 

OCP 패턴을 적용할 때 주의해야 하는 부분이 있다. 모든 부분에서 OCP를 준수하는 객체지향 디자인을 만들려면 적지 않은 시간과 노력이 필요하다. 디자인의 모든 부분을 깔끔하게 만들 만큼 여유가 있지 않기도 하다.(그렇게 할 필요도 없다.) 또한 OCP를 지키다 보면 새로운 단계에 추상화가 필요한 경우가 있는데, 코드가 복잡ㅂ해지기도 한다. 그래서 우리가 디자인한 것 중에서 가장 바뀔 가능성이 높은 부분을 중점적으로 살펴보고 OCP를 적용하는 것이 좋다.

 

데코레이터 패턴

OCP를 활용해 데코레이터 패턴을 알아보자 데코레이터 패턴(Decorator Pattern)은 객체 지향 디자인 패턴 중 하나로, 객체에 동적으로 새로운 행동을 추가하는 방법을 제공하는 패턴이다. 이 패턴은 상속을 사용하지 않고도 객체의 기능을 유연하게 확장할 수 있다.

 

음료에 첨가물을 장식(decorate)한다고 가정해보자. 다음 과정을 거쳐 장식할 수 있다.

1. DarkRoast 객체를 가져온다.

2. Mocha 객체로 장식한다.

3. Whip 객체로 장식한다.

4. cost() 메서도를 호출한다. 이때 첨가물의 가격을 계산하는 일은 해당 객체에게 위임한다. 

 

다음 그림을 통해 과정을 확인할 수 있다.

 

데코레이터 패턴을 적용하기 위해 다음 내용을 알아야 한다.

1. 데코레이터(whip, mocha)의 슈퍼클래스는 자신이 장식하고 있는 객체(DarkRoast)의 슈퍼 클래스(Beverage)와 같다. 

2. 한 객체를 어려 개의 데코레이터로 감쌀 수 있다.

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

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

 

데코레이터 패턴을 보여주는 다이어그램을 그려보도록 하겠다.

 

이를 반영하여 Beverage 클래스를 설계하겠다.

 

코드로 구현해 보겠다.

 

Beverage 클래스는 다음과 같다.

package decorator;

public abstract class Beverage {
    protected String description = "no description";

    public String getDescription() {
        return description;
    }

    public abstract double cost();
}

 

데코레이터 추상 클래스는 다음과 같다.

package decorator;

public abstract class CondimentDecorator extends Beverage {
    public Beverage beverage;

    public abstract String getDescription();
}

 

Beverage를 상속받는 음료코드를 구현하겠다. 모든 음료를 구현하지 않고 HouseBlend 1개만 구현하도록 하겠다.

package decorator;

public class HouseBlend extends Beverage {
    public HouseBlend() {
        description = "house blend coffee";
    }

    @Override
    public double cost() {
        return 5.0;
    }
}

 

데코레이터 코드는 다음과 같다.

package decorator;

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

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

    @Override
    public String getDescription() {
        return beverage.getDescription() + " + whip";
    }
}
package decorator;

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


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

    @Override
    public String getDescription() {
        return beverage.getDescription() + " + milk";
    }
}

 

테스트 코드 및 결과물은 다음과 같다.

package decorator;

public class main {
    public static void main(String[] args) {
        Beverage beverage = new HouseBlend();
        System.out.println(beverage.getDescription() + ", " + beverage.cost());

        beverage = new Milk(beverage);
        beverage = new Milk(beverage);
        System.out.println(beverage.getDescription() + ", " + beverage.cost());

        beverage = new Whip(beverage);
        beverage = new Whip(beverage);
        System.out.println(beverage.getDescription() + ", " + beverage.cost());
    }
}

 

결론

처음 다이어그램을 보고 구현할 때 당황할 것이다. 데코레이터가 슈퍼클래스(Beverage)를 상속받고 또 구성을 사용하기 때문이다. 이렇게 설계한 것에는 이유가 있다. 

 

데코레이터가 슈퍼클래스를 상속하는 이유는 형식을 맞추기 위해서이다. beverage = new Whip(beverage)처럼 구성 요소가 들어갈 자리에 자기가 들어갈 수 있어야 하기 때문에 상속을 받는 것이다. 

행동을 추가하기 위해서는 구성을 활용하는 것이다. 인스턴스 변수를 활용하여 행동을 추가한다. 구성을 사용하면 실행 중에 데코레이터를 마음대로 조합해서 사용할 수 있으며 새로운 행동을 추가할 수 있는 장점을 갖게 된다.

 

데코레이터가 적용된 대표적인 예시는 자바 I/O이다. InputStream을 슈퍼 클래스로 설정하고 FileInputStream, StringBufferInputStream, 등은 구성 요소 역할을 한다. FileInputStream은 추상 데코레이터 역할을, PushbackInputStream, BufferedInputStream 등은 데코레이터 역할을 한다. 이처럼 다양한 예제에서 데코레이터 패턴을 확인할 수 있다.