포스트

State Pattern

상태별 분기문 대신 State 인터페이스와 ConcreteState 클래스로 행위를 캡슐화해 Context의 동작을 바꾸는 패턴. Strategy 패턴과의 차이.

State Pattern

난이도 중급 · 선행 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과의 차이

구조는 거의 같다. 의도가 다르다.

 StateStrategy
무엇을 바꾸나상태알고리즘
핵심 의도lifecycle 상태에 따른 행동교체 가능한 정책·알고리즘
선택·전이 주체State·Context·전이표 모두 가능클라이언트·설정·Context 모두 가능
전이 개념있음 — A → B → C 흐름없음 — 독립적인 선택지들
사용 예신호등, 주문 진행, TCP 연결결제 수단, 정렬 비교자, 압축 알고리즘

State에는 허용 전이와 lifecycle 의미가 있고, Strategy에는 독립적으로 교체 가능한 정책이라는 의미가 있습니다. 구현 객체들이 서로를 아는지는 부차적입니다.

자세한 비교는 Strategy 패턴 글 참고.

안티패턴 / 주의

  • 상태가 2~3개에 분기가 단순하면 도입하지 마라. 그냥 enum + switch가 읽기 쉽다. 상태가 5개 이상이거나 메서드별 분기가 반복될 때 비용이 정당화된다.
  • 상태 간 결합도가 의외로 높다. PendingStatePaidState를 생성한다 → 두 클래스가 컴파일 의존성을 갖는다. 큰 상태 머신은 Context가 전이를 책임지는 변형(상태 테이블) 고려.
  • 상태 객체에 데이터를 쌓지 말 것. 상태는 행동을 표현하는 곳이지 데이터를 들고 있는 곳이 아니다. 데이터는 Context에 둔다.

스스로 점검

1. 주문 상태에 “Refunded”를 새로 추가하면 코드 어디를 손봐야 하나?

RefundedState 클래스를 새로 만들고, 그 상태로의 전이 경로를 가진 다른 상태(예: PaidState::cancel)에 전이 코드를 추가한다. 무관한 상태들(ShippedState 등)은 손대지 않는다.

2. 상태가 2개뿐이고 분기도 단순하다. State 패턴 적용?

과한 적용. enum + if가 더 읽기 쉽다. State는 상태 5개 이상이거나, 메서드별로 분기가 반복될 때 비용이 정당화된다.

3. PendingState::payPaidState를 직접 생성하는 코드는 무엇이 잠재적으로 위험한가?

상태 간 컴파일 의존성이 생긴다. 큰 상태 머신은 그래프가 복잡해져서 관리가 어렵다. 대안: Context(Order)가 전이를 책임지는 변형(상태 전이 테이블).

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.