C++로 Decorating Pattern 이해하기

Kang Chang Hwan·2024년 5월 24일

CS - Design-Pattern

목록 보기
3/6
post-thumbnail

스타벅즈 예시

스타버즈(ㅋㅋ) 커피 주문 시스템을 만드는 예시가 나온다.

Beverage라는 추상 클래스를 만들고 우유, 시럽, 휘핑 등등 모든 음료에 각 첨가물을 더한 케이스를 음료 클래스를 상속해서 사용하고 있는 것이었다.

그래서 이런 아이디어가 나온다.

"아니 ;; 첨가물을 boolean으로 base class에 변수로 추가하고 고객이 각 첨가물을 추가하는지를 추적해서 비용을 계산하고 이를 상속받는 하위 클래스(모든 음료들)는 자기 음료 값만 계산하고 super의 첨가물 비용만 계산해서 더해주면 안되냐?"

소스 코드

Beverage.h

DarkRoast.h

그렇게 설계하면 위와 같이 클래스를 만들 수 있다.

그런데 이 경우에는 프로젝트가 변경될 시 디자인에 영향을 미칠 만한 요소는 다음과 같다.

  • 첨가물 가격이 바뀔 때마다 기존 코드를 수정해야 한다.
  • 첨가물의 종류가 많아지면 새로운 메소드를 추가하고 기존 base class의 코드도 변경해야 한다.
  • 그리고 base class에서 관리하는 첨가물을 필요로 하지 않는 음료의 경우에도 상속을 받는다.
  • ㅋㅋ 그리고 더블 모카를 주문하게 되면...?
    • mocha는 boolean으로 관리하고 있으므로 이를 두 번 더하는 케이스를 생성하고 이러한 모든 경우에 대해서 다 base class의 로직을 건드려야 할 것 같다.

Open-Closed 원칙

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

새로운 행동을 추가할 때 저희 클래스의 확장을 통해 맘껏 하세요~

저희가 버그 고치느라 엄청 애를 먹었거든요... 변경은 안될 것 같으니까 맘에 안들면 매니저 만나고 오세요~

라는 예시가 나와있었다.

즉, 요지는 새로운 행동(변경 또는 요구사항)을 추가할 때는 기존 클래스를 확장하고 대신 기존 클래스의 코드는 변경하지 않도록 하는 것이다.

이러한 원칙을 배우게 되면 오우 이거 무조건 지키면서 해야겠는데...! 라고 의지를 불태울 수 있지만 책에서는 다음과 같이 안내한다.

ㅎㅎ 모든 케이스에 대해서 이 원칙을 지키는 건 불가능하고 변경이 일어날 확률이 제일 높은 부분을 중점적으로 살펴보고 OCP를 적용하는게 제일 좋다고 한다.

Decorator 패턴

Decorator를 Wrapper라고 생각하라고 하는데, 이게 무슨 말일까..?

처음에는 이런 생각을 했다.

흠.. 예를 들어 아이스티에 샷 추가를 한다고 하면 위 방식에선 Beverage 클래스에 샷이라는 첨가물을 추가하고 비용을 계산하는 cost 메서드를 추가하는 방식이었다.

그럼.. 어떻게 이를 구현할 수 있을까? wrapper라고 힌트를 줘서 다음과 같이 생각해볼 수 있었다.

"오히려, 시럽 클래스에 인터페이스로 Beverage 포인터 변수를 선언하고 runtime에 해당 주소에 접근하면 실제 음료(유자차)가 있을 테니 자신의 가격을 알려주고 시럽의 가격과 더하는 방식은 어떨까?"

과연 맞을까..

개념적으로는 이렇게 생겨먹었다. 더 구체적인 설명은 아래를 통해서 살펴보자.

먼저 디자인 원칙인 인터페이스를 중점으로 코딩한다!를 적용해보면

Beverage가 인터페이스 또는 추상 클래스로 존재하고 1) 첨가물과 2) Beverage를 상속받은 다른 음료 간에 즉, 1), 2) 간에 데이터 타입을 교환할 수 있으려면 Beverage를 구현하거나 상속받아야 한다.

앞으로 구현할 코드를 클래스 다이어그램으로 표현하면 위와 같다.

위 예제에서 상속을 통해 해당 문제를 해결했을 때는 Beverage에서 첨가물(휘핑크림, 모카 추가 등)에 대한 관리를 하고 있는 구조였다.

"고객: 하우스 블렌드 커피에 우유 1번, 휘핑 크림 1번, 샷 1번 추가할게요!!"

원래라면 다음과 같이 처리했었다.

"HouseBlend: Beverage님 들으셨죠? 우유 1번, 휘핑 크림1번, 샷 1번 추가하면 총 얼마예요?"

"Beverage: HouseBlend님 잠시만용... 어 ... 샷 ...하 잠시만요 처리 좀 하고 올게요(샷 관리 프로퍼티랑 메서드 추가).... XXXX원이요"

하지만 새로운 구조에 따르면 이렇게 된다.

"HouseBlend: Milk님 좀 추가할게요(가격 추가)"
"Milk: Whip님 좀 추가할게요~(가격 추가)"
"Whip: Shot님.. 이 없네?? (빠르게 Condiment_Decorator를 상속해 Shot 클래스 추가(인터페이스 동일)) 어 계신다. Shot님 좀 추가할게요~(가격 추가)
"Shot: 고객님 총 HouseBlend, Milk, whip, shot 하나씩 해서 XXXX원 입니다!"

그리고 상속을 통해 처리한 경우에는 유자차 처럼 커피 관련 첨가물이 필요없는 애들도 자신이 관리해야 되기 때문에 커피 메뉴판 하나만 봐도 얼마나 비효율적인지 알 수 있을 것이다.

"상속이 아니라 Composition(다른 객체에 대한 참조를 선언)을 이용해 runtime에 동적으로 다른 객체의 cost( )메서드를 추가할 수 있다."

  Beverage* beverage2 = new HouseBlend();
  beverage2 = new Mocha(beverage2);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Milk(beverage2);
  beverage2 = new Whip(beverage2);

  cout << "주문하신 " << beverage2->getDescription() << "의 가격은: " << beverage2->cost() << "원 입니다." << endl;
  
  // 출력결과
  주문하신 하우스 블렌드 커피, Mocha, Mocha, Milk, Whip의 가격은: 7200원 입니다.

  1. Beverage* beverage2 = new HouseBlend();
    heap영역에 메모리가 할당(0x01) 되고 HouseBlend 객체가 초기화 된다.

  2. beverage2 = new Mocha(beverage2);
    heap영역에 다른 메모리 주소(0x02)를 할당하고 Mocha 객체를 초기화 시키면서 1에서 가리키고 있던 메모리 주소(0x01)를 Mocha에게 넘긴다.

3, 4, 5를 통해 마지막으로 Whip 객체를 heap영역의 메모리 공간에 초기화 시키킨다(0x05) Whip은 0x04에 대한 포인터를 프로퍼티로 가지고 있고 표현해보면 다음과 같다.

0x05(Whip)->0x04(Milk)->0x03(Mocha)->0x02(Mocha)->0x01(HouseBlend)

이 구조에서 Whip의 cost 메서드를 호출해보면

class Whip : public CondimentDecorator {
private:
  Beverage* beverage;
public:
  Whip(Beverage* beverage) {
    this->beverage = beverage;
  }

  string getDescription() override {
    return beverage->getDescription() + ", Whip";
  }

  double cost() override {
    double whipPrice = 500;
    return whipPrice + beverage->cost();
  }
};

beverage 변수에는 0x04(Milk)가 있고 휩 가격 + Milk의 가격 계산 메서드를 호출하고

class Milk : public CondimentDecorator {
private:
  Beverage* beverage;
public:
  Milk(Beverage* beverage) {
    this->beverage = beverage;
  }
  ~Milk(){ delete beverage; }

  string getDescription() override {
    return beverage->getDescription() + ", Milk";
  }

  double cost() override {
    double milkPrice = 1200;
    return milkPrice + beverage->cost();
  }
};

마찬가지로 0x03의 cost( )를 호출하면 0x02의 cost()를 호출하면 0x01의 cost( )를 호출을 마지막으로 모든 첨가물과 커피가격까지 합이 도출되게 되는 것이다.

컴파일러는 symbol table에는 가상 함수의 경우 가상인지 아닌지 표기를 해놓고 Virtual table에는 클래스의 가상 함수 호출을 위해 가상 함수의 주소를 저장해 놓는다.

런타임 때는 실제 객체가 생성되고 beverage2가 가리키는 주소(Whip)에 접근 후 vptr을 통해 vtable에 있는 Whip::cost( )를 호출하고 Whip이 감싸고 있는(Decorated) 객체들의 cost메서드를 재귀적으로 호출하게 됨으로써 총 가격을 계산할 수 있게 됐다.

Beverage ptr -> Whip vptr -> vtable -> Whip::cost( ) -> Milk vptr -> vtable -> Milk::cost( ) .. 이하 반복

beverage.h

#ifndef BEVERAGE_H
#define BEVERAGE_H

#include <iostream>
using namespace std;

class Beverage {
protected:
  string description;
public:
  virtual ~Beverage() {}
  virtual string getDescription() = 0;
  virtual double cost() = 0;
};

#endif

dark_roast.h

#ifndef DARKROAST_H
#define DARKROAST_H

#include "beverage.h"
#include <iostream>
using namespace std;

class DarkRoast : public Beverage {

public:
  DarkRoast() { description = "최고의 다크 로스트 커피"; }
  string getDescription() override { return description; }
  double cost () override {
    return 3000;
  }
};

#endif

whip.h

#ifndef WHIP_H
#define WHIP_H

#include "condiment_decorator.h"
#include "../beverages/beverage.h"

class Whip : public CondimentDecorator {
private:
  Beverage* beverage;
public:
  Whip(Beverage* beverage) {
    this->beverage = beverage;
  }

  string getDescription() override {
    return beverage->getDescription() + ", Whip";
  }

  double cost() override {
    double whipPrice = 500;
    return whipPrice + beverage->cost();
  }
};

#endif

kiosk.cpp

#include "./condiment/mocha.h"
#include "./condiment/whip.h"
#include "./condiment/milk.h"
#include "./beverages/houseblend.h"
#include <iostream>
using namespace std;

int main() {
  Beverage* houseBlend = new HouseBlend();
  Beverage* mocha1 = new Mocha(houseBlend);
  Beverage* mocha2 = new Mocha(mocha1);
  Beverage* milk = new Milk(mocha2);
  Beverage* whip = new Whip(milk);

  cout << "주문하신 " << whip->getDescription() << "의 가격은: " << whip->cost() << "원 입니다." << endl;

  Beverage* beverage2 = new HouseBlend();
  beverage2 = new Mocha(beverage2);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Milk(beverage2);
  beverage2 = new Whip(beverage2);

  cout << "주문하신 " << beverage2->getDescription() << "의 가격은: " << beverage2->cost() << "원 입니다." << endl;

  return 0;
};

처음에 답을 보지 않고 구현했을 때는 final의 개념을 잘 활용하지 못했다.

즉, 책에서는 Beverage 타입의 포인터에 객체를 계속 감싸면서 구현했다.(아래 처럼) 왜냐하면 포인터가 final이 아니기 때문에 runtime에 다른 객체를 가리킬 수 있는 것이다.

근데 이 사실을 인지하지 못하고 처음에는 타입은 Beverage로 같지만 변수이름을 달리하여 객체를 초기화 시키고 해당 포인터를 넘기는 방식으로 구현했다.

대신 이 경우에는 메모리 할당의 해제를 신경써야 한다.

음료에 사이즈를 추가하려면 어떻게 구현해야 할까?

힌트로는 Beverage 클래스에 enum으로 Size를 구현했음을 보여주고 size라는 프로퍼티를 생성해 음료마다 size를 관리하도록 했다. 추가로 setSize와 getSize를 통해 주문한 음료의 Size를 가져오거나 설정하는 게터와 세터를 추가함을 암시하고 이외에는 알아서 구현해봐~ 였다.

#ifndef SIZE_H
#define SIZE_H

#include <iostream>
using namespace std;

enum class Size { TALL, GRANDE, VENTI, Unknown };

Size getSizeFromString(const string& sizeStr) {
  if (sizeStr == "TALL") return Size::TALL;
  else if (sizeStr == "GRANDE") return Size::GRANDE;
  else if (sizeStr == "VENTI") return Size::VENTI;
  else return Size::Unknown;
}

string getStringFromSize(const Size& size) {
  if (size == Size::TALL) return "TALL";
  else if (size == Size::GRANDE ) return "GRANDE" ;
  else if (size == Size::VENTI ) return "VENTI";
  else return "Unknown";
}

#endif

나는 size와 관련한 헤더 파일을 따로 만들어서 관리하도록 만들었고 enum class를 이용한 이유는 그냥 GRANDE라고 밸류를 전달하는 것보다 가독성이 좋기 때문이었다.

그리고 생각해보면 우리는 카페에 가서 첨가물의 크기가 아니라 음료의 크기를 조절하기 때문에 Beverage를 상속하는 음료들의 cost 메서드에 크기 별로 가격을 대응하도록 구현해줬다.

그리고 원래 있던 getDescription 메서드에 현재 음료의 크기를 확인해주는 문구만 넣었다.

beverage.h

class Beverage {
protected:
  string description;
  Size size = Size::TALL;
  
public:
  virtual ~Beverage() {}
  virtual string getDescription() = 0;
  virtual double cost() = 0;

  void setSize (Size size) {
    this->size = size;
  }
  Size getSize () { return this->size; }
};

#endif

dark_roast.h


class DarkRoast : public Beverage {

public:
  DarkRoast() { description = "최고의 다크 로스트 커피"; }

  string getDescription() override { 
    string orderedSize = getStringFromSize(getSize());
    return orderedSize + " 크기의" + description ; 
  }

  double cost () override {
    switch (getSize())
    {
    case Size::TALL:
      return 3000;
      break;
    case Size::VENTI:
      return 4000;
    case Size::GRANDE:
      return 4500;
    default:
      return 3000;
      break;
    }
  }
};

#endif

그리고 문제는 그럼 유저가 주문하는 건 어떻게 처리하지? 였는데 setter가 있으므로 음료 객체를 감싸기 전에 고객이 원하는 사이즈를 설정하도록 처리했다.

kiosk.cpp


int main() {
  cout << "원하시는 음료의 사이즈를 입력해주세요: " << endl;
  string input;
  cin >> input;

  Size size = getSizeFromString(input);

  Beverage* beverage2 = new HouseBlend();
  beverage2->setSize(size);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Milk(beverage2);
  beverage2 = new Whip(beverage2);

  cout << "주문하신 " << beverage2->getDescription() << "의 가격은: " << beverage2->cost() << "원 입니다." << endl;

  return 0;
};
  1. 고객이 원하는 음료의 사이즈를 입력 받는다.
  2. getSizeFromString() -> 입력 받은 사이즈 값의 데이터 형은 문자열이므로 이를 Size enum class의 값과 매핑한다.
  3. beverage2->setSize(size);
    그리고 음료의 크기를 설정한다.
  4. 나머지는 동일.
  5. 음료의 설명과 가격을 출력해보면

위와 같은 출력 값이 나오는 걸 알 수 있다.

Size를 Condiment로 추가해서 구현

바로 위 예시 처럼 구현했더니 음료 마다 가격을 하드코딩해줘야 하는 문제가 있고 가격을 처리하기 위한 분기문이 음료 클래스마다 반복되는 단점이 있었다.

따라서 사이즈에 따라 가격을 대응하는 배수를 정해서 이를 사용해 가격을 구현하도록 변경해봤다.

CondimentDecorator를 상속한 BeverageSize 라는 클래스를 정의하고 해당 메서드에서 음료의 Size에 따라 가격의 배수를 반환하도록 구현했다.

beverage_size.cpp

#include "beverage_size.h"

double BeverageSize::cost() {
  switch (getSize())
    {
    case Size::TALL:
      return 1.0;
      break;
    case Size::VENTI:
      return 1.2;
    case Size::GRANDE:
      return 1.5;
    default:
      return 1.0;
      break;
    }
}

이 구현 또한 단점은 있었다. Beverage 인터페이스를 활용할 수가 없었다. 왜냐하면 컴파일러는 Beverage 포인터가 가리키는 Beverage를 상속한 객체를 컴파일 타임 때 알 수 없기 때문이었다. 따라서 BeverageSize 자체를 Composition을 통해 음료 클래스에 구현했다.

class BeverageSize : public CondimentDecorator {
private:
  Beverage* beverage;
public:
  BeverageSize(Beverage* beverage) { this->beverage = beverage; }
  string getDescription() override { 
    return getStringFromSize( beverage->getSize()) + "사이즈 " +beverage->getDescription(); 
    }
  double cost() override ;
};

#include "beverage_size.h"

double BeverageSize::cost() {
  switch (beverage->getSize())
    {
    case Size::TALL:
      return 1.0 * beverage->cost();
      break;
    case Size::VENTI:
      return 1.2 * beverage->cost();
    case Size::GRANDE:
      return 1.5 * beverage->cost();
    default:
      return 1.0 * beverage->cost();
      break;
    }
}

적고 보니 반대로는 활용이 가능할 것 같아서 위와 같이 해당 첨가물 클래스에 Beverage 타입의 포인터 변수를 선언했다. 이는 음료의 코드를 변경하지 않고 활용하기 위함인데, 이렇게 되면 음료의 사이즈도 알 수 있고 그 사이즈 별로 배수를 곱해서 최종 음료 가격을 도출할 수 있었다.

HouseBlend.h

class HouseBlend : public Beverage {
public:
  HouseBlend() { description = "하우스 블렌드 커피"; }

  string getDescription() override { return description; }

  double cost () override {
    double houseBlendBasicPrice = 2900;
    return houseBlendBasicPrice;
  }
};

위 BeverageSize 클래스에서 가격 처리를 하므로 해당 음료 클래스에서는 제일 작은 사이즈인 TALL 사이즈의 가격만 반환하도록 구현했다.

kiosk.cpp

int main() {
  cout << "원하시는 음료의 사이즈를 입력해주세요: " << endl;
  string input;
  cin >> input;
  Size size = getSizeFromString(input);

  BeverageSize beverageSize1 = BeverageSize();
  beverageSize1.setSize(size);

  Beverage* beverage2 = new HouseBlend(beverageSize1);
  beverage2->setSize(size);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Milk(beverage2);
  beverage2 = new Whip(beverage2);

  cout << "주문하신 " << beverage2->getDescription() << "의 가격은: " << beverage2->cost() << "원 입니다." << endl;
  return 0;
};

그리고 마지막 단점은.... Size에 따른 가격 배수를 계산하기 위해 BeverageSize와 HouseBlend 각각에 Size를 넘겨줘야 한다는 점이었다.

왜냐하면 BeverageSize는 Condiment 추상 클래스를 상속했는데 cost 메서드의 경우 인자를 선언하지 않았기 때문에 인자를 받을 수 없는 구조라 유저가 사이즈를 입력하면 BeverageSize와 음료에 각각 Size를 넘겨야했다.

수정 후 Kiosk.cpp

int main() {
  cout << "원하시는 음료의 사이즈를 입력해주세요: " << endl;
  string input;
  cin >> input;
  Size size = getSizeFromString(input);

  Beverage* beverage2 = new HouseBlend();
  beverage2->setSize(size);
  beverage2 = new BeverageSize(beverage2);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Mocha(beverage2);
  beverage2 = new Milk(beverage2);
  beverage2 = new Whip(beverage2);

  cout << "주문하신 " << beverage2->getDescription() << "의 가격은: " << beverage2->cost() << "원 입니다." << endl;
  return 0;
};

하지만 BEVERAGESIZE에 음료를 받도록 구현했을 때는 위와 같이 깔끔하게 음료에만 사이즈를 넘겨주면 그 정보를 활용해 BeverageSize에서 처리할 수 있었다.

정리

새로운 객체 지향 원칙 추가

Classes should be open for extension but closed for modification.

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

Decorator Pattern

객체에 추가적인 행동을 동적으로 더할 수 있다. 데코레이터를 사용하면 subclass를 만들 때보다 훨씬 유연하게 기능을 확장할 수 있다.

즉, 기존 클래스(HouseBlend Coffee)에 변경(휘핑, 모카 추가 등)하지 않고도 동적으로 기능을 추가할 수 있었다.

profile
아쉬움 없이 살자. 모든 순간을 100%로!

0개의 댓글