메뉴는 실행할 수 있는 항목(Open, Save)과 다른 메뉴를 담는 팝업 메뉴(File, Recent)로 이루어지고, 팝업 안에 팝업이 다시 들어가는 트리 구조다. 두 종류를 따로 다루면 "이 항목이 팝업인가?"를 묻는 분기가 곳곳에 생기고, 팝업 안에 팝업을 넣을 수도 없다. 컴포지트(Composite) 패턴은 개별 객체와 객체를 담는 그릇을 같은 기반 클래스로 묶어 똑같이 다루게 한다. 이 글에서는 콘솔 메뉴 시스템을 컴포지트로 만들고, 재귀 연산과 add()를 어디에 둘지(투명성 vs 안전성)를 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (6) — 입력 검증으로 보는 템플릿 메서드와 전략(LineEdit)
- C++ 예제로 배우는 소프트웨어 설계 기법 (7) — 템플릿 인자로 전달하는 정책(Template Strategy)
- C++ 예제로 배우는 소프트웨어 설계 기법 (9) — 메뉴 이벤트 처리(Menu Event)
- C++ 예제로 배우는 소프트웨어 설계 기법 (10) — 컨테이너 설계(Container)
항목만 담는 팝업 메뉴
#include <string>
#include <vector>
struct MenuItem {
std::string title;
void command() {}
};
struct PopupMenu {
std::string title;
std::vector<MenuItem*> items; // MenuItem만 담을 수 있다
void add(MenuItem* m) { items.push_back(m); }
};
int main()
{
PopupMenu root{"root"}, file{"File"};
MenuItem open{"Open"};
file.add(&open);
root.add(&file); // 하위 메뉴를 넣고 싶다
}$ g++ -std=c++20 -c naive.cpp 2>&1 | grep -E "error|note: +no known" | head -2
naive.cpp:20:14: error: cannot convert ‘PopupMenu*’ to ‘MenuItem*’
PopupMenu가 MenuItem*만 담으므로 하위 메뉴를 넣을 수 없다.
공통 기반 클래스로 묶기
#pragma once
#include <cstdio>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class BaseMenu { // 메뉴 항목과 팝업 메뉴의 공통 기반
std::string title_;
public:
explicit BaseMenu(std::string t) : title_(std::move(t)) {}
virtual ~BaseMenu() = default;
const std::string& title() const { return title_; }
virtual void command() = 0; // 선택됐을 때 할 일
};
class MenuItem : public BaseMenu {
int id_;
public:
MenuItem(std::string t, int id) : BaseMenu(std::move(t)), id_(id) {}
void command() override { std::printf(" >> run '%s' (id=%d)\n", title().c_str(), id_); }
};
class PopupMenu : public BaseMenu {
std::vector<std::unique_ptr<BaseMenu>> items_; // 항목도, 하위 팝업도 담는다
public:
using BaseMenu::BaseMenu;
PopupMenu* add(std::unique_ptr<BaseMenu> m) { items_.push_back(std::move(m)); return this; }
std::size_t size() const { return items_.size(); }
BaseMenu* at(std::size_t i) const { return items_[i].get(); }
void command() override // 팝업이 선택되면 목록을 보여주고 다시 선택받는다
{
for (;;) {
std::printf(" [%s]", title().c_str());
for (std::size_t i = 0; i < items_.size(); ++i) std::printf(" %zu.%s", i + 1, items_[i]->title().c_str());
std::printf(" 0.back\n");
std::size_t sel;
if (!(std::cin >> sel) || sel == 0) return;
if (sel <= items_.size()) items_[sel - 1]->command(); // 항목인지 팝업인지 묻지 않는다
}
}
};#include "menu.h"
int main()
{
auto file = std::make_unique<PopupMenu>("File");
file->add(std::make_unique<MenuItem>("Open", 11))
->add(std::make_unique<MenuItem>("Save", 12));
auto recent = std::make_unique<PopupMenu>("Recent");
recent->add(std::make_unique<MenuItem>("a.txt", 21))
->add(std::make_unique<MenuItem>("b.txt", 22));
file->add(std::move(recent)); // 팝업 안에 팝업
PopupMenu root("Main");
root.add(std::move(file))
->add(std::make_unique<MenuItem>("Exit", 99));
root.command();
}$ g++ -std=c++20 -Wall -o cp composite.cpp
$ printf "1\n3\n2\n0\n1\n0\n2\n0\n" | ./cp
[Main] 1.File 2.Exit 0.back
[File] 1.Open 2.Save 3.Recent 0.back
[Recent] 1.a.txt 2.b.txt 0.back
>> run 'b.txt' (id=22)
[Recent] 1.a.txt 2.b.txt 0.back
[File] 1.Open 2.Save 3.Recent 0.back
>> run 'Open' (id=11)
[File] 1.Open 2.Save 3.Recent 0.back
[Main] 1.File 2.Exit 0.back
>> run 'Exit' (id=99)
[Main] 1.File 2.Exit 0.back
입력은 1 3 2 0 1 0 2 0(File → Recent → b.txt → 뒤로 → Open → 뒤로 → Exit → 종료)이다. PopupMenu::command()는 선택된 것이 항목인지 팝업인지 묻지 않고 command()만 부르고, 팝업이면 다시 자기 목록을 보여준다.
트리 전체에 대한 재귀 연산
#include <cstdio>
#include <memory>
#include <string>
#include <vector>
struct BaseMenu {
std::string title;
explicit BaseMenu(std::string t) : title(std::move(t)) {}
virtual ~BaseMenu() = default;
virtual void print(int depth) const = 0;
virtual int count_items() const = 0; // 실행 가능한 항목 수
virtual const BaseMenu* find(const std::string& t) const { return t == title ? this : nullptr; }
};
struct MenuItem : BaseMenu {
using BaseMenu::BaseMenu;
void print(int d) const override { std::printf("%*s- %s\n", d * 2 + 2, "", title.c_str()); }
int count_items() const override { return 1; }
};
struct PopupMenu : BaseMenu {
std::vector<std::unique_ptr<BaseMenu>> items;
using BaseMenu::BaseMenu;
template <typename... M> PopupMenu(std::string t, M... m) : BaseMenu(std::move(t))
{
(items.push_back(std::unique_ptr<BaseMenu>(m)), ...);
}
void print(int d) const override
{
std::printf("%*s+ %s\n", d * 2 + 2, "", title.c_str());
for (auto& m : items) m->print(d + 1); // 자식에게 같은 요청을 위임
}
int count_items() const override
{
int n = 0;
for (auto& m : items) n += m->count_items();
return n;
}
const BaseMenu* find(const std::string& t) const override
{
if (t == title) return this;
for (auto& m : items) if (auto r = m->find(t)) return r;
return nullptr;
}
};
int main()
{
PopupMenu root("Main",
new PopupMenu("File", new MenuItem("Open"), new MenuItem("Save"),
new PopupMenu("Recent", new MenuItem("a.txt"), new MenuItem("b.txt"))),
new PopupMenu("Edit", new MenuItem("Copy"), new MenuItem("Paste")),
new MenuItem("Exit"));
root.print(0);
std::printf(" items=%d\n", root.count_items());
std::printf(" find(b.txt)=%s, find(Redo)=%s\n",
root.find("b.txt") ? "found" : "-", root.find("Redo") ? "found" : "-");
}$ g++ -std=c++20 -Wall -o rc recursive.cpp
$ ./rc
+ Main
+ File
- Open
- Save
+ Recent
- a.txt
- b.txt
+ Edit
- Copy
- Paste
- Exit
items=7
find(b.txt)=found, find(Redo)=-
출력, 항목 수 세기, 이름으로 찾기 모두 팝업은 자식에게 같은 요청을 위임하고 항목은 자기 몫만 처리한다. 트리 깊이가 늘어도 호출하는 쪽 코드는 root.xxx() 한 줄이다.
add()는 어디에 둘까
#include <cstdio>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
// 투명성 우선: add()를 기반 클래스에 둔다
struct BaseMenu {
std::string title;
explicit BaseMenu(std::string t) : title(std::move(t)) {}
virtual ~BaseMenu() = default;
virtual void add(std::unique_ptr<BaseMenu>) { throw std::logic_error("cannot add to '" + title + "'"); }
virtual BaseMenu* submenu(std::size_t) { return nullptr; }
};
struct MenuItem : BaseMenu { using BaseMenu::BaseMenu; };
struct PopupMenu : BaseMenu {
std::vector<std::unique_ptr<BaseMenu>> items;
using BaseMenu::BaseMenu;
void add(std::unique_ptr<BaseMenu> m) override { items.push_back(std::move(m)); }
BaseMenu* submenu(std::size_t i) override { return i < items.size() ? items[i].get() : nullptr; }
};
int main()
{
PopupMenu root("Main");
root.add(std::make_unique<PopupMenu>("File"));
root.add(std::make_unique<MenuItem>("Exit"));
root.submenu(0)->add(std::make_unique<MenuItem>("Open")); // 캐스팅 없이 추가
std::printf(" File has an item now\n");
try {
root.submenu(1)->add(std::make_unique<MenuItem>("Oops")); // 항목에 추가: 실행 중 에러
} catch (const std::exception& e) {
std::printf(" error: %s\n", e.what());
}
}$ g++ -std=c++20 -Wall -o tr transparency.cpp
$ ./tr
File has an item now
error: cannot add to 'Exit'
add()를 기반 클래스에 두면 캐스팅 없이 어떤 메뉴에든 추가할 수 있지만(투명성), 항목에 추가하는 실수는 실행 중에야 드러난다.
#include <memory>
#include <string>
#include <vector>
// 안전성 우선: add()는 PopupMenu에만 있다
struct BaseMenu { std::string title; virtual ~BaseMenu() = default; };
struct MenuItem : BaseMenu {};
struct PopupMenu : BaseMenu {
std::vector<std::unique_ptr<BaseMenu>> items;
void add(std::unique_ptr<BaseMenu> m) { items.push_back(std::move(m)); }
};
int main()
{
PopupMenu root;
root.add(std::make_unique<PopupMenu>());
BaseMenu* first = root.items[0].get();
first->add(std::make_unique<MenuItem>()); // 컴파일 에러: 기반 타입에는 add가 없다
if (auto* p = dynamic_cast<PopupMenu*>(first)) p->add(std::make_unique<MenuItem>()); // 대신 캐스팅 필요
}$ g++ -std=c++20 -c safety.cpp 2>&1 | grep error
safety.cpp:18:12: error: ‘struct BaseMenu’ has no member named ‘add’
PopupMenu에만 두면 실수가 컴파일 에러가 되는 대신(안전성), 기반 포인터로 받은 메뉴에 추가하려면 dynamic_cast가 필요하다.
| 구성 요소 | 메뉴 시스템 | 역할 |
|---|---|---|
| — | — | — |
| Component | BaseMenu | 공통 인터페이스 (title, command) |
| Leaf | MenuItem | 실제 동작 수행 |
| Composite | PopupMenu | 자식 보관, 요청을 자식에게 위임 |
add() 위치 | 장점 | 단점 |
|---|---|---|
| — | — | — |
| 기반 클래스 (투명성) | 모든 노드를 같은 타입으로 조작 | 항목에 add 시 실행 중 에러 |
| Composite에만 (안전성) | 잘못된 호출이 컴파일 에러 | 기반 포인터에서 캐스팅 필요 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| — | — | — |
| 자식을 raw 포인터로 보관 | 소유권 불명확, 누수·이중 해제 | std::unique_ptr로 부모가 소유 |
| 팝업이 자기 조상을 자식으로 추가 | 순환으로 재귀 연산이 끝나지 않음 | add()에서 조상 검사 |
| 자식에게 부모 포인터 저장 | 부모 이동 시 dangling | 부모를 이동 불가로 하거나 이동 시 갱신 |
| 깊은 트리에서 재귀 연산 | 스택 사용 증가 | 명시적 스택으로 순회 |
| Leaf에 맞지 않는 연산을 기반에 추가 | 의미 없는 기본 구현 증가 | 공통 연산만 기반에 두고 나머지는 Visitor 등으로 |
마무리
- 항목과 그릇을 같은 기반 클래스로 묶으면 클라이언트는 둘을 구분하지 않고 다룬다.
- 그릇은 요청을 자식에게 위임하므로 트리 전체 연산이 재귀 한 번으로 끝난다.
add()를 기반에 둘지 그릇에만 둘지는 투명성과 안전성 사이의 선택이다.