[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #14] 커맨드(Command) 패턴: 함수 객체와 코루틴으로 실행 명령 캡슐화하기

커맨드(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, 비동기, 로깅, 파이프라인 처리 등 다양한 기능을 쉽고 타입 안전하게 추가 가능하며, 유지보수성과 확장성이 비약적으로 향상됩니다.

반응형