상태가 하나 늘면 조건은 하나만 늘지 않는다
Esc

    상태가 하나 늘면 조건은 하나만 늘지 않는다

    –Unity

    적 하나를 만듭니다. 평소에는 가만히 있다가, 플레이어를 보면 쫓아가고, 가까워지면 공격하고, 체력이 다하면 죽습니다.

    처음에는 이렇게 시작합니다.

    public class Enemy : MonoBehaviour
    {
        bool isChasing;
        bool isAttacking;
        bool isDead;
    
        void Update()
        {
            if (isDead) return;
    
            if (isAttacking) { Attack(); return; }
            if (isChasing)   { Chase();  return; }
    
            LookAround();
        }
    }

    동작합니다. 그런데 bool 세 개가 만들 수 있는 조합은 여덟 가지인데 실제로 말이 되는 것은 네 가지뿐입니다.

    bool 세 개가 만드는 조합 F F F 대기 F T F 공격 T F F 추격 F F T 사망 + 있을 수 없는 조합 T T F 추격하며 공격? T F T 죽었는데 추격? F T T 죽었는데 공격? T T T 전부? 컴파일러가 막아 주지 않습니다
    플래그를 끄는 것을 한 번만 빠뜨리면 오른쪽 상태가 실제로 만들어집니다.

    isDead = true 를 켜면서 isChasing = false 를 빠뜨리면 죽었는데 추격 중인 적이 생깁니다. 그리고 플래그가 넷, 다섯으로 늘어나면 조합은 열여섯, 서른둘이 됩니다. 유효한 것은 여전히 몇 개뿐인데요.

    enum 으로 한 걸음

    문제의 뿌리는 상태가 여러 변수에 흩어져 있다는 것입니다. 하나에 모으면 애초에 잘못된 조합이 만들어지지 않습니다.

    enum State { Idle, Chase, Attack, Dead }
    
    State state = State.Idle;
    
    void Update()
    {
        switch (state)
        {
            case State.Idle:   LookAround(); break;
            case State.Chase:  Chase();      break;
            case State.Attack: Attack();     break;
            case State.Dead:                 break;
        }
    }

    변수 하나에 값 하나뿐이라 두 상태가 동시에 참일 수 없습니다. 대부분의 현장 코드가 여기까지 와 있고, 상태가 서너 개면 이걸로 충분합니다.

    그런데 왜 다시 무너지나

    enum 이 무너지는 것은 상태가 늘어서가 아니라 같은 enum 을 여러 곳에서 다시 검사하기 시작해서입니다.

    void Update()          { switch (state) { ... } }   // 매 프레임 무엇을 하나
    void OnPlayerSpotted() { switch (state) { ... } }   // 발견했을 때 어디로 가나
    void OnDamaged()       { switch (state) { ... } }   // 맞았을 때 어디로 가나
    void OnAnimEnd()       { switch (state) { ... } }   // 애니메이션이 끝나면

    상태를 하나 추가하면 이 switch 를 전부 찾아서 case 를 넣어야 합니다. 하나를 빠뜨리면 컴파일은 되고 그 상태에서만 아무 반응이 없습니다. 제목의 이야기가 이것입니다. 상태 하나가 늘면 고칠 곳은 하나가 아닙니다.

    여기에 하나가 더 붙습니다. “이 상태에 들어올 때 딱 한 번만 할 일” 을 적을 자리가 없습니다. 추격을 시작할 때 울음소리를 한 번 내려면 결국 이렇게 됩니다.

    case State.Chase:
        if (!hasRoared) { Roar(); hasRoared = true; }   // 플래그가 돌아왔습니다
        Chase();
        break;

    애써 없앤 bool 이 다시 기어 들어옵니다.

    상태를 객체로

    switch 의 각 case 를 클래스 하나씩으로 뜯어냅니다. 그리고 들어올 때와 나갈 때를 문법으로 만들어 줍니다.

    public interface IState
    {
        void Enter();    // 이 상태가 될 때 한 번
        void Update();   // 이 상태인 동안 매 프레임
        void Exit();     // 이 상태를 떠날 때 한 번
    }
    public class ChaseState : IState
    {
        readonly Enemy enemy;
        public ChaseState(Enemy e) => enemy = e;
    
        public void Enter()  => enemy.Roar();              // 한 번만, 플래그 없이
        public void Update() => enemy.MoveTowardPlayer();
        public void Exit()   => enemy.StopMoving();
    }

    바꾸는 쪽은 이렇게 짧아집니다.

    public class StateMachine
    {
        public IState Current { get; private set; }
    
        public void Change(IState next)
        {
            Current?.Exit();
            Current = next;
            Current.Enter();
        }
    
        public void Tick() => Current?.Update();
    }

    이제 상태를 추가하려면 클래스 파일 하나를 새로 만들면 됩니다. Enemy 도 StateMachine 도 고치지 않습니다. switch 를 찾아 헤맬 일도, 빠뜨릴 일도 없습니다.

    전이는 누가 정하나

    여기가 이 패턴의 진짜 결정입니다. 다음 상태를 누가 고르느냐입니다.

    가장 흔한 답은 상태 자신입니다.

    public class ChaseState : IState
    {
        public void Update()
        {
            if (enemy.DistanceToPlayer < 2f)
                enemy.Machine.Change(enemy.AttackState);   // 스스로 넘긴다
    
            enemy.MoveTowardPlayer();
        }
    }

    추격 상태만이 “언제 공격으로 넘어가는가” 를 압니다. 대기 상태는 그 조건을 몰라도 되고, 알 필요도 없습니다. 각 상태가 자기 출구만 아는 것이 이 구조의 핵심입니다.

    전이 규칙을 상태 밖의 표로 빼는 방식도 있습니다. 전이가 복잡해서 한눈에 보여야 하거나, 데이터로 편집하고 싶을 때 씁니다. 대신 상태와 규칙이 두 곳으로 갈리므로 처음부터 그렇게 갈 이유는 없습니다.

    유니티에서 챙길 것

    상태 객체를 매 전이마다 만들지 않습니다.

    // 전이할 때마다 new 하면 쓰레기가 계속 쌓입니다
    machine.Change(new ChaseState(this));
    
    // 미리 하나씩 만들어 두고 재사용합니다
    void Awake()
    {
        idleState   = new IdleState(this);
        chaseState  = new ChaseState(this);
        attackState = new AttackState(this);
    }

    상태 객체에 이번 회차에만 쓰는 값이 있다면 Enter() 에서 초기화하면 됩니다. 그러면 재사용해도 문제가 없습니다.

    상태는 MonoBehaviour 가 아니어도 됩니다. 순수 C# 클래스로 두면 오브젝트에 컴포넌트가 잔뜩 붙지 않고, 인스펙터도 깔끔합니다. 유니티 API 는 Enemy 참조를 통해 쓰면 됩니다.

    그리고 이미 쓰고 있습니다. Animator 의 상태 머신이 정확히 이 구조입니다. 상태가 있고, 전이 조건이 화살표로 붙어 있고, 각 상태에 진입과 종료가 있습니다. StateMachineBehaviour 를 상속하면 OnStateEnter 와 OnStateExit 를 직접 받습니다.

    언제 쓰지 않는가

    상태가 둘이고 앞으로도 둘이면 bool 하나가 정답입니다. IState 인터페이스와 클래스 두 개를 만드는 것은 그 자체가 비용입니다.

    기준은 이렇게 잡습니다.

    상태가 셋을 넘고, 상태마다 진입이나 종료 처리가 있고, 앞으로 더 늘어날 것 같으면 객체로 뺀다.

    프로토타입은 enum 과 switch 로 빠르게 만들고, 확장이 보일 때 옮기는 것이 현실적인 순서입니다. 처음부터 완성된 구조로 시작하려다 아무것도 못 만드는 것보다 낫습니다.

    정리

    • bool 플래그는 있을 수 없는 조합까지 만든다. 플래그 셋이면 여덟 가지가 생기고 넷은 오류다.
    • enum 은 상태를 한 변수에 모아 그 문제를 없앤다. 셋넷까지는 이걸로 충분하다.
    • enum 이 무너지는 것은 같은 값을 여러 곳에서 다시 switch 하기 때문이다.
    • 상태 하나를 추가하면 그 switch 를 전부 고쳐야 하고, 빠뜨려도 컴파일된다.
    • “들어올 때 한 번만” 을 적을 자리가 없어서 bool 이 다시 등장한다.
    • 상태를 객체로 만들면 Enter 와 Exit 가 문법이 된다. 추가는 파일 하나다.
    • 전이는 보통 상태 자신이 정한다. 각 상태가 자기 출구만 알면 된다.
    • Exit 이 Enter 보다 먼저다. 상태 객체는 미리 만들어 재사용한다.