겹겹이 쌓이는 기능의 미학: C++ 데코레이터 패턴
데코레이터 패턴의 핵심은 “객체를 동일한 인터페이스의 다른 객체로 감싸서 기능을 동적으로 확장하는 것”입니다. 마치 러시아의 마트료시카 인형처럼, 알맹이는 그대로 두되 겉면에 새로운 장식을 덧칠하는 방식이죠.

겹겹이 쌓이는 기능의 미학: C++ 데코레이터 패턴
여러분은 추운 겨울날 외출을 준비하며 어떤 고민을 하시나요? 아마도 얇은 옷을 여러 겹 껴입는 '레이어드 룩'을 선택하실 겁니다. 기온이 떨어지면 스웨터를 더하고, 바람이 불면 코트를 걸치죠. 만약 우리에게 이런 유연함이 없다면, 우리는 '반팔만 입은 나', '반팔에 스웨터를 입은 나', '반팔에 코트만 입은 나'처럼 모든 경우의 수에 대비한 별개의 몸을 준비해야 했을지도 모릅니다.
소프트웨어 설계에서도 이와 똑같은 비극이 일어납니다. 우리는 기능을 추가하기 위해 습관적으로 '상속'을 선택하곤 하죠. 하지만 요구사항이 늘어날수록 클래스의 계보도는 겉잡을 수 없이 복잡해지고, 결국 '클래스 폭발(Class Explosion)'이라는 야만적인 혼돈에 직면하게 됩니다. 데코레이터 패턴은 바로 이 지점에서 상속 대신 '조합'이라는 우아한 해법을 제시합니다.
1. 데코레이터 패턴: 객체에 입히는 투명한 겉옷
데코레이터 패턴의 핵심은 **“객체를 동일한 인터페이스의 다른 객체로 감싸서 기능을 동적으로 확장하는 것”**입니다. 마치 러시아의 마트료시카 인형처럼, 알맹이는 그대로 두되 겉면에 새로운 장식을 덧칠하는 방식이죠.
이 패턴을 사용하면 우리는 실행 시간(Runtime)에 객체의 행동을 변경할 수 있습니다. 상속이 '정적인 확장'이라면, 데코레이터는 '동적인 변신'인 셈입니다.
2. C++로 구현하는 커피 주문 시스템
가장 고전적이면서도 명확한 예시인 커피 전문점 시스템을 통해 이 패턴의 정수를 살펴보겠습니다. 에스프레소라는 본질 위에 우유, 시럽, 휘핑 크림이라는 '장식'을 얹어가는 과정입니다.
주문: 에스프레소 + 우유 + 모카
[ 모카 장식 [ 우유 장식 [ 에스프레소 알맹이 ] ] ]
각 장식들은 알맹이가 무엇인지 정확히 몰라도 됩니다. 그저 "내 가격을 더하고, 안쪽 내용물의 가격을 물어봐서 합친다"는 자기 역할만 수행하면 됩니다.

#include <iostream>
#include <string>
#include <memory>
using namespace std;
// 1. 추상 성분 (Component): 모든 음료의 기본 인터페이스
class Beverage {
public:
virtual ~Beverage() {}
virtual string getDescription() const = 0;
virtual double cost() const = 0;
};
// 2. 구체적인 성분 (Concrete Component): 장식될 본질 (에스프레소)
class Espresso : public Beverage {
public:
string getDescription() const override { return "에스프레소"; }
double cost() const override { return 1.99; }
};
// 3. 데코레이터 (Decorator): 음료를 감싸는 추상 클래스
// 핵심은 감싸려는 대상과 '동일한 인터페이스'를 상속받으면서,
// 그 대상을 '멤버 변수'로 포함하는 것입니다.
class CondimentDecorator : public Beverage {
protected:
unique_ptr<Beverage> beverage; // 장식될 알맹이
public:
CondimentDecorator(unique_ptr<Beverage> bev) : beverage(move(bev)) {}
};
// 4. 구체적인 데코레이터 (Concrete Decorators): 실제 추가될 재료들
class Milk : public CondimentDecorator {
public:
Milk(unique_ptr<Beverage> bev) : CondimentDecorator(move(bev)) {}
string getDescription() const override {
return beverage->getDescription() + ", 우유";
}
double cost() const override {
return beverage->cost() + 0.10; // 기존 가격에 자신의 가격을 더함
}
};
class Mocha : public CondimentDecorator {
public:
Mocha(unique_ptr<Beverage> bev) : CondimentDecorator(move(bev)) {}
string getDescription() const override {
return beverage->getDescription() + ", 모카";
}
double cost() const override {
return beverage->cost() + 0.20;
}
};
// ---------------------------------------------------------
// 실행 예시
int main() {
// 1. 순수한 에스프레소를 주문합니다.
unique_ptr<Beverage> myCoffee = make_unique<Espresso>();
// 2. 우유를 추가(장식)합니다.
myCoffee = make_unique<Milk>(move(myCoffee));
// 3. 모카 시럽을 한 번 더 추가합니다.
myCoffee = make_unique<Mocha>(move(myCoffee));
cout << "메뉴: " << myCoffee->getDescription() << endl;
cout << "가격: $" << myCoffee->cost() << endl;
return 0;
}
3. 분석: 상속의 족쇄를 끊어내는 '유연한 전술'
위 코드의 하이라이트는 myCoffee 변수가 여러 번 감싸지면서도 여전히 Beverage라는 단일한 인터페이스로 다뤄진다는 점입니다.
- 동적 확장성: 컴파일 시점에 음료의 종류를 결정할 필요가 없습니다. 고객이 주문하는 즉시 우유를 넣을지, 모카를 넣을지 결정하고 객체를 감싸면 그만입니다.
- 단일 책임 원칙(SRP): 각 데코레이터는 자신의 기능(우유 가격 계산 등)에만 집중합니다. 베이스 클래스가 수만 가지의 옵션을 모두 알 필요가 없죠.
- 조합의 폭발력: 우유 두 번, 모카 세 번과 같은 복잡한 조합도 클래스 추가 없이 단순히 객체를 중첩하는 것만으로 구현 가능합니다.
하지만 이 우아한 마법에도 대가는 따릅니다. 객체를 너무 많이 감싸다 보면 이른바 '데코레이터 지옥'에 빠질 수 있습니다. 디버깅 시점에 지금 내가 들여다보고 있는 객체가 몇 겹으로 감싸진 것인지 파악하기 어려워질 수 있고, 작은 객체들이 너무 많이 생성되어 관리가 복잡해질 우려도 있죠.
4. 확장: 현대 소프트웨어의 '필터'와 '스트림'
데코레이터 패턴은 단순히 커피를 만드는 데 그치지 않습니다. 여러분이 매일 사용하는 I/O 스트림(예: Java의 InputStream, C++의 iostream 확장)이나 네트워크 패킷을 처리하는 필터링 시스템, 혹은 UI 컴포넌트에 테두리나 스크롤바를 동적으로 입히는 과정 등 현대 소프트웨어의 도처에 깔려 있습니다.데이터를 압축하고, 암호화하고, 다시 버퍼에 담는 연쇄적인 과정들. 이 모든 것이 데코레이터 패턴이 선사하는 '유연한 연결고리' 덕분에 가능합니다.
본질을 가리지 않는 우아한 장식
데코레이터 패턴은 우리에게 "상속보다는 조합(Composition over Inheritance)"이라는 객체지향의 위대한 원칙을 몸소 보여줍니다. 본질(Component)을 훼손하지 않으면서도 외부의 요구사항에 맞춰 끊임없이 변신하는 객체의 모습은, 변화무쌍한 현대의 소프트웨어 환경에서 우리가 지향해야 할 유연성의 표상과도 같습니다.
여러분의 코드는 어떤가요? 새로운 기능 하나를 추가하기 위해 거대한 클래스 계층도를 다시 설계하고 있지는 않나요? 혹시 지금 여러분에게 필요한 것은 새로운 클래스가 아니라, 기존의 객체를 따뜻하게 감싸 안아줄 '투명한 겉옷' 한 벌일지도 모릅니다. 결국 중요한 것은 객체의 혈통이 아니라, 객체가 지금 어떤 옷을 입고 어떤 역할을 수행하고 있느냐 하는 문제일 테니까요.