상태 패턴으로 플레이어 FSM을 깔끔하게 설계하는 방법

상태 패턴으로 플레이어 FSM을 깔끔하게 설계하는 방법

대기·이동·점프·낙하 로직을 상태 객체로 분리해 기능이 늘어나도 수정하기 쉬운 플레이어 FSM을 만든다. Unity 6과 C#을 기준으로 상태 전이, 입력 수집, 물리 처리, 애니메이션 연동의 책임을 나눠 본다.

플레이어 코드가 조건문투성이가 되는 이유

플레이어의 행동은 생각보다 빠르게 늘어난다. 처음에는 대기와 이동만 있던 코드에 점프, 낙하, 공격, 대시, 피격, 벽타기 같은 기능이 하나씩 붙는다.

기능이 적을 때는 Update() 안의 조건문만으로도 충분하다. 하지만 행동이 많아지면 조건끼리 얽히기 시작한다.

  • 공중에서 공격할 수 있는가?
  • 피격 중에는 이동 입력을 무시해야 하는가?
  • 착지한 프레임에 공격 입력이 들어오면 어떤 행동을 먼저 실행해야 하는가?
  • 대시 도중 점프나 피격으로 전환할 수 있는가?

이런 규칙을 한 메서드에서 모두 처리하면 조건 하나를 수정했을 뿐인데 전혀 다른 행동이 망가지는 일이 생긴다.

상태 패턴은 현재 행동을 하나의 객체로 표현하고 그 행동에 필요한 진입·갱신·종료 로직을 해당 객체에 모으는 방식이다. 플레이어 컨트롤러는 공통 데이터와 상태 전환을 담당하고 각 상태는 자신의 행동 규칙에만 집중한다.

flowchart LR
    Player[PlayerController] --> Idle[IdleState]
    Player --> Move[MoveState]
    Player --> Jump[JumpState]
    Player --> Fall[FallState]
    Idle -->|이동 입력| Move
    Idle -->|점프 입력| Jump
    Move -->|입력 없음| Idle
    Move -->|점프 입력| Jump
    Jump -->|수직 속도 0 이하| Fall
    Fall -->|지면 감지| Idle
    Fall -->|지면 감지 + 이동 입력| Move

여기서 핵심은 상태 객체가 플레이어 전체를 제어하지 않는다는 점이다. 상태는 전환 시점을 판단하지만 실제 교체 작업은 컨트롤러의 ChangeState()를 통해서만 수행한다.

먼저 책임부터 나눈다

이 예제에서는 책임을 다음과 같이 나눈다.

  • PlayerController: 입력값, 물리 컴포넌트, 애니메이터, 지면 감지 결과처럼 여러 상태가 공유하는 데이터를 관리한다.
  • IPlayerState: 각 상태가 구현해야 할 생명주기를 정의한다.
  • 개별 상태 클래스: 현재 행동과 다른 상태로 넘어가는 조건을 처리한다.
  • ChangeState(): 기존 상태의 Exit()와 새 상태의 Enter()가 항상 같은 순서로 호출되도록 보장한다.

상태 안에서 매번 GetComponent()를 호출하거나 입력 시스템을 직접 조회하지 않는 것도 중요하다. 필요한 의존성을 생성자로 전달하면 어떤 데이터에 접근하는지 코드만 봐도 알 수 있고 테스트하기도 쉬워진다.

상태 인터페이스 정의하기

상태마다 필요한 기본 생명주기는 네 가지다.

public interface IPlayerState
{
    void Enter();
    void Tick();
    void FixedTick();
    void Exit();
}

Tick()은 입력 확인과 일반적인 프레임 단위 판단에 사용한다. FixedTick()Rigidbody2D의 속도를 바꾸거나 물리 상태를 확인하는 데 사용한다.

모든 상태가 네 메서드를 전부 활용하는 것은 아니다. 그래도 호출 경로를 하나로 유지하면 컨트롤러 쪽 코드가 단순해진다. 빈 메서드가 지나치게 많아지면 인터페이스 대신 기본 구현을 제공하는 추상 클래스로 바꿔도 된다.

플레이어 컨트롤러 구현하기

다음 예제는 Unity 6의 Rigidbody2D.linearVelocity를 사용한다.

입력은 설명을 단순하게 하기 위해 기존 Input Manager API로 읽는다. 새 프로젝트라면 Unity Input System 패키지를 사용하는 편이 좋지만 입력을 컨트롤러 한곳에서 수집한다는 설계 원칙은 그대로 적용된다.

using UnityEngine;

public sealed class PlayerController : MonoBehaviour
{
    [Header("Components")]
    [SerializeField] private Rigidbody2D body;
    [SerializeField] private Animator animator;
    [SerializeField] private Transform groundCheck;

    [Header("Movement")]
    [SerializeField] private float moveSpeed = 6f;
    [SerializeField] private float jumpSpeed = 12f;

    [Header("Ground Check")]
    [SerializeField] private float groundCheckRadius = 0.15f;
    [SerializeField] private LayerMask groundMask;

    public Rigidbody2D Body => body;
    public Animator Animator => animator;
    public float MoveSpeed => moveSpeed;
    public float JumpSpeed => jumpSpeed;

    public float MoveInput { get; private set; }
    public bool JumpPressed { get; private set; }
    public bool IsGrounded { get; private set; }

    private IPlayerState currentState;

    private void Awake()
    {
        ValidateReferences();
        RefreshGrounded();
        ChangeState(new PlayerIdleState(this));
    }

    private void Update()
    {
        ReadInput();
        currentState?.Tick();

        // GetButtonDown은 한 프레임 입력이므로 처리 후 초기화한다.
        JumpPressed = false;
    }

    private void FixedUpdate()
    {
        RefreshGrounded();
        currentState?.FixedTick();
    }

    public void ChangeState(IPlayerState nextState)
    {
        if (nextState == null)
        {
            Debug.LogError("전환할 플레이어 상태가 없습니다.");
            return;
        }

        currentState?.Exit();
        currentState = nextState;
        currentState.Enter();
    }

    private void ReadInput()
    {
        MoveInput = Input.GetAxisRaw("Horizontal");
        JumpPressed = Input.GetButtonDown("Jump");
    }

    private void RefreshGrounded()
    {
        IsGrounded = Physics2D.OverlapCircle(
            groundCheck.position,
            groundCheckRadius,
            groundMask) != null;
    }

    private void ValidateReferences()
    {
        Debug.Assert(body != null, "Rigidbody2D가 연결되지 않았습니다.", this);
        Debug.Assert(animator != null, "Animator가 연결되지 않았습니다.", this);
        Debug.Assert(groundCheck != null, "Ground Check가 연결되지 않았습니다.", this);
    }

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        if (groundCheck == null)
        {
            return;
        }

        Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
    }
#endif
}

입력을 컨트롤러에서 한 번만 읽어 두면 상태 클래스는 특정 입력 API에 의존하지 않는다. 나중에 Input System으로 교체하더라도 입력 수집 부분만 바꾸면 된다.

Physics2D.OverlapCircle()은 원 안에 겹치는 Collider2D가 있으면 해당 콜라이더를 없으면 null을 반환한다. 따라서 결과를 bool 프로퍼티에 넣을 때는 != null을 명시하는 편이 읽기 쉽다.

대기 상태 구현하기

대기 상태는 수평 속도를 멈추고 이동 또는 점프 입력을 기다린다.

using UnityEngine;

public sealed class PlayerIdleState : IPlayerState
{
    private readonly PlayerController player;

    public PlayerIdleState(PlayerController player)
    {
        this.player = player;
    }

    public void Enter()
    {
        player.Animator.Play("Idle");
    }

    public void Tick()
    {
        if (player.JumpPressed && player.IsGrounded)
        {
            player.ChangeState(new PlayerJumpState(player));
            return;
        }

        if (!Mathf.Approximately(player.MoveInput, 0f))
        {
            player.ChangeState(new PlayerMoveState(player));
        }
    }

    public void FixedTick()
    {
        Vector2 velocity = player.Body.linearVelocity;
        velocity.x = 0f;
        player.Body.linearVelocity = velocity;
    }

    public void Exit() { }
}

상태를 바꾼 직후에는 return으로 메서드를 끝내는 편이 안전하다. 그렇지 않으면 같은 프레임에 다음 조건까지 검사해 두 번 이상 전환할 수 있다.

속도 변경은 FixedTick()에서 처리한다. Rigidbody2D 값을 일반 프레임과 물리 프레임 양쪽에서 번갈아 수정하면 동작을 추적하기 어려워질 수 있기 때문이다.

이동 상태 구현하기

이동 상태는 수평 속도만 담당한다. 점프가 시작될 때의 수직 속도는 점프 상태가 설정한다.

using UnityEngine;

public sealed class PlayerMoveState : IPlayerState
{
    private readonly PlayerController player;

    public PlayerMoveState(PlayerController player)
    {
        this.player = player;
    }

    public void Enter()
    {
        player.Animator.Play("Run");
    }

    public void Tick()
    {
        if (player.JumpPressed && player.IsGrounded)
        {
            player.ChangeState(new PlayerJumpState(player));
            return;
        }

        if (Mathf.Approximately(player.MoveInput, 0f))
        {
            player.ChangeState(new PlayerIdleState(player));
        }
    }

    public void FixedTick()
    {
        Vector2 velocity = player.Body.linearVelocity;
        velocity.x = player.MoveInput * player.MoveSpeed;
        player.Body.linearVelocity = velocity;
    }

    public void Exit() { }
}

상태별로 어느 축의 속도를 책임지는지 정해 두면 이후 기능을 추가하기가 쉬워진다. 예를 들어 넉백 상태가 속도 전체를 소유하고 이동 상태는 수평 속도만 소유하도록 규칙을 정할 수 있다.

점프와 낙하를 나누는 이유

점프와 낙하는 모두 공중에 있는 상태지만 시작 조건과 애니메이션 전이 규칙이 다르다.

점프 상태는 처음 물리 프레임에 수직 속도를 설정하고 상승이 끝나면 낙하 상태로 넘어간다.

using UnityEngine;

public sealed class PlayerJumpState : IPlayerState
{
    private readonly PlayerController player;
    private bool jumpApplied;

    public PlayerJumpState(PlayerController player)
    {
        this.player = player;
    }

    public void Enter()
    {
        jumpApplied = false;
        player.Animator.Play("Jump");
    }

    public void Tick() { }

    public void FixedTick()
    {
        if (!jumpApplied)
        {
            Vector2 velocity = player.Body.linearVelocity;
            velocity.y = player.JumpSpeed;
            player.Body.linearVelocity = velocity;
            jumpApplied = true;
            return;
        }

        if (player.Body.linearVelocity.y <= 0f)
        {
            player.ChangeState(new PlayerFallState(player));
        }
    }

    public void Exit() { }
}

낙하 상태는 지면에 닿았는지 확인한 뒤 현재 이동 입력에 따라 대기 또는 이동 상태로 전환한다.

using UnityEngine;

public sealed class PlayerFallState : IPlayerState
{
    private readonly PlayerController player;

    public PlayerFallState(PlayerController player)
    {
        this.player = player;
    }

    public void Enter()
    {
        player.Animator.Play("Fall");
    }

    public void Tick() { }

    public void FixedTick()
    {
        if (!player.IsGrounded)
        {
            return;
        }

        IPlayerState landingState =
            Mathf.Approximately(player.MoveInput, 0f)
                ? new PlayerIdleState(player)
                : new PlayerMoveState(player);

        player.ChangeState(landingState);
    }

    public void Exit() { }
}

이 예제에서는 공중 이동을 구현하지 않았다. 공중에서도 방향을 바꿀 수 있게 하려면 점프와 낙하 상태의 FixedTick()에서 별도의 공중 이동 속도 또는 가속도를 적용하면 된다.

이 예제를 실제 게임에 적용할 때 보완할 점

지금까지의 코드는 상태 패턴의 구조를 보여 주기 위한 최소 예제다. 실제 게임에서는 다음 기능을 추가하는 경우가 많다.

점프 입력 버퍼

현재 예제는 점프 버튼을 누른 그 프레임에만 입력을 유지한다. 착지 직전에 버튼을 눌러도 자연스럽게 점프하게 만들려면 마지막 점프 입력 시각을 저장하고 일정 시간 동안 입력을 유효하게 처리해야 한다.

입력 버퍼는 특정 상태의 필드보다 컨트롤러나 별도 입력 버퍼 객체에 두는 편이 좋다. 여러 상태가 같은 입력 기록을 사용할 수 있기 때문이다.

코요테 타임

발판에서 막 떨어진 직후에도 짧은 시간 동안 점프를 허용하면 조작감이 좋아진다. 마지막으로 지면에 닿아 있던 시각을 기록한 뒤 허용 시간 안에서는 IsGroundedfalse여도 점프할 수 있게 만든다.

상태 인스턴스 재사용

예제는 전환할 때마다 상태 객체를 새로 만든다. 플레이어 하나를 다루는 일반적인 FSM에서는 대개 큰 문제가 되지 않는다.

다만 상태 전환이 매우 잦거나 같은 FSM을 수백 개의 AI가 사용한다면 할당량을 프로파일링한 뒤 상태 인스턴스를 캐시하는 방식을 검토할 수 있다. 측정하기 전에 복잡한 풀링 구조부터 도입할 필요는 없다.

전이 우선순위

점프, 공격, 피격 조건이 같은 프레임에 동시에 성립할 수 있다. 이때는 조건문의 순서가 곧 게임 규칙이 된다.

예를 들어 피격이 다른 모든 행동을 중단해야 한다면 피격 조건을 가장 먼저 검사해야 한다. 우선순위를 코드와 테스트에서 명확하게 남겨 두지 않으면 상태가 늘어날수록 버그를 찾기 어려워진다.

상태로 만들 것과 데이터로 둘 것을 구분한다

상태 패턴을 사용한다고 해서 모든 값을 상태로 만들어야 하는 것은 아니다.

바라보는 방향, 체력, 장비, 현재 입력값은 여러 행동에서 공유하는 데이터에 가깝다. 이를 각각 상태로 만들면 IdleFacingLeft, IdleFacingRight, MoveFacingLeft처럼 조합이 끝없이 늘어난다.

반면 공격 중 이동 제한, 대시 중 무적, 벽타기 중 중력 감소처럼 행동 규칙 자체를 바꾸는 요소는 독립 상태나 상태에 연결된 정책으로 표현하는 편이 명확하다.

판단 기준은 간단하다.

값만 달라지는가 아니면 실행 규칙 자체가 달라지는가?

값만 달라진다면 데이터일 가능성이 높고 갱신 방식과 전이 규칙까지 달라진다면 상태로 분리할 가치가 있다.

자주 발생하는 설계 실수

상태끼리 내부 구현을 직접 건드린다

MoveStateJumpState의 필드를 바꾸거나 특정 상태만 가진 메서드를 직접 호출하면 상태 사이의 결합도가 높아진다.

여러 상태가 공유하는 값은 컨트롤러나 별도 컨텍스트 객체에 두고 상태 변경은 ChangeState()를 통해 표현하는 편이 낫다.

Enter()에서 바꾼 값을 Exit()에서 복구하지 않는다

대시 상태에서 중력을 끄거나 피격 상태에서 입력을 잠갔다면 상태를 빠져나갈 때 원래 값으로 돌려놓아야 한다.

상태가 공통 설정을 변경했다면 무엇을 바꿨는지 목록으로 관리하고 Exit()에서 정리하자. 그렇지 않으면 특정 전이 경로에서만 중력이나 입력 잠금이 남는 버그가 생길 수 있다.

코드 FSM과 Animator가 각각 게임 규칙을 결정한다

코드와 Animator가 서로 독립적으로 상태 전이를 판단하면 두 시스템이 쉽게 어긋난다.

게임플레이 규칙은 코드 FSM이 소유하고 Animator는 현재 상태를 화면에 표현하는 역할에 가깝게 두는 편이 디버깅하기 쉽다. 복잡한 블렌딩이 필요하더라도 핵심 행동의 전이 권한은 한곳에 두는 것이 좋다.

마무리

상태 패턴의 목적은 클래스를 많이 만드는 데 있지 않다. 핵심은 행동별 규칙을 한곳에 모으고 상태 전환이 일어나는 지점을 코드에 분명하게 드러내는 것이다.

처음부터 공격, 대시, 피격까지 모두 분리하려 하지 말고 대기·이동·점프·낙하처럼 경계가 뚜렷한 상태부터 시작해 보자.

새 기능을 추가할 때는 두 가지만 먼저 확인하면 된다.

  1. 이 기능은 여러 상태가 공유하는 데이터인가, 독립된 행동인가?
  2. 어떤 상태에서 시작할 수 있고 어떤 행동을 중단할 수 있는가?

이 두 질문에 답할 수 있다면 상태가 늘어나도 FSM의 구조를 비교적 안정적으로 유지할 수 있다.

#Unity#C##디자인 패턴#FSM#게임플레이 프로그래밍

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs