TCP 연결 객체는 같은 send() 호출에도 닫힌 상태에서는 오류를, 연결된 상태에서는 전송을 해야 한다. 상태를 열거형 하나로 두고 메서드마다 switch를 쓰면, 상태가 추가될 때마다 모든 메서드의 분기를 찾아 고쳐야 하고 하나라도 놓치면 이상하게 동작한다. 상태(State) 패턴은 상태마다 행동을 별도 객체로 분리하고, 객체(Context)는 현재 상태 객체에 요청을 위임해서, 상태가 바뀌면 객체의 행동이 바뀐 것처럼 보이게 한다. 이 글에서는 GoF가 든 TCP 연결 예제로 switch 방식의 문제, 상태 클래스, std::variant 상태 머신, C의 전이 표를 비교한다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (17) — 메멘토(Memento)
- C/C++ 디자인 패턴 (18) — 옵저버(Observer)
- C/C++ 디자인 패턴 (20) — 전략(Strategy)
- C/C++ 디자인 패턴 (21) — 템플릿 메서드(Template Method)
switch로 흩어진 상태 분기
// switch_fsm.cpp — 상태별 분기가 모든 메서드에 흩어진다
#include <iostream>
#include <string>
enum class State { Closed, Listen, Established, Closing }; // Closing을 새로 추가했다
class Connection {
public:
std::string send(const std::string &data)
{
switch (state_) {
case State::Closed: return "error: not connected";
case State::Listen: return "error: handshake not done";
case State::Established: return "sent " + std::to_string(data.size()) + " bytes";
} // Closing 처리 누락
return "?";
}
void open() { if (state_ == State::Closed) state_ = State::Listen; }
void ack() { if (state_ == State::Listen) state_ = State::Established; }
void close() { state_ = State::Closing; }
private:
State state_ = State::Closed;
};
int main()
{
Connection c;
std::cout << c.send("hi") << '\n';
c.open(); c.ack();
std::cout << c.send("hello") << '\n';
c.close();
std::cout << c.send("late") << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o switch_fsm switch_fsm.cpp && ./switch_fsm
switch_fsm.cpp: In member function ‘std::string Connection::send(const std::string&)’:
switch_fsm.cpp:11:24: warning: enumeration value ‘Closing’ not handled in switch [-Wswitch]
11 | switch (state_) {
| ^
error: not connected
sent 5 bytes
?
새 상태 Closing을 추가했지만 send()의 분기를 고치지 않아 ?가 반환됐다. -Wall이 켠 -Wswitch가 경고를 냈지만, default:를 써 두었다면 이 경고마저 사라진다.
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Context | 클라이언트가 쓰는 객체, 현재 상태 객체를 보관하고 위임 | Connection |
| State | 상태별 행동 인터페이스 | TcpState |
| ConcreteState | 한 상태의 행동과 다음 상태로의 전이 | Closed, Listen, Established |
상태 클래스
// state_classes.cpp — GoF 상태 패턴: 상태마다 클래스, Context는 현재 상태 객체에 위임
#include <iostream>
#include <memory>
#include <string>
class Connection;
struct TcpState {
virtual ~TcpState() = default;
virtual const char *name() const = 0;
virtual void open(Connection &) { std::cout << " (open ignored in " << name() << ")\n"; }
virtual void ack(Connection &) { std::cout << " (ack ignored in " << name() << ")\n"; }
virtual void close(Connection &) { std::cout << " (close ignored in " << name() << ")\n"; }
virtual std::string send(Connection &, const std::string &) { return std::string("error: cannot send in ") + name(); }
};
class Connection { // Context
public:
Connection();
void open() { state_->open(*this); }
void ack() { state_->ack(*this); }
void close() { state_->close(*this); }
std::string send(const std::string &d) { return state_->send(*this, d); }
void transition(std::unique_ptr<TcpState> next)
{
std::cout << " " << state_->name() << " -> " << next->name() << '\n';
state_ = std::move(next);
}
int bytesSent = 0;
private:
std::unique_ptr<TcpState> state_;
};
struct Closed;
struct Established : TcpState {
const char *name() const override { return "Established"; }
std::string send(Connection &c, const std::string &d) override
{ c.bytesSent += d.size(); return "sent " + std::to_string(d.size()) + " bytes"; }
void close(Connection &c) override;
};
struct Listen : TcpState {
const char *name() const override { return "Listen"; }
void ack(Connection &c) override { c.transition(std::make_unique<Established>()); }
void close(Connection &c) override;
};
struct Closed : TcpState {
const char *name() const override { return "Closed"; }
void open(Connection &c) override { c.transition(std::make_unique<Listen>()); }
};
void Established::close(Connection &c) { c.transition(std::make_unique<Closed>()); }
void Listen::close(Connection &c) { c.transition(std::make_unique<Closed>()); }
Connection::Connection() : state_(std::make_unique<Closed>()) {}
int main()
{
Connection c;
std::cout << c.send("hi") << '\n';
c.ack(); // Closed에서는 무시
c.open();
c.ack();
std::cout << c.send("hello") << '\n';
c.close();
std::cout << c.send("late") << "\ntotal sent=" << c.bytesSent << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o state_classes state_classes.cpp && ./state_classes
error: cannot send in Closed
(ack ignored in Closed)
Closed -> Listen
Listen -> Established
sent 5 bytes
Established -> Closed
error: cannot send in Closed
total sent=5
기본 구현은 “이 상태에서는 무시”이고, 각 상태 클래스는 자기가 처리하는 이벤트만 재정의한다. 상태를 추가하면 클래스 하나가 늘 뿐 기존 상태 클래스는 바뀌지 않는다.
std::variant 상태 머신
상태와 이벤트를 모두 std::variant로 두고 std::visit에 두 개를 함께 넘기면, 전이 규칙이 “(상태, 이벤트) → 다음 상태” 오버로드 목록이 된다. 상태가 자기 데이터(포트, 전송량)를 값으로 들고 다녀서, 다른 상태의 데이터를 잘못 읽을 수 없다.
// variant_fsm.cpp — 상태를 variant로, 전이를 (상태, 이벤트) 쌍의 오버로드로
#include <iostream>
#include <string>
#include <variant>
struct Closed {};
struct Listen { int port; };
struct Established { int port; long bytes = 0; };
using State = std::variant<Closed, Listen, Established>;
struct Open { int port; };
struct Ack {};
struct Send { std::string data; };
struct Close {};
using Event = std::variant<Open, Ack, Send, Close>;
template <class... F> struct overloaded : F... { using F::operator()...; };
State next(const State &s, const Event &e)
{
return std::visit(overloaded{
[](const Closed &, const Open &o) -> State { return Listen{o.port}; },
[](const Listen &l, const Ack &) -> State { return Established{l.port}; },
[](const Established &est, const Send &d) -> State { auto n = est; n.bytes += d.data.size(); return n; },
[](const Listen &, const Close &) -> State { return Closed{}; },
[](const Established &, const Close &) -> State { return Closed{}; },
#ifndef STRICT
[](const auto &cur, const auto &) -> State { std::cout << " (ignored)\n"; return cur; }, // 그 외 조합
#endif
}, s, e);
}
const char *name(const State &s)
{
return std::visit(overloaded{
[](const Closed &) { return "Closed"; },
[](const Listen &) { return "Listen"; },
[](const Established &) { return "Established"; },
}, s);
}
int main()
{
State s = Closed{};
for (Event e : {Event{Ack{}}, Event{Open{8080}}, Event{Ack{}}, Event{Send{"hello"}}, Event{Send{"world!"}}, Event{Close{}}}) {
State prev = s;
s = next(s, e);
std::cout << name(prev) << " -> " << name(s);
if (auto *est = std::get_if<Established>(&s))
std::cout << " port=" << est->port << " bytes=" << est->bytes;
std::cout << '\n';
}
std::cout << "sizeof(State)=" << sizeof(State) << " (no heap allocation per transition)\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o variant_fsm variant_fsm.cpp && ./variant_fsm
(ignored)
Closed -> Closed
Closed -> Listen
Listen -> Established port=8080 bytes=0
Established -> Established port=8080 bytes=5
Established -> Established port=8080 bytes=11
Established -> Closed
sizeof(State)=24 (no heap allocation per transition)
$ g++ -std=c++20 -DSTRICT -c variant_fsm.cpp 2>&1 | grep -c 'error:'; g++ -std=c++20 -DSTRICT -c variant_fsm.cpp 2>&1 | grep -m1 'no matching function' | grep -oE 'const [A-Za-z]+&, const [A-Za-z]+&\)’$'
8
const Closed&, const Ack&)’
전이마다 힙 할당이 없고, 정의하지 않은 조합은 마지막 일반 람다(auto, auto)가 받는다. -DSTRICT로 일반 람다를 빼면 std::visit 자체가 컴파일되지 않고, 첫 에러가 처리되지 않은 조합(Closed 상태의 Ack)을 가리킨다.
C 구현: 전이 표
임베디드 펌웨어와 프로토콜 구현에서 흔한 방식이다. 상태 × 이벤트 2차원 표의 각 칸에 다음 상태와 동작 함수를 두고, 처리 함수는 표만 본다.
/* table_fsm.c — C 상태 머신: (현재 상태, 이벤트) → (다음 상태, 동작) 전이 표 */
#include <stdio.h>
enum state { CLOSED, LISTEN, ESTABLISHED, NSTATE };
enum event { EV_OPEN, EV_ACK, EV_SEND, EV_CLOSE, NEVENT };
static const char *state_name[] = { "CLOSED", "LISTEN", "ESTABLISHED" };
static const char *event_name[] = { "open", "ack", "send", "close" };
struct conn { enum state st; long bytes; };
static void act_none(struct conn *c, int arg) { (void)c; (void)arg; }
static void act_send(struct conn *c, int arg) { c->bytes += arg; }
static void act_reset(struct conn *c, int arg) { (void)arg; c->bytes = 0; }
struct transition {
enum state next;
void (*action)(struct conn *c, int arg);
int valid;
};
/* 전이 규칙이 한눈에 보이는 표. 빈 칸(0)은 허용되지 않는 이벤트 */
static const struct transition table[NSTATE][NEVENT] = {
[CLOSED] = { [EV_OPEN] = { LISTEN, act_none, 1 } },
[LISTEN] = { [EV_ACK] = { ESTABLISHED, act_none, 1 },
[EV_CLOSE] = { CLOSED, act_none, 1 } },
[ESTABLISHED] = { [EV_SEND] = { ESTABLISHED, act_send, 1 },
[EV_CLOSE] = { CLOSED, act_reset, 1 } },
};
static void dispatch(struct conn *c, enum event e, int arg)
{
const struct transition *t = &table[c->st][e];
if (!t->valid) {
printf(" %-11s --%-5s--> rejected\n", state_name[c->st], event_name[e]);
return;
}
t->action(c, arg);
printf(" %-11s --%-5s--> %-11s bytes=%ld\n", state_name[c->st], event_name[e], state_name[t->next], c->bytes);
c->st = t->next;
}
int main(void)
{
struct conn c = { CLOSED, 0 };
struct { enum event e; int arg; } script[] = {
{ EV_SEND, 3 }, { EV_OPEN, 0 }, { EV_ACK, 0 }, { EV_SEND, 5 }, { EV_SEND, 6 }, { EV_OPEN, 0 }, { EV_CLOSE, 0 },
};
for (size_t i = 0; i < sizeof(script) / sizeof(script[0]); i++)
dispatch(&c, script[i].e, script[i].arg);
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -o table_fsm table_fsm.c && ./table_fsm
CLOSED --send --> rejected
CLOSED --open --> LISTEN bytes=0
LISTEN --ack --> ESTABLISHED bytes=0
ESTABLISHED --send --> ESTABLISHED bytes=5
ESTABLISHED --send --> ESTABLISHED bytes=11
ESTABLISHED --open --> rejected
ESTABLISHED --close--> CLOSED bytes=0
C99 지정 초기화자 덕분에 표에는 허용되는 전이만 적고, 나머지 칸은 0(valid = 0)으로 남아 자동으로 거부된다. 전이 규칙 전체가 표 하나에 모여 리뷰하기 쉽다.
방식 비교
| 방식 | 상태 추가 | 이벤트 추가 | 누락 검출 | 적합한 곳 |
|---|---|---|---|---|
switch | 모든 메서드 수정 | 메서드 하나 추가 | -Wswitch(default 없을 때) | 상태 2~3개 |
| 상태 클래스 | 클래스 하나 | 모든 상태에 가상 함수 추가(기본 구현으로 완화) | – | 상태별 행동이 복잡할 때 |
std::variant | variant와 오버로드 추가 | variant와 오버로드 추가 | 컴파일 에러로 강제 가능 | 상태별 데이터가 다를 때 |
| C 전이 표 | 표의 행 추가 | 표의 열 추가 | 빈 칸이 자동 거부 | 임베디드, 프로토콜 |
주의사항
- 전이 중 자기 자신을 파괴한다:
Listen::ack()안의transition()이 현재 상태 객체(this)를 해제한다. 예제는 그 뒤에 멤버를 쓰지 않아 ASan에서도 문제가 없지만, 전이 뒤에 멤버에 접근하는 코드가 추가되면 use-after-free가 된다. 전이는 메서드의 마지막에 둔다. - 상태 객체 재사용: 상태에 데이터가 없다면 전이마다
make_unique하는 대신 정적 인스턴스를 공유할 수 있다(플라이웨이트). 상태별 데이터는 Context에 둔다. - 무시와 오류를 구분한다: 잘못된 이벤트를 조용히 무시하면 프로토콜 버그가 숨는다. 예제처럼 로그를 남기거나, 상황에 따라 오류로 처리한다.
- 진입·이탈 동작: 상태에 들어갈 때 타이머를 켜고 나갈 때 끄는 일이 많다면,
onEnter()/onExit()를 State 인터페이스에 두고transition()이 부르게 한다. - 전략 패턴과의 차이: 구조는 같지만, 상태 객체는 스스로 다음 상태로 전이시키고 클라이언트는 상태를 고르지 않는다. 전략은 클라이언트가 골라 끼운다.
마무리
상태 패턴은 “상태에 따라 다른 행동”을 조건문이 아닌 객체(또는 타입, 표)로 표현한다. 상태가 늘어나도 기존 코드를 건드리지 않고, 전이 규칙을 한눈에 볼 수 있게 된다. 다음 글에서는 알고리즘을 갈아 끼우는 전략(Strategy)을 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), State
- cppreference — std::visit (여러 variant 동시 방문)