State Pattern
상태별 분기문 대신 State 인터페이스와 ConcreteState 클래스로 행위를 캡슐화해 Context의 동작을 바꾸는 패턴. Strategy 패턴과의 차이.
난이도 중급 · 선행 Strategy
한 줄 요약
객체의 현재 상태를 별도 클래스로 떼어내고, 메서드 호출을 그 상태 객체에 위임한다. if (status == PAID) ... else if (status == SHIPPED) ... 같은 거대한 상태 분기가 보이면 State를 떠올린다.
어떤 문제를 푸는가
주문(Order) 객체가 결제·배송·취소 흐름을 따라간다. 상태마다 가능한 행동이 다르다. 단순하게 짜면 모든 메서드에 분기가 들어간다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum class Status { Pending, Paid, Shipped, Delivered };
class Order {
Status status = Status::Pending;
public:
void pay() {
if (status == Status::Pending) {
std::cout << "결제 처리" << std::endl;
status = Status::Paid;
} else if (status == Status::Paid) {
throw std::runtime_error("이미 결제됨");
} else if (status == Status::Shipped) {
throw std::runtime_error("이미 배송 중");
} /* ... */
}
void ship() {
if (status == Status::Paid) { /* ... */ }
else if (status == Status::Pending) { throw ...; }
else if (status == Status::Shipped) { throw ...; }
/* ... */
}
void cancel() { /* 또 같은 분기 */ }
};
문제:
- 상태가 늘어날 때마다 모든 메서드의 분기를 다 수정해야 한다.
- 한 상태의 동작이 여러 메서드에 흩어진다. “결제 완료 상태에서 할 수 있는 일”을 보려면 모든 메서드를 다 읽어야 한다.
- 분기를 빠뜨리는 실수가 잦다 (특히 새 상태 추가 시).
패턴 적용 후
상태마다 클래스를 만들고, Order는 현재 상태 객체에 위임만 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>
#include <memory>
#include <stdexcept>
class Order;
class OrderState {
public:
virtual void pay(Order& order) = 0;
virtual void ship(Order& order) = 0;
virtual void cancel(Order& order) = 0;
virtual const char* name() const = 0;
virtual ~OrderState() = default;
};
class Order {
std::unique_ptr<OrderState> state;
public:
Order();
void setState(std::unique_ptr<OrderState> s) { state = std::move(s); }
const char* currentState() const { return state->name(); }
void pay() { state->pay(*this); }
void ship() { state->ship(*this); }
void cancel() { state->cancel(*this); }
};
class PendingState : public OrderState {
public:
void pay(Order& order) override;
void ship(Order&) override { throw std::runtime_error("결제 먼저"); }
void cancel(Order& order) override;
const char* name() const override { return "Pending"; }
};
class PaidState : public OrderState {
public:
void pay(Order&) override { throw std::runtime_error("이미 결제됨"); }
void ship(Order& order) override;
void cancel(Order& order) override;
const char* name() const override { return "Paid"; }
};
class ShippedState : public OrderState {
public:
void pay(Order&) override { throw std::runtime_error("이미 결제됨"); }
void ship(Order&) override { throw std::runtime_error("이미 배송 중"); }
void cancel(Order&) override { throw std::runtime_error("배송 시작 후 취소 불가"); }
const char* name() const override { return "Shipped"; }
};
class CancelledState : public OrderState {
public:
void pay(Order&) override { throw std::runtime_error("취소된 주문"); }
void ship(Order&) override { throw std::runtime_error("취소된 주문"); }
void cancel(Order&) override { throw std::runtime_error("이미 취소됨"); }
const char* name() const override { return "Cancelled"; }
};
Order::Order() : state(std::make_unique<PendingState>()) {}
void PendingState::pay(Order& order) {
std::cout << "결제 처리" << std::endl;
order.setState(std::make_unique<PaidState>());
}
void PendingState::cancel(Order& order) {
order.setState(std::make_unique<CancelledState>());
}
void PaidState::ship(Order& order) {
std::cout << "배송 시작" << std::endl;
order.setState(std::make_unique<ShippedState>());
}
void PaidState::cancel(Order& order) {
std::cout << "결제 환불 후 취소" << std::endl;
order.setState(std::make_unique<CancelledState>());
}
int main() {
Order order;
std::cout << order.currentState() << std::endl; // Pending
order.pay();
std::cout << order.currentState() << std::endl; // Paid
order.ship();
std::cout << order.currentState() << std::endl; // Shipped
}
달라진 점:
- 한 상태에서 가능한 동작이 그 상태 클래스 안에 모인다.
PaidState만 보면 “결제 완료 후 할 수 있는 일”이 한눈에 들어온다. - 새 상태 추가 = 새 클래스 추가. 기존 상태 클래스 무손상.
- 분기문이 사라진다. 상태 전이가
setState호출로 명시적.
구조
1
2
3
4
5
Context ──has──▶ State (interface)
▲
┌───────────┼───────────┬───────────┐
PendingState PaidState ShippedState CancelledState
(전이는 State 또는 Context가 관리)
- State: 상태별 행동을 정의하는 인터페이스 (
OrderState) - ConcreteState: 실제 상태 (
PendingState,PaidState, …) - Context: 현재 상태를 보유하고 위임 (
Order)
핵심은 Context의 행동이 현재 lifecycle 상태에 따라 달라지고 전이 규칙이 존재한다는 점입니다. ConcreteState가 다음 상태를 직접 알 수도 있지만, 큰 상태 머신에서는 Context나 전이표가 전이를 관리할 수 있습니다.
실전 사례
- TCP 연결:
LISTEN,SYN_SENT,ESTABLISHED,FIN_WAIT,CLOSED등 명확한 상태 머신. - 자판기: 동전 투입 / 상품 선택 / 배출 / 잔돈 반환 상태 (Head First DP 정통 예제).
- 결제·주문 워크플로: 위 예제가 그대로 e-커머스 도메인.
- React/Vue 컴포넌트 라이프사이클: 마운트·업데이트·언마운트도 상태 머신의 한 형태.
Strategy Pattern과의 차이
구조는 거의 같다. 의도가 다르다.
| State | Strategy | |
|---|---|---|
| 무엇을 바꾸나 | 상태 | 알고리즘 |
| 핵심 의도 | lifecycle 상태에 따른 행동 | 교체 가능한 정책·알고리즘 |
| 선택·전이 주체 | State·Context·전이표 모두 가능 | 클라이언트·설정·Context 모두 가능 |
| 전이 개념 | 있음 — A → B → C 흐름 | 없음 — 독립적인 선택지들 |
| 사용 예 | 신호등, 주문 진행, TCP 연결 | 결제 수단, 정렬 비교자, 압축 알고리즘 |
State에는 허용 전이와 lifecycle 의미가 있고, Strategy에는 독립적으로 교체 가능한 정책이라는 의미가 있습니다. 구현 객체들이 서로를 아는지는 부차적입니다.
자세한 비교는 Strategy 패턴 글 참고.
안티패턴 / 주의
- 상태가 2~3개에 분기가 단순하면 도입하지 마라. 그냥 enum + switch가 읽기 쉽다. 상태가 5개 이상이거나 메서드별 분기가 반복될 때 비용이 정당화된다.
- 상태 간 결합도가 의외로 높다.
PendingState가PaidState를 생성한다 → 두 클래스가 컴파일 의존성을 갖는다. 큰 상태 머신은 Context가 전이를 책임지는 변형(상태 테이블) 고려. - 상태 객체에 데이터를 쌓지 말 것. 상태는 행동을 표현하는 곳이지 데이터를 들고 있는 곳이 아니다. 데이터는 Context에 둔다.
스스로 점검
1. 주문 상태에 “Refunded”를 새로 추가하면 코드 어디를 손봐야 하나?
답
RefundedState 클래스를 새로 만들고, 그 상태로의 전이 경로를 가진 다른 상태(예: PaidState::cancel)에 전이 코드를 추가한다. 무관한 상태들(ShippedState 등)은 손대지 않는다.
2. 상태가 2개뿐이고 분기도 단순하다. State 패턴 적용?
답
과한 적용. enum + if가 더 읽기 쉽다. State는 상태 5개 이상이거나, 메서드별로 분기가 반복될 때 비용이 정당화된다.
3. PendingState::pay가 PaidState를 직접 생성하는 코드는 무엇이 잠재적으로 위험한가?
답
상태 간 컴파일 의존성이 생긴다. 큰 상태 머신은 그래프가 복잡해져서 관리가 어렵다. 대안: Context(Order)가 전이를 책임지는 변형(상태 전이 테이블).