[2025_12_12]FSM에서 StatePattern사용

2025. 12. 12. 21:03TIL

Unity에서 State Pattern으로 유닛 FSM 구현하기

배경

오토배틀 게임을 만들면서 유닛의 상태를 관리할 필요가 생겼다. 유닛은 이동(MoveState), 공격(AttackState), 사망(DieState) 등 여러 상태를 가지는데, 이를 if-else나 switch문으로 관리하면 코드가 복잡해지고 유지보수가 어려워진다.

State Pattern이란?

객체의 내부 상태가 변경될 때 행동을 변경할 수 있게 하는 디자인 패턴. 각 상태를 별도의 클래스로 분리해서 상태별 로직을 캡슐화한다.

 

1. State 인터페이스 정의

모든 State가 구현해야 할 메서드를 정의했다.

csharp
public interface IUnitState
{
    void Enter(UnitFSM unitFSM);           // 상태 진입 시
    void Execute(UnitFSM unitFSM);         // 매 프레임
    void PhysicsExecute(UnitFSM unitFSM);  // 물리 업데이트
    void Exit(UnitFSM unitFSM);            // 상태 종료 시
}

처음엔 Enter/Exit만 있었는데, Update와 FixedUpdate를 분리할 필요가 생겨서 Execute와 PhysicsExecute를 나눴다. 이동은 물리 계산이 필요하고, 타겟 탐색은 일반 업데이트에서 처리하는 게 맞다고 판단했다.

2. FSM 매니저 구현

상태를 관리하고 전환하는 FSM 클래스를 만들었다.

csharp
public class UnitFSM : MonoBehaviour
{
    private UnitBase owner;
    private IUnitState currentState;

    public void Init(UnitBase unit)
    {
        owner = unit;
        ChangeState(new MoveState());  // 초기 상태
    }

    public void ChangeState(IUnitState newState)
    {
        currentState?.Exit(this);
        currentState = newState;
        currentState?.Enter(this);
    }

    private void Update()
    {
        currentState?.Execute(this);
    }

    private void FixedUpdate()
    {
        currentState?.PhysicsExecute(this);
    }

    public UnitBase GetOwner() => owner;
}

핵심은 ChangeState 메서드다. 이전 상태를 Exit하고, 새 상태로 Enter한다. Null 체크는 ?. 연산자로 간결하게 처리했다.

3. 구체적인 State 구현

MoveState (이동 상태)

 
csharp
public class MoveState : IUnitState
{
    private UnitBase unit;
    private Vector3 moveDirection;

    public void Enter(UnitFSM unitFSM)
    {
        unit = unitFSM.GetOwner();
        moveDirection = unit.GetMoveDirection();
    }

    public void Execute(UnitFSM unitFSM)
    {
        // 적 탐색
        UnitBase enemy = unit.FindNearestEnemy();
        if (enemy != null)
        {
            unit.SetCurrentTarget(enemy);
            moveDirection = (enemy.transform.position - unit.transform.position).normalized;
            
            // 사거리 안에 들어오면 공격 상태로 전환
            if (unit.IsTargetInAttackRange(enemy))
            {
                unitFSM.ChangeState(new AttackState());
                return;
            }
        }
    }

    public void PhysicsExecute(UnitFSM unitFSM)
    {
        // Transform 직접 이동 (Physics2D 사용 안 함)
        float moveSpeed = unit.Data.unitMoveSpeed ?? 2f;
        unit.transform.position += moveDirection * moveSpeed * Time.fixedDeltaTime;
    }

    public void Exit(UnitFSM unitFSM) { }
}

AttackState (공격 상태)

csharp
public class AttackState : IUnitState
{
    private UnitBase unit;
    private float attackTimer;
    private Vector3 combatPosition;

    public void Enter(UnitFSM unitFSM)
    {
        unit = unitFSM.GetOwner();
        attackTimer = 0f;
        combatPosition = unit.transform.position;  // 전투 위치 고정
        unit.SetCombatState(true);
    }

    public void Execute(UnitFSM unitFSM)
    {
        UnitBase target = unit.GetCurrentTarget();
        
        // 타겟 유효성 체크
        if (target == null || target.IsDead)
        {
            unitFSM.ChangeState(new MoveState());
            return;
        }

        // 사거리 벗어나면 이동 상태로
        if (!unit.IsTargetInAttackRange(target))
        {
            unitFSM.ChangeState(new MoveState());
            return;
        }

        // 공격 타이머
        attackTimer += Time.deltaTime;
        float attackSpeed = unit.Data.unitAttackSpeed ?? 1f;
        
        if (attackTimer >= 1f / attackSpeed)
        {
            attackTimer = 0f;
            target.TakeDamage(unit.CurAtk);
        }
    }

    public void PhysicsExecute(UnitFSM unitFSM)
    {
        // 위치 고정 (밀림 방지)
        unit.transform.position = combatPosition;
    }

    public void Exit(UnitFSM unitFSM)
    {
        unit.SetCombatState(false);
        
        // 다음 적 즉시 탐색
        UnitBase nextEnemy = unit.FindNearestEnemy();
        if (nextEnemy != null)
        {
            unit.SetCurrentTarget(nextEnemy);
        }
    }
}

마주친 문제들과 해결

문제 1: 상태 전환이 너무 자주 일어남

적을 찾았다가 놓쳤다가를 반복하면서 MoveState ↔ AttackState를 왔다갔다했다.

해결: 적 감지를 0.3초 간격으로 체크하도록 최적화했다. 매 프레임 체크할 필요가 없었다.

 
csharp
private float detectionCheckInterval = 0.3f;
private float lastDetectionCheck = 0f;

public void Execute(UnitFSM unitFSM)
{
    lastDetectionCheck += Time.deltaTime;
    if (lastDetectionCheck >= detectionCheckInterval)
    {
        lastDetectionCheck = 0f;
        // 적 탐색
    }
}

문제 2: 적 처치 후 불필요하게 전진함

AttackState에서 적을 죽이고 MoveState로 돌아왔을 때, 다음 적을 찾기까지 0.3초 동안 원래 방향(위쪽)으로 전진했다.

해결:

  1. AttackState의 Exit에서 즉시 다음 적을 탐색
  2. MoveState에서 타겟이 없으면 moveDirection을 Vector3.zero로 설정해서 정지

문제 3: 전투 중 뒷유닛이 앞유닛을 밀어냄

유닛끼리 충돌하면서 전투 중인 유닛이 밀려났다.

해결:

  1. AttackState에서 위치를 완전히 고정 (transform.position = combatPosition)
  2. Physics2D를 사용하지 않아서 물리 엔진의 간섭이 없음

문제 4: State 간 중복 코드

MoveState와 AttackState 모두 FindNearestEnemy() 메서드가 필요했다.

해결: UnitBase에 공용 메서드로 빼서 중복 제거

 
 
csharp
// UnitBase.cs
public UnitBase FindNearestEnemy()
{
    float detectionRange = Data.unitDetectionRange ?? 5f;
    Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, detectionRange);
    // ...
}

// State에서 사용
UnitBase enemy = unit.FindNearestEnemy();

State Pattern의 장점 (실제로 느낀 점)

1. 코드 가독성

각 상태의 로직이 분리되어 있어서 MoveState 파일만 열면 이동 관련 로직만 볼 수 있다. if-else 지옥에서 벗어났다.

2. 유지보수

공격 로직을 수정하려면 AttackState만 건드리면 된다. 다른 상태에 영향을 주지 않는다.

3. 확장성

새로운 상태 추가가 쉽다. IUnitState를 구현한 새 클래스만 만들면 된다.

 
 
csharp
public class IdleState : IUnitState { }
public class SkillState : IUnitState { }
public class StunState : IUnitState { }

4. 테스트

각 State를 독립적으로 테스트할 수 있다.

State Pattern의 단점 (겪어본 것)

1. 클래스 개수 증가

상태마다 파일이 생긴다. 지금은 3개지만 나중에 10개 넘어가면 관리가 복잡해질 수 있다.

2. State 간 데이터 공유 어려움

MoveState에서 계산한 값을 AttackState에서 쓰고 싶을 때가 있는데, UnitBase를 통해 우회해야 한다.

3. 메모리 할당

상태 전환할 때마다 new MoveState()로 새 객체를 생성한다. 나중에 오브젝트 풀링이 필요할 수도 있다.

배운 점

  1. 디자인 패턴은 도구다: State Pattern이 만능은 아니다. 상태가 2-3개면 오히려 과한 설계일 수 있다. 하지만 상태가 많고 전환이 복잡하면 확실히 효과적이다.
  2. 상태 전환 타이밍: 상태 전환을 언제 할지가 중요하다. 너무 자주 하면 왔다갔다하고, 너무 늦으면 반응이 느리다. 적절한 체크 간격(0.3초)을 찾는 게 핵심이었다.
  3. Exit의 중요성: 단순히 정리만 하는 게 아니라, 다음 상태를 위한 준비도 할 수 있다. AttackState의 Exit에서 다음 적을 미리 찾아두니 끊김이 없어졌다.
  4. 중복 제거는 나중에: 처음엔 각 State에 FindNearestEnemy()를 따로 만들었다. 일단 동작하게 만들고, 나중에 리팩토링해서 UnitBase로 옮겼다. 처음부터 완벽하게 설계하려 하지 말자.

다음에 시도해볼 것

  1. State 재사용: new MoveState() 대신 State를 미리 만들어두고 재사용하면 GC 부담을 줄일 수 있을 것 같다.
  2. Sub-State Machine: AttackState 안에서도 "조준 -> 공격 -> 쿨다운" 같은 서브 상태가 필요할 수 있다.
  3. Transition 클래스 분리: 상태 전환 조건이 복잡해지면 Transition을 별도 클래스로 만드는 것도 고려해볼 만하다.