[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #12] 상태(State) 패턴: std::variant, Concepts, 그리고 코루틴으로 선언적 상태 머신 구현하기

이전 글에서는 옵저버(Observer) 패턴을 모던 C++ 관점에서 재해석하며, 상속 기반 인터페이스 대신 람다, std::function, std::expected, Ranges, 코루틴 등을 활용해 더욱 유연하고 타입 안전하며 이벤트 중심적인 구조를 구현할 수 있음을 확인했습니다. 이번에는 행동(Behavioral) 패턴 중 또 하나의 대표 주자인 상태(State) 패턴에 주목합니다.

상태 패턴은 객체가 내부 상태에 따라 다른 행동을 하도록 하며, 상태 전이를 명확하게 표현하는 패턴입니다. 전통적으로는 상속 기반 상태 클래스와 가상 함수를 통해 상태 변화를 구현했지만, 모던 C++20 이상에서는 std::variant, std::visit, Concepts, 그리고 코루틴(coroutines) 등을 활용해 상태 전이를 더 선언적이고 유지보수성 높게 표현할 수 있습니다. 이를 통해 상태 머신을 간결하고 타입 안전하게 구성할 수 있습니다.

패턴 소개: 상태(State)

의도:

  • 객체의 행동을 내부 상태에 따라 바꾸는 패턴.
  • 상태를 별도 클래스로 캡슐화하고, 런타임에 상태 객체 교체로 행동 변경.
  • 예: 문서(Editor)의 상태(초안, 검토중, 발행됨)에 따라 다른 메서드 동작. 뱅크 계좌(계정)가 잔액에 따라 인출 가능/불가능 상태 변화.

전통적 구현 문제점:

  • 상태마다 클래스를 상속해 vtable 기반 다형성으로 구현.
  • 새로운 상태 추가 시 클래스, 파일 증가.
  • 상태 전이 로직 관리 복잡, 에러 처리, 비동기 전이 등 어려움.

기존 C++ 스타일 구현 (C++11/14/17)

예를 들어, 문서(Document) 상태: Draft, Review, Published 상태를 각각 클래스로 정의하고, Document가 현재 상태를 가리키도록 하는 식.

#include <iostream>
#include <memory>
#include <string>

struct DocumentState {
    virtual ~DocumentState() = default;
    virtual void publish() = 0;
};

struct DraftState : DocumentState {
    void publish() override {
        std::cout << "From Draft to Review\n";
    }
};

struct ReviewState : DocumentState {
    void publish() override {
        std::cout << "From Review to Published\n";
    }
};

struct PublishedState : DocumentState {
    void publish() override {
        std::cout << "Already Published\n";
    }
};

struct Document {
    std::unique_ptr<DocumentState> state;
    Document() : state(std::make_unique<DraftState>()) {}
    void publish() {
        state->publish();
        // 여기서 상태 전이가 필요하다면 state를 교체
        // ex: DraftState -> ReviewState
        // 하지만 전이 로직이 분산되어 있어 관리 불편
    }
};

고찰:

  • 상속 기반 상태 클래스, 전이 로직 분산.
  • 새로운 상태 추가 시 클래스 파일 증가.
  • 상태 전이 코드가 Document 클래스나 상태 클래스 곳곳에 산재.

모던 C++20 이상의 개선: std::variant, std::visit, Concepts, 코루틴

1. std::variant로 상태 표현

상태를 별도 클래스로 하지 않고, std::variant에 담아 값 기반 다형성으로 표현할 수 있습니다. 예를 들어:

#include <variant>
#include <string>
#include <expected>
#include <iostream>

struct DraftState {};
struct ReviewState {};
struct PublishedState {};

using DocumentStateVariant = std::variant<DraftState, ReviewState, PublishedState>;

이제 상태는 DocumentStateVariant로 표현하며, vtable 필요 없음.

2. Concepts로 상태 전이 인터페이스 정의

상태 전이 함수 publish()가 필요한지 Concepts로 정의할 수 있습니다. 다만 각 상태별로 publish 구현 방식이 다를 수 있으므로, std::visit를 활용해 상태별 로직을 람다로 표현할 수 있습니다.

template<typename State>
concept PublishableState = requires(State& s) {
    // 가령 publish 시 std::expected<DocumentStateVariant,std::string> 반환
    { publishState(s) } -> std::convertible_to<std::expected<DocumentStateVariant,std::string>>;
};

// 상태 전이를 위한 함수
auto publishState(auto& st) -> std::expected<DocumentStateVariant,std::string> {
    using T = std::decay_t<decltype(st)>;
    if constexpr (std::is_same_v<T, DraftState>) {
        std::cout << "From Draft to Review\n";
        return DocumentStateVariant(ReviewState{});
    } else if constexpr (std::is_same_v<T, ReviewState>) {
        std::cout << "From Review to Published\n";
        return DocumentStateVariant(PublishedState{});
    } else if constexpr (std::is_same_v<T, PublishedState>) {
        return std::unexpected("Already Published");
    } else {
        return std::unexpected("Unknown state");
    }
}

고찰:

  • publishState 함수에서 if constexpr로 상태별 로직 분기.
  • 상속 없이도 상태 전이 로직을 템플릿 메타프로그래밍으로 처리 가능.
  • std::expected로 전이 실패 시 에러 메시지 반환.

3. Document 클래스에서 std::variant와 std::visit 활용

struct Document {
    DocumentStateVariant state{DraftState{}};

    void publish() {
        auto res = std::visit([](auto& st){
            return publishState(st);
        }, state);

        if(!res) {
            std::cerr << "Publish error: " << res.error() << "\n";
        } else {
            state = std::move(*res);
        }
    }

    void printState() const {
        std::visit([](auto& st){
            using T = std::decay_t<decltype(st)>;
            if constexpr (std::is_same_v<T,DraftState>) std::cout << "Draft\n";
            else if constexpr (std::is_same_v<T,ReviewState>) std::cout << "Review\n";
            else if constexpr (std::is_same_v<T,PublishedState>) std::cout << "Published\n";
        }, state);
    }
};

int main() {
    Document doc;
    doc.printState(); // Draft
    doc.publish();
    doc.printState(); // Review
    doc.publish();
    doc.printState(); // Published
    doc.publish(); // Already Published error
}

고찰:

  • std::variant + std::visit로 상태 전이를 값 기반 다형성으로 표현.
  • 상태 전이 로직이 if constexpr 기반으로 명시적이고 선언적.
  • std::expected로 에러 처리 명확화.

4. Ranges나 코루틴을 통한 상태 머신 확장

만약 상태 전이가 외부 이벤트 스트림이나 비동기 I/O에 의존한다면, Ranges를 통해 이벤트 흐름을 파이프라인 처리하거나, 코루틴을 통해 비동기 상태 전이를 구현할 수 있다.

// 가상의 예: event 흐름을 코루틴으로 처리, co_await 이벤트, 상태 전이 co_return
// 또는 Ranges로 이벤트 컨테이너 transform, filter 후 publishState 적용

고찰:

  • 비동기 상태 머신, 반응형(Reactive) 패턴 구현 용이.
  • Concepts로 비동기 전이 가능 여부를 제약, std::format으로 디버깅 메시지 강화.

5. std::format으로 디버깅 메시지 정교화

#include <format>

auto debugPublishState(auto& st) -> std::expected<DocumentStateVariant,std::string> {
    auto result = publishState(st);
    if(!result) {
        std::cout << std::format("Transition failed: {}\n", result.error());
    } else {
        std::cout << "Transition success\n";
    }
    return result;
}

고찰:

  • std::format로 상태 전이 디버깅 메시지 가독성 향상.

비교 및 분석

  • 전통적 구현(C++11 전후):
    • 상태 클래스 상속 기반 인터페이스, 상태 전이 때 클래스 교체
    • 새로운 상태 추가 시 클래스 증가, 전이 로직 분산
    • 비동기 처리나 에러 처리 제한적
  • 모던 C++(C++20 이상):
    • std::variant와 std::visit로 값 기반 상태 표현
    • if constexpr와 Concepts로 상태 전이 로직 컴파일 타임 확인 가능
    • std::expected로 전이 실패 명확화, std::format으로 로깅 개선
    • Coroutines나 Ranges로 비동기나 반응형 상태 머신 구현 용이
    • 상속 없는 간결한 상태 관리, 유지보수성 증대

결국 모던 C++에서는 상태 패턴을 상속 기반 다형성 없이도 구현할 수 있으며, std::variant, Concepts, std::expected, std::format, coroutines, Ranges 등을 활용해 더 선언적이고 직관적인 상태 머신을 구현할 수 있습니다.

마무리

상태 패턴은 상태 전이에 따른 객체 행동 변화를 명확히 표현하는 강력한 기법입니다. 모던 C++20 이상에서는 값 기반 다형성(std::variant)과 템플릿 메타프로그래밍(if constexpr), std::expected를 통한 에러 처리, std::format을 통한 로깅, 그리고 coroutine, Ranges를 통한 비동기/반응형 상태 관리까지 활용해 상속 없이도 유지보수성이 높은 상태 머신을 구현할 수 있습니다.

다음 글에서는 행동 패턴 중 다른 패턴들(예: 책임 연쇄(Chain of Responsibility), 커맨드(Command), 상태(State) 외 등)을 모던 C++로 어떻게 재구성할 수 있는지 이어서 탐구해볼 예정입니다.

반응형