커맨드(Command) 패턴은 실행할 작업(명령)을 객체로 캡슐화하여, 명령을 매개변수로 전달하거나 큐잉, Undo/Redo, 비동기 실행 등의 기능을 가능하게 하는 중요한 패턴입니다. 하지만 전통적 구현(특히 C++98/03, 심지어 C++11/14/17 시절)에서는 다음과 같은 단점이 있었습니다.
- 전통적 방식:
- 추상 커맨드 인터페이스(abstract Command) 정의
- 이를 상속하는 구체 커맨드 클래스 다수 정의 → 클래스 수 증가, 유지보수성 저하
- Undo/Redo, 비동기 처리, 로깅 추가 시 복잡성 폭발
- 명령 교체, 에러 처리 등 구현이 번거로움
하지만, C++20 이상 모던 C++에서는 람다, std::function, Concepts, std::expected, std::format, coroutine, Ranges 등의 풍부한 기능을 통해 상속 기반 설계 없이도 더 단순하고 직관적인 명령 시스템을 구현할 수 있습니다. 이 글에서는 전통적 구현과 C++11/14/17 스타일, 그리고 C++20 이상의 모던 접근을 비교하고, 다양한 예제를 통해 모던 C++이 제공하는 이점과 유연성을 보여줍니다.
패턴 소개: 커맨드(Command) 패턴
의도:
- 실행할 작업(행동)을 하나의 "명령"으로 캡슐화해, 이를 파라미터로 전달하거나, 명령 목록을 관리하고 Undo/Redo 기능 등을 제공.
- 예: 메뉴 명령, 단축키 명령, Undo/Redo 시스템, 명령 기록/재실행, 비동기 요청 처리.
전통적 구현 방식 (C++98/03 ~ C++11/14/17)
전통적 상속 기반 구현
// 추상 커맨드 인터페이스
struct Command {
virtual ~Command() = default;
virtual void execute() = 0;
};
// 구체 커맨드
struct CutCommand : Command {
void execute() override {
std::cout << "Cut executed\n";
}
};
// Invoker
struct Invoker {
std::unique_ptr<Command> cmd;
void setCommand(std::unique_ptr<Command> c) { cmd = std::move(c); }
void run() {
if (cmd) cmd->execute();
}
};
단점:
- 구체 커맨드 추가 시 클래스 생성 필요 → 클래스 수 증가
- Undo/Redo 구현 시 각 명령마다 상태 관리 필요, 또 다른 클래스나 포인터 관리 필요
- 에러 처리(예외 던지기나 bool 반환), 비동기 처리(쓰레드 관리), 로깅 시 상속 기반 구조로 인한 확장성 제약
C++11/14/17 시절 개선점
C++11 이후 std::unique_ptr나 람다, std::function 도입으로 명령을 함수 포인터나 람다로 다루는 시도 가능해졌으나, 여전히 다음과 같은 제약 존재:
- 람다나 std::function 활용 가능하지만, 타입 안전한 인터페이스 제약(Concepts) 불가능
- std::optional, std::variant는 C++17에서야 등장 → 에러 처리나 다양한 반환 구조 작성 난해
- coroutine, Ranges, std::expected 등 기능 미비 → 비동기 처리나 에러 처리 개선 한계
결국 C++11/14/17에서도 상속 기반 구조보다 조금 나은 정도에 불과했으며, Undo/Redo, 비동기, 파이프라인 실행 등 고급 요구사항 대응 시 여전히 복잡.
모던 C++20 이상에서의 개선
C++20 이상에서 제공하는 Concepts, coroutine, Ranges, std::expected, std::format 등은 명령 시스템을 훨씬 단순하게 재구성할 수 있습니다.
1. Command Interface를 Concepts로 정의
#include <concepts>
#include <string>
#include <expected>
#include <functional>
#include <format>
#include <iostream>
template<typename C>
concept CommandConcept = requires(C& c) {
{ c.execute() } -> std::convertible_to<std::expected<void,std::string>>;
};
비교:
- 전통적 구현: 추상 클래스+가상함수 필요
- C++11/14/17: 인터페이스 정적 검사 어려움, std::function 정도 가능
- C++20 이상: Concepts로 타입 안전한 인터페이스 제약 가능, 상속 없음
2. 람다나 함수 객체로 명령 정의
auto CutCommand = []() -> std::expected<void,std::string> {
std::cout << "Cut executed\n";
return {};
};
static_assert(CommandConcept<decltype(CutCommand)>);
비교:
- 전통적: 구체 명령 클래스를 상속해 구현 필요
- C++11/14/17: 람다 가능하지만 Command 인터페이스와 정적 타입 검사 한계
- C++20: 람다로 명령 구현, Concepts로 인터페이스 보장, 상속 없음
3. Invoker로 명령 관리
struct Invoker {
std::function<std::expected<void,std::string>()> cmd;
void setCommand(CommandConcept auto c) {
cmd = [c](){ return c.execute(); };
}
void run() {
if (!cmd) {
std::cerr << "No command set.\n";
return;
}
auto res = cmd();
if(!res) {
std::cerr << "Command error: " << res.error() << "\n";
} else {
std::cout << "Command executed successfully.\n";
}
}
};
비교:
- 전통적: std::unique_ptr<Command>로 인터페이스 포인터 관리
- C++11/14/17: std::function 가능하지만 에러 처리나 타입 안전성 미흡
- C++20: std::function + Concepts + std::expected로 명령 성공/실패 명확 처리
4. Undo/Redo 지원
Undo/Redo를 위해 execute/undo 메서드 모두 갖는 UndoableCommandConcept 정의:
template<typename C>
concept UndoableCommandConcept = requires(C& c) {
{ c.execute() } -> std::convertible_to<std::expected<void,std::string>>;
{ c.undo() } -> std::convertible_to<std::expected<void,std::string>>;
};
UndoableInvoker 예제:
struct UndoableInvoker {
std::vector<std::function<std::expected<void,std::string>()>> history;
std::vector<std::function<std::expected<void,std::string>()>> undoHistory;
void run(UndoableCommandConcept auto cmd) {
auto execRes = cmd.execute();
if(!execRes) {
std::cerr << "Execution error: " << execRes.error() << "\n";
} else {
history.push_back([cmd](){return cmd.execute();});
undoHistory.push_back([cmd](){return cmd.undo();});
}
}
void undoLast() {
if (undoHistory.empty()) {
std::cerr << "No command to undo\n";
return;
}
auto undoCmd = undoHistory.back();
undoHistory.pop_back();
history.pop_back();
auto res = undoCmd();
if(!res) std::cerr << "Undo error: " << res.error() << "\n";
else std::cout << "Undo succeeded.\n";
}
};
비교:
- 전통적: Undo/Redo 구현 시 상태 추적, 상속 기반 클래스 증가 문제
- C++11/14/17: 람다 가능하지만 Undo 상태 관리 여전히 번거로움
- C++20: 람다 캡처나 구조체로 상태 관리, Concepts로 Undoable 제약, std::expected로 undo 에러 처리 명확화
UndoableCommand 예제 (텍스트 편집):
struct TextDocument {
std::string text;
};
struct InsertTextCommand {
TextDocument& doc;
std::string inserted;
std::expected<void,std::string> execute() {
doc.text += inserted;
std::cout << std::format("Inserted '{}'. Current text: {}\n", inserted, doc.text);
return {};
}
std::expected<void,std::string> undo() {
if (doc.text.size() < inserted.size()) return std::unexpected("Undo failed: not enough text");
doc.text.erase(doc.text.size()-inserted.size());
std::cout << std::format("Undo insertion. Current text: {}\n", doc.text);
return {};
}
};
static_assert(UndoableCommandConcept<InsertTextCommand>);
비동기 명령: Coroutine 사용
비동기 명령 실행 시 coroutine으로 구현 가능:
#include <coroutine>
struct AsyncNetworkCommand {
struct promise_type {
std::expected<void,std::string> value;
AsyncNetworkCommand get_return_object() { return {*this}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_value(std::expected<void,std::string> v) { value = v; }
void unhandled_exception() {
value = std::unexpected("Unhandled exception");
}
};
promise_type& p;
std::expected<void,std::string> execute() { return p.value; }
};
AsyncNetworkCommand makeNetworkCommand(std::string url) {
co_return (url.empty())
? std::unexpected("URL empty")
: (std::cout << "Fetching " << url << "\n", std::expected<void,std::string>{});
}
static_assert(CommandConcept<AsyncNetworkCommand>);
비교:
- 전통적: 비동기 처리 시 별도 쓰레드 관리, 콜백 필요
- C++11/14/17: 비동기 처리 가능하나 coroutine 미지원, Promise/Future 등 별도 라이브러리 필요
- C++20: coroutine으로 간결한 비동기 명령, std::expected로 결과 처리 명확
로깅(데코레이터) 적용 예제
std::format으로 명령 실행 전후 로깅 가능:
auto makeLoggingCommand = [](CommandConcept auto baseCmd) {
return [baseCmd]() -> std::expected<void,std::string> {
std::cout << std::format("[LOG] Executing command...\n");
auto res = baseCmd.execute();
if(!res) {
std::cout << std::format("[LOG] Error: {}\n", res.error());
} else {
std::cout << "[LOG] Command executed successfully.\n";
}
return res;
};
};
auto LoggingCut = makeLoggingCommand([]() -> std::expected<void,std::string> {
std::cout << "Cut executed with logging.\n";
return {};
});
int main() {
auto res = LoggingCut();
if(!res) std::cerr << "Error: " << res.error() << "\n";
}
비교:
- 전통적: 로깅 기능 추가 시 상속 기반 Decorator 필요
- C++11/14/17: 람다 데코레이터 가능하지만 Concept 기반 인터페이스 보장 어려움
- C++20: 람다 장식으로 로깅 추가, 상속 없음, 타입 안전성 유지
Ranges를 통한 명령 파이프라인 실행
여러 명령을 모아 순차 실행하거나 조건부 실행하는 파이프라인 구성 가능:
#include <vector>
#include <ranges>
#include <algorithm>
std::vector<std::function<std::expected<void,std::string>()>> commands = {
[](){ std::cout << "Command1\n"; return std::expected<void,std::string>{}; },
[](){ std::cout << "Command2\n"; return std::expected<void,std::string>{}; },
[](){ return std::unexpected("Command3 failed"); }
};
// 첫 실패 시 중단하는 파이프라인
for (auto& cmd : commands) {
auto res = cmd();
if(!res) {
std::cerr << "Pipeline error: " << res.error() << "\n";
break;
}
}
비교:
- 전통적: 명령 리스트를 관리하려면 다형성 포인터 필요, 조건부 실행 추가 시 복잡
- C++11/14/17: 람다 명령 리스트 가능하나 Ranges 미지원 → STL 알고리즘 사용
- C++20: Ranges로 조건부 실행, 변환, 필터링 등 파이프라인 처리 가능
전통적 구현 vs C++11/14/17 vs C++20 이상 비교 요약
전통적 (C++98/03):
- 추상 Command 클래스 + 구체 명령 상속 필요
- 클래스 파일 증가, 유지보수 어려움
- 에러 처리, Undo/Redo, 비동기 처리, 로깅 등 추가 기능 구현 시 복잡성 급증
- 타입 안전성 제한, 주로 vtable 기반 런타임 다형성 의존
C++11/14/17 시대:
- 람다, std::function 도입 → 상속 없이도 일부 명령 처리 가능
- 여전히 Concepts, std::expected, coroutine, Ranges 부재 → 정적 타입 제약, 명확한 에러 처리, 비동기 친화적 코드 작성 어려움
- Undo/Redo, 로깅, 비동기 등 고급 요구사항 구현은 다소 개선되나 여전히 boilerplate 증가
C++20 이상 (모던 C++):
- Concepts로 Command 인터페이스 요구사항을 정적, 타입 안전하게 명시
- 람다와 함수 객체로 명령 구현, 클래스 수 대폭 감소, 상속 없음
- std::expected로 에러 처리 명확화, std::format으로 로깅 용이
- coroutine으로 비동기 명령 구현, Ranges로 명령 파이프라인 처리, Undo/Redo 시 상태 캡처/구조체 사용 용이
- 복잡한 요구사항(Undo/Redo, 비동기, 로깅, 조건부 실행 등)도 적은 코드로 간단하게 처리
- 유지보수성, 확장성, 타입 안전성, 성능(템플릿 기반 정적 다형성) 모두 개선
결론
커맨드 패턴은 명령을 객체화해 시스템을 유연하게 만드는 핵심 패턴이지만, 전통적 구현은 상속 기반 접근으로 클래스 증가, 유지보수 어려움을 야기했습니다. C++11/14/17 시대에는 람다와 std::function으로 약간 개선되었으나, 여전히 에러 처리, 비동기 처리, Undo/Redo 지원, 파이프라인 실행 같은 고급 기능 구현은 번거로웠습니다.
C++20 이상에서는 Concepts, coroutine, std::expected, std::format, Ranges 등 언어 및 표준 라이브러리 기능을 활용해 상속 없는 명령 시스템을 구축할 수 있습니다. 이를 통해 명령을 람다 하나로 표현하고, Undo/Redo, 비동기, 로깅, 파이프라인 처리 등 다양한 기능을 쉽고 타입 안전하게 추가 가능하며, 유지보수성과 확장성이 비약적으로 향상됩니다.