이전 글에서는 책임 연쇄(Chain of Responsibility) 패턴을 모던 C++ 관점에서 재해석하며, 상속 없이 람다와 함수 합성, std::expected, coroutine, Ranges, std::format 등을 활용해 요청 처리 파이프라인을 단순하고 유연하게 구현할 수 있음을 확인했습니다. 이제는 행동(Behavioral) 패턴 중 상태(State) 패턴을 다룹니다.
상태 패턴은 객체가 내부 상태에 따라 서로 다른 행동을 수행하도록 하는 패턴입니다. 전통적으로는 State 인터페이스, ConcreteState 클래스를 상속해 상태별로 다른 메서드를 오버라이드하고, 상태 전이 시 상태 객체 교체 방식으로 구현했습니다. 이는 새로운 상태 추가 시 클래스 증가, 상태 전이 로직 분산, 유지보수 어려움을 야기합니다.
C++20 이상에서는 std::variant, std::visit, Concepts, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없이도 값 기반 상태 머신을 구현할 수 있습니다. 이로써 상태 전이 로직을 if constexpr와 std::visit로 명확히 선언할 수 있고, 비동기 상태 전이, 조건부 전이, 로깅, 에러 처리 등 다양한 요구사항에도 쉽게 대응할 수 있으며, 전통적 구현 대비 유지보수성과 확장성이 크게 향상됩니다.
패턴 소개: 상태(State)
의도:
- 객체가 내부 상태에 따라 행동을 달리하도록 하고, 상태 전이를 명확히 관리.
- 예: 문서(editor)의 상태(초안, 검토중, 발행됨)마다 다른 동작 수행, 계좌 상태(잔액 수준)에 따른 인출 가능/불가능 등.
전통적 구현 문제점:
- State 인터페이스 + 여러 ConcreteState 클래스 필요
- 상태 추가/변경 시 클래스 증가, 상태 전이 로직 분산
- 비동기 전이, 조건부 전이, 로깅, 에러 처리 등 구현 시 복잡성 증가
기존 C++ 스타일 구현 (전통적 방식)
문서 상태 예: Draft → Review → Published 상태 전이, 상속 기반 구현
#include <iostream>
struct DocumentState {
virtual ~DocumentState()=default;
virtual void publish(class Document& doc)=0;
};
struct DraftState : DocumentState {
void publish(Document& doc) override;
};
struct ReviewState : DocumentState {
void publish(Document& doc) override;
};
struct PublishedState : DocumentState {
void publish(Document&) override {
std::cout << "Already Published\n";
}
};
struct Document {
DocumentState* state;
Document() : state(new DraftState){}
void publish() {
state->publish(*this);
}
};
void DraftState::publish(Document& doc) {
std::cout << "From Draft to Review\n";
delete doc.state;
doc.state = new ReviewState;
}
void ReviewState::publish(Document& doc) {
std::cout << "From Review to Published\n";
delete doc.state;
doc.state = new PublishedState;
}
int main() {
Document doc;
doc.publish(); // Draft->Review
doc.publish(); // Review->Published
doc.publish(); // Already Published
}
문제점:
- DraftState, ReviewState, PublishedState 등 클래스 증가
- 상태 전이 시 delete/new 호출, 메모리 관리 복잡
- 비동기 전이, 에러 처리 등 구현 시 복잡성 증가
모던 C++20 이상 개선: std::variant, std::visit, Concepts
1. 값 기반 상태 표현: std::variant
struct DraftState {};
struct ReviewState {};
struct PublishedState {};
using DocumentStateVariant = std::variant<DraftState, ReviewState, PublishedState>;
struct Document2 {
DocumentStateVariant state{DraftState{}};
};
비교:
- 전통적: State 인터페이스 + ConcreteState 클래스로 상속 계층
- C++20: std::variant로 상태를 값으로 표현, 상속 없음
2. publish 로직: std::visit와 if constexpr
상태 전이 함수: (Document2&) -> std::expected<void,std::string> 반환, 에러 발생 가능성 가정:
#include <expected>
std::expected<void,std::string> publish(Document2& doc) {
return std::visit([&](auto& st)->std::expected<void,std::string> {
using T = std::decay_t<decltype(st)>;
if constexpr(std::is_same_v<T,DraftState>) {
std::cout << "From Draft to Review\n";
doc.state = ReviewState{};
return {};
} else if constexpr(std::is_same_v<T,ReviewState>) {
std::cout << "From Review to Published\n";
doc.state = PublishedState{};
return {};
} else if constexpr(std::is_same_v<T,PublishedState>) {
return std::unexpected("Already Published");
} else {
return std::unexpected("Unknown state");
}
}, doc.state);
}
비교:
- 전통적: 각 State 클래스 오버라이드 필요, 상태 전이 시 delete/new 필요
- C++20: std::visit + if constexpr로 상태별 처리, std::expected로 에러 처리 명확화, 메모리 관리 불필요
사용 예
int main() {
Document2 doc;
auto res1 = publish(doc); // Draft->Review
auto res2 = publish(doc); // Review->Published
auto res3 = publish(doc); // Already Published
if(!res3) std::cerr << "Error: " << res3.error() << "\n";
}
출력:
From Draft to Review
From Review to Published
Error: Already Published
조건부 전이, Undo/Redo, 로깅, 비동기 처리, Ranges 적용
조건부 전이(예: 특정 조건에서 상태 전이 거부):
auto conditionalPublish = [&](auto basePublish) {
return [=](Document2& doc)->std::expected<void,std::string> {
bool allowTransition = true; // 가상 조건
if(!allowTransition) return std::unexpected("Transition not allowed");
return basePublish(doc);
};
};
auto conditionalPub = conditionalPublish(publish);
Undo/Redo 지원: 상태 히스토리 관리. std::vector<DocumentStateVariant>로 히스토리 관리 후 Undo 시 이전 상태 복원 가능.
로깅(std::format):
auto loggingPublish = [&](auto basePublish) {
return [=](Document2& doc)->std::expected<void,std::string> {
std::cout << "[LOG] Publishing...\n";
auto res = basePublish(doc);
if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
else std::cout << "[LOG] Success.\n";
return res;
};
};
auto loggedPub = loggingPublish(publish);
비동기 처리: coroutine으로 상태 전이 비동기 처리 가능(가상 예):
// co_publish(doc) : co_await I/O, 완료 후 std::expected 반환
Ranges: 여러 문서 리스트를 순회하며 상태 전이 파이프라인 처리:
#include <vector>
#include <ranges>
std::vector<Document2> docs(3); // 세 문서 모두 Draft
for (auto res : docs | std::views::transform(loggedPub)) {
if(!res) std::cerr << "One doc transition failed: " << res.error() << "\n";
}
비교:
- 전통적: 상태 추가, Undo/Redo, 조건부 전이, 비동기 처리, 로깅 등 구현 시 클래스나 코드 폭증
- C++20: 람다 합성으로 기능 추가, coroutine으로 비동기, Ranges로 파이프라인 처리, std::expected로 에러 처리 등 다양한 상황에 대응 쉬움
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적(C++98/03):
- State 인터페이스와 ConcreteState 상속 계층
- 상태 전이 시 클래스 변경 필요, 상태 추가 시 클래스 증가
- 조건부 전이, Undo/Redo, 비동기 처리, 로깅 등 구현 시 복잡성 증가
C++11/14/17:
- 람다/std::function 일부 도움되나 Concepts, coroutine, std::expected, Ranges 부재 → 정적 타입 제약, 비동기/에러 처리 표현 한계
C++20 이상(모던 C++):
- std::variant로 상태 값 표현, std::visit + if constexpr로 상태별 처리
- Concepts로 타입 안전한 요구사항 정의, std::expected로 에러 처리 명확
- coroutine으로 비동기 전이, Ranges로 문서 리스트 파이프라인 처리, std::format으로 로깅 용이
- 클래스 증가 없이 확장 가능, 유지보수성과 확장성, 타입 안전성 대폭 향상
결론
상태(State) 패턴은 객체 내부 상태에 따라 행동을 달리하는 패턴이지만, 전통적 구현은 State 인터페이스와 ConcreteState 클래스로 인한 클래스 증가, 상태 전이 로직 분산 등 유지보수 어려움을 야기했습니다.
C++20 이상에서는 std::variant, std::visit, Concepts, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없이 값 기반 상태 머신을 구현하고, 조건부 전이, Undo/Redo, 비동기 처리, 로깅 등 다양한 요구사항을 적은 코드와 높은 타입 안전성으로 처리할 수 있습니다. 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.