C++ Composite 패턴: 파일 시스템·UI 트리를 하나의 인터페이스로 다루기

이 글의 핵심

Composite 패턴으로 잎 노드와 복합 노드를 같은 방식으로 다루는 법을 파일 시스템·UI 계층·조직도 예제로 구현하고, 소유권과 순회에서 생기는 문제를 짚습니다.

부분과 전체를 같은 인터페이스로 다루기

트리·부분-전체를 한 인터페이스로 묶는 흐름은 구조 패턴 시리즈의 다른 패턴들과 비교해 읽으면 좋습니다.

파일과 폴더를 따로 처리할 때 생기는 분기

파일과 폴더를 서로 다른 방식으로 처리하면 호출하는 쪽 코드가 타입마다 분기해야 합니다.

// 나쁜 설계: 타입 체크 필요
void printSize(FileSystemItem* item) {
    if (auto* file = dynamic_cast<File*>(item)) {
        std::cout << file->getSize() << '\n';
    } else if (auto* folder = dynamic_cast<Folder*>(item)) {
        for (auto& child : folder->getChildren()) {
            printSize(child);  // 재귀
        }
    }
}

Composite 패턴은 leaf(파일)와 composite(폴더)를 같은 인터페이스로 다뤄서 이 분기를 없앱니다.

// 좋은 설계: Composite
class Component {
public:
    virtual int getSize() const = 0;  // 통일된 인터페이스
    virtual ~Component() = default;
};
class File : public Component {
    int size_;
public:
    int getSize() const override { return size_; }
};
class Folder : public Component {
    std::vector<std::shared_ptr<Component>> children_;
public:
    int getSize() const override {
        int total = 0;
        for (const auto& child : children_)
            total += child->getSize();  // 재귀
        return total;
    }
};
flowchart TD
    client[Client]
    component["Component<br/>(getSize)"]
    leaf["Leaf<br/>(File)"]
    composite["Composite<br/>(Folder)"]
    
    client --> component
    leaf -.implements.-> component
    composite -.implements.-> component
    composite --> component

“나쁜 설계” 코드의 진짜 문제는 dynamic_cast 자체보다 분기가 흩어진다는 것입니다. 크기 계산, 출력, 검색, 복사 같은 기능마다 똑같은 if (File) ... else if (Folder) 분기가 반복되고, 나중에 심볼릭 링크나 압축 파일 같은 세 번째 타입이 생기면 그 분기를 전부 찾아 고쳐야 합니다. 하나라도 빠뜨리면 컴파일러는 아무 말도 하지 않고, 새 타입은 그 함수에서 조용히 무시됩니다. Composite는 “크기를 구하는 방법”을 각 타입 안으로 옮겨서, 새 타입은 인터페이스만 구현하면 모든 순회 코드에서 자동으로 동작하게 만듭니다.

다이어그램에서 composite --> component 화살표가 이 패턴의 핵심입니다. Composite는 자식을 구체 타입이 아닌 Component로 보관합니다. 그래서 폴더 안에 파일이 들어갈 수도, 또 다른 폴더가 들어갈 수도 있고, 깊이 제한 없이 트리가 만들어집니다. 클라이언트 입장에서는 루트 하나에 getSize()를 호출하면 끝이고, 재귀는 트리 구조 자체가 처리합니다.


Component·Leaf·Composite 기본 구조

#include <algorithm>
#include <vector>
#include <memory>
#include <iostream>
class Component {
public:
    virtual void operation() const = 0;
    virtual void add(std::shared_ptr<Component>) {}
    virtual void remove(std::shared_ptr<Component>) {}
    virtual ~Component() = default;
};
// Leaf: 자식 없음
class Leaf : public Component {
    int id_;
public:
    explicit Leaf(int id) : id_(id) {}
    void operation() const override {
        std::cout << "Leaf " << id_ << '\n';
    }
};
// Composite: 자식 목록 보유
class Composite : public Component {
    std::vector<std::shared_ptr<Component>> children_;
public:
    void add(std::shared_ptr<Component> c) override {
        children_.push_back(std::move(c));
    }
    void remove(std::shared_ptr<Component> c) override {
        children_.erase(
            std::remove(children_.begin(), children_.end(), c),
            children_.end()
        );
    }
    void operation() const override {
        std::cout << "Composite [\n";
        for (const auto& c : children_)
            c->operation();
        std::cout << "]\n";
    }
};
int main() {
    auto root = std::make_shared<Composite>();
    root->add(std::make_shared<Leaf>(1));
    
    auto branch = std::make_shared<Composite>();
    branch->add(std::make_shared<Leaf>(2));
    branch->add(std::make_shared<Leaf>(3));
    root->add(branch);
    
    root->operation();
    // 출력:
    // Composite [
    // Leaf 1
    // Composite [
    // Leaf 2
    // Leaf 3
    // ]
    // ]
    return 0;
}

이 기본 구조에서 결정해야 하는 것이 세 가지 있습니다.

add/remove를 어디에 둘 것인가. 위 코드는 Component에 빈 기본 구현을 두는 “투명성(transparency)” 방식입니다. 클라이언트가 모든 노드를 같은 타입으로 다룰 수 있는 대신, Leaf에 add를 호출해도 컴파일러가 막지 못합니다. 반대로 다음 절의 파일 시스템 예제는 add를 Folder에만 두는 “안전성(safety)” 방식입니다. GoF 원서도 두 방식의 트레이드오프를 설명할 뿐 정답을 정하지 않습니다. 트리를 조립하는 코드가 구체 타입을 알고 있다면 안전성 쪽이 대부분 낫습니다.

소유권을 무엇으로 표현할 것인가. 예제는 shared_ptr을 쓰지만, 트리는 본질적으로 부모가 자식을 단독 소유하는 구조라 unique_ptr이 더 정확한 경우가 많습니다. shared_ptr은 같은 노드를 두 부모에 붙이는 것을 허용해 버려서, 한쪽에서 수정하면 다른 쪽 트리도 바뀌는 의도치 않은 공유가 생깁니다. 노드를 여러 곳에서 참조해야 하거나(예: UI 이벤트 핸들러가 위젯을 잡고 있는 경우) 트리 밖으로 노드를 넘겨야 할 때만 shared_ptr을 고르세요.

remove의 비교 기준. std::remove는 shared_ptr끼리 ==로 비교하므로 같은 객체를 가리키는 포인터만 지웁니다. 내용이 같은 다른 객체는 지워지지 않습니다. 또 std::remove는 원소를 뒤로 밀어 둘 뿐이라 반드시 erase와 함께 써야 하는데, 위 코드는 이 erase-remove 관용구를 지키고 있습니다.


파일과 폴더 크기를 재귀로 계산하기

#include <vector>
#include <memory>
#include <iostream>
#include <string>
class FileSystemItem {
public:
    virtual int getSize() const = 0;
    virtual void print(int indent = 0) const = 0;
    virtual ~FileSystemItem() = default;
};
class File : public FileSystemItem {
    std::string name_;
    int size_;
public:
    File(std::string name, int size) : name_(std::move(name)), size_(size) {}
    
    int getSize() const override { return size_; }
    
    void print(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "File: " << name_ 
                  << " (" << size_ << " bytes)\n";
    }
};
class Folder : public FileSystemItem {
    std::string name_;
    std::vector<std::shared_ptr<FileSystemItem>> children_;
public:
    explicit Folder(std::string name) : name_(std::move(name)) {}
    
    void add(std::shared_ptr<FileSystemItem> item) {
        children_.push_back(std::move(item));
    }
    
    int getSize() const override {
        int total = 0;
        for (const auto& child : children_)
            total += child->getSize();
        return total;
    }
    
    void print(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "Folder: " << name_ 
                  << " (" << getSize() << " bytes total)\n";
        for (const auto& child : children_)
            child->print(indent + 2);
    }
};
int main() {
    auto root = std::make_shared<Folder>("root");
    root->add(std::make_shared<File>("readme.txt", 100));
    
    auto src = std::make_shared<Folder>("src");
    src->add(std::make_shared<File>("main.cpp", 500));
    src->add(std::make_shared<File>("utils.cpp", 300));
    root->add(src);
    
    auto docs = std::make_shared<Folder>("docs");
    docs->add(std::make_shared<File>("manual.pdf", 2000));
    root->add(docs);
    
    root->print();
    // 출력:
    // Folder: root (2900 bytes total)
    //   File: readme.txt (100 bytes)
    //   Folder: src (800 bytes total)
    //     File: main.cpp (500 bytes)
    //     File: utils.cpp (300 bytes)
    //   Folder: docs (2000 bytes total)
    //     File: manual.pdf (2000 bytes)
    
    return 0;
}

루트에 getSize()를 한 번 호출하면 폴더마다 자식의 getSize()를 재귀적으로 호출해 전체 트리의 크기를 계산합니다.

이 예제에는 눈에 잘 띄지 않는 비용이 하나 있습니다. Folder::print()가 자기 줄을 찍을 때 getSize()를 호출하고, 그 getSize()는 하위 트리 전체를 다시 순회합니다. 그런 다음 자식마다 print()를 호출하면 자식 폴더도 또 자기 하위 트리를 순회합니다. 결국 깊이가 d인 파일은 d번 방문되어, 트리가 깊고 넓어지면 출력 한 번에 O(N·d)의 비용이 듭니다. 수십 개 노드에서는 문제가 없지만 실제 디스크처럼 수십만 개 항목을 다루면 체감됩니다. 해결책은 두 가지입니다. 한 번의 후위 순회로 크기를 계산해 반환하면서 출력하거나, 폴더마다 크기를 캐싱해 두고 자식이 추가·삭제될 때만 무효화하는 것입니다. 캐싱을 택하면 자식 변경을 부모에게 알려야 하므로 부모 포인터가 필요해지고, 설계가 한 단계 복잡해집니다.

int로 크기를 합산하는 것도 실제 파일 시스템이라면 바꿔야 합니다. 2GB를 넘는 순간 오버플로가 나므로 std::uintmax_t(std::filesystem::file_size의 반환 타입)를 쓰는 편이 맞습니다.


GUI 위젯 트리 그리기

#include <vector>
#include <memory>
#include <iostream>
#include <string>
class Widget {
public:
    virtual void render() const = 0;
    virtual void add(std::shared_ptr<Widget>) {}
    virtual ~Widget() = default;
};
class Button : public Widget {
    std::string label_;
public:
    explicit Button(std::string label) : label_(std::move(label)) {}
    
    void render() const override {
        std::cout << "[Button: " << label_ << "]\n";
    }
};
class Label : public Widget {
    std::string text_;
public:
    explicit Label(std::string text) : text_(std::move(text)) {}
    
    void render() const override {
        std::cout << "Label: " << text_ << '\n';
    }
};
class Panel : public Widget {
    std::string title_;
    std::vector<std::shared_ptr<Widget>> children_;
public:
    explicit Panel(std::string title) : title_(std::move(title)) {}
    
    void add(std::shared_ptr<Widget> widget) override {
        children_.push_back(std::move(widget));
    }
    
    void render() const override {
        std::cout << "=== Panel: " << title_ << " ===\n";
        for (const auto& child : children_)
            child->render();
        std::cout << "===================\n";
    }
};
int main() {
    auto mainPanel = std::make_shared<Panel>("Main Window");
    mainPanel->add(std::make_shared<Label>("Welcome!"));
    
    auto buttonPanel = std::make_shared<Panel>("Actions");
    buttonPanel->add(std::make_shared<Button>("OK"));
    buttonPanel->add(std::make_shared<Button>("Cancel"));
    mainPanel->add(buttonPanel);
    
    mainPanel->render();
    // 출력:
    // === Panel: Main Window ===
    // Label: Welcome!
    // === Panel: Actions ===
    // [Button: OK]
    // [Button: Cancel]
    // ===================
    // ===================
    
    return 0;
}

Panel은 다른 Panel이나 Button을 자식으로 가질 수 있으므로 중첩된 UI 계층을 그대로 표현합니다.

실제 GUI 프레임워크(Qt의 QWidget, 웹 브라우저의 DOM)도 이 구조를 씁니다. 다만 실무의 위젯 트리에는 예제에 없는 요구가 두 가지 더 붙습니다. 첫째, 렌더링은 위에서 아래로, 이벤트는 아래에서 위로 흐릅니다. 버튼을 클릭하면 버튼이 먼저 처리하고 처리하지 않으면 부모 패널로 이벤트를 올려 보내야 하므로 자식이 부모를 알아야 합니다. 이때 부모 참조를 shared_ptr로 두면 부모와 자식이 서로를 소유해 순환 참조가 생기므로, 부모 쪽은 반드시 weak_ptr이나 원시 포인터(부모가 자식보다 오래 산다는 보장이 있을 때)로 둡니다. 둘째, 레이아웃 계산처럼 자식의 결과를 부모가 모아야 하는 연산이 많아, render()처럼 결과 없이 순회하는 연산보다 getSize()처럼 값을 합성하는 연산이 더 자주 등장합니다.


Leaf의 add()·순환 참조·소유권 문제

Leaf에 add()를 호출했을 때

// 나쁜 예: 기반 클래스에 빈 add()가 있는 설계(기본 구조·GUI 예제 방식)에서
// Leaf에 add() 호출 시 무시됨
auto file = std::make_shared<File>("test.txt", 100);
file->add(anotherFile);  // 아무 일도 안 일어남

이 문제는 기본 구조의 Component나 GUI 위젯 트리의 Widget처럼 기반 클래스에 빈 add()를 둔 설계에서만 생깁니다. 파일 시스템 예제의 FileSystemItem처럼 add()가 Folder에만 있다면 위 코드는 애초에 “‘class File’ has no member named ‘add‘“로 컴파일되지 않습니다. 빈 기본 구현이 위험한 이유는, 파일을 폴더로 착각해 자식을 붙인 코드가 에러 없이 지나가고 추가한 항목이 사라지는 형태로 나중에 발견되기 때문입니다.

기반 클래스에 add()를 남겨야 한다면, 최소한 Leaf의 add()가 예외를 던지게 해서 실수가 드러나게 합니다.

// 좋은 예: 예외 던지기
class File : public FileSystemItem {
public:
    void add(std::shared_ptr<FileSystemItem>) override {
        throw std::logic_error("Cannot add to a file");
    }
};

(이 코드는 FileSystemItem에 virtual void add(std::shared_ptr<FileSystemItem>)가 선언되어 있다고 가정하며, <stdexcept>가 필요합니다.) 예외를 던지면 최소한 실수가 조용히 묻히지는 않지만, 여전히 런타임에야 발견됩니다. 더 확실한 방법은 기반 클래스에 virtual Folder* asComposite() { return nullptr; } 같은 질의 함수를 두고 Composite에서만 this를 반환하게 해서, 호출자가 자식을 붙이기 전에 확인하게 만드는 것입니다. GoF 책이 소개하는 GetComposite() 방식이 이것입니다.

자식이 부모를 shared_ptr로 참조하는 순환

// 나쁜 예: 순환 참조
auto folder1 = std::make_shared<Folder>("A");
auto folder2 = std::make_shared<Folder>("B");
folder1->add(folder2);
folder2->add(folder1);  // 순환!

부모 포인터를 두고, 자식을 붙이기 전에 부모 사슬을 거슬러 올라가며 순환 여부를 검사합니다.

// 좋은 예: 부모 사슬로 순환 검사
class Folder : public FileSystemItem,
               public std::enable_shared_from_this<Folder> {
    std::string name_;
    std::weak_ptr<Folder> parent_;   // 소유하지 않는 부모 참조
    std::vector<std::shared_ptr<FileSystemItem>> children_;
public:
    bool add(std::shared_ptr<FileSystemItem> item) {
        if (auto folder = std::dynamic_pointer_cast<Folder>(item)) {
            // 붙이려는 폴더가 this 자신이거나 this의 조상이면 순환이 된다
            for (auto p = shared_from_this(); p; p = p->parent_.lock())
                if (p == folder) return false;
            folder->parent_ = weak_from_this();  // C++17
        }
        children_.push_back(std::move(item));
        return true;
    }
    // getSize(), print()는 앞의 예제와 같음
};

순환이 생기면 두 가지 일이 동시에 벌어집니다. 첫째, getSize()나 print()가 A → B → A → B …로 끝없이 재귀하다가 스택 오버플로로 프로그램이 죽습니다(Linux에서는 “Segmentation fault”, Windows에서는 0xC00000FD). 둘째, A와 B가 서로를 shared_ptr로 붙잡고 있어서 바깥 변수가 모두 사라져도 참조 카운트가 0이 되지 않아 둘 다 영원히 해제되지 않습니다.

위 add는 새로 추가할 항목이 폴더라면 this에서 시작해 parent_를 따라 루트까지 올라가며 그 폴더가 조상 중에 있는지(또는 this 자신인지) 확인하고, 있으면 추가를 거부합니다. 부모 사슬만 확인하면 되므로 트리 깊이만큼의 비용이면 충분합니다. parent_를 weak_ptr로 두는 이유는 부모 방향의 참조가 소유권을 갖지 않게 하기 위해서입니다. weak_ptr 자체가 순환을 막아 주는 것은 아니고, 순환 검사는 이렇게 별도로 해야 합니다. 자식의 parent_에 자기 자신을 넣으려면 Folder가 std::enable_shared_from_this<Folder>를 상속해야 합니다. shared_from_this()는 객체가 이미 shared_ptr로 관리되고 있을 때만 동작하고, 스택에 만든 객체에서 호출하면 C++17부터 std::bad_weak_ptr 예외가 납니다(그 이전 표준에서는 미정의 동작).

unique_ptr로 자식을 소유하는 설계라면 이 문제가 구조적으로 사라집니다. 노드를 한 부모에 넘기면(std::move) 호출자는 더 이상 그 노드를 들고 있지 않으므로, 같은 노드를 자기 후손에 다시 붙이는 코드를 쓰기가 훨씬 어려워집니다. 그래서 노드를 공유해야 할 확실한 이유가 없다면 unique_ptr 자식과 원시 포인터 부모 조합으로 시작하는 편이 순환 참조를 구조적으로 막기 쉽습니다.

raw 포인터 자식의 메모리 누수

// 나쁜 예: raw pointer 사용
class Composite {
    std::vector<Component*> children_;  // 누수 위험
};

원시 포인터로 자식을 들고 있으면 소멸자에서 자식을 직접 delete해야 하고, 복사·대입까지 손으로 관리해야 합니다(rule of three). 스마트 포인터로 바꾸면 이 책임이 사라집니다.

// 좋은 예
class Composite {
    std::vector<std::shared_ptr<Component>> children_;
};

Visitor·Iterator와 함께 쓰기

Visitor로 트리 연산 분리

class Visitor {
public:
    virtual void visitFile(File* file) = 0;
    virtual void visitFolder(Folder* folder) = 0;
};
class SizeCalculator : public Visitor {
    int total_ = 0;
public:
    void visitFile(File* file) override { total_ += file->getSize(); }
    void visitFolder(Folder* folder) override {
        for (auto& child : folder->getChildren())
            child->accept(this);
    }
    int getTotal() const { return total_; }
};

이 코드가 동작하려면 FileSystemItem에 virtual void accept(Visitor*) = 0;이 있고, File::accept는 v->visitFile(this), Folder::accept는 v->visitFolder(this)를 호출해야 합니다. 이 “이중 디스패치”로 방문자가 노드의 실제 타입에 맞는 함수를 받게 됩니다. Visitor를 붙이는 이유는 연산을 트리 클래스 밖으로 빼기 위해서입니다. Composite만 쓰면 크기 계산, 검색, 직렬화, 권한 검사 같은 연산이 늘 때마다 모든 노드 클래스에 가상 함수를 추가해야 하는데, Visitor를 쓰면 연산 하나가 방문자 클래스 하나가 됩니다. 대신 반대 방향의 비용이 생겨서, 새 노드 타입을 추가하면 모든 방문자에 함수를 추가해야 합니다. 노드 종류는 거의 고정이고 연산이 자주 늘어나는 도메인(컴파일러의 AST가 대표적)에 잘 맞습니다. 노드 타입 집합이 닫혀 있다면 C++17의 std::variant와 std::visit로 같은 효과를 얻는 방법도 있습니다.

Iterator로 트리 순회 감추기

class Composite {
    std::vector<std::shared_ptr<Component>> children_;
public:
    auto begin() { return children_.begin(); }
    auto end() { return children_.end(); }
};
// 사용
for (auto& child : composite) {
    child->operation();
}

이 방식은 직계 자식만 순회합니다. 트리 전체를 깊이 우선으로 훑는 반복자가 필요하다면 명시적인 스택(std::vector<Component*>)에 방문할 노드를 쌓아 두는 반복자를 만들어야 합니다. 재귀 대신 명시적 스택을 쓰면 트리가 수만 단계로 깊어져도 호출 스택이 넘치지 않고, 순회를 중간에 멈췄다가 이어갈 수도 있습니다. 또 내부 vector의 반복자를 그대로 노출하면 호출자가 순회 중에 add를 호출해 반복자가 무효화되는 문제가 생길 수 있으니, 읽기 전용이면 const 버전만 공개하는 편이 안전합니다.


조직도 시스템 만들기

#include <vector>
#include <memory>
#include <iostream>
#include <string>
class Employee {
public:
    virtual void showDetails(int indent = 0) const = 0;
    virtual int getSalary() const = 0;
    virtual void add(std::shared_ptr<Employee>) {}
    virtual ~Employee() = default;
};
class Developer : public Employee {
    std::string name_;
    int salary_;
public:
    Developer(std::string name, int salary) 
        : name_(std::move(name)), salary_(salary) {}
    
    void showDetails(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "Developer: " << name_ 
                  << " ($" << salary_ << ")\n";
    }
    
    int getSalary() const override { return salary_; }
};
class Manager : public Employee {
    std::string name_;
    int salary_;
    std::vector<std::shared_ptr<Employee>> team_;
public:
    Manager(std::string name, int salary) 
        : name_(std::move(name)), salary_(salary) {}
    
    void add(std::shared_ptr<Employee> emp) override {
        team_.push_back(std::move(emp));
    }
    
    void showDetails(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "Manager: " << name_ 
                  << " ($" << salary_ << ") - Team size: " << team_.size() << '\n';
        for (const auto& emp : team_)
            emp->showDetails(indent + 2);
    }
    
    int getSalary() const override {
        int total = salary_;
        for (const auto& emp : team_)
            total += emp->getSalary();
        return total;
    }
};
int main() {
    auto ceo = std::make_shared<Manager>("Alice", 150000);
    
    auto engManager = std::make_shared<Manager>("Bob", 120000);
    engManager->add(std::make_shared<Developer>("Charlie", 80000));
    engManager->add(std::make_shared<Developer>("David", 85000));
    ceo->add(engManager);
    
    auto salesManager = std::make_shared<Manager>("Eve", 110000);
    salesManager->add(std::make_shared<Developer>("Frank", 70000));
    ceo->add(salesManager);
    
    ceo->showDetails();
    std::cout << "\nTotal company payroll: $" << ceo->getSalary() << '\n';
    
    // 출력:
    // Manager: Alice ($150000) - Team size: 2
    //   Manager: Bob ($120000) - Team size: 2
    //     Developer: Charlie ($80000)
    //     Developer: David ($85000)
    //   Manager: Eve ($110000) - Team size: 1
    //     Developer: Frank ($70000)
    //
    // Total company payroll: $615000
    
    return 0;
}

조직도 예제는 Composite가 Leaf와 자신의 데이터도 함께 가진다는 점이 파일 시스템 예제와 다릅니다. 폴더는 자기 크기가 없고 자식 합만 반환하지만, Manager는 자기 급여에 팀원 급여를 더합니다. 합산 연산을 설계할 때 “Composite 자신의 값을 포함하는가”를 명확히 정해 두지 않으면 합계가 중복되거나 빠지는 버그가 생깁니다. 또 현실의 조직은 한 사람이 두 팀에 걸치는 겸직이 있어 순수한 트리가 아니게 되는데, 이 경우 같은 Developer를 두 매니저에 add하면 총 급여가 두 번 합산됩니다. 트리 구조가 깨지는 도메인이라면 Composite보다 그래프 모델과 방문 기록(visited set)이 필요합니다.


같이 보면 좋은 글