서비스
교육 철학

상태 패턴 (State)

객체의 내부 상태에 따라서 스스로 행동을 변경 할 수 있는 패턴으로, 이렇게 하면 객체가 내부에서 마치 자신의 클래스를 바꾸는 것처럼 사용할수 있다.
상태패턴에서는 finite state machine (FSM) 을 언급할 수밖에 없다. 그러다 보니 hierarchical state machine(HSM) 과 pushdown automata까지 이어졌다.
많은걸 다루다보니 분량 조절을 위해서 세세한 부분은 설명하지 않겠다. 우리한테 중요한건 큰 그림을 그리고 파악하는 것이다.
간단한 횡스크롤 플랫포머를 만든다고 보자. 게임월드의 주인공이 사용자 입력에 따라서 이동하도록 구현해야한다. B버튼을 누른다면 점프를 한다고 생각해보자.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } }
C++
복사
자세히 들여다보면 버그가 보일 것이다. 어디서든 점프가 가능하다. 즉 공중에서도 점프가 가능할 것이다. 그러면 해당 공중점프를 막아야 한다. bool 변수를 추가해서 막아보자.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_) { isJumping_ = true; // Jump... } } }
C++
복사
주인공이 땅에 있을 때 아래버튼 누르면 엎드리고, 버튼을 때면 다시 일어나는 기능을 추가해보자.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { // Jump if not jumping... } else if (input == PRESS_DOWN) { if (!isJumping_) { setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { setGraphics(IMAGE_STAND); } }
C++
복사
이번에도 버그를 찾아보자. 어떤것이 보이는가?
1.
아래버튼을 누른다.
2.
B버튼을 엎드린 상태에서 점프를 하고
3.
공중에서 아래버튼을 떄면 스탠딩 상태( 점프중인데도 땅에서 서있는 애니메이션 재생)
이런걸 방지하기 위해서 플래그 변수가 더 필요할 것이다.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // Jump... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true; setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { if (isDucking_) { isDucking_ = false; setGraphics(IMAGE_STAND); } } }
C++
복사
점프중에 아래 버튼 눌러 내려찍기 공격을 할 수 있게 해보자.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // Jump... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true; setGraphics(IMAGE_DUCK); } else { isJumping_ = false; setGraphics(IMAGE_DIVE); } } else if (input == RELEASE_DOWN) { if (isDucking_) { // Stand... } } }
C++
복사
결과적으로 이 과정을 반복하다보면 계속 수많은 버그를 만들어지게 될것이고 else if문의 무차별 증가로 코드의 가독성도 굉장히 해치게 되어서 버그를 야기시킬수밖에 없는 코드가 될것이다.
무언가 설계단부터 잘못 되었음을 여러분들은 짐작할수 있다.
이를 해결할 방법중에 FSM을 활용 해 볼 것이다.

FSM이 이를 구원해주리라.

위코드를 작성하다가 점점점 동작들이 늘어나게 되고 머리가 굉장히 복잡해졌을 것이다. 이를 간단하게 표현하기 위해서 우리는 코드를 작성하지말고 간단한 그림을 그려서 플로우차트를 만들어보자.
축하합니다. 위 그림이 바로 FSM이다. FSM은 컴퓨터 과학 분야 중의 하나인 오토마타 이론에서 나왔다. 오토마타 중에 유명한 튜링 기계도 있다. FSM 은 위 분야에서 제일 간단한 측에 속한다.
FSM의 핵심은 요렇다.
•
가질 수 있는 ‘상태’가 존재한다. 위 예제에서는 점프상태, 엎드리기 상태, 스탠딩상태… 등등이 될것이다.
•
한번에 한가지 상태만 활성화 될 수 있다. 주인공은 점프와 동시에 스탠딩을 할 수 없다. 한가지가 완벽하게 끝나거나 특정조건에만 다른상태로 바꿀수 있도록 만들어야한다. 핵심은 두가지상태가 존재할수 없다.
•
‘입력’이나 ‘이벤트’가 상태머신에 전달된다. 버튼 누르기와 버튼 때기가 이에 해당한다.
•
각 상태에서는 입력에 따라 다른 상태로 바뀌는 ‘Transition 전이’ 가 있다. 입력이 들어왔을 때 현재 상태에 해당하는 Transition이 있다면 그에 알맞은 상태로 변경해준다.
순수하게 형식만 놓고 보면 상태, 입력(이벤트), 전이가 FSM의 전부다. 컴파일러는 우리가 끄적거리는 차트를 이해 할 수 없으니. 우리는 코드로 구현 해보자.

Enum(열거형)과 다중 선택문

상태를 열거형으로 정의할 수 있다.
enum class eState { STATE_STANDING, STATE_JUMPING, STATE_DUCKING, STATE_DIVING };
C++
복사
플래그 변수 여러개를 사용하는 대신에 state 필드 하나만 있으면 된다. 분기 순서도 바뀌지 않는다. 이전에는 입력에 따라 먼저 분기한 뒤에 상태에 따라 분기했지만 하나의 버튼 입력에 대한 코드는 모아둘수 있었으나 하나의 상태에 따른 코드는 전부 흩어져 있었다. 상태 관련 코드를 한곳에 모아두기 위해서 먼저 상태에 따라 분기하게 해보자.
void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_B) { state_ = STATE_JUMPING; yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } else if (input == PRESS_DOWN) { state_ = STATE_DUCKING; setGraphics(IMAGE_DUCK); } break; case STATE_JUMPING: if (input == PRESS_DOWN) { state_ = STATE_DIVING; setGraphics(IMAGE_DIVE); } break; case STATE_DUCKING: if (input == RELEASE_DOWN) { state_ = STATE_STANDING; setGraphics(IMAGE_STAND); } break; } }
C++
복사
if문을 중복해서 사용한거에 비하면 코드가 훨씬 나아졌습니다. 다 없애진 못했지만 업데이트 해야 할 상태 변수가 하나로 줄었고, 하나의 상태를 관리하는 코드는 깔금하게 한곳에 모였다.
대부부분의 상태가 많지 않은 작은 로직들은 위와같이 사용하는 방법으로 충분 할 수 있다.
하지만 동작이 많아지고 복잡해지면 위와같은 방법도 부족 할 수 있다.
예를 들어서 엎드려 있으면 기를 모아서 놓는 순간 특수 공격을 할 수 있게 만든다고 생각해보자.
엎드려 있는 시간을 기록해야 할 것이다.
void Heroine::update() { if (state_ == STATE_DUCKING) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { superBomb(); } } }
C++
복사
엎드릴 때마다 시간을 초기화 해야하니 handleInput()을 바꿔보자
void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_DOWN) { state_ = STATE_DUCKING; chargeTime_ = 0; setGraphics(IMAGE_DUCK); } // Handle other inputs... break; // Other states... } }
C++
복사
기모으기 공격을 추가하기 위해서 함수 두개를 수정하고 엎드리기 상태에서만 의미있는 ChargeTime_ 필드가 추가 되야 한다. 이것보다는 모든 코드와 데이터를 한곳아 모아둘수 있는게 더 좋을 것이다.

상태 패턴

객체지향에서 모든 분기문을 동적 디스패치(C++ 가상함수)로 바꾸려 하는 사람들이 있다. 그거는 너무 과한 경우다. 떄로는 간단한 if문 만으로도 충분하다.
하지만 위 상태패턴 예제정도라면 객체지향, 즉 상태 패턴을 사용하는게 더 낫다.

상태 인터페이스

상태 인터페이스부터 정의해보자. 상태에 의존하는 모든 코드, 즉 다중 if문이 동작을 하던 부분들을 전부 인터페이싀 가상 메서드로 만든다. 예제에서는 handInput(), update() 가 해당된다.
class HeroineState { public: virtual ~HeroineState() {} virtual void handleInput(Heroine& heroine, Input input) {} virtual void update(Heroine& heroine) {} };
C++
복사

상태별 클래스 만들어주기

상태 별로 인터페이스를 구현하는 클래스도 정의한다. 메서드에서는 정해진 상태가 되었을 떄 주인공이 언떤 행동을 할지를 정의한다. 다중 선택문이 있던 case별로 클래스를 만들어주면 된다.
class DuckingState : public HeroineState { public: DuckingState() : chargeTime_(0) {} virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Change to standing state... heroine.setGraphics(IMAGE_STAND); } } virtual void update(Heroine& heroine) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { heroine.superBomb(); } } private: int chargeTime_; };
C++
복사
chargeTime_ 변수를 Heroin에서 DuckingState 클래스 옮겼다. chargeTime_은 엎드리기 상태에서만 의미 있다는 점을 위 구조에서 명확하게 보여준다.

Delegate to the state(상태를 위임하기)

Heroin 클르스에 자신의 현재상태를 상태 객체 포인터를 추가해서, 거대한 다중 if문을 제거하고 상태 객체가 우리 기존에 실행하던 update문을 대해야한다.
class Heroine { public: virtual void handleInput(Input input) { state_->handleInput(*this, input); } virtual void update() { state_->update(*this); } // Other methods... private: HeroineState* state_; };
C++
복사
상태를 바꾸려면 state_ 포인터에 HerolineState 를 상속받는 다른객체를 할당해주기만 하면 된다. 이게 상태패턴의 전부이다.

상태객체는 어디에 둬야 할까?

앞에서 얼버무리고 넘어간것이 있다. 상태를 바꾸려면 state_에 새로운 상태 객체를 할당해줘야 한다.
그렇다면 이 객체는 어디에서 받아와야 할것인가? 열거형은 숫자처럼 기본 자료형이기 때문에 신경쓸게 없지만 상태패턴은 클래스를 활용하여 포인터를 사용하기 때문에 실제 포인터가 가리킬 객체(인스턴스)가 필요하다.

정적객체

상태 객체에 필드가 따로 없다면 가상 함수 호출에 필요한 vtable포인터만 있는 셈이다. 이럴 경우 모든 인스턴스가 같기 때문에 인스턴스는 하나만 있으면 된다.
이제 정적객체 하나만 만들어지면 된다. 여러 FSM이 동시에 돌더라고 상태기계는 다 같으므로 인스턴스 하나를 같이 사용 하면된다.
class HeroineState { public: static StandingState standing; static DuckingState ducking; static JumpingState jumping; static DivingState diving; // Other code... };
C++
복사
각각의 정적 변수가 게임에서 사용하는 상태 인스턴스다. 서 있는 상태에서 점프하게 하려면 이렇게 하면 된다.
if (input == PRESS_B) { heroine.state_ = &HeroineState::jumping; heroine.setGraphics(IMAGE_JUMP); }
C++
복사

상태 객체 만들기

정적 객체만으로 부족 할 때도 있다. 엎드리기 상태에는 chargeTime_ 필드가 있는데 이 값이 주인공 마다 다르다 보니 정적 객체로 만들 수 없다. 주인공이 하나라면 어떻게든 되겠지만, 협동 플레이 기능을 추가해 두 주인공이 한 화면을 보아야 한다면 문제가 된다.
이럴 때는 전이할 때마다 상태 겍체를 만들어야 한다. 이러면 FSM이 상태별로 인스턴스를 갖게된다.
새로 상태를 할당 했기 때문에 이전 상태를 해제해줘야 한다. 상태를 바꾸는 코드가 현재 상태 메서드에 있기 떄문에 삭제할떄 이미 실행되고 있는 This를 지우지 않도록 해야 한다.
이를 위해서 상태가 바뀔 때에만 새로운 상태를 반환하고, 밖에서는 반환값에 따라 예전 상태를 삭제하고 새로운 상태를 저장하도록 바꿔보자.
void Heroine::handleInput(Input input) { //새로운 상태 가져오기 HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; } }
C++
복사
handleInput 메서드가 새로운 상태를 반환하지 않는다면 현재상태를 삭제하지 않는다. 서있기 상태에서 엎드리기 상태로 전이하려면 새로운상태를 생성해 반환한다.
HeroineState* StandingState::handleInput(Heroine& heroine, Input input) { if (input == PRESS_DOWN) { // Other code... return new DuckingState(); } // Stay in this state. return NULL; }
C++
복사
가능하다면 매번 객체를 할당하기 위해 메모리, CPU를 낭비하지 않아도 된다. 정적 상태를 쓰려고 하는 편이 더 좋다. 지금부터는 상태 패턴을 좀 상태스럽게 만들어 보도록 하겠다.
또한 위 코드처럼 동적으로 new, delete가 호출되는 건 요즘 시대와는 맞지는 않다. new,delete는 연산이 매우 많은 명령어중 하나 이기 때문에 요즘 메모리가 매우 풍족한 경우를 따지자면 동적으로 new, delete를 하는 것보다 미리 만들어주고 반납해주는 방식으로 사용하는 것도 권장한다.

입장과 퇴장

상태 패턴의 목표는 같은 상태에 대한 모든 동작과 데이터클래스 하나에 캡슐화 목표이다. 이런 면에서 예제는 아직 부족한 면이 있다.
주인공은 상태를 변경하면서 주인공의 스프라이트도 같이 바꾼다. 지금까지 이전상태에서 스프라이트를 변경 했다. 예를 들어 엎드리기에서 서기로 넘어 갈 때에는 엎드리기 상태에서 주인공 이미지를 변경했다.
HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { heroine.setGraphics(IMAGE_STAND); return new StandingState(); } // Other code... }
C++
복사
이렇게 하는 것보다는 상태에서 그래픽까지 제어하는게 바람직하다. 이를 위해 입장기능을 추가하자.
class StandingState : public HeroineState { public: virtual void enter(Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); } // Other code... };
C++
복사
Heroin 클래스에서는 새로운 상태에 들어 있는 enter함수를 호출하도록 상태 변경 코드를 수정한다.
void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; // Call the enter action on the new state. state_->enter(*this); } }
C++
복사
이제 엎드리기 코드를 더 단순하게 만들 수 있다.
HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { return new StandingState(); } // Other code... }
C++
복사
서기 상태로 변경하기만 하면 서기상태가 알아서 애니메이션(그래픽)까지 변경해서 동작한다.
이래야 제대로 상태가 캡슐화 되었다고 볼수 있다.
이제는 입장 가능한 상태에 모든 코드가 모여져 있다고 보면된다. 상태가 새로운 상태로 교체되기 직전에 호출되는 퇴장 코드도 이런 식으로 활용 할수 있다.

단점은?

FSM 의 장점얘기만 주구장창 했다. 그렇다면 단점이 없을까? 있다!!!
상태 기계는 엄격하게 제한된 구조를 강제함으로 써 복잡하게 얽힌 코드를 정리 할 수 있게 해준다. FSM에는 미리 정해놓은 여러 상태와 현재 상태 하나, 하드코딩 되어 있는 전이만 존재한다.
FSM은 인고지능 같이 복잡한 곳에 적용하다 보면 한계를 부딪히게 된다. 다행이 이전 세대 개발자들은 이를 극복할 방법을 마련해 두었다. 이중 몇가지 방법을 알아보고 상태패턴을 마무리 지어보자.

병행 상태 기계

주인공이 총을 들 을 수 있게 만들어보자고 해보자. 총을 장착한 후에도 이전에 할 수 있었던 달리기, 점프, 엎드리기, 동작을 모두 할 수있어야한다. 그러면서 동시에 총도 쏠수 있어야 한다.
FSM 방식을 고수했다면 모든 상태를 총을 장착한 상태 + 기존의 상태로 전부 새롭게 구현해줘야한다.
무기가 추가될수록 상태조합이 폭발 적으로 if문이 추가될것이다. 그러며 다시 상태패턴 상태 이전으로 돌아가게 되는것이다.
즉 두종류의 상태, 무엇을 하는가와 무엇을 들고 있는가?를 한 상태기계에 넣었다 보니 생긴 문제다. 모든 가능한 조합에 대해 모델링 하려니 모든 쌍에대해 상태를 만들어줘야한다.
해결방법은 너무 간단하다. 상태기계를 여러개 로 만들어주면된다.
class Heroine { // Other code... private: HeroineState* state_; HeroineState* equipment_; };
C++
복사
Heroin에서는 입력을 상태에 위임 할 떄에는 입력을 상태기계 양쪽에 다 전달한다.
void Heroine::handleInput(Input input) { state_->handleInput(*this, input); equipment_->handleInput(*this, input); }
C++
복사
단점을 보자면 현실적으로 점프 도중에 총을 못쓴다던가, 무장한 상태에서는 내려찍기를못한다든가 하는 식으로 복수의 상태기계가 상호작용 해야 할 수도 있다. 이를 위해 어떤 상태 코드에서는 다른 상태 기계의 상태가 무엇인지를 검사하는 지저분한 코드를 만들수도 있다.

계층형 상태기계

주인공 동작을 덧붙이다 보면 서기, 걷기, 달리기… 등등의 상태가 생길것이다. 이들 상태에선 B버튼을 누르면 점프하고, 아래버튼을 누르면 엎드린다.
단순한 상태기계 에서는 이런 코드를 모든 상태마다 중복해서 넣어야한다. 그보다는 한번만 구현하고 재사용하는게 좋다.
상태 기계아니라 객체지향 코드라고 생각해보면, 상속으로 여러 상태가 코드를 공유 할수 있다. 점프와 엎드리기는 땅위에 있는 클래스를 정의해 처리한다. 서기, 걷기, 달리기는 ‘땅 위 클래스’ 상태클래스를 상속받아 고유 동작을 추가한다.
이런 구조를 계층형 상태기계라고 한다. 어떤 상태는 상위상태를 가질수 있고, 그경우 하위 상태가 된다. 이벤트가 들어 올때 하위 상태에서 처리하지 않으면 상위상태로 넘어간다. 말하자면 상속받는 메서드를 오버라이드 하는것과 같다.
class OnGroundState : public HeroineState { public: virtual void handleInput(Heroine& heroine, Input input) { if (input == PRESS_B) { // Jump... } else if (input == PRESS_DOWN) { // Duck... } } };
C++
복사
또 이를 상속받는 더킹스테이트다.
class DuckingState : public OnGroundState { public: virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Stand up... } else { // Didn't handle input, so walk up hierarchy. OnGroundState::handleInput(heroine, input); } } };
C++
복사
계층형을 꼭 이렇게 구현해야 하는것은 아니다. 클래스를 사용하는 GOF식 상태패턴을 쓰지 않는다면 이런 구현이 불가능하다. 그럴때는 클래스에 상태를 하나만 두지 않고 상태 스택을 만들어서 명시적으로 현재 상태의 상위 상태 연쇄를 모델링 할수도 있다.

푸시다운 오토마타

상태 스택을 활용하여 FSM을 확장하는 다른 방법도 있다. 계층형 FSM에서 봤던 스펙과는 상태를 담는 방식도 다르고 해결하려는 문제도 다르다. FSM에는 이력(history)라는 개념이 없다는 문제가 있다. 현재상태는 알수 있지만 직전 상태에대한 정보가 없기 떄문에 이전 상태로 되돌릴수 없다.
예를들어서 주인공이 완전무장을 했다. 주인공이 총을 쏘면 발사 애니메이션이 재생과 함께 총알과 시각 이펙트를 생성하는 새로운 상태가 필요하다. 총을 쏠수 있는 모든 상태에서는 발사 버튼을 눌렀을때 전이할 FiringState라는 상태를 만들어보자.
이때 어려운 부분은 총을 쏜 뒤에 어느 상태로 돌아가야 하는지 하는 점이다. 달리기, 서기, 걷기 등등으로 이전상태로 돌아가야한다.
일반적인 FSM은 이렇게 이전상태를 알수없다. 이것보다 총쏘기 전 상태를 저장해두었다가 나중에 불러와서 써먹으면 된다. 이럴때 써먹을 만한 것이 푸시다운 오토마타이다.
FSM 이 한개의 상태를 포인터로 관리했다면 푸시다운 오토마타는 상태를 스택으로 관리한다. FSM은 이정상태를 덮어 쓰고 전이하는 방식이라면 푸시다운오토마타는 2가지 명령어가 더 있다.
•
새로운 상태를 스택에 넣는다.(스택의 최상의 상태가 현재 상태가 된다.)
•
최상위 상태를 스텍에서 뺸다.(현재상태가 제거되고 이전상태가 새롭게 현재상태가 된다)
총을 쏘기상태에서 뺴면, 푸시다운 오토마타가 이전상태로 되돌려 준다.

얼마나 유용한가?

FSM에는 몇가지 확장된 버전이 많이 있지만 FSM만으로는 한계가 있다. 요즘 게임 AI 행동트리(behavior tree), 계획 시스템(planning system)을 더 많이 쓰는 추세다.
FSM 이나 푸시다운 오토마타, 그외 간단한 시스템들이 쓸모없는건 아니다. 상황에 따라서 동작의 가짓수 또는 여러 시스템을 고려하여 본인에게 맞는 모델링을 선택하고 사용하는것이 개발에 있어서는 제일 중요하다.
너무 과한 패턴을 사용해도 안되고 그렇다고 너무 단순하게 작업해서도 안된다. 정답은 없지만 해답은 있다는것을 인지하자.
•
내부 상태에 따라 객체 동작이 바뀔때
•
이런 상태가 그다지 많지않은 선택지로 구분할수 있을 때
•
객체가 입력이나 이벤트에 따라 반응할 떄
요정도가 주로 사용되는 조건이라고 볼수 있다.
게임에서 가장많이 사용 되지만 메뉴화면, 문자해석, 네트워크 프로토콜, 비동기 동작을 구분하는데도 사용하는 편이다.
참고자료

원문 전체 해설·코드·도해

💡
위의 수업용 정리와 실습을 먼저 읽은 뒤, 아래에서 책의 원문 흐름·모든 코드·원본 도해·각주를 빠짐없이 확인할 수 있습니다.
객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴입니다. 이렇게 하면 객체는 마치 자신의 클래스를 바꾸는 것처럼 보입니다. — 『GoF의 디자인 패턴』, 한국어판 395쪽
저자는 이번 장에서 조금 흥분한 나머지 내용을 너무 많이 넣었다고 고백합니다. 겉으로는 상태 패턴을 다루지만, 이를 설명하려면 더 근본적인 유한 상태 기계(finite state machine, FSM)를 이야기해야 했습니다. 그러다 보니 계층형 상태 기계(hierarchical state machine)와 푸시다운 오토마타(pushdown automata)까지 이어졌습니다.
많은 내용을 다루므로 코드의 세세한 부분은 생략했습니다. 그 부분은 독자가 채우더라도 큰 그림을 이해하기에는 충분할 것입니다.
상태 기계라는 말을 처음 듣더라도 부끄러워할 필요는 없습니다. AI나 컴파일러 개발자가 아니라면 익숙하지 않을 수 있습니다. 하지만 알아 두면 좋은 개념이므로 다른 분야에서 어떻게 활용할 수 있는지 살펴보겠습니다.
원주 1: AI와 컴파일러는 초창기 인공지능 연구의 도움을 많이 받았습니다. 1950~1960년대의 많은 AI 연구는 자연어 처리에 집중했습니다. 당시 인간 언어를 분석하려고 등장한 여러 기법은 지금 컴파일러에서 프로그래밍 언어를 파싱하는 데 활용됩니다.

7.1 추억의 게임 만들기(A Familiar Platformer)

간단한 횡스크롤 플랫포머를 만든다고 해 보겠습니다. 게임 세계의 주인공이 사용자 입력에 반응하도록 구현해야 합니다. 먼저 B 버튼을 누르면 점프하는 기능부터 만듭니다.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } }
C++
복사
버그가 보이나요? 공중 점프를 막는 코드가 없습니다. 공중에서 B를 연타하면 계속 떠 있을 수 있습니다. Heroine에 isJumping_이라는 불리언 필드를 추가해 점프 중인지 검사하면 간단히 고칠 수 있습니다.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_) { isJumping_ = true; // 점프 관련 코드... } } }
C++
복사
원주 2·옮긴이: 플랫포머는 《슈퍼 마리오브라더스》처럼 발판(platform)을 밟으며 진행하는 게임입니다.
원주 3: 예제를 단순하게 보여 주려고 생략했지만, 실제로는 땅에 도착하면 isJumping_을 false로 되돌려야 합니다.
이번에는 땅에 있을 때 아래 버튼을 누르면 엎드리고, 버튼을 떼면 다시 일어서게 해 보겠습니다.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { // 점프 중이 아니라면 점프한다. } else if (input == PRESS_DOWN) { if (!isJumping_) { setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { setGraphics(IMAGE_STAND); } }
C++
복사
이번에도 버그가 있습니다. 다음 순서로 입력해 보세요.
1.
아래 버튼을 눌러 엎드립니다.
2.
B 버튼을 눌러 엎드린 상태에서 점프합니다.
3.
공중에서 아래 버튼을 뗍니다.
점프 중인데도 땅에 서 있는 모습으로 보입니다. 플래그 변수가 하나 더 필요합니다.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // 점프 관련 코드... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true; setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { if (isDucking_) { isDucking_ = false; setGraphics(IMAGE_STAND); } } }
C++
복사
이번에는 점프 중 아래 버튼을 누르면 내려찍기 공격을 하도록 추가해 보겠습니다.
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // 점프 관련 코드... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true; setGraphics(IMAGE_DUCK); } else { isJumping_ = false; setGraphics(IMAGE_DIVE); } } else if (input == RELEASE_DOWN) { if (isDucking_) { // 일어서기 관련 코드 } } }
C++
복사
또 버그가 생겼습니다. 공중 점프를 막으려고 점프 중인지는 검사하지만, 내려찍기 중인지는 검사하지 않습니다. 또 플래그 변수를 추가해야 할까요?
방향을 잘못 잡은 것이 분명합니다. 코드가 얼마 되지도 않는데 조금만 건드리면 망가집니다. 아직 걷기조차 구현하지 않았습니다. 이동에 필요한 작업이 많은데 이런 식이면 버그에 파묻혀 구현을 끝낼 수 없을 것입니다.
원주 4: 언제나 완벽한 코드를 만들 것 같은 뛰어난 개발자도 우리와 같은 사람입니다. 차이가 있다면 어떤 코드에서 버그가 생기기 쉬운지 감각이 있어 미리 피한다는 점입니다. 분기가 복잡하거나 상태가 변경 가능한(mutable) 코드에는 버그가 생기기 쉬운데, 이 예제는 두 조건에 모두 해당합니다.

7.2 FSM이 우리를 구원하리라(Finite State Machines)

막혔다고 느껴지면 컴퓨터를 끄고 펜과 종이로 플로차트를 그려 보겠습니다. 주인공의 동작인 서기·점프·엎드리기·내려찍기를 각각 네모 칸에 적습니다.
버튼을 눌렀을 때 상태가 바뀐다면 이전 상태에서 다음 상태로 화살표를 그립니다. 선에는 눌렀던 버튼을 적습니다. 다음 그림에서 같은 아래 버튼도 현재 상태에 따라 다른 곳으로 향한다는 점을 살펴보세요.
그림 7-1. 상태 기계를 그린 플로차트
방금 유한 상태 기계를 만들었습니다. FSM은 컴퓨터 과학의 한 분야인 오토마타 이론에서 나왔습니다. 오토마타에는 유명한 튜링 기계도 있으며, FSM은 이 분야에서 가장 간단한 축에 속합니다. 요점은 다음과 같습니다.
•
가질 수 있는 상태가 한정됩니다. 이 예제에는 서기·점프·엎드리기·내려찍기가 있습니다.
•
한 번에 한 가지 상태만 될 수 있습니다. 주인공이 점프하면서 동시에 서 있을 수는 없습니다. 두 상태가 동시에 되는 것을 막는 일이 FSM을 쓰는 이유 중 하나입니다.
•
입력이나 이벤트가 기계에 전달됩니다. 버튼 누르기와 떼기가 그 예입니다.
•
각 상태에는 입력에 따라 다음 상태로 바뀌는 전이(transition)가 있습니다. 현재 상태에 해당 입력의 전이가 있으면 그 전이가 가리키는 다음 상태로 바뀝니다.
서 있을 때 아래 버튼을 누르면 엎드리기로 전이합니다. 점프 중 아래 버튼을 누르면 내려찍기로 전이합니다. 현재 상태에 들어온 입력의 전이가 없으면 그 입력은 무시합니다.
형식적으로는 상태·입력·전이가 FSM의 전부입니다. 하지만 컴파일러는 종이에 그린 플로차트를 이해하지 못하므로 구현해야 합니다. GoF의 상태 패턴은 구현 방법 중 하나지만, 그 전에 더 간단한 방법부터 알아보겠습니다.
원주 5: 저자는 FSM을 옛 텍스트 어드벤처 게임 《조크(Zork)》에 자주 비유합니다. 주인공은 다른 방과 연결된 방에 서 있고, ‘북으로 가기(go north)’처럼 키보드로 명령을 입력해 이동합니다. 머드 게임의 지도는 상태 기계와 같습니다. 방은 상태, 주인공이 있는 방은 현재 상태, 방의 출구는 전이, 이동 명령은 입력으로 볼 수 있습니다.
💡
불가능한 조합을 표현하지 않게 만들기
isJumping, isDucking 같은 불리언을 계속 추가하면 ‘점프하면서 엎드리기’도 값으로 표현할 수 있습니다. 현재 상태를 하나의 이름으로 잡고 허용하는 전이만 정의하면, 어떤 입력을 어느 상황에서 처리하는지 분명해집니다.
아래는 착지까지 더해 읽어 보는 보충 도해입니다. 책의 원본 그림은 바로 위에 있습니다.
flowchart LR
  A["서 있음"] -->|점프| B["공중"]
  B -->|착지| A
  A -->|아래 키| C["웅크림"]
  C -->|키 놓기| A
Mermaid
복사

7.3 열거형과 다중 선택문(Enums and Switch Statements)

Heroine의 문제 하나는 불리언 값 조합이 유효하지 않을 수 있다는 것입니다. isJumping_과 isDucking_은 동시에 참이면 안 됩니다. 여러 플래그 중 하나만 참인 경우가 많다면 열거형(enum)이 필요하다는 신호입니다.
FSM의 상태를 열거형으로 정의합니다.
enum State { STATE_STANDING, STATE_JUMPING, STATE_DUCKING, STATE_DIVING };
C++
복사
이제 여러 플래그 대신 state_ 하나면 됩니다. 분기 순서도 바꿉니다. 앞의 코드는 입력에 따라 먼저 분기한 뒤 상태에 따라 분기했으므로, 한 버튼의 코드는 모였지만 한 상태의 코드는 흩어져 있었습니다.
상태와 관련된 코드를 모으기 위해 상태로 먼저 분기합니다.
void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_B) { state_ = STATE_JUMPING; yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } else if (input == PRESS_DOWN) { state_ = STATE_DUCKING; setGraphics(IMAGE_DUCK); } break; case STATE_JUMPING: if (input == PRESS_DOWN) { state_ = STATE_DIVING; setGraphics(IMAGE_DIVE); } break; case STATE_DUCKING: if (input == RELEASE_DOWN) { state_ = STATE_STANDING; setGraphics(IMAGE_STAND); } break; } }
C++
복사
사소해 보여도 훨씬 나아졌습니다. 분기를 전부 없애지는 않았지만 갱신할 상태 변수는 하나로 줄었고, 한 상태를 관리하는 코드는 한곳에 모였습니다. 열거형은 상태 기계를 구현하는 가장 간단한 방법이며 이 정도면 충분한 경우도 꽤 있습니다.
원주 6: 특히 주인공이 유효하지 않은 상태가 되는 일을 막을 수 있습니다. 불리언 여러 개로 관리하면 어떤 값 조합은 유효하지 않을 수 있지만, 열거형으로 정의한 한 상태를 선택하면 그런 조합이 생기지 않습니다.
물론 열거형만으로 부족할 수도 있습니다. 엎드려 있으면 기가 모이고 놓는 순간 특수 공격을 쏘도록 만든다고 해 보겠습니다. 엎드려 기를 모은 시간을 기록해야 합니다.
Heroine에 chargeTime_을 추가합니다. 매 프레임 호출되는 update()가 이미 있다고 가정하고 다음 코드를 넣습니다.
void Heroine::update() { if (state_ == STATE_DUCKING) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { superBomb(); } } }
C++
복사
엎드릴 때마다 시간을 초기화해야 하므로 handleInput()도 바꿉니다.
void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_DOWN) { state_ = STATE_DUCKING; chargeTime_ = 0; setGraphics(IMAGE_DUCK); } // 다른 입력 처리 break; // 다른 상태 처리... } }
C++
복사
기 모으기 공격 하나를 추가하려고 함수 두 개를 수정하고, 엎드린 상태에서만 의미 있는 chargeTime_을 Heroine에 넣었습니다. 그보다는 관련 코드와 데이터를 한곳에 모을 수 있으면 좋겠습니다. GoF가 나설 차례입니다.
원주 7: 여기에서 업데이트 메서드 패턴(10장)을 떠올렸다면 이미 패턴 전문가가 다 되었다는 것이 저자의 농담입니다.
💡
설명과 예제 코드의 발사 시점
문장은 ‘놓는 순간 특수 공격’을 말하지만, 위 원문 코드는 chargeTime_ > MAX_CHARGE인 프레임마다 superBomb()를 호출합니다. 버튼을 놓았을 때 한 번 발사하려면 그 입력을 처리하는 곳에서 충전량을 검사해야 합니다. 위 코드는 상태별 데이터가 일반 캐릭터 클래스에 섞이는 문제를 보여 주는 단계입니다.

7.4 상태 패턴(State Pattern)

객체지향에 너무 몰입해 모든 분기를 동적 디스패치, C++로는 가상 함수로 바꾸려는 사람도 있습니다. 그건 지나칩니다. 때로는 if만으로 충분합니다.
하지만 Heroine 정도의 예제라면 객체지향 방식인 상태 패턴을 쓰는 편이 낫습니다. GoF의 상태 패턴을 적용해 보겠습니다.

상태 인터페이스 만들기

상태에 의존하는 코드를 인터페이스의 가상 메서드로 만듭니다. 다중 선택문에 있던 동작을 옮기는 것입니다. 이 예제에서는 handleInput()과 update()입니다.
class HeroineState { public: virtual ~HeroineState() {} virtual void handleInput(Heroine& heroine, Input input) {} virtual void update(Heroine& heroine) {} };
C++
복사
원주 8: 모든 분기를 메서드로 바꾸려는 생각에는 역사적 배경이 있습니다. GoF나 『리팩토링』의 저자 마틴 파울러 같은 객체지향의 거장들은 스몰토크 개발자 출신입니다. 스몰토크의 ifTrue:는 true 객체와 false 객체에서 다르게 구현한 메서드에 불과합니다. 옮긴이는 다음 조건문을 예로 듭니다.
(x = y) ifTrue: [^x] ifFalse: [^y].
Smalltalk
복사
옮긴이의 설명에 따르면 (x = y)의 결과에 따라 True 또는 False 객체를 생성한 후 ifTrue:와 ifFalse: 메시지를 보냅니다. 조건이 참이면 true의 ifFalse:는 ^nil, 즉 nil을 반환하도록 되어 있어 아무것도 하지 않고 ifTrue: 블록만 실행합니다. False 객체에서는 그 반대입니다.

상태별 클래스 만들기

상태마다 인터페이스를 구현하는 클래스를 정의합니다. 메서드는 해당 상태에서 주인공이 어떤 행동을 할지 정합니다. 다중 선택문의 case마다 클래스를 만들고 코드를 옮기면 됩니다.
class DuckingState : public HeroineState { public: DuckingState() : chargeTime_(0) {} virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // 일어선 상태로 바꾼다... heroine.setGraphics(IMAGE_STAND); } } virtual void update(Heroine& heroine) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { heroine.superBomb(); } } private: int chargeTime_; };
C++
복사
chargeTime_을 Heroine에서 DuckingState로 옮겼다는 점을 놓치지 마세요. 이 변수가 엎드리기 상태에서만 의미 있다는 사실을 객체 모델 자체가 보여 줍니다. 훨씬 나아졌습니다.

동작을 상태에 위임하기

Heroine에 현재 상태 객체를 가리키는 포인터를 추가합니다. 거대한 다중 선택문을 없애고 상태 객체에 처리를 위임합니다.
class Heroine { public: virtual void handleInput(Input input) { state_->handleInput(*this, input); } virtual void update() { state_->update(*this); } // 다른 메서드들... private: HeroineState* state_; };
C++
복사
상태를 바꾸려면 state_에 HeroineState를 상속한 다른 객체를 할당하면 됩니다. 이것이 상태 패턴의 전부입니다.
원주 9: GoF의 전략 패턴, 타입 객체 패턴(13장)도 주요 클래스가 하위 객체에 동작을 위임한다는 점에서 비슷합니다. 차이는 의도에 있습니다.
•
전략 패턴은 주요 클래스를 일부 동작으로부터 디커플링하는 것이 목표입니다.
•
타입 객체 패턴은 같은 타입 객체의 참조를 공유해 여러 객체를 비슷하게 동작시키는 것이 목표입니다.
•
상태 패턴은 위임하는 객체를 바꾸어 주요 클래스의 동작을 바꾸는 것이 목표입니다.

7.5 상태 객체는 어디에 두나요?(State Object Lifetime)

앞에서 얼버무리고 지나간 문제가 있습니다. 상태를 바꾸려면 state_에 새로운 상태 객체를 할당해야 합니다. 그 객체는 어디에서 올까요?
열거형은 숫자 같은 기본 자료형이어서 신경 쓸 일이 없지만, 상태 패턴은 클래스를 사용하므로 포인터에 넣을 실제 인스턴스가 필요합니다. 두 방법을 살펴보겠습니다.

정적 객체 공유하기

상태 객체에 필드가 없다면 가상 메서드 호출에 필요한 vtable 포인터만 있습니다. 이 경우 모든 인스턴스가 같으므로 하나만 있으면 됩니다. 정적 인스턴스를 하나 만들어 여러 FSM이 공유할 수 있습니다.
원주 10: 상태 클래스에 필드가 없고 가상 메서드도 하나라면, 더 단순하게 정적 함수로 바꿀 수도 있습니다. 이때 state_는 함수 포인터가 됩니다.
원주 11: 이처럼 인스턴스를 공유하는 것이 경량 패턴(3장)입니다.
정적 인스턴스는 원하는 곳에 둬도 됩니다. 특별히 다른 곳이 없다면 상위 상태 클래스에 둡니다.
class HeroineState { public: static StandingState standing; static DuckingState ducking; static JumpingState jumping; static DivingState diving; // 다른 코드들... };
C++
복사
각 정적 변수가 게임에서 사용하는 상태 인스턴스입니다. 서 있는 상태에서 점프하려면 다음과 같이 합니다.
if (input == PRESS_B) { heroine.state_ = &HeroineState::jumping; heroine.setGraphics(IMAGE_JUMP); }
C++
복사

상태 객체를 그때그때 만들기

정적 객체로 충분하지 않을 수도 있습니다. 엎드리기 상태의 chargeTime_은 주인공마다 달라야 하므로 정적 객체로 만들 수 없습니다. 주인공이 하나라면 어떻게든 되겠지만, 협동 플레이를 추가해 두 주인공이 한 화면에 나오면 문제가 됩니다.
이럴 때는 전이할 때마다 상태 객체를 만들어 각 FSM이 상태별 인스턴스를 갖게 합니다. 새 상태를 할당했으므로 이전 상태를 해제해야 합니다. 상태를 바꾸는 코드는 현재 상태의 메서드 안에 있으므로, 실행 중인 this를 스스로 지우지 않도록 주의해야 합니다.
handleInput()이 상태가 바뀔 때에만 새 상태를 반환하도록 바꾸겠습니다. 호출한 쪽에서 반환값을 보고 이전 상태를 삭제하고 새 상태를 저장합니다.
void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; } }
C++
복사
새 상태를 반환하지 않으면 현재 상태를 삭제하지 않습니다. 서기에서 엎드리기로 전이할 때는 새 인스턴스를 만들어 반환합니다.
HeroineState* StandingState::handleInput( Heroine& heroine, Input input) { if (input == PRESS_DOWN) { // 다른 코드들... return new DuckingState(); } // 지금 상태를 유지한다. return NULL; }
C++
복사
저자는 가능하다면 매번 객체를 할당하느라 메모리와 CPU를 쓰지 않아도 되는 정적 상태를 선호합니다. 이제 상태 패턴을 조금 더 ‘상태스럽게’ 만드는 방법을 살펴보겠습니다.
원주 12: 동적 할당으로 인한 메모리 단편화가 걱정된다면 객체 풀 패턴(19장)을 고려해 보세요.
💡
공유 객체와 소유 객체를 섞지 않기
앞의 정적 상태는 여러 주인공이 공유할 수 있지만, 뒤의 delete state_ 예제는 new로 만든 상태를 각 주인공이 소유한다는 전제입니다. 정적 객체를 가리키는 포인터에 그대로 delete를 적용하면 안 됩니다. 또한 이 단계부터 handleInput()의 반환형이 void에서 HeroineState*로 바뀌므로 인터페이스와 하위 클래스의 선언도 함께 맞춰야 합니다.

7.6 입장과 퇴장(Entry and Exit Actions)

상태 패턴의 목표는 한 상태에 관련된 동작과 데이터를 하나의 클래스에 캡슐화하는 것입니다. 이 점에서 아직 예제에는 부족한 부분이 있습니다.
주인공은 상태를 바꿀 때 스프라이트도 바꿉니다. 지금까지는 이전 상태가 그래픽을 바꿨습니다. 예를 들어 엎드리기에서 서기로 넘어갈 때 엎드리기 상태가 이미지를 변경했습니다.
HeroineState* DuckingState::handleInput( Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { heroine.setGraphics(IMAGE_STAND); return new StandingState(); } // 다른 코드들... }
C++
복사
그보다 새 상태가 자기 그래픽까지 제어하는 편이 좋습니다. 입장 기능을 추가합니다.
class StandingState : public HeroineState { public: virtual void enter(Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); } // 다른 코드들... };
C++
복사
Heroine은 새 상태의 enter()를 호출하도록 전이 코드를 수정합니다.
void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; // 새로운 상태의 입장 함수를 호출한다. state_->enter(*this); } }
C++
복사
이제 엎드리기 코드는 더 단순해집니다.
HeroineState* DuckingState::handleInput( Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { return new StandingState(); } // 다른 코드들... }
C++
복사
Heroine은 서기 상태로 바꾸기만 하면 됩니다. 서기 상태가 알아서 그래픽까지 챙깁니다. 이제 상태가 제대로 캡슐화되었습니다. 이전 상태와 관계없이 항상 같은 입장 코드를 실행하는 것도 장점입니다.
실제 게임에서는 점프 후 착지하거나 내려찍기 후 착지하는 식으로 여러 전이가 같은 상태로 들어올 수 있습니다. 전이가 일어나는 곳마다 중복 코드를 넣는 대신 입장 기능 한곳에 모을 수 있습니다.
새 상태로 교체하기 직전에 호출하는 퇴장 코드도 같은 방식으로 활용할 수 있습니다.
💡
전이할 때 한 번, 머무르는 동안 여러 번
상태 객체는 그 행동에 필요한 데이터와 입력 처리를 묶습니다. 입장·퇴장 동작은 전이할 때 한 번, update()는 그 상태에 머무르는 동안 반복한다는 차이가 중요합니다. 엎드리는 애니메이션 시작은 입장에, 충전 시간 증가는 업데이트에, 충전 효과 정리는 퇴장에 두는 식으로 역할을 나눌 수 있습니다.

7.7 단점은 무엇일까요?(Limitations)

지금까지 FSM의 장점을 이야기했지만 단점도 있습니다. 앞의 설명은 모두 사실이고 FSM으로 해결할 수 있는 문제도 많습니다. 하지만 그 장점은 동시에 단점이기도 합니다.
상태 기계는 엄격하게 제한한 구조를 강제하여 복잡하게 얽힌 코드를 정리합니다. 미리 정한 여러 상태, 현재 상태 하나, 하드코딩한 전이만 존재합니다.
인공지능처럼 더 복잡한 문제에 적용하다 보면 한계에 부딪칩니다. 다행히 이전 세대 개발자들이 한계를 벗어나는 방법을 찾아 두었습니다. 몇 가지를 살펴보며 이 장을 마무리하겠습니다.
원주 13: FSM은 튜링 완전(Turing complete)하지도 않습니다. 오토마타 이론은 추상 모델을 이용해 더 복잡한 문제를 계산하는 방법을 다룹니다. 튜링 기계는 그중 표현력이 풍부한 모델입니다. 어떤 시스템, 보통은 프로그래밍 언어가 튜링 기계를 구현할 정도로 강력하면 ‘튜링 완전’하다고 합니다. 모든 튜링 완전 언어는 어떤 의미에서 표현력이 같지만, FSM은 거기까지 이르지 못합니다.

7.8 병행 상태 기계(Concurrent State Machines)

주인공이 총을 들게 만든다고 해 보겠습니다. 무장 후에도 달리기·점프·엎드리기를 할 수 있어야 하며 동시에 총도 쏠 수 있어야 합니다.
하나의 FSM을 고집하면 서기·무장한 채 서기·점프·무장한 채 점프처럼 무장 여부마다 상태를 두 개씩 만들어야 합니다. 무기를 늘릴수록 조합이 폭발합니다. 상태 수뿐 아니라, 총 쏘기 코드 조금을 제외하면 같은 코드를 중복한다는 점도 문제입니다.
무엇을 하는가와 무엇을 들고 있는가를 한 상태 기계에 넣었기 때문에 생긴 문제입니다. 모든 조합을 표현하려다 보니 모든 쌍에 대해 상태를 만들어야 했습니다. 해결책은 간단합니다. 상태 기계를 둘로 나눕니다.
동작 상태 기계는 그대로 두고 장비 상태 기계를 따로 정의합니다. Heroine이 각각을 참조합니다.
class Heroine { // 다른 코드들... private: HeroineState* state_; HeroineState* equipment_; };
C++
복사
입력을 위임할 때는 두 상태 기계에 모두 전달합니다.
void Heroine::handleInput(Input input) { state_->handleInput(*this, input); equipment_->handleInput(*this, input); }
C++
복사
각각은 입력에 따라 동작을 실행하고 독립적으로 상태를 바꿀 수 있습니다. 두 상태 기계가 서로 연관되지 않았다면 잘 들어맞습니다.
현실에서는 점프 중에는 총을 못 쏘거나, 무장하면 내려찍기를 못 하는 식으로 상호작용해야 할 수도 있습니다. 그러면 한 상태의 코드가 다른 기계의 상태를 검사하는 다소 지저분한 코드가 생길 수 있습니다. 바람직하지는 않지만 문제를 해결하는 방법은 될 것입니다.
원주 14: 동작 상태가 n개, 장비 상태가 m개일 때 하나로 합치면 n×m개의 상태가 필요합니다. 두 기계로 나누면 n+m개의 상태만 있으면 됩니다.
원주 15: 설명을 위해 장비에도 상태 기계를 사용했습니다. 사실 ‘장착했다’와 ‘장착하지 않았다’라는 두 상태뿐이라면 불리언 플래그만으로도 충분합니다.
원주 16: 더 정교하게 만든다면 첫 상태 기계가 입력을 소비(consume)하여 다음 기계까지 가지 않도록 할 수 있습니다. 두 기계가 같은 입력을 함께 처리해 잘못 반응하는 일을 막습니다.
💡
무기와 점프를 같은 enum에 넣으면?
이동 상태 4개와 무기 상태 3개를 조합하면 최대 12개 상태를 표현해야 합니다. 독립적인 축으로 나누면 4+3개로 관리할 수 있습니다. 다만 ‘공중에서는 재장전 금지’ 같은 규칙은 두 축 사이의 제약으로 명시해야 합니다. 여기서 병행은 두 상태를 함께 유지한다는 뜻이며, 반드시 서로 다른 스레드에서 실행한다는 뜻은 아닙니다.

7.9 계층형 상태 기계(Hierarchical State Machines)

동작을 늘리다 보면 서기·걷기·달리기·미끄러지기처럼 비슷한 상태가 많아집니다. 이들 상태에서는 모두 B를 누르면 점프하고 아래 버튼을 누르면 엎드려야 합니다.
단순한 구현에서는 상태마다 같은 코드를 넣어야 합니다. 한 번 구현해 재사용하는 편이 낫습니다. 객체지향 코드로 생각하면 상속으로 코드를 공유할 수 있습니다.
‘땅 위에 있는’ 상태 클래스를 만들고 점프와 엎드리기를 처리합니다. 서기·걷기·달리기·미끄러지기는 그 클래스를 상속해 자신만의 동작을 추가합니다.
이것이 계층형 상태 기계입니다. 어떤 상태는 상위 상태(superstate)를 가질 수 있고, 자신은 하위 상태(substate)가 됩니다. 이벤트를 하위 상태에서 처리하지 않으면 상위 상태로 넘깁니다. 상속받은 메서드를 오버라이드하는 것과 비슷합니다.
상태 패턴으로 만든 FSM에서는 클래스 상속으로 이 계층을 표현할 수 있습니다. 상위 상태 클래스를 정의합니다.
class OnGroundState : public HeroineState { public: virtual void handleInput( Heroine& heroine, Input input) { if (input == PRESS_B) { // 점프... } else if (input == PRESS_DOWN) { // 엎드리기... } } };
C++
복사
하위 상태는 이 클래스를 상속합니다.
class DuckingState : public OnGroundState { public: virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // 서기... } else { // 따로 입력을 처리하지 않고, 상위 상태로 보낸다. OnGroundState::handleInput(heroine, input); } } };
C++
복사
원주 17: 상속은 강력한 코드 재사용 방법이지만 두 코드를 강하게 결합합니다. 거대한 망치이므로 조심해서 휘둘러야 합니다.
계층형 상태 기계를 꼭 이렇게 구현할 필요는 없습니다. 클래스를 사용하는 GoF 방식이 아니라면 이런 구현이 불가능할 수도 있습니다. 그때는 주 클래스에 상태 하나 대신 상태 스택을 두어 현재 상태의 상위 상태 연쇄를 명시적으로 표현할 수 있습니다.
스택 맨 위에 현재 상태, 그 아래에 바로 위 상위 상태, 다시 그 아래에 그 상위 상태의 상위 상태를 놓습니다. 상태 관련 동작이 들어오면 누군가 처리할 때까지 위에서 아래로 전달합니다. 아무도 처리하지 않으면 무시합니다.

7.10 푸시다운 오토마타(Pushdown Automata)

스택으로 FSM을 확장하는 다른 방법도 있습니다. 앞에서 본 계층형 FSM의 스택과는 저장 방식도, 해결하려는 문제도 다릅니다.
FSM에는 이력(history)이 없습니다. 현재 상태는 알지만 직전 상태가 무엇이었는지 저장하지 않으므로 이전 상태로 쉽게 돌아갈 수 없습니다.
무장한 주인공이 총을 쏜다고 해 보겠습니다. 발사 애니메이션을 재생하고 총알과 시각 효과를 만드는 새 상태가 필요합니다. 총을 쏠 수 있는 모든 상태에서 발사 버튼을 누르면 전이하는 FiringState를 만들 수 있습니다.
어려운 것은 총을 쏜 뒤 어디로 돌아갈 것인가입니다. 서기·달리기·점프·엎드리기 중 모두 총을 쏠 수 있다면 발사 후 그전 상태로 돌아가야 합니다.
일반적인 FSM은 이전 상태를 모르므로 ‘서서 총 쏘기’, ‘달리며 총 쏘기’, ‘점프하며 총 쏘기’처럼 상태마다 발사 상태를 하나씩 더 만들고 돌아갈 상태를 하드코딩해야 합니다.
그보다는 발사 전 상태를 저장했다가 다시 사용하는 편이 낫습니다. 오토마타 이론의 푸시다운 오토마타가 도움이 됩니다.
FSM이 포인터로 상태 하나를 관리했다면 푸시다운 오토마타는 스택으로 관리합니다. 기존 상태를 덮어쓰고 새 상태로 전이하는 동작 외에 두 명령이 추가됩니다.
•
넣기(push): 새 상태를 스택에 넣습니다. 최상위가 현재 상태이므로 새 상태가 활성화됩니다. 이전 상태는 버리지 않고 그 아래에 남습니다.
•
빼기(pop): 최상위 상태를 제거합니다. 바로 아래에 있던 상태가 다시 현재 상태가 됩니다.
원주 18: FiringState로 옮기는 동작도 여러 상태에서 중복되므로 계층형 상태 기계를 활용해 코드를 재사용할 수 있습니다.
그림 7-2. 넣기와 빼기—라킹과 파핑과는 다릅니다
원주 19·옮긴이: 라킹과 파핑은 서로 비슷한 춤 장르입니다. 스택에서 ‘빼기(popping)’와 춤 이름 ‘파핑(popping)’은 철자가 같습니다.
그림에서 서기 상태는 발사가 올라와도 밑에 남습니다. 발사를 빼면 서기가 다시 드러납니다. 총 쏘기 상태를 구현하기에 알맞습니다.
총 쏘기 상태를 하나 만든 뒤 어느 상태에서든 발사 버튼을 누르면 스택에 넣습니다. 발사 애니메이션이 끝나면 그 상태를 빼고, 푸시다운 오토마타가 알아서 이전 상태로 돌려보냅니다.
💡
같은 스택이라도 무엇을 기억하는지가 다릅니다.
계층형 상태 기계의 스택은 현재 상태와 상위 상태의 관계를 담아 공통 입력 처리를 찾습니다. 푸시다운 스택은 잠시 중단한 이전 상태를 담아 돌아갈 곳을 기억합니다. 예를 들어 달리던 중 발사를 잠깐 올렸다가 빼면 달리기로 복귀합니다. 이 차이를 구분하면 상속 계층과 실행 이력을 혼동하지 않을 수 있습니다.

7.11 얼마나 유용한가요?(When to Use It)

FSM의 확장판이 몇 가지 있지만 FSM만으로 해결하는 데는 한계가 있습니다. 원서 집필 당시 저자는 게임 AI에서 행동 트리(behavior tree)나 계획 시스템(planning system)을 더 많이 쓰는 추세라고 설명했습니다. 복잡한 AI에 관심이 있다면 이 장은 맛보기로 생각하고 다른 책도 더 읽어야 합니다.
그렇다고 FSM이나 푸시다운 오토마타 같은 단순한 시스템이 쓸모없는 것은 아닙니다. 특정 문제를 모델링하는 데는 이것만으로도 충분합니다. FSM은 다음 조건에서 유용합니다.
•
내부 상태에 따라 객체 동작이 바뀝니다.
•
상태가 너무 많지 않은 선택지로 분명히 구분됩니다.
•
객체가 입력이나 이벤트에 반응합니다.
게임에서 FSM은 AI에 쓰이는 것으로 가장 잘 알려졌지만, 입력 처리·메뉴 화면 전환·문자 해석·네트워크 프로토콜·비동기 동작 구현에도 많이 사용됩니다.
원문: 사용자 제공 한국어판 『게임 프로그래밍 패턴』, 로버트 나이스트롬 지음·박일 옮김, 7장. PDF 122~140페이지(인쇄 121~139쪽), PDF 141페이지는 빈 페이지입니다. 원본 그림 7-1·7-2와 본문·코드·원주 1~19를 순서대로 옮겼습니다. 회색 💡 상자는 추가 설명입니다.