이전 글에서는 브리지(Bridge) 패턴을 모던 C++ 관점에서 재해석하며, 상속 기반 추상/구현 분리 없이도 람다, Concepts, std::expected, coroutine, Ranges, std::format 등을 활용해 추상과 구현을 유연하게 연결할 수 있음을 확인했습니다. 이번에는 구조적 패턴 중 컴포지트(Composite) 패턴을 다룹니다.
컴포지트 패턴은 객체를 트리 구조로 구성해, 개별 객체(Leaf)와 복합 객체(Composite)를 동일하게 다룰 수 있게 하는 패턴입니다. 전통적으로는 Component 추상 클래스, Leaf, Composite 클래스 상속 계층을 정의했으나, 이는 클래스 증가와 유지보수 어려움을 야기합니다.
C++20 이상에서는 std::variant, std::visit, Concepts, std::expected, std::format, coroutine, Ranges 등을 활용해 상속 없이도 부분-전체(Leaf/Composite) 구조를 값 기반 다형성으로 처리할 수 있습니다. 이로써 조건부 처리, 에러 처리, 비동기 연산, 로깅, 파이프라인 처리 등 다양한 요구사항에도 쉽게 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성을 대폭 향상시킬 수 있습니다.
패턴 소개: 컴포지트(Composite)
의도:
- 개별 객체(Leaf)와 복합 객체(Composite)들을 트리 구조로 구성하고, 이들을 동일한 방식으로 처리할 수 있게 한다.
- 예: GUI 위젯 트리(윈도우, 패널, 버튼 등), 파일시스템 디렉토리(Composite)와 파일(Leaf) 구조를 단일 인터페이스로 처리.
전통적 구현 문제점:
- Component 추상 클래스 + Leaf, Composite 상속 계층 필요
- 새로운 요소 타입이나 기능 추가 시 클래스 증가
- 조건부 처리, 비동기 연산, 로깅, 에러 처리 등 고급 기능 구현 시 복잡성 증가
기존 C++ 스타일 구현 (전통적 방식)
예를 들어, GUI 요소를 표현:
#include <iostream>
#include <memory>
#include <vector>
struct Component {
virtual ~Component()=default;
virtual void render()=0;
};
struct Leaf : Component {
void render() override {
std::cout << "Rendering Leaf\n";
}
};
struct Composite : Component {
std::vector<std::unique_ptr<Component>> children;
void add(std::unique_ptr<Component> c) {
children.push_back(std::move(c));
}
void render() override {
std::cout << "Rendering Composite start\n";
for (auto& c : children) c->render();
std::cout << "Rendering Composite end\n";
}
};
int main() {
Composite root;
root.add(std::make_unique<Leaf>());
Composite sub;
sub.add(std::make_unique<Leaf>());
root.add(std::make_unique<Composite>(std::move(sub)));
root.render();
}
문제점:
- Component, Leaf, Composite 클래스 필요
- 타입 추가 시 클래스 증가
- 에러 처리나 조건부 처리, 비동기 처리 등 확장 어려움
모던 C++20 이상 개선: std::variant, std::visit로 값 기반 다형성
1. 값 기반 표현: std::variant
Leaf, Composite를 값 타입으로 정의:
struct LeafNode {};
struct CompositeNode {
std::vector<std::shared_ptr<struct Node>> children;
};
struct Node {
std::variant<LeafNode, CompositeNode> data;
};
비교:
- 전통적: Component 추상 클래스 상속 필요
- C++20: std::variant로 Leaf/Composite를 값 기반 다형성, 상속 없음
2. render 함수 구현: std::visit
#include <expected>
#include <string>
std::expected<void,std::string> renderNode(const std::shared_ptr<Node>& node);
std::expected<void,std::string> handleLeaf(const LeafNode&) {
std::cout << "Rendering Leaf\n";
return {};
}
std::expected<void,std::string> handleComposite(const CompositeNode& comp) {
std::cout << "Rendering Composite start\n";
for (auto& child : comp.children) {
auto res = renderNode(child);
if(!res) return res; // 첫 에러 시 중단
}
std::cout << "Rendering Composite end\n";
return {};
}
std::expected<void,std::string> renderNode(const std::shared_ptr<Node>& node) {
return std::visit([](auto& elem) -> std::expected<void,std::string> {
using T = std::decay_t<decltype(elem)>;
if constexpr (std::is_same_v<T,LeafNode>) {
return handleLeaf(elem);
} else if constexpr(std::is_same_v<T,CompositeNode>) {
return handleComposite(elem);
} else {
return std::unexpected("Unknown node type");
}
}, node->data);
}
비교:
- 전통적: Component::render() 가상 함수, 상속 기반 dispatch
- C++20: std::visit + if constexpr로 타입별 처리, 상속 없음, std::expected로 에러 처리 명확
사용 예
int main() {
auto root = std::make_shared<Node>(Node{CompositeNode{}});
auto leaf = std::make_shared<Node>(Node{LeafNode{}});
auto subComposite = std::make_shared<Node>(Node{CompositeNode{}});
// subComposite에 leaf 추가
std::get<CompositeNode>(subComposite->data).children.push_back(leaf);
// root에 subComposite 추가
std::get<CompositeNode>(root->data).children.push_back(subComposite);
auto res = renderNode(root);
if(!res) std::cerr << "Error: " << res.error() << "\n";
}
출력:
Rendering Composite start
Rendering Composite start
Rendering Leaf
Rendering Composite end
Rendering Composite end
조건부 처리, 로깅, Ranges, 비동기
조건부 처리
조건에 따라 특정 노드 무시:
auto conditionalRender = [&](auto baseRender) {
return [=](const std::shared_ptr<Node>& node) -> std::expected<void,std::string> {
// 특정 조건: Leaf 노드 무시
// node 검사 후 Leaf면 skip
// Composite면 baseRender 호출
return std::visit([&](auto& elem)->std::expected<void,std::string> {
using T = std::decay_t<decltype(elem)>;
if constexpr (std::is_same_v<T,LeafNode>) {
return {}; // skip leaf
} else {
return baseRender(node);
}
}, node->data);
};
};
auto conditionalRenderer = conditionalRender(renderNode);
로깅 추가
auto loggingRender = [&](auto baseRender) {
return [=](const std::shared_ptr<Node>& node)->std::expected<void,std::string> {
std::cout << "[LOG] Rendering node...\n";
auto res = baseRender(node);
if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
else std::cout << "[LOG] Success.\n";
return res;
};
};
auto loggedRenderer = loggingRender(renderNode);
Ranges로 노드 목록 처리
여러 개의 루트 노드가 있다면 Ranges로 파이프라인 처리:
std::vector<std::shared_ptr<Node>> roots = {root, subComposite};
for (auto res : roots | std::views::transform(loggedRenderer)) {
if(!res) std::cerr << "Error in one of the roots\n";
}
비동기 처리: coroutine
비동기 I/O 기반 렌더나 상태 로딩 시 coroutine 사용:
// co_renderNode(...) 가정: co_await 비동기 I/O 처리
// coroutines로 비동기 composite 처리 가능
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적(C++98/03):
- Component 추상 클래스 + Leaf/Composite 상속 계층
- 새로운 노드 타입 추가 시 클래스 증가
- 조건부 처리, 비동기 렌더, 로깅, 에러 처리 등 구현 시 복잡성 증가
C++11/14/17:
- 람다, std::function로 일부 단순화 가능
- 여전히 Concepts, std::expected, coroutine, Ranges 미지원 → 정적 타입 제약, 비동기/에러 처리 쉽지 않음
C++20 이상(모던 C++):
- std::variant + std::visit로 값 기반 다형성, 상속 없이 타입별 처리
- Concepts로 요구사항 정의, std::expected로 에러 처리 명확, std::format으로 로깅, coroutine으로 비동기 처리, Ranges로 파이프라인 처리
- 클래스 증가 없이 확장 가능, 유지보수성, 확장성, 타입 안전성 대폭 향상
결론
컴포지트(Composite) 패턴은 부분-전체 관계를 트리 구조로 표현하고, 개별 객체와 복합 객체를 동일하게 다루도록 하는 패턴입니다. 전통적 구현은 Component 추상 클래스와 Leaf, Composite 상속 계층으로 인해 클래스 증가, 유지보수 어려움을 겪습니다.
C++20 이상에서는 std::variant와 std::visit로 상속 없이도 부분-전체 구조를 값 기반으로 표현하고, Concepts, std::expected, coroutine, Ranges, std::format 등을 결합해 조건부 처리, 비동기 연산, 로깅, 에러 처리 등 다양한 요구사항을 적은 코드와 높은 타입 안전성으로 해결할 수 있습니다. 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.