C/C++ 디자인 패턴 (19) — 상태(State)

TCP 연결 객체는 같은 send() 호출에도 닫힌 상태에서는 오류를, 연결된 상태에서는 전송을 해야 한다. 상태를 열거형 하나로 두고 메서드마다 switch를 쓰면, 상태가 추가될 때마다 모든 메서드의 분기를 찾아 고쳐야 하고 하나라도 놓치면 이상하게 동작한다. 상태(State) 패턴은 상태마다 행동을 별도 객체로 분리하고, 객체(Context)는 현재 상태 객체에 요청을 위임해서, 상태가 바뀌면 객체의 행동이 바뀐 것처럼 보이게 한다. 이 글에서는 GoF가 든 TCP 연결 예제로 switch 방식의 문제, 상태 클래스, std::variant 상태 머신, C의 전이 표를 비교한다.

이 시리즈의 다른 글

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::variantvariant와 오버로드 추가variant와 오버로드 추가컴파일 에러로 강제 가능상태별 데이터가 다를 때
C 전이 표표의 행 추가표의 열 추가빈 칸이 자동 거부임베디드, 프로토콜

주의사항

  • 전이 중 자기 자신을 파괴한다: Listen::ack() 안의 transition()이 현재 상태 객체(this)를 해제한다. 예제는 그 뒤에 멤버를 쓰지 않아 ASan에서도 문제가 없지만, 전이 뒤에 멤버에 접근하는 코드가 추가되면 use-after-free가 된다. 전이는 메서드의 마지막에 둔다.
  • 상태 객체 재사용: 상태에 데이터가 없다면 전이마다 make_unique하는 대신 정적 인스턴스를 공유할 수 있다(플라이웨이트). 상태별 데이터는 Context에 둔다.
  • 무시와 오류를 구분한다: 잘못된 이벤트를 조용히 무시하면 프로토콜 버그가 숨는다. 예제처럼 로그를 남기거나, 상황에 따라 오류로 처리한다.
  • 진입·이탈 동작: 상태에 들어갈 때 타이머를 켜고 나갈 때 끄는 일이 많다면, onEnter()/onExit()를 State 인터페이스에 두고 transition()이 부르게 한다.
  • 전략 패턴과의 차이: 구조는 같지만, 상태 객체는 스스로 다음 상태로 전이시키고 클라이언트는 상태를 고르지 않는다. 전략은 클라이언트가 골라 끼운다.

마무리

상태 패턴은 “상태에 따라 다른 행동”을 조건문이 아닌 객체(또는 타입, 표)로 표현한다. 상태가 늘어나도 기존 코드를 건드리지 않고, 전이 규칙을 한눈에 볼 수 있게 된다. 다음 글에서는 알고리즘을 갈아 끼우는 전략(Strategy)을 다룬다.

참고

답글 남기기