디렉터리 크기를 구하려면 안에 든 파일 크기를 더하고, 하위 디렉터리가 있으면 그 안으로 다시 들어가야 한다. 파일과 디렉터리를 별개 타입으로 다루면 순회하는 코드마다 “이게 파일인가 디렉터리인가”를 분기해야 한다. 컴포지트(Composite)는 개별 객체(Leaf)와 객체 묶음(Composite)이 같은 인터페이스를 갖게 해서, 클라이언트가 트리의 한 노드든 전체 트리든 똑같이 다루게 한다. 이 글에서는 파일 시스템 트리로 기본형을 만들고, std::variant를 쓴 값 기반 컴포지트, 부모 포인터와 집계값 캐시, 순환 방지, C의 first-child/next-sibling 트리를 차례로 다룬다.
메뉴 시스템 예제로 공통 기반 클래스와 add()의 위치(투명성 vs 안전성)를 다룬 내용은 설계 기법 (8) 메뉴 시스템에 있다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (6) — 어댑터(Adapter)
- C/C++ 디자인 패턴 (7) — 브리지(Bridge)
- C/C++ 디자인 패턴 (9) — 데코레이터(Decorator)
- C/C++ 디자인 패턴 (10) — 퍼사드(Facade)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Component | Leaf와 Composite의 공통 인터페이스 | Node (size(), print()) |
| Leaf | 자식이 없는 말단 객체 | File |
| Composite | 자식을 보관하고, 연산을 자식에게 재귀적으로 위임 | Dir |
| Client | Component 인터페이스로만 트리를 다룸 | main() |
기본형: 파일 시스템 트리
// fs.cpp — 파일과 디렉터리를 같은 인터페이스(Node)로 다룬다
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class Node {
public:
explicit Node(std::string name) : name_(std::move(name)) {}
virtual ~Node() = default;
virtual long size() const = 0;
virtual void print(int depth) const = 0;
const std::string &name() const { return name_; }
protected:
std::string name_;
};
class File : public Node { // Leaf
public:
File(std::string n, long bytes) : Node(std::move(n)), bytes_(bytes) {}
long size() const override { return bytes_; }
void print(int d) const override
{ std::cout << std::string(d * 2, ' ') << name_ << " (" << bytes_ << ")\n"; }
private:
long bytes_;
};
class Dir : public Node { // Composite
public:
using Node::Node;
Dir &add(std::unique_ptr<Node> n) { children_.push_back(std::move(n)); return *this; }
long size() const override
{
long total = 0;
for (const auto &c : children_)
total += c->size(); // 자식이 파일이든 디렉터리든 같은 호출
return total;
}
void print(int d) const override
{
std::cout << std::string(d * 2, ' ') << name_ << "/ [" << size() << "]\n";
for (const auto &c : children_)
c->print(d + 1);
}
private:
std::vector<std::unique_ptr<Node>> children_;
};
int main()
{
auto bin = std::make_unique<Dir>("bin");
bin->add(std::make_unique<File>("ls", 142312)).add(std::make_unique<File>("cat", 35280));
auto etc = std::make_unique<Dir>("etc");
etc->add(std::make_unique<File>("hosts", 221));
Dir root("usr");
root.add(std::move(bin)).add(std::move(etc)).add(std::make_unique<File>("README", 1024));
root.print(0);
const Node &leaf = File("x", 7), &tree = root; // 클라이언트는 구분하지 않는다
std::cout << "leaf=" << leaf.size() << " tree=" << tree.size() << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o fs fs.cpp && ./fs
usr/ [178837]
bin/ [177592]
ls (142312)
cat (35280)
etc/ [221]
hosts (221)
README (1024)
leaf=7 tree=178837
Dir::size()는 자식이 파일인지 디렉터리인지 묻지 않고 size()를 부른다. 마지막 줄처럼 클라이언트도 리프 하나와 트리 전체를 같은 타입으로 다룬다.
std::variant로 만든 값 기반 컴포지트
노드 종류가 고정돼 있다면 상속과 힙 포인터 없이 std::variant로 재귀 구조를 표현할 수 있다. std::vector는 C++17부터 불완전 타입을 원소로 허용하므로 Dir 안에 std::vector<Node>를 둘 수 있다.
// variant_tree.cpp — 상속 없이 std::variant로 만든 값 기반 컴포지트
#include <iostream>
#include <string>
#include <variant>
#include <vector>
struct File;
struct Dir;
using Node = std::variant<File, Dir>;
struct File { std::string name; long bytes; };
struct Dir { std::string name; std::vector<Node> children; }; // vector는 불완전 타입 허용(C++17)
template <class... F> struct overloaded : F... { using F::operator()...; };
long size(const Node &n)
{
return std::visit(overloaded{
[](const File &f) { return f.bytes; },
[](const Dir &d) {
long t = 0;
for (const auto &c : d.children)
t += size(c);
return t;
},
}, n);
}
int count_files(const Node &n)
{
if (auto *d = std::get_if<Dir>(&n)) {
int c = 0;
for (const auto &ch : d->children)
c += count_files(ch);
return c;
}
return 1;
}
int main()
{
Node root = Dir{"usr", {
Dir{"bin", {File{"ls", 142312}, File{"cat", 35280}}},
Dir{"etc", {File{"hosts", 221}}},
File{"README", 1024},
}};
Node copy = root; // 값 의미: 트리 전체가 깊게 복사된다
std::get<Dir>(copy).children.push_back(File{"NEW", 1});
std::cout << "root size=" << size(root) << " files=" << count_files(root) << '\n'
<< "copy size=" << size(copy) << " files=" << count_files(copy) << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o variant_tree variant_tree.cpp && ./variant_tree
root size=178837 files=4
copy size=178838 files=5
대입 한 줄로 트리 전체가 깊게 복사되고, 복사본에 파일을 추가해도 원본은 그대로다. 대신 새 노드 종류를 추가하면 모든 std::visit 지점을 고쳐야 한다.
| 구분 | 상속 + unique_ptr | std::variant |
|---|---|---|
| 새 노드 종류 추가 | 클래스 하나 추가 | variant와 모든 visit 수정 |
| 새 연산 추가 | 모든 클래스에 가상 함수 추가 | 함수 하나 추가 |
| 복사 | clone() 직접 구현 | 기본 복사가 깊은 복사 |
| 메모리 | 노드마다 힙 할당 | 자식 벡터 단위 할당 |
부모 포인터, 캐시, 순환 방지
실제 트리에는 “경로를 구한다”, “집계값을 캐시한다”, “잘못된 이동을 막는다” 같은 요구가 붙는다. 셋 다 자식이 부모를 알아야 풀린다.
// parent_cache.cpp — 부모 포인터로 경로를 만들고, 크기 캐시를 위로 무효화한다
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
class Dir;
class Node {
public:
explicit Node(std::string n) : name_(std::move(n)) {}
virtual ~Node() = default;
virtual long size() const = 0;
std::string path() const;
Dir *parent() const { return parent_; }
protected:
friend class Dir;
std::string name_;
Dir *parent_ = nullptr; // 소유하지 않는 역참조
void invalidateUp();
};
class Dir : public Node {
public:
using Node::Node;
Node &add(std::unique_ptr<Node> n)
{
for (Node *p = this; p; p = p->parent()) // 자기 조상을 자식으로 넣는 순환 방지
if (p == n.get())
throw std::logic_error("cycle: '" + n->path() + "' is an ancestor of '" + path() + "'");
n->parent_ = this;
children_.push_back(std::move(n));
invalidateUp();
return *children_.back();
}
std::unique_ptr<Node> detach(const Node &n)
{
for (auto it = children_.begin(); it != children_.end(); ++it)
if (it->get() == &n) {
auto out = std::move(*it);
children_.erase(it);
out->parent_ = nullptr;
invalidateUp();
return out;
}
return nullptr;
}
long size() const override
{
if (!cached_) {
++recomputes;
long t = 0;
for (const auto &c : children_)
t += c->size();
cache_ = t;
cached_ = true;
}
return cache_;
}
static inline int recomputes = 0;
private:
friend class Node;
std::vector<std::unique_ptr<Node>> children_;
mutable long cache_ = 0;
mutable bool cached_ = false;
};
class File : public Node {
public:
File(std::string n, long b) : Node(std::move(n)), bytes_(b) {}
long size() const override { return bytes_; }
void resize(long b) { bytes_ = b; invalidateUp(); }
private:
long bytes_;
};
std::string Node::path() const { return parent_ ? parent_->path() + "/" + name_ : name_; }
void Node::invalidateUp()
{
for (Dir *p = dynamic_cast<Dir *>(this) ? static_cast<Dir *>(this) : parent_; p; p = p->parent_)
p->cached_ = false;
}
int main()
{
Dir root("");
auto &usr = static_cast<Dir &>(root.add(std::make_unique<Dir>("usr")));
auto &bin = static_cast<Dir &>(usr.add(std::make_unique<Dir>("bin")));
auto &ls = static_cast<File &>(bin.add(std::make_unique<File>("ls", 142312)));
std::cout << ls.path() << '\n';
for (int q = 1; q <= 3; ++q) {
Dir::recomputes = 0;
long s = root.size();
std::cout << "query " << q << ": size=" << s << " recomputes=" << Dir::recomputes << '\n';
}
ls.resize(150000);
Dir::recomputes = 0;
std::cout << "after resize size=" << root.size() << " recomputes=" << Dir::recomputes << '\n';
try {
auto moved = usr.parent()->detach(usr); // root에서 usr를 떼어
bin.add(std::move(moved)); // 자기 자손 밑에 넣으려 하면
} catch (const std::exception &e) {
std::cout << "error: " << e.what() << '\n';
}
}$ g++ -std=c++20 -Wall -Wextra -O2 -fsanitize=address -o parent_cache parent_cache.cpp && ./parent_cache
/usr/bin/ls
query 1: size=142312 recomputes=3
query 2: size=142312 recomputes=0
query 3: size=142312 recomputes=0
after resize size=150000 recomputes=3
error: cycle: 'usr' is an ancestor of 'usr/bin'
첫 질의에서 디렉터리 3개가 한 번씩 계산되고, 이후 질의는 캐시만 읽는다. 파일 크기가 바뀌면 그 위 조상들만 다시 계산되고, 자기 조상을 자손 밑에 넣으려는 시도는 예외로 막혔다.
깊은 트리: 재귀 대신 명시적 스택
컴포지트의 연산은 자연스럽게 재귀가 되지만, 깊이가 스레드 스택 크기를 넘으면 순회와 소멸 모두 스택을 넘친다. 한 줄로 이어진 100만 단계 트리로 확인했다.
// deep.cpp — 아주 깊은 트리에서 재귀 순회·재귀 소멸은 스택을 넘친다
#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Node {
long bytes = 1;
std::vector<std::unique_ptr<Node>> children;
~Node() // 재귀 소멸을 피하려면 자식을 먼저 평탄화해서 해제
{
std::vector<std::unique_ptr<Node>> pending = std::move(children);
while (!pending.empty()) {
auto n = std::move(pending.back());
pending.pop_back();
for (auto &c : n->children)
pending.push_back(std::move(c));
n->children.clear(); // n은 여기서 자식 없이 해제된다
}
}
};
long size_recursive(const Node &n)
{
long t = n.bytes;
for (const auto &c : n.children)
t += size_recursive(*c);
return t;
}
long size_iterative(const Node &root)
{
long t = 0;
std::vector<const Node *> stack{&root}; // 명시적 스택
while (!stack.empty()) {
const Node *n = stack.back();
stack.pop_back();
t += n->bytes;
for (const auto &c : n->children)
stack.push_back(c.get());
}
return t;
}
int main(int argc, char **argv)
{
int depth = std::atoi(argv[1]);
bool recursive = argc > 2 && std::string(argv[2]) == "recursive";
auto root = std::make_unique<Node>();
Node *cur = root.get();
for (int i = 1; i < depth; ++i) { // 한 줄로 이어진 깊은 트리
cur->children.push_back(std::make_unique<Node>());
cur = cur->children.back().get();
}
std::cout << (recursive ? "recursive" : "iterative") << " depth=" << depth << " size="
<< (recursive ? size_recursive(*root) : size_iterative(*root)) << std::endl;
}$ g++ -std=c++20 -Wall -Wextra -O0 -o deep deep.cpp && ./deep 1000000 && ./deep 1000 recursive && ./deep 1000000 recursive; echo "exit=$?"
iterative depth=1000000 size=1000000
recursive depth=1000 size=1000
bash: line 1: 34587 Segmentation fault (core dumped) ./deep 1000000 recursive
exit=139
재귀 버전은 깊이 1000에서는 동작하지만 100만에서는 SIGSEGV(139)로 죽는다. 기본 소멸자도 자식의 소멸자를 재귀적으로 부르므로, 예제처럼 자식을 벡터로 옮겨 가며 해제해야 같은 문제를 피한다.
C 구현: first-child / next-sibling
C에서는 자식 배열 대신 첫 자식과 다음 형제 포인터 두 개로 임의 개수의 자식을 표현하는 방식이 흔하다. 노드 구조체 하나가 리프와 컴포지트를 겸한다.
/* tree.c — C 컴포지트: first_child / next_sibling 링크로 만든 트리 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct node {
char name[16];
long bytes; /* 리프만 의미 있음 */
int is_dir;
struct node *parent, *first_child, *next_sibling;
};
static struct node *node_new(const char *name, int is_dir, long bytes)
{
struct node *n = calloc(1, sizeof(*n));
if (!n)
abort();
snprintf(n->name, sizeof(n->name), "%s", name);
n->is_dir = is_dir;
n->bytes = bytes;
return n;
}
static struct node *node_add(struct node *dir, struct node *child)
{
child->parent = dir;
child->next_sibling = dir->first_child; /* 앞에 끼우기 */
dir->first_child = child;
return child;
}
static long node_size(const struct node *n) /* 리프와 컴포지트를 같은 함수로 */
{
long total = n->is_dir ? 0 : n->bytes;
for (const struct node *c = n->first_child; c; c = c->next_sibling)
total += node_size(c);
return total;
}
static void node_print(const struct node *n, int depth)
{
printf("%*s%s%s [%ld]\n", depth * 2, "", n->name, n->is_dir ? "/" : "", node_size(n));
for (const struct node *c = n->first_child; c; c = c->next_sibling)
node_print(c, depth + 1);
}
static void node_free(struct node *n)
{
struct node *c = n->first_child;
while (c) {
struct node *next = c->next_sibling;
node_free(c);
c = next;
}
free(n);
}
int main(void)
{
struct node *root = node_new("usr", 1, 0);
struct node *bin = node_add(root, node_new("bin", 1, 0));
node_add(bin, node_new("ls", 0, 142312));
node_add(bin, node_new("cat", 0, 35280));
node_add(root, node_new("README", 0, 1024));
node_print(root, 0);
node_free(root);
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -fsanitize=address -o tree tree.c && ./tree
usr/ [178616]
README [1024]
bin/ [177592]
cat [35280]
ls [142312]
앞에 끼워 넣기 때문에 출력 순서가 추가 순서의 역순이다. 리눅스 커널의 디바이스 트리 노드(struct device_node)도 parent, child, sibling 포인터로 같은 구조를 이룬다.
주의사항
- 소유는 위에서 아래로만: 부모가 자식을
unique_ptr로 소유하고, 자식의 부모 포인터는 소유하지 않는 원시 포인터로 둔다. 양방향을shared_ptr로 만들면 순환 참조로 해제되지 않는다. - 재귀 깊이: 사용자 입력으로 트리가 만들어진다면(파서, 파일 시스템 탐색) 깊이를 제한하거나 처음부터 반복 순회로 작성한다. 최적화 수준에 따라 한계 깊이가 달라지므로 “이 정도면 괜찮다”는 값을 믿지 않는다.
- 캐시 무효화 누락: 리프를 바꾸는 모든 경로(크기 변경, 추가, 제거, 이동)가 조상 캐시를 무효화해야 한다. 하나라도 빠지면 틀린 값이 조용히 나온다.
- 순환 검사 비용: 예제의 순환 검사는 조상을 따라 올라가므로 O(깊이)다. 노드 이동이 잦지 않다면 충분하다.
- 가상 함수의 기본 인자:
virtual void print(int depth = 0)처럼 기반에 기본 인자를 두면, 파생 클래스 객체로 직접 부를 때는 적용되지 않아 컴파일 에러가 난다. 기본 인자는 비가상 래퍼에 둔다.
마무리
컴포지트는 재귀적인 데이터 구조를 재귀적인 연산으로 다루게 해 주는 패턴이다. 노드 종류가 열려 있으면 상속, 닫혀 있으면 std::variant가 잘 맞는다. 다음 글에서는 객체를 감싸 기능을 덧붙이는 데코레이터(Decorator)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Composite
- cppreference — std::visit
- Linux kernel docs — Linux and the Devicetree