[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #23] 컴포지트(Composite) 패턴: std::variant와 Ranges로 부분-전체 트리 구조 간단히 표현하기

이전 글에서는 브리지(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 등을 결합해 조건부 처리, 비동기 연산, 로깅, 에러 처리 등 다양한 요구사항을 적은 코드와 높은 타입 안전성으로 해결할 수 있습니다. 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.

반응형