[Design Pattern] - 데코레이터 패턴

janjanee·2021년 2월 19일
0

design patterns

목록 보기
1/1
post-thumbnail

자바 I/O에 대해 공부하다가 I/O 패키지의 많은 부분들이 데코레이터 패턴을 이용하여 만들어졌다는 것을 알았다.
그래서 데코레이터 패턴이 뭐지? 하는 궁금증에 데코레이터 패턴에 대해서도 조금 공부해봤다.

데코레이터 패턴
객체에 추가적인 요건을 동적으로 첨가한다. 데코레이터는 서브클래스를 만드는 것을 통해서 기능을
유연하게 확장할 수 있는 방법을 제공한다.

당연히 정의만 봐서 무슨 소리인지 이해를 못하겠다.
예제를 보며 차분히 이해해보자.

스타버즈라는 카페가 있다. 스타버즈는 엄청난 급속도로 성장해서 다양한 음료들을 포괄하는 주문시스템을
이제서야 겨우 갖추려고 준비중이다.

처음 사업 시작 시 클래스들은 다음과 같이 구성되어 있었다.

  • Beverage는 음료를 나타내는 추상 클래스이며, 모든 음료는 이 클래스의 서브 클래스가 된다.
  • description 인스턴스변수는 각 서브클래스에서 설정되고, "가장 훌륭한 다크 로스트 커피" 같은 음료 설명이 적힌다.
  • cost() 메소드는 추상메소드 이다. 따라서 모든 서브클래스에서 음료의 가격을 리턴하는 cost() 메소드를 구현해야한다.

커피를 주문할 때 스팀 우유, 두유, 모카(초코), 휘핑과 같은 토핑을 변경할 수 있는데 이런 경우
기존 구성을 어떻게 변경해야 할까?

처음 스타버즈는 이렇게 해보기로 했다.

  • Beverage라는 기본 클래스의 각 음료에 우유, 두유, 모카, 휘핑이 들어가는지 여부를 나타내는 인스턴스 변수를 추가
  • cost()를 추상클래스로 하지 않고, 구현해 놓기로 한다. 각 음료 인스턴스마다 추가 토핑에 해당하는
    추가 가격까지 포함시킬 수 있도록 말이다.
    • 이렇게 하더라도 서브클래스에서 cost()를 오버라이드 해야한다.
    • 오버라이드 할 때 super를 호출하여 추가 비용을 합친 총 가격을 리턴하는 방식으로.

와 뭔가 잘 될 것 같다! 라고 생각할 수 있지만 이 구조에는 몇 가지의 문제점이 있을 수 있다.

  1. 토핑 가격이 바뀔 때마다 기존 코드를 수정해야함
  2. 토핑 종류가 많아지면 새로운 메소드를 추가하고, 수퍼클래스의 cost() 메소드도 고쳐야 함
  3. 새로운 음료가 출시되었다! 루이보스 차! 루이보스 차에는 휘핑 같은 토핑이 들어가서는 안되는데 불필요한 hasWhip() 같은 메소드를 여전히 상속받게된다.
  4. 더블 모카를 주문한 경우는 어떻게 될까???

이런 문제점으로 인해 바로 위의 구조인 상속을 써서 음료 가격과 토핑 가격을 합한 총 가격을 계산하는 방법은
그리 좋은 방법이 아니다.

스타버즈는 다음 대안으로 다음과 같이 생각해본다. 우선 특정 음료에서 시작해서, 토핑으로 그 음료를 장식(decorate) 할 것이다. 예를 들어 손님이 모카하고 휘핑을 추가한 에스프레소를 주문한다면 다음과 같다.

  • Espresso 객체를 가져온다.
  • Mocha 객체로 장식한다.
  • Whip 객체로 장식한다.
  • cost() 메소드를 호출한다. 이 때 토핑 가격을 계산하는 일은 해당 객체들에게 위임된다.

그러면 객체를 어떻게 "장식" 할 수 있을까?

1️⃣ Espresso 객체에서 시작한다.

  • Beverage를 상속받기 때문에 cost() 메소드를 가짐

2️⃣ 모카 토핑을 주문했으니 Mocha 객체를 만들고 그 객체로 Espresso를 감싼다.

  • Mocha 객체는 데코레이터이다. 이 객체의 형식은 이 객체가 장식하고 있는 객체(Beverage)를 반영한다.
    • 반영(mirror)한다는 것은 "같은 형식을 갖는다"는 뜻으로 이해
    • Mocha에도 cost() 메소드가 있고, 다형성을 통해 Mocha가 감싸고 있는 Espresso도 Beverage 객체로 간주할 수 있다.
    • Mocha도 Beverage의 서브클래스 형식이다.

3️⃣ 휘핑 크림도 같이 주문했기 때문에 Whip 데코레이터를 만들고 그 객체로 Mocha를 감싼다.

  • Whip도 데코레이터기 때문에 Espresso의 형식을 반영하고, 따라서 cost() 메소드를 가진다.
  • Mocha와 Whip으로 싸여 있는 Espresso는 여전히 Beverage 객체이기 때문에 cost() 메소드 호출을 비롯한
    그냥 Espresso일 때와 같이 모든 행동을 할 수 있다.

4️⃣ 마지막으로 가격을 구한다. 가격을 구할 때는 가장 바깥쪽에 있는 데코레이터인 Whip의 cost()를 호출로 시작한다.

이제 실제 코드를 구현하기 전해 조금 더 이해하기 편하도록 클래스 다이어그램을 살펴보자.

  • Beverage는 구성요소를 나타내는 Component 추상클래스와 같은 개념이다.
    • 각 구성요소는 직접 쓰일 수도 있고 데코레이터로 감싸져서 쓰일 수도 있다.
  • 왼쪽의 커피 종류마다 구성요소를 나타내는 구상 클래스를 하나씩 만든다.
  • ToppingDecorator는 자신이 장식할 구성요소와 같은 인터페이스 또는 추상클래스를 구현한다.
  • Milk, Mocha.. 와 같은 데코레이터에는 그 객체가 장식하고 있는 객체를 위한 인스턴스 변수가 있다.
    • Beverage beverage

이제 실제 코드를 작성하며 앞의 내용들을 더 명확하게 알아보자.

🥤 Beverage 클래스 🥤

public abstract class Beverage {

    private String description = "제목없음";

    public void setDescription(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public abstract int cost();

}
  • 추상클래스이며 cost()는 서브클래스에서 구현할 수 있도록 추상메소드로 작성되어있다.
  • description은 음료의 설명이 들어간다.

🥛 ToppingDecorator 클래스 🥛

public abstract class ToppingDecorator extends Beverage {
    public abstract String getDescription();
}
  • 토핑을 나타내는 추상클래스(데코레이터 클래스)이다.
  • Beverage 객체가 들어갈 수 있도록 Beverage 클래스를 상속받는다.
  • 모든 토핑 데코레이터(Milk, Mocha..)에서 getDescription() 메소드를 새로 구현하도록 추상 메소드로 선언해준다.

☕️ Espresso 클래스 (음료 클래스 구현) ☕️

public class Espresso extends Beverage {

    public Espresso () {
        setDescription("에스프레소");
    }

    @Override
    public int cost() {
        return 4000;
    }
}
  • Beverage 클래스를 상속받는다.
  • 생성자에서 description 값을 에스프레소로 지정
  • 에스프레소 가격을 리턴한다. 이 때 토핑과 관련된 계산은 걱정할 필요가없다. 그저 에스프로 가격만 리턴해두자.
  • 나머지 HouseBlend, Decaf, DarkRoast도 동일하게 만든다.

🍫 Mocha 클래스(토핑 데코레이터 클래스) 🍫

추상 구성요소 (Beverage), 구상 구성요소 (Esppreso), 추상 데코레이터(ToppingDecorator) 까지 만들었으니
마지막으로 구상 데코레이터를 구현하자.

public class Mocha extends ToppingDecorator {

    Beverage beverage;

    public Mocha(Beverage beverage) {
        this.beverage = beverage;
    }

    @Override
    public int cost() {
        return 1000 + beverage.cost();
    }

    @Override
    public String getDescription() {
        return beverage.getDescription() + ", 모카";
    }
}
  • Mocha는 데코레이터 이므로 추상 데코레이터 ToppingDecorator를 상속받는다.
  • Mocha 인스턴스에는 Beverage에 대한 레퍼런스가 들어있다. 이래야 감싸고자 하는 음료를 저장할 수 있다.
  • 위에서 getDescription()을 추상메소드로 만든이유는 여기있다. "에스프레소" 만 들어있으면 어떤 첨가물이
    들어있는지 알 수 없으니 ", 모카"를 덧붙여준다.
  • cost()는 장식하고있는 객체의 가격을 구한 뒤 그 가격에 모카를 추가한 가격을 리턴한다.
  • Soy, SteamMilk, Whip 클래스도 위와 동일하게 작성한다.

이제 준비가 다 됐으니 커피를 주문해보자.

🛎 실행 🛎

public class StarbuzzCoffee {
    public static void main(String[] args) {

        Beverage beverage = new Espresso();
        System.out.println(beverage.getDescription() + " " + beverage.cost() +"원");

        Beverage beverage2 =new DarkRoast();
        beverage2 = new Mocha(beverage2);
        beverage2 = new Mocha(beverage2);
        beverage2 = new Whip(beverage2);
        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() + "원");

    }
}
// 결과
에스프레소 4000원
다크 로스트, 모카, 모카, 휘핑 7000원
하우스 블렌드, 두유, 모카, 휘핑 7200원

첫 번째 에스프레소는 아무것도 들어가지 않는 에스프레소를 주문하고,
두 번째, 세 번째 커피는 각각 토핑을 추가하여 토핑 데코레이터로 감싸서 최종 주문을 할 수 있다.

이 쯤 되니 조금 눈치를 채보면 토핑 데코레이터로 감싸는 부분을 어디서 많이 본 것도 같다?!?!

데코레이터가 적용된 I/O

BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(tempFile));

위에 공부한 자바 I/O에서 많이 본 코드이다. 몰랐지만 열심히 데코레이터를 쓰고있었다!

  • FileOutputStream이 데코레이터로 포장될 구성 요소(ex- Espresso)이다.
    • FileOutputStream은 InputStream을 상속받았는데 InputStream이 추상 구성요소(ex-Beverage)가 된다.
  • BufferedOutputStream은 구상 데코레이터(ex- Mocha)이다.
    • BufferedOutputStream은 FilterOutputStream을 상속받았는데 여기서 FilterOutputStream이 추상 데코레이터(ex- ToppingStream) 역할을 한다.

I/O 데코레이터 실습

데코레이터 패턴도 알았으니 직접 I/O 입력 데코레이터를 만들 수 있다.

👉 입력 스트림에 있는 대문자를 전부 소문자로 바꿔주는 데코레이터를 만들자!

public class LowerCaseInputStream extends FilterInputStream {

    protected 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() 메소드를 구현한다. 각각 byte 값 하나, byte[] 배열을 읽고 각 byte를 검사하여
    대문자이면 소문자로 변환한다.
public class InputTest {
    public static void main(String[] args) {
        int c;

        try (LowerCaseInputStream in =
                     new LowerCaseInputStream(
                        new BufferedInputStream(
                            new FileInputStream("test.txt")))) {

            while((c = in.read()) != -1) {
                System.out.print((char)c);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • 위에서 만든 LowerCaseInputStream을 테스트 해보자

I love pizza, because I just like Pizza.

test.txt 파일의 내용이 아래와 같이 대문자 -> 소문자로 변경된 것을 확인할 수 있다.

i love pizza, because i just like pizza.


데코레이터 패턴을 이용하면 일반적인 상속관계보다 유연하게 기능을 확장할 수 있지만 너무 많은 클래스가
생긴다거나 감싼 구조가 많아지다 보면 구조 때문에 디버깅이 어려워질 수 도 있다.
필요한 부분에 적절하게 사용해야하는데 항상 적절하게? 라는 말은 어렵다🥲

데코레이터 예제 전체 소스코드 링크

References

  • 에릭 프리먼 외 3, 『헤드퍼스트 디자인 패턴』, OREILLY(2005)
  • 출처가 없는 이미지는 직접 그림
profile
얍얍 개발 펀치

0개의 댓글