[TIL] 39일 차 - 유한 상태 머신 (FSM)에 대해 알아보자

ChangBeom·2025년 3월 21일

TIL

목록 보기
40/53
post-thumbnail

오늘은 Unity 3D 심화 강의를 통해 유한 상태 머신이라는 것을 알게 되었는데, 잘 사용하면 이후 프로젝트에서 유용하게 쓸 수 있을 것 같아서 정리해보려고 한다.


[유한 상태 머신(FSM)]

<유한 상태 머신(FSM)이란?>

FSM은 Finite-state machine의 약자로 유한한 개수의 상태를 가지며, 각 상태와 상태 간의 전환을 기반으로 동작하도록 설계하는 것이다.


FSM의 구성요소는 다음과 같다.

  • 상태(State) : 시스템이 취할 수 있는 다양한 형태
  • 전환 조건 (Transition Condition) : 상태 간 전환을 결정하는 조건
  • 동작 (Action) : 상태에 따라 수행되는 동작 또는 로직

FSM의 핵심은 항상 하나의 상태를 가지고 있다 는 것이다. 항상 상태에 맞는 동작을 수행하고 전환 조건에 의해 상태가 전환되면 전환된 상태에 맞는 동작을 수행하기 때문에 어떤 동작을 구현할 때 어떤 상태일때 수행하는 동작인지 명확하게 정의할 수 있어서 개발에 용이하다.


플레이어의 상태를 FSM으로 구현한다고 가정하면 다음과 같다.

  • 상태 : 정지, 이동, 점프
  • 전환 조건 : 이동 입력, 점프 입력, 충돌 등
  • 동작 : 이동 애니메이션, 점프 처리, 이동 속도 조정 등

이 예시를 간단하게 enum형으로 상태를 정의하고 switch문을 통해 FSM을 구현해보자.

using UnityEngine;

public Enum State
{
	Idle,
    Move,
    Jump,
}

public class Player : Monobehaviour
{
	private State state;
   
    void Start()
    {
    	stage = State.Idle;
    }

	void Update()
    {
    	switch(state)
        {
        	case State.Idle:
            	//  Idle 동작 구현부
                break;
            case State.Move:
                //  Move 동작 구현부
                break;
            case State.Attack:
                //  Attack 동작 구현부
                break;
        }
    }
}

이런 느낌으로 간단하게 FSM을 구현할 수 있다.

하지만 switch 문으로 FSM를 구현하면 다음과 같은 단점이 존재한다.

  • 상태가 많아지고 조건이 복잡해지면 코드가 지나치게 길어진다. (가독성이 떨어짐)
    • ex) 어떤 동작을 수행할 때 특정 키의 입력을 막는 조건이 들어갈 경우 등
  • 유지보수가 어렵다.
  • 상태가 추가될 때마다 새로운 분기를 작성해야되기 때문에 중복된 코드가 많아진다.

*그래서 FSM은 State Pattern(상태패턴)을 활용해서 구현해야 한다.

위 이미지를 토대로 예시 코드를 작성해보면 다음과 같다.

public interface IState
{
    public void Enter();
    public void Exit();
    public void HandleInput();
    public void Update();
    public void PhysicsUpdate();
}

public abstract class StateMachine
{
    protected IState currentState;

    public void ChangeState(IState state)
    {
        currentState?.Exit();
        currentState = state;
        currentState?.Enter();
    }

    public void HandleInput()
    {
        currentState?.HandleInput();
    }

    public void Update()
    {
        currentState?.Update();
    }

    public void PhysicsUpdate()
    {
        currentState?.PhysicsUpdate();
    }
}

먼저 가장 기초가 되는 StateMachine 이라는 추상클래스와 IState 인터페이스를 만든다. StateMachine 클래스는 상태에 따른 처리를 하거나 상태를 전환 시키는 데 사용하고, IState는 각 상태를 구현하기 위해 필요한 것들을 모아놓은 것이다.

IState의 함수들에 대해 간단하게 설명하면 다음과 같다.

  • Enter : 상태에 진입할 때
  • Exit : 상태에서 벗어날 때
  • HandleInput : 입력에 따른 처리
  • Update : 지속적인 처리
  • PhysicsUpdate : 물리관련 지속적인 처리

이제 StateMachine을 상속받아 PlayerStateMachine을 만들고, PlayerStateMacine에서 Player의 상태를 관리해주면 된다.

using UnityEngine;

public class PlayerStateMachine : StateMachine
{
    public Player Player { get; }

    public Vector2 MovementInput { get; set; }
    public float MovementSpeed { get; private set; }
    public float RotationDamping { get; private set; }
    public float MovementSpeedModifier { get; set; } = 1f;
   
    public float JumpForce { get; set; }
    public bool IsAttacking { get; set; }
    public int ComboIndex { get; set; }

    public Transform MainCamTransform { get; set; }

    public PlayerIdleState IdleState { get; private set; }
    public PlayerWalkState WalkState { get; private set; }
    public PlayerRunState RunState { get; private set; }

    public PlayerJumpState JumpState { get; private set; }
    public PlayerFallState FallState { get; private set; }

    public PlayerComboAttackState ComboAttackState { get; private set; }

    public PlayerStateMachine(Player player)
    {
        this.Player = player;

        MainCamTransform = Camera.main.transform;

        IdleState = new PlayerIdleState(this);
        WalkState = new PlayerWalkState(this);
        RunState = new PlayerRunState(this);

        JumpState = new PlayerJumpState(this);
        FallState = new PlayerFallState(this);

        ComboAttackState = new PlayerComboAttackState(this);

        MovementSpeed = player.Data.GroundData.BaseSpeed;
        RotationDamping = player.Data.GroundData.BaseRotationDamping;
    }
}

PlayerStateMachine의 코드는 플레이어를 조작하기 위해 필요한 변수들과 그것을 초기화하는 내용이므로 설명은 생략한다.

중간 부분을 건너 뛰고 간단하게 PlayerGroundState에 속해있는 PlayerIdleState, PlayerWalkState, PlayerRunState들만 보면 다음과 같다.

//	PlayerIdleState.cs
using UnityEngine;

public class PlayerIdleState : PlayerGroundState
{
    public PlayerIdleState(PlayerStateMachine stateMachine) : base(stateMachine)
    {
    }

    public override void Enter()
    {
        base.Enter();
        //	구현부
    }

    public override void Exit()
    {
        base.Exit();
        //	구현부
    }

    public override void Update()
    {
        base.Update();
		//	구현부
    }
}

//	PlayerWalkState.cs
using UnityEngine.InputSystem;

public class PlayerWalkState : PlayerGroundState
{
    public PlayerWalkState(PlayerStateMachine stateMachine) : base(stateMachine)
    {
    }

    public override void Enter()
    {
        base.Enter();
        //	구현부
    }

    public override void Exit()
    {
        base.Exit();
		//	구현부
    }

    protected override void OnRundStarted(InputAction.CallbackContext context)
    {
        base.OnRundStarted(context);
        stateMachine.ChangeState(stateMachine.RunState);
    }
}

//	PlayerRunState.cs
using UnityEngine;

public class PlayerRunState : PlayerGroundState
{
    public PlayerRunState(PlayerStateMachine stateMachine) : base(stateMachine)
    {
    }

    public override void Enter()
    {
        base.Enter();
        //	구현부
    }

    public override void Exit()
    {
        base.Exit();
        //	구현부
    }
}

이런 느낌으로 각 상태에 따라 스크립트(클래스)를 나눠서 각 상태에 따라 어떤 동작을 해야되는 지 구현하면 된다.


이렇게 상태패턴을 활용해서 FSM을 구현하는 이유는 다음과 같은 장점이 있기 때문이다.

  • 기본적으로 객체지향의 다형성을 활용한다.
  • 상태를 명확하게 정의하고 상태 간 전환을 일관되게 관리할 수 있다.
  • 복잡한 동작을 상태와 전환 조건으로 나눠 구현하기 때문에 코드의 유지 보수가 용이하다.
  • 다양한 동작을 유기적으로 조합하여 원하는 동작을 구현할 수 있다.

FSM이 항상 옳은 것은 아니다. 각각의 상태를 뚜렷하게 정의할 수 없거나 상태의 종류가 매우 많아지면 가독성이 떨어지며 디버깅이 어려워진다는 단점을 가지고 있다.

따라서 FSM은 상태의 수가 적고 상태별 동작이 명확하게 구별될 때 가장 빛을 발한다. 무작정 사용하지 말고 이런 장단점을 명확하게 인지하고 상황에 맞게 사용해보자.


참고 링크 : https://dodobug.tistory.com/16

0개의 댓글