
상태 패턴으로 만드는 플레이어 이동·애니메이션 컨트롤러
입력, 이동 물리, 애니메이션 전환을 상태 단위로 분리해 확장하기 쉬운 Unity 플레이어 컨트롤러를 구현한다. 걷기·달리기·점프·낙하 상태와 애니메이터 동기화 규칙을 단계별로 살펴본다.
왜 상태 패턴이 필요한가
간단한 이동은 Update 안의 조건문만으로도 만들 수 있다. 하지만 걷기, 달리기, 점프, 낙하, 피격, 벽 타기처럼 행동이 늘어나면 입력 처리와 물리 처리, 애니메이션 조건이 한곳에 섞인다. 이때 특정 행동을 추가하거나 수정할 때 다른 행동까지 함께 깨질 가능성이 커진다.
상태 패턴은 플레이어가 현재 수행하는 행동을 하나의 객체로 표현한다. 각 상태는 진입 시 초기화할 값, 매 프레임 처리할 로직, 다른 상태로 전환하는 조건을 직접 가진다. 플레이어 본체는 공통 데이터와 상태 전환만 관리한다.
flowchart TD
Input[입력 수집] --> Player[PlayerController]
Player --> State{현재 상태}
State --> Idle[IdleState]
State --> Move[MoveState]
State --> Jump[JumpState]
State --> Fall[FallState]
State --> Animator[Animator 파라미터 갱신]
State --> Physics[이동 및 중력 적용]
이 구조의 핵심은 상태 객체가 플레이어의 모든 것을 소유하지 않는다는 점이다. Rigidbody, 이동 속도, 점프 힘, Animator 같은 공통 자원은 PlayerController가 보관하고 상태는 이를 사용해 행동만 정의한다.
상태 인터페이스 설계
먼저 모든 상태가 따라야 할 최소 계약을 만든다. Enter는 상태 진입 직후 한 번 실행되고 Tick은 매 프레임 호출되며 Exit는 다른 상태로 바뀌기 전에 실행된다.
public interface IPlayerState
{
void Enter();
void Tick();
void Exit();
}
상태마다 컨트롤러 참조를 전달하는 방식을 사용하면 공통 데이터와 전환 함수에 접근할 수 있다.
public abstract class PlayerState : IPlayerState
{
protected readonly PlayerController player;
protected PlayerState(PlayerController player)
{
this.player = player;
}
public virtual void Enter() { }
public virtual void Tick() { }
public virtual void Exit() { }
}
상속 계층을 깊게 만들 필요는 없다. 이 예제의 추상 클래스는 컨트롤러 참조를 중복 작성하지 않기 위한 편의 장치다. 상태별로 완전히 다른 의존성이 필요하다면 인터페이스만 유지하고 생성자 구성을 다르게 해도 된다.

PlayerController는 공통 데이터와 전환을 담당한다
아래 예제는 3D Rigidbody 기반의 기본 구조다. 입력 축은 구형 Input Manager의 Horizontal, Vertical, Jump를 사용한다. 새 Input System을 사용하더라도 입력을 읽는 부분만 바꾸면 상태 구조는 그대로 유지할 수 있다.
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public class PlayerController : MonoBehaviour
{
[Header("Movement")]
[SerializeField] private float walkSpeed = 4f;
[SerializeField] private float runSpeed = 7f;
[SerializeField] private float jumpForce = 6f;
[SerializeField] private float groundCheckDistance = 0.2f;
[SerializeField] private LayerMask groundMask;
[Header("References")]
[SerializeField] private Animator animator;
public Rigidbody Body { get; private set; }
public Vector2 MoveInput { get; private set; }
public bool JumpPressed { get; private set; }
public bool RunHeld { get; private set; }
public bool IsGrounded { get; private set; }
public float WalkSpeed => walkSpeed;
public float RunSpeed => runSpeed;
public float JumpForce => jumpForce;
private IPlayerState currentState;
private void Awake()
{
Body = GetComponent<Rigidbody>();
}
private void Start()
{
ChangeState(new IdleState(this));
}
private void Update()
{
MoveInput = new Vector2(
Input.GetAxisRaw("Horizontal"),
Input.GetAxisRaw("Vertical")
).normalized;
JumpPressed = Input.GetButtonDown("Jump");
RunHeld = Input.GetKey(KeyCode.LeftShift);
IsGrounded = CheckGrounded();
currentState.Tick();
UpdateAnimator();
}
public void ChangeState(IPlayerState nextState)
{
currentState?.Exit();
currentState = nextState;
currentState.Enter();
}
public void MoveHorizontal(float speed)
{
Vector3 direction = new Vector3(MoveInput.x, 0f, MoveInput.y);
Vector3 velocity = Body.linearVelocity;
velocity.x = direction.x * speed;
velocity.z = direction.z * speed;
Body.linearVelocity = velocity;
}
public void StopHorizontal()
{
Vector3 velocity = Body.linearVelocity;
velocity.x = 0f;
velocity.z = 0f;
Body.linearVelocity = velocity;
}
public void Jump()
{
Vector3 velocity = Body.linearVelocity;
velocity.y = 0f;
Body.linearVelocity = velocity;
Body.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}
private bool CheckGrounded()
{
Vector3 origin = transform.position + Vector3.up * 0.1f;
return Physics.Raycast(origin, Vector3.down, groundCheckDistance, groundMask);
}
private void UpdateAnimator()
{
Vector3 horizontalVelocity = Body.linearVelocity;
horizontalVelocity.y = 0f;
animator.SetFloat("Speed", horizontalVelocity.magnitude);
animator.SetBool("Grounded", IsGrounded);
animator.SetFloat("VerticalSpeed", Body.linearVelocity.y);
}
}
최근 Unity 버전에서는 Rigidbody.velocity 대신 Rigidbody.linearVelocity를 사용한다. 사용 중인 Unity 버전에 해당 속성이 없다면 같은 위치를 velocity로 바꾸면 된다.
물리 값을 Update에서 직접 수정하는 방식은 간단한 예제에는 충분하지만 더 엄밀하게 구성하려면 입력과 전환 판단은 Update에서 수행하고 Rigidbody 조작은 FixedUpdate로 모으는 편이 좋다. 특히 네트워크 동기화나 복잡한 충돌 반응이 있는 프로젝트에서는 이 구분이 중요하다.
지상 상태: 대기와 이동
대기 상태는 입력이 생기면 이동 상태로 바꾸고 점프 입력이 있으면 점프 상태로 바꾼다. 이동 상태는 수평 이동을 처리하며 입력이 사라졌을 때 대기 상태로 돌아간다.
public class IdleState : PlayerState
{
public IdleState(PlayerController player) : base(player) { }
public override void Enter()
{
player.StopHorizontal();
}
public override void Tick()
{
if (!player.IsGrounded)
{
player.ChangeState(new FallState(player));
return;
}
if (player.JumpPressed)
{
player.ChangeState(new JumpState(player));
return;
}
if (player.MoveInput.sqrMagnitude > 0f)
{
player.ChangeState(new MoveState(player));
}
}
}
public class MoveState : PlayerState
{
public MoveState(PlayerController player) : base(player) { }
public override void Tick()
{
if (!player.IsGrounded)
{
player.ChangeState(new FallState(player));
return;
}
if (player.JumpPressed)
{
player.ChangeState(new JumpState(player));
return;
}
if (player.MoveInput.sqrMagnitude == 0f)
{
player.ChangeState(new IdleState(player));
return;
}
float speed = player.RunHeld ? player.RunSpeed : player.WalkSpeed;
player.MoveHorizontal(speed);
}
}
전환 뒤에는 즉시 return하는 습관이 중요하다. 상태를 바꾼 뒤 기존 상태의 로직을 계속 실행하면 새 상태가 설정한 값이 같은 프레임에 덮어써질 수 있다.
공중 상태: 점프와 낙하
점프 상태는 진입 시 한 번만 위쪽 충격을 적용한다. 이후 상승 중에는 점프 상태를 유지하고 수직 속도가 음수가 되면 낙하 상태로 넘어간다. 낙하 상태는 착지했을 때 입력 유무에 따라 대기 또는 이동 상태로 전환한다.
public class JumpState : PlayerState
{
public JumpState(PlayerController player) : base(player) { }
public override void Enter()
{
player.Jump();
}
public override void Tick()
{
player.MoveHorizontal(player.WalkSpeed);
if (player.Body.linearVelocity.y <= 0f)
{
player.ChangeState(new FallState(player));
}
}
}
public class FallState : PlayerState
{
public FallState(PlayerController player) : base(player) { }
public override void Tick()
{
player.MoveHorizontal(player.WalkSpeed);
if (!player.IsGrounded)
{
return;
}
if (player.MoveInput.sqrMagnitude > 0f)
{
player.ChangeState(new MoveState(player));
return;
}
player.ChangeState(new IdleState(player));
}
}
공중에서도 이동을 허용할지 지상보다 약하게 허용할지는 게임 설계의 문제다. 이를 위해 MoveHorizontal에 속도 대신 제어 계수를 전달하거나 AirMoveSpeed를 별도 필드로 두면 된다. 중요한 점은 공중 제어 규칙을 JumpState, FallState에 두어 지상 이동 규칙과 섞지 않는 것이다.

애니메이션은 상태 이름보다 관측 가능한 값에 연결한다
상태마다 애니메이션 트리거를 직접 호출할 수도 있다. 그러나 이동 애니메이션은 대개 속도, 접지 여부, 수직 속도처럼 물리적으로 관측 가능한 값에서 결정하는 편이 안정적이다.
이 예제에서는 다음 파라미터를 Animator Controller에 만든다.
Speed: Float. 지상 Blend Tree에서 Idle, Walk, Run을 보간한다.Grounded: Bool. 지상과 공중 상태 그룹을 구분한다.VerticalSpeed: Float. 상승과 하강 애니메이션을 구분한다.
예를 들어 Grounded == false일 때 공중 Blend Tree로 전환하고 VerticalSpeed > 0이면 Jump 클립, 그 외에는 Fall 클립을 재생할 수 있다. 착지 여부를 애니메이터 전이 조건과 코드 전환 조건 모두에서 사용하므로 바닥 판정의 원점과 거리, 레이어 마스크는 한곳에서 관리해야 한다.
Speed에 Rigidbody의 실제 수평 속도를 넣으면 벽에 막혔거나 넉백을 받는 상황도 애니메이션에 반영된다. 반대로 입력값의 크기를 넣으면 캐릭터가 실제로 움직이지 않아도 달리기 애니메이션이 재생될 수 있다. 어느 값을 쓸지는 표현 의도에 따라 선택하되 한 프로젝트 안에서는 기준을 일관되게 유지한다.
자주 생기는 문제와 개선 방향
상태를 매번 새로 만들 때의 비용
위 예제는 이해를 위해 전환할 때마다 new로 상태를 생성한다. 상태가 적고 전환 빈도가 낮은 일반적인 플레이어 컨트롤러에서는 대개 문제가 되지 않는다. 다만 많은 AI가 자주 상태를 바꾸거나 GC 할당을 줄여야 한다면 상태 인스턴스를 컨트롤러가 미리 생성해 재사용할 수 있다.
private IdleState idleState;
private MoveState moveState;
private void Awake()
{
Body = GetComponent<Rigidbody>();
idleState = new IdleState(this);
moveState = new MoveState(this);
}
이때 상태 내부에 타이머나 콤보 단계처럼 개별 진입마다 초기화해야 할 데이터가 있다면 반드시 Enter에서 초기화해야 한다. 재사용되는 객체에 이전 상태의 값이 남는 것이 흔한 버그 원인이다.
전환 조건이 여러 상태에 중복될 때
IsGrounded 검사처럼 거의 모든 상태에서 필요한 조건은 중복될 수 있다. 무조건 공통 상태로 빼기보다 해당 조건이 각 상태에서 어떤 의미인지 먼저 판단하는 편이 낫다. 예를 들어 공중에서 바닥을 밟는 것은 착지지만 대기 중 바닥을 잃는 것은 추락이다. 같은 판정이라도 전환 목적이 다르다.
반복이 정말 많아지면 상위 상태를 도입할 수 있다. GroundedState와 AirborneState가 공통 규칙을 맡고 그 아래에 Idle, Move, Jump, Fall을 배치하는 방식이다. 다만 초기에 계층 상태 머신까지 도입하면 흐름을 추적하기 어려워질 수 있으므로 중복이 실제 유지보수 문제로 나타날 때 적용하는 것이 좋다.
애니메이션 이벤트의 역할
공격 타격 판정, 발소리, 착지 이펙트처럼 특정 프레임에 발생해야 하는 효과는 Animation Event가 유용하다. 반면 이동 가능 여부나 점프 상태 종료처럼 게임 규칙을 바꾸는 핵심 전환을 애니메이션 이벤트에만 맡기면 클립 수정이 게임 로직을 바꾸게 된다. 상태 전환은 코드의 명시적 조건으로 유지하고 연출 타이밍만 이벤트에 맡기는 구성이 관리하기 쉽다.
마무리
상태 패턴의 목적은 클래스를 많이 만드는 데 있지 않다. 현재 행동의 규칙과 전환 조건을 가까이 두어 새 기능이 기존 이동 코드를 무너뜨리지 않게 만드는 데 있다.
먼저 Idle, Move, Jump, Fall처럼 분명한 상태만 분리해 보자. 이후 대시, 슬라이드, 벽 점프, 피격 경직을 추가할 때 각 기능이 어느 상태에서 시작되고 어떤 조건으로 끝나는지 자연스럽게 드러난다. 애니메이션은 상태 이름에 억지로 묶기보다 속도와 접지, 수직 속도 같은 일관된 파라미터에 연결하면 물리와 연출의 어긋남도 줄일 수 있다.


