앞선 글에서는 브리지(Bridge) 패턴을 통해 모던 C++에서 상속 기반의 전통적 설계를 어떻게 Concepts, 함수 객체, std::variant 등으로 대체하며 더 유연한 구조를 만들 수 있는지 살펴보았습니다. 이번에는 데코레이터(Decorator) 패턴을 재조명합니다.
데코레이터 패턴은 객체에 기능을 동적으로 추가하는 방법을 제시합니다. 전통적으로는 상속이나 래핑(wrapper) 클래스를 사용해 "장식(Decoration)"을 더했지만, 모던 C++에서는 람다, std::function, Ranges, Concepts 등의 기능을 활용해 상속 계층 폭발을 줄이고, 훨씬 간결하고 가독성 높은 코드로 기능을 확장할 수 있습니다.
패턴 소개: 데코레이터(Decorator)
의도:
- 객체에 추가 책임(기능)을 동적으로 부여하는 패턴.
- 상속 대신 객체 합성을 사용해 다양한 장식을 유연하게 적용.
- 예: 그래픽 객체에 스크롤바 기능 추가, I/O 스트림에 압축/암호화 기능 추가 등.
전통적 구현 문제점:
- 상속 기반 데코레이터는 "기능 A + B + C" 조합을 할 때마다 새 클래스 정의 필요.
- 상속 계층이 커지고 복잡해지며, 클래스 수 증가.
기존 C++ 스타일 구현 (C++11/14/17)
예를 들어, Shape 인터페이스를 가진 객체를 데코레이터로 감싸서 색칠, 테두리 그리기 등의 기능을 붙여보겠습니다.
#include <iostream>
#include <memory>
struct Shape {
virtual ~Shape() = default;
virtual void draw() const = 0;
};
struct Circle : Shape {
void draw() const override {
std::cout << "Drawing circle\n";
}
};
// 데코레이터 기반 상속
struct ShapeDecorator : Shape {
ShapeDecorator(std::unique_ptr<Shape> s) : shape(std::move(s)) {}
void draw() const override {
shape->draw();
}
protected:
std::unique_ptr<Shape> shape;
};
struct ColorDecorator : ShapeDecorator {
ColorDecorator(std::unique_ptr<Shape> s, std::string c) : ShapeDecorator(std::move(s)), color(std::move(c)) {}
void draw() const override {
ShapeDecorator::draw();
std::cout << "Coloring with " << color << "\n";
}
private:
std::string color;
};
struct BorderDecorator : ShapeDecorator {
BorderDecorator(std::unique_ptr<Shape> s, int thickness) : ShapeDecorator(std::move(s)), thickness(thickness) {}
void draw() const override {
ShapeDecorator::draw();
std::cout << "Border thickness: " << thickness << "\n";
}
private:
int thickness;
};
int main() {
auto shape = std::make_unique<Circle>();
// Color + Border 장식
auto decorated = std::make_unique<BorderDecorator>(
std::make_unique<ColorDecorator>(
std::move(shape), "Red"
), 3
);
decorated->draw();
}
고찰:
- 상속 기반으로 데코레이터를 구현하면 클래스 수가 늘어남.
- 새로운 장식 기능 추가 시 새 데코레이터 클래스 필요.
- 다중 기능 조합 시 중첩된 생성 호출 복잡.
모던 C++20 이상의 개선: 함수 합성과 파이프라인
1. Concept로 "그릴 수 있는(Drawable)" 타입 제약
Shape 인터페이스를 가상 함수 대신 Concepts로 정의할 수 있습니다. "그릴 수 있는 객체"는 draw() const 메서드를 제공해야 한다고 제약할 수 있습니다.
#include <concepts>
#include <string>
#include <iostream>
template<typename S>
concept Drawable = requires(const S& s) {
{ s.draw() } -> std::same_as<void>;
};
struct Circle2 {
void draw() const {
std::cout << "Drawing circle\n";
}
};
static_assert(Drawable<Circle2>);
이제 Decorator를 상속하지 않고, "장식 기능"을 함수로 표현할 수 있습니다.
2. 함수 합성을 통한 데코레이터 적용
데코레이터가 하는 일은 draw() 호출 전후에 추가 기능을 실행하는 것입니다. 이를 람다나 함수 객체로 쉽게 구현할 수 있습니다.
// Draw 함수를 인자로 받아 장식 후 새로운 Draw 함수를 반환하는 헬퍼
auto makeColorDecorator(std::string color) {
return [color](auto drawFunc) {
return [=](auto&& obj) {
drawFunc(obj);
std::cout << "Coloring with " << color << "\n";
};
};
}
auto makeBorderDecorator(int thickness) {
return [thickness](auto drawFunc) {
return [=](auto&& obj) {
drawFunc(obj);
std::cout << "Border thickness: " << thickness << "\n";
};
};
}
고찰:
- makeColorDecorator와 makeBorderDecorator는 "데코레이터 제조 함수".
- 입력: 기존 drawFunc (draw를 호출하는 함수)
- 출력: 새로운 drawFunc(장식된 draw 함수)를 반환.
- 람다로 기능을 합성하므로, 클래스 상속 필요 없음.
3. Pipeline(파이프라인)으로 장식 적용
std::function이나 람다 파이프라인을 통해 원본 그리기 함수를 차례로 장식할 수 있습니다.
#include <functional>
template<Drawable D>
void drawObject(const D& obj, std::function<void(const D&)> drawFunc) {
drawFunc(obj);
}
int main() {
Circle2 c;
// 원본 draw 함수
auto baseDraw = [](const auto& obj) { obj.draw(); };
// 파이프라인으로 데코레이션 적용
// baseDraw -> ColorDecorator(red) -> BorderDecorator(3)
auto decoratedDraw = makeBorderDecorator(3)(
makeColorDecorator("Red")(
baseDraw
)
);
drawObject(c, decoratedDraw);
}
고찰:
- 데코레이터를 단순히 함수 조합으로 구현, 상속 없이 다양한 기능 조합 가능.
- 함수 합성으로 다양한 장식 조합을 동적으로 구성.
- 새로운 데코레이터 추가 시 함수 하나 추가하면 됨, 클래스 수 증가 없음.
4. Ranges나 Concepts와 연계
만약 장식 과정이 복잡한 변환 시퀀스로 구성된다면, Ranges를 통해 파이프라인 구성 가능. 예를 들어, 여러 장식을 벡터에 담고 fold 작업을 통해 하나의 drawFunc로 축약할 수 있습니다.
#include <vector>
#include <ranges>
int main() {
Circle2 c;
auto baseDraw = [](const auto& o) { o.draw(); };
std::vector decorators = {
makeColorDecorator("Blue"),
makeBorderDecorator(5)
};
// fold 계열 연산 (accumulate)으로 파이프라인 결합
auto combinedDraw = std::accumulate(decorators.begin(), decorators.end(), baseDraw,
[](auto acc, auto decorator) {
return decorator(acc); // acc를 decorator로 장식
}
);
drawObject(c, combinedDraw);
}
고찰:
- 함수 파이프라인을 표준 알고리즘으로 처리, 동적 조합 가능.
- Concepts, 람다, STL 알고리즘을 통해 정적/동적 다형성 혼합 가능.
- 장식 기능 추가 시 상속 필요 없이 함수 하나 추가하고 벡터에 넣으면 끝.
5. std::expected, std::format으로 개선
만약 장식 과정에서 에러가 발생 가능하다면 std::expected로 처리할 수 있습니다. 또, std::format으로 디버깅 메시지를 더 명확히 출력할 수도 있습니다.
#include <expected>
#include <format>
auto makeSafeBorderDecorator(int thickness) {
return [thickness](auto drawFunc) {
return [=](auto&& obj) -> std::expected<void,std::string> {
auto res = drawFunc(obj);
if (!res) return res; // drawFunc가 expected 반환한다 가정
if (thickness < 0) return std::unexpected("Negative thickness");
std::cout << std::format("Border thickness: {}\n", thickness);
return {};
};
};
}
고찰:
- decorator 체인을 expected 기반으로 설계해, 실패 발생 시 즉시 propagate.
- std::format으로 메시지 포맷 일관성 유지.
비교 및 분석
- 전통적 구현(C++11 전후):
- 상속 기반 데코레이터 계층, 여러 기능 조합 시 클래스 폭발.
- 기능 추가 시 새 클래스 필요, 코드량 증가.
- 런타임 다형성, vtable로 인한 비용과 복잡성.
- 모던 C++(C++20 이상):
- 상속 대신 함수 합성, 람다로 데코레이터 기능 표현.
- Concepts로 "그릴 수 있는(Drawable)" 타입을 제약, 타입 안전성 향상.
- std::function, 람다 파이프라인, Ranges 알고리즘으로 동적/정적 조합 쉽게 처리.
- std::expected로 에러 처리 명확화, std::format으로 디버깅 개선.
- 클래스 수 감소, 코드 가독성 및 유지보수성 상승.
결국 모던 C++에서 데코레이터 패턴은 상속 계층 생성 대신 함수 합성과 파이프라인을 통해 구현하는 방향으로 진화할 수 있습니다. 이는 패턴의 핵심 의도(동적 기능 추가)를 더 간결하고 확장 가능하게 해줍니다.
마무리
데코레이터 패턴은 객체에 런타임에 새로운 기능을 추가하는 강력한 방법을 제시하지만, 전통적 구현에서는 상속과 클래스 증가 문제로 번거로웠습니다. 모던 C++20 시대에는 Concepts, 람다, 함수 합성, Ranges 등을 활용해 상속 없이도 유연하게 기능을 "쌓아올릴" 수 있습니다. 이렇게 하면 코드가 더욱 모듈화되고, 필요에 따라 쉽게 기능을 추가하거나 제거할 수 있습니다.
다음 글에서는 구조적 패턴 중 또 다른 패턴인 퍼사드(Facade)를 다루며, 복잡한 서브시스템을 단일 인터페이스로 감싸는 고전적 아이디어가 모던 C++에서 어떻게 단순화될 수 있는지 살펴보겠습니다.