Search
Duplicate

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 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 이나 푸시다운 오토마타, 그외 간단한 시스템들이 쓸모없는건 아니다. 상황에 따라서 동작의 가짓수 또는 여러 시스템을 고려하여 본인에게 맞는 모델링을 선택하고 사용하는것이 개발에 있어서는 제일 중요하다.
너무 과한 패턴을 사용해도 안되고 그렇다고 너무 단순하게 작업해서도 안된다. 정답은 없지만 해답은 있다는것을 인지하자.
내부 상태에 따라 객체 동작이 바뀔때
이런 상태가 그다지 많지않은 선택지로 구분할수 있을 때
객체가 입력이나 이벤트에 따라 반응할 떄
요정도가 주로 사용되는 조건이라고 볼수 있다.
게임에서 가장많이 사용 되지만 메뉴화면, 문자해석, 네트워크 프로토콜, 비동기 동작을 구분하는데도 사용하는 편이다.