18. Unity State 패턴: Update 지옥을 끊는 구조와 엔진 호출 흐름
Unity에서 State 패턴이 필요한 이유를 PlayerLoop, C#→네이티브 호출, GC/성능 관점으로 설명하고, 이동/점프 예제로 구조를 바로 적용한다. (초보자용) 15년차 실무 튜토리얼 스타일로 구성했다.) 참고: description은 140~160자 제한이므로 아래 문자열을 사용한다.
Unity State 패턴: Update 지옥을 끊는 구조와 엔진 호출 흐름
게임 만들다 겪는 사고가 하나 있다. 캐릭터가 걷고, 달리고, 점프하고, 피격되고, 넉백되고, 대시까지 붙는 순간 Update가 if/else로 폭발한다. 며칠 뒤 버그 리포트가 온다. “점프 중 피격되면 착지 판정이 사라져요.” 로그는 조용하고, 재현은 가끔 된다. 원인은 보통 ‘상태가 섞여서 동시에 실행되는 코드’다. State 패턴은 이 섞임을 끊고, Unity가 매 프레임 호출하는 지점(Update/FixedUpdate)에 어떤 로직이 올라타는지 통제한다.
핵심 개념
State 패턴은 ‘현재 상태 객체’가 매 프레임 실행할 동작을 책임지고, 상태 전환도 명시적으로 관리하는 구조다. Unity에서는 MonoBehaviour.Update가 엔진에 의해 매 프레임 호출되기 때문에, 상태가 늘어날수록 Update 안의 분기(if/switch)가 커진다. 분기가 커지는 자체보다 더 큰 문제는 “어떤 코드가 어떤 상태에서 실행되는지”가 흐려진다는 점이다.
초보 단계에서 흔한 구조는 Update에 입력 처리, 이동, 점프, 애니메이션 파라미터, 피격 무적, 쿨다운을 다 넣는 형태다. 이때 버그는 ‘조건 누락’에서 생긴다. 예를 들어 피격 중에는 점프 입력을 무시해야 하는데, 한 군데만 막고 다른 군데는 못 막는다. State 패턴은 상태별로 처리 가능한 입력과 물리 업데이트를 분리해서 이런 누락을 줄인다.
용어를 5개만 잡고 간다. 1) 상태(State): 걷기/점프/피격 같은 “서로 배타적이어야 하는 모드”. 2) 전환(Transition): 상태가 바뀌는 조건과 실행(예: 착지하면 Jump→Move). 3) 컨텍스트(Context): 상태들이 공유하는 데이터(Transform, Rigidbody, 입력, Animator). 4) 엔트리/엑시트(Enter/Exit): 상태 진입/이탈 시 1회 실행되는 훅. 5) 틱(Tick): 매 프레임(또는 고정 프레임) 반복 실행되는 함수(Update/FixedUpdate에 대응).
왜 필요한지의 핵심은 ‘Unity가 호출하는 루프는 하나(Update)인데, 게임의 논리는 여러 모드로 분기된다’는 점이다. State 패턴은 Update를 ‘상태 머신의 디스패처’로 축소한다. 그러면 프로파일링에서도 Update 안의 비용이 상태별로 분리되어 보이고, 특정 상태에서만 GC Alloc이 터지는 지점을 찾기 쉬워진다.
1using UnityEngine;
2
3public class SimpleStateMachineExample : MonoBehaviour
4{
5 private IState _state;
6
7 private void Awake()
8 {
9 _state = new IdleState();
10 _state.Enter(this);
11 }
12
13 private void Update()
14 {
15 _state.Tick(this, Time.deltaTime);
16 }
17
18 public void ChangeState(IState next)
19 {
20 _state.Exit(this);
21 _state = next;
22 _state.Enter(this);
23 }
24
25 private interface IState
26 {
27 void Enter(SimpleStateMachineExample ctx);
28 void Tick(SimpleStateMachineExample ctx, float dt);
29 void Exit(SimpleStateMachineExample ctx);
30 }
31
32 private class IdleState : IState
33 {
34 public void Enter(SimpleStateMachineExample ctx) { Debug.Log("Enter Idle"); }
35 public void Tick(SimpleStateMachineExample ctx, float dt)
36 {
37 if (Input.GetKeyDown(KeyCode.Space)) ctx.ChangeState(new JumpState());
38 }
39 public void Exit(SimpleStateMachineExample ctx) { Debug.Log("Exit Idle"); }
40 }
41
42 private class JumpState : IState
43 {
44 public void Enter(SimpleStateMachineExample ctx) { Debug.Log("Enter Jump"); }
45 public void Tick(SimpleStateMachineExample ctx, float dt)
46 {
47 if (Time.time % 1.0f < 0.02f) ctx.ChangeState(new IdleState());
48 }
49 public void Exit(SimpleStateMachineExample ctx) { Debug.Log("Exit Jump"); }
50 }
51}이 코드를 실행하면 Space를 누르는 순간 콘솔에 Enter/Exit 로그가 순서대로 찍힌다. 중요한 건 로그가 찍히는 위치가 Update 한 군데라는 점이다. Update는 엔진이 호출하고, 상태 객체는 순수 C# 객체라서 엔진 메시지(리플렉션/메시지 디스패치)와 분리된다. 상태 전환이 ‘한 함수(ChangeState)’로 모이기 때문에, 전환 시점에만 필요한 처리(애니메이션 트리거, 속도 초기화, 코루틴 정리)를 한 곳에 넣을 수 있다.
엔진 관점에서의 내부 동작
Unity에서 MonoBehaviour.Update는 C#이 임의로 호출하는 함수가 아니다. 플레이 모드에서 PlayerLoop가 돌고, 그 안의 ScriptRunBehaviourUpdate 단계에서 엔진이 ‘활성화된 MonoBehaviour 목록’을 순회하며 Update를 호출한다. 이 목록은 네이티브(C++) 쪽에서 관리되고, 각 항목은 Managed 객체(C#)와 연결된 핸들로 매핑된다.
상태 머신을 쓰면 Update 호출 횟수 자체는 줄지 않는다. 여전히 프레임당 1회 호출된다. 대신 Update 내부에서 벌어지는 분기와 서로 다른 기능(입력/이동/피격)이 뒤엉키는 구조가 사라진다. 엔진 관점에서는 ‘Update에서 어떤 일을 하든’ 결국 C# 코드 실행 시간으로 잡힌다. 구조가 바뀌면 Timeline에서 특정 상태(Tick)만 두드러지게 보이기 시작한다.
C# 스크립트가 네이티브 엔진과 만나는 지점은 대부분 바인딩 호출이다. 예를 들어 transform.position을 읽고 쓰면 내부적으로 C# 프로퍼티가 네이티브 Transform에 접근한다. 상태 패턴에서 Tick이 자주 호출되면, 그 안에서 transform/Rigidbody/Animator 접근이 반복된다. 여기서 성능이 갈린다. 상태 객체를 분리했다고 자동으로 빨라지지 않는다. ‘네이티브 호출을 얼마나 자주 하느냐’가 그대로 비용이 된다.
메모리 관점에서 상태 객체는 보통 managed heap에 할당된다. new JumpState()를 전환마다 생성하면, 전환이 잦은 게임(대시/피격/구르기)에서 GC 압력이 생긴다. 프레임당이 아니라 ‘몇 초마다’ GC가 튀는 형태로 나타나서 찾기 더 짜증난다. 상태 객체는 재사용(캐싱)하거나, struct로 만들기보다 참조형을 유지하되 인스턴스를 미리 만들어 두는 방식이 실무에서 흔하다.
Update와 FixedUpdate 분리도 상태 패턴에서 중요해진다. 물리(Rigidbody)는 FixedUpdate 타이밍에 맞춰 힘을 주는 편이 안정적이다. 엔진은 fixedDeltaTime 간격으로 Physics.Simulate에 해당하는 스텝을 돌리고, 그 전후로 ScriptRunBehaviourFixedUpdate를 호출한다. 점프 상태에서 AddForce를 Update에서 호출하면 프레임레이트에 따라 힘이 달라지는 느낌이 난다.
Coroutine이 yield return null을 요구하는 이유도 PlayerLoop와 연결된다. 코루틴은 ‘다음 프레임에 다시 실행할 IEnumerator’를 엔진이 저장해두고, 특정 루프 단계에서 재개한다. yield return null은 “다음 Update 사이클로 넘겨라”라는 신호다. 상태 전환 시 코루틴을 안 끊으면, 이전 상태의 코루틴이 다음 프레임에도 재개되어 ‘죽은 상태 로직이 살아있는’ 버그가 생긴다. 상태 머신의 Exit에서 StopCoroutine을 모아두는 이유가 여기 있다.
1using UnityEngine;
2
3public class PlayerLoopProbe : MonoBehaviour
4{
5 private int _frame;
6
7 private void Awake()
8 {
9 Debug.Log("Awake frame=" + Time.frameCount);
10 }
11
12 private void OnEnable()
13 {
14 Debug.Log("OnEnable frame=" + Time.frameCount);
15 }
16
17 private void Start()
18 {
19 Debug.Log("Start frame=" + Time.frameCount);
20 }
21
22 private void FixedUpdate()
23 {
24 Debug.Log("FixedUpdate frame=" + Time.frameCount + " fixedTime=" + Time.fixedTime);
25 }
26
27 private void Update()
28 {
29 _frame++;
30 if (_frame % 60 == 0)
31 Debug.Log("Update frame=" + Time.frameCount + " time=" + Time.time);
32 }
33
34 private void LateUpdate()
35 {
36 if (Time.frameCount % 60 == 0)
37 Debug.Log("LateUpdate frame=" + Time.frameCount);
38 }
39}이 스크립트를 빈 오브젝트에 붙이고 Play를 누르면 Awake→OnEnable→Start 순서가 찍히고, FixedUpdate 로그가 Update보다 여러 번 찍히거나 덜 찍히는 프레임이 생긴다(프레임레이트와 fixedDeltaTime 관계). 상태 머신에서 ‘물리 기반 상태’는 FixedTick, ‘입력/애니메이션 상태’는 Tick으로 분리하는 이유가 콘솔 로그로 눈에 들어온다.
1using UnityEngine;
2
3public class NativeCallPressureDemo : MonoBehaviour
4{
5 [SerializeField] private Transform target;
6 [SerializeField] private int iterations = 5000;
7
8 private void Update()
9 {
10 if (target == null) return;
11
12 Vector3 p = Vector3.zero;
13 for (int i = 0; i < iterations; i++)
14 {
15 p += target.position; // 네이티브 Transform 접근이 반복됨
16 }
17
18 if (Time.frameCount % 120 == 0)
19 Debug.Log("sum=" + p.x);
20 }
21}iterations를 5000~20000으로 올리면 Game 뷰가 버벅이고 Profiler에서 Update 비용이 튄다. target.position은 단순한 프로퍼티처럼 보이지만 네이티브 Transform을 읽는다. 상태 패턴을 도입해도 Tick 안에서 이런 호출을 많이 하면 비용은 그대로다. 상태 패턴은 ‘구조’이고, 성능은 ‘네이티브 경계 호출 빈도’와 ‘할당’이 좌우한다.
한 문단 요약: State 패턴은 Update 호출을 줄이는 기술이 아니라, Update에 몰린 분기·전환·코루틴 잔재를 분리해 버그 재현성과 프로파일링 가시성을 올리는 구조다. 엔진 관점 비용은 네이티브 경계 호출(transform/rigidbody/animator) 빈도와 상태 객체 할당 방식에서 결정된다.
실습하기
1단계: 프로젝트 만들기와 기본 세팅
Unity Hub → Projects → New project에서 2022.3 LTS(또는 2021.3 LTS) + 3D(Core) 템플릿을 선택한다. 프로젝트 이름은 StatePatternLab로 둔다. 플레이어 이동만 볼 거라 URP가 아니어도 된다.
Edit → Project Settings → Time에서 Fixed Timestep을 0.02로 유지한다(기본값). Edit → Project Settings → Player에서 ‘Run In Background’를 체크하면 포커스가 나가도 재현이 편하다. Console 창은 Window → General → Console로 열고, Collapse 체크는 꺼둔다(상태 전환 로그가 겹치면 보기 힘들다).
2단계: 씬 구성(오브젝트 배치, Inspector 입력)
Hierarchy 우클릭 → 3D Object → Plane을 만들고 이름을 Ground로 바꾼다. Hierarchy 우클릭 → 3D Object → Capsule을 만들고 이름을 Player로 바꾼다. Player의 Transform Position은 (0, 1, 0)으로 둔다.
Player 선택 → Inspector에서 Add Component → Rigidbody를 추가한다. Rigidbody에서 Constraints의 Freeze Rotation X/Z를 체크한다(캡슐이 넘어지지 않게). Add Component → Capsule Collider가 이미 붙어있다. Ground에는 기본 Collider가 붙는다. 카메라는 Main Camera 선택 → Transform Position을 (0, 6, -8), Rotation을 (30, 0, 0) 정도로 맞춘다.
1using UnityEngine;
2
3public class PlayerContext : MonoBehaviour
4{
5 [Header("Tuning")]
6 [SerializeField] public float moveSpeed = 6f;
7 [SerializeField] public float jumpImpulse = 7f;
8 [SerializeField] public LayerMask groundMask;
9 [SerializeField] public float groundCheckDistance = 0.2f;
10
11 [Header("Refs")]
12 [SerializeField] public Rigidbody rb;
13
14 public bool IsGrounded { get; private set; }
15 public Vector2 MoveInput { get; private set; }
16 public bool JumpPressed { get; private set; }
17
18 private void Reset()
19 {
20 rb = GetComponent<Rigidbody>();
21 groundMask = LayerMask.GetMask("Default");
22 }
23
24 private void Awake()
25 {
26 if (rb == null) rb = GetComponent<Rigidbody>();
27 }
28
29 private void Update()
30 {
31 MoveInput = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
32 JumpPressed = Input.GetKeyDown(KeyCode.Space);
33 }
34
35 private void FixedUpdate()
36 {
37 IsGrounded = Physics.Raycast(transform.position, Vector3.down, 1.0f + groundCheckDistance, groundMask);
38 }
39}Player 오브젝트에 이 스크립트를 붙인다. Inspector에서 Rb 필드에 Player의 Rigidbody가 자동으로 들어가면 정상이다. Ground Mask는 Default로 둔다. Play를 누르고 Space를 눌러도 아직 점프는 안 한다. 콘솔에는 아무것도 안 찍힌다. 이 단계는 ‘입력과 접지 판정이 PlayerLoop에서 어디서 갱신되는지’를 분리해두는 준비다(Update=입력, FixedUpdate=접지).
1using UnityEngine;
2
3public class PlayerStateMachine : MonoBehaviour
4{
5 private IPlayerState _state;
6 private PlayerContext _ctx;
7
8 private readonly IdleState _idle = new IdleState();
9 private readonly MoveState _move = new MoveState();
10 private readonly JumpState _jump = new JumpState();
11
12 private void Awake()
13 {
14 _ctx = GetComponent<PlayerContext>();
15 ChangeState(_idle);
16 }
17
18 private void Update()
19 {
20 _state.Tick(_ctx, Time.deltaTime, this);
21 }
22
23 private void FixedUpdate()
24 {
25 _state.FixedTick(_ctx, Time.fixedDeltaTime, this);
26 }
27
28 public void ChangeState(IPlayerState next)
29 {
30 if (_state == next) return;
31 _state?.Exit(_ctx, this);
32 _state = next;
33 _state.Enter(_ctx, this);
34 }
35
36 public void ToIdle() => ChangeState(_idle);
37 public void ToMove() => ChangeState(_move);
38 public void ToJump() => ChangeState(_jump);
39
40 public interface IPlayerState
41 {
42 void Enter(PlayerContext ctx, PlayerStateMachine sm);
43 void Tick(PlayerContext ctx, float dt, PlayerStateMachine sm);
44 void FixedTick(PlayerContext ctx, float fdt, PlayerStateMachine sm);
45 void Exit(PlayerContext ctx, PlayerStateMachine sm);
46 }
47
48 private class IdleState : IPlayerState
49 {
50 public void Enter(PlayerContext ctx, PlayerStateMachine sm) { Debug.Log("Enter Idle"); }
51 public void Tick(PlayerContext ctx, float dt, PlayerStateMachine sm)
52 {
53 if (ctx.JumpPressed && ctx.IsGrounded) sm.ToJump();
54 else if (ctx.MoveInput.sqrMagnitude > 0.01f) sm.ToMove();
55 }
56 public void FixedTick(PlayerContext ctx, float fdt, PlayerStateMachine sm) { }
57 public void Exit(PlayerContext ctx, PlayerStateMachine sm) { Debug.Log("Exit Idle"); }
58 }
59
60 private class MoveState : IPlayerState
61 {
62 public void Enter(PlayerContext ctx, PlayerStateMachine sm) { Debug.Log("Enter Move"); }
63 public void Tick(PlayerContext ctx, float dt, PlayerStateMachine sm)
64 {
65 if (ctx.JumpPressed && ctx.IsGrounded) sm.ToJump();
66 else if (ctx.MoveInput.sqrMagnitude <= 0.01f) sm.ToIdle();
67 }
68 public void FixedTick(PlayerContext ctx, float fdt, PlayerStateMachine sm)
69 {
70 Vector3 dir = new Vector3(ctx.MoveInput.x, 0f, ctx.MoveInput.y).normalized;
71 Vector3 v = ctx.rb.linearVelocity;
72 v.x = dir.x * ctx.moveSpeed;
73 v.z = dir.z * ctx.moveSpeed;
74 ctx.rb.linearVelocity = v;
75 }
76 public void Exit(PlayerContext ctx, PlayerStateMachine sm) { Debug.Log("Exit Move"); }
77 }
78
79 private class JumpState : IPlayerState
80 {
81 private bool _impulsed;
82
83 public void Enter(PlayerContext ctx, PlayerStateMachine sm)
84 {
85 Debug.Log("Enter Jump");
86 _impulsed = false;
87 }
88
89 public void Tick(PlayerContext ctx, float dt, PlayerStateMachine sm)
90 {
91 if (_impulsed && ctx.IsGrounded) sm.ToIdle();
92 }
93
94 public void FixedTick(PlayerContext ctx, float fdt, PlayerStateMachine sm)
95 {
96 if (_impulsed) return;
97 Vector3 v = ctx.rb.linearVelocity;
98 v.y = 0f;
99 ctx.rb.linearVelocity = v;
100 ctx.rb.AddForce(Vector3.up * ctx.jumpImpulse, ForceMode.Impulse);
101 _impulsed = true;
102 }
103
104 public void Exit(PlayerContext ctx, PlayerStateMachine sm)
105 {
106 Debug.Log("Exit Jump");
107 }
108 }
109}Player에 PlayerStateMachine을 추가한다. Play 후 WASD를 누르면 콘솔에 Enter Move가 찍히고, 입력을 떼면 Enter Idle이 찍힌다. Space를 누르면 Enter Jump가 찍히고, 착지하면 Idle로 돌아온다. Game 뷰에서는 캡슐이 평면 위를 미끄러지듯 이동하고 점프한다. 상태별로 Update/FixedUpdate 책임이 갈려서, 점프 힘은 FixedUpdate에서 1회만 들어간다.
3단계: 디버깅 포인트 만들기(전환과 루프를 눈으로 확인)
Window → Analysis → Profiler를 열고, CPU Usage 모듈을 선택한다. Record 버튼을 켠 상태에서 Play 후 WASD/Space를 반복한다. Timeline 뷰에서 ScriptRunBehaviourUpdate 아래에 PlayerStateMachine.Update가 보이고, FixedUpdate에서는 PlayerStateMachine.FixedUpdate가 보인다.
콘솔 로그가 너무 많으면 PlayerStateMachine의 Debug.Log를 Time.frameCount 조건으로 줄인다. 중요한 확인 포인트는 ‘JumpState.FixedTick에서 AddForce가 한 번만 실행되는지’다. 만약 점프가 너무 높게 튄다면, _impulsed가 제대로 막지 못했거나 FixedTick이 여러 번 호출되는 동안 AddForce가 반복 호출되는 구조다.
심화 활용
패턴 1: 상태 객체 캐싱 + 전환 테이블(할당과 조건을 분리)
상태 전환을 if로 직접 쓰면 상태가 10개 넘어가는 순간 다시 분기 지옥이 된다. 실무에서는 ‘상태는 캐싱’하고, ‘전환 조건은 테이블’로 분리하는 편이 많다. 이렇게 하면 상태 클래스는 동작만 담당하고, 전환 규칙은 한 곳에서 읽힌다.
1using System;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class TableDrivenStateMachine : MonoBehaviour
6{
7 private PlayerContext _ctx;
8 private IState _current;
9
10 private readonly Dictionary<Type, IState> _states = new();
11 private readonly List<Transition> _any = new();
12 private readonly Dictionary<Type, List<Transition>> _map = new();
13
14 private void Awake()
15 {
16 _ctx = GetComponent<PlayerContext>();
17 _states[typeof(Idle)] = new Idle();
18 _states[typeof(Move)] = new Move();
19 _states[typeof(Jump)] = new Jump();
20
21 AddTransition<Idle, Move>(() => _ctx.MoveInput.sqrMagnitude > 0.01f);
22 AddTransition<Move, Idle>(() => _ctx.MoveInput.sqrMagnitude <= 0.01f);
23 AddTransition<Idle, Jump>(() => _ctx.JumpPressed && _ctx.IsGrounded);
24 AddTransition<Move, Jump>(() => _ctx.JumpPressed && _ctx.IsGrounded);
25 AddTransition<Jump, Idle>(() => _ctx.IsGrounded);
26
27 SetState<Idle>();
28 }
29
30 private void Update()
31 {
32 if (TryTransitions()) return;
33 _current.Tick(_ctx, Time.deltaTime);
34 }
35
36 private void FixedUpdate()
37 {
38 _current.FixedTick(_ctx, Time.fixedDeltaTime);
39 }
40
41 private bool TryTransitions()
42 {
43 foreach (var t in _any)
44 if (t.Condition()) { SetState(t.To); return true; }
45
46 if (_map.TryGetValue(_current.GetType(), out var list))
47 foreach (var t in list)
48 if (t.Condition()) { SetState(t.To); return true; }
49
50 return false;
51 }
52
53 private void AddTransition<TFrom, TTo>(Func<bool> condition)
54 where TFrom : IState where TTo : IState
55 {
56 var from = typeof(TFrom);
57 if (!_map.TryGetValue(from, out var list)) _map[from] = list = new List<Transition>();
58 list.Add(new Transition(typeof(TTo), condition));
59 }
60
61 private void SetState<T>() where T : IState => SetState(typeof(T));
62
63 private void SetState(Type t)
64 {
65 var next = _states[t];
66 if (_current == next) return;
67 _current?.Exit(_ctx);
68 _current = next;
69 _current.Enter(_ctx);
70 Debug.Log("State=" + t.Name);
71 }
72
73 private readonly struct Transition
74 {
75 public readonly Type To;
76 public readonly Func<bool> Condition;
77 public Transition(Type to, Func<bool> condition) { To = to; Condition = condition; }
78 }
79
80 private interface IState
81 {
82 void Enter(PlayerContext ctx);
83 void Tick(PlayerContext ctx, float dt);
84 void FixedTick(PlayerContext ctx, float fdt);
85 void Exit(PlayerContext ctx);
86 }
87
88 private class Idle : IState
89 {
90 public void Enter(PlayerContext ctx) { }
91 public void Tick(PlayerContext ctx, float dt) { }
92 public void FixedTick(PlayerContext ctx, float fdt) { }
93 public void Exit(PlayerContext ctx) { }
94 }
95
96 private class Move : IState
97 {
98 public void Enter(PlayerContext ctx) { }
99 public void Tick(PlayerContext ctx, float dt) { }
100 public void FixedTick(PlayerContext ctx, float fdt)
101 {
102 Vector3 dir = new Vector3(ctx.MoveInput.x, 0f, ctx.MoveInput.y).normalized;
103 Vector3 v = ctx.rb.linearVelocity;
104 v.x = dir.x * ctx.moveSpeed;
105 v.z = dir.z * ctx.moveSpeed;
106 ctx.rb.linearVelocity = v;
107 }
108 public void Exit(PlayerContext ctx) { }
109 }
110
111 private class Jump : IState
112 {
113 private bool _impulsed;
114 public void Enter(PlayerContext ctx) { _impulsed = false; }
115 public void Tick(PlayerContext ctx, float dt) { }
116 public void FixedTick(PlayerContext ctx, float fdt)
117 {
118 if (_impulsed) return;
119 Vector3 v = ctx.rb.linearVelocity;
120 v.y = 0f;
121 ctx.rb.linearVelocity = v;
122 ctx.rb.AddForce(Vector3.up * ctx.jumpImpulse, ForceMode.Impulse);
123 _impulsed = true;
124 }
125 public void Exit(PlayerContext ctx) { }
126 }
127}이 구조를 실행하면 상태 전환은 TryTransitions에서만 일어난다. 상태 클래스는 조건을 몰라도 된다. 전환 조건이 늘어도 상태 클래스가 비대해지지 않는다. 단, Func<bool> 캡처는 할당을 만들 수 있다. 이 예제는 Awake에서 한 번만 생성되니 프레임당 GC는 없다. 전환 조건을 Update에서 매번 new Func로 만들면 그때부터 GC가 터진다.
패턴 2: 애니메이션/이펙트는 상태 밖으로 빼고 ‘이벤트’로 연결
상태 클래스 안에서 Animator.SetBool/SetTrigger를 직접 호출하면, 상태 전환과 애니메이션 파라미터가 강결합된다. 애니메이션 컨트롤러 변경이 들어오면 상태 코드까지 깨진다. 실무에서는 상태 머신이 ‘상태 변경 이벤트’를 발생시키고, 별도 컴포넌트가 그 이벤트를 받아 Animator를 건드리는 형태가 많이 산다.
1using System;
2using UnityEngine;
3
4public class StateEventBridge : MonoBehaviour
5{
6 public event Action<string> OnStateChanged;
7
8 [SerializeField] private Animator animator;
9
10 private void Reset()
11 {
12 animator = GetComponentInChildren<Animator>();
13 }
14
15 public void Raise(string stateName)
16 {
17 OnStateChanged?.Invoke(stateName);
18 }
19
20 private void OnEnable()
21 {
22 OnStateChanged += Handle;
23 }
24
25 private void OnDisable()
26 {
27 OnStateChanged -= Handle;
28 }
29
30 private void Handle(string stateName)
31 {
32 if (animator == null) return;
33 animator.SetInteger("State", Animator.StringToHash(stateName));
34 }
35}이벤트 브리지는 상태 머신이 “Move로 바뀌었다” 같은 신호만 던지고, Animator는 그 신호를 받아 처리한다. PlayerLoop 관점에서 Animator 파라미터 적용은 내부적으로 애니메이션 업데이트 단계에서 평가된다. 상태 전환 프레임에 SetInteger를 호출해도 실제 포즈 반영은 애니메이션 시스템이 돌아가는 시점에 일어난다. 그래서 ‘전환 프레임에 즉시 포즈가 바뀌지 않는 것처럼 보이는’ 현상이 생기기도 한다.
처음에 나도 이 타이밍을 몰라서 3시간 삽질한 적이 있다. 점프 전환 프레임에 Trigger를 쐈는데, 가끔 점프 애니메이션이 한 프레임 늦게 들어가면서 발이 땅에 박혔다. Profiler Timeline에서 ScriptRunBehaviourUpdate는 정상인데, Animator.Update가 그 다음 단계에서 돌아가고 있었다. 해결은 ‘전환 시점에 물리(힘)부터 주지 말고, 애니메이션 이벤트나 FixedUpdate에서 임펄스를 넣는 방식으로 동기화’였다.
또 한 번은 Exit에서 StopCoroutine을 안 해서 생긴 버그였다. 피격 상태에서 깜빡임 코루틴을 돌리고, 상태 전환 후에도 코루틴이 계속 돌아서 Move 상태에서도 렌더러가 꺼졌다 켜졌다 했다. 콘솔에는 에러가 없고, 재현도 랜덤이라 더 괴로웠다. Exit에 ‘상태가 시작한 코루틴 핸들’을 모아 StopCoroutine으로 끊고, 상태 전환 시점에만 정리하도록 바꾸니 재현이 0이 됐다.
자주 하는 실수
실수 1: 상태 전환마다 new로 상태를 생성해서 GC가 주기적으로 튐
증상: 10~30초마다 프레임이 툭 끊기고, Profiler에서 GC.Collect가 보인다. 전환이 잦은 액션 게임에서 특히 눈에 띈다.
원인: ChangeState에서 new DashState(), new HitState() 같은 할당이 누적된다. managed heap에 쌓인 객체가 임계치에 도달하면 GC가 돌고, 그 프레임의 메인 스레드가 멈춘다.
해결: 상태 인스턴스를 필드로 캐싱한다(Idle/Move/Jump를 readonly로 들고 있는 형태). 상태가 내부 데이터를 가진다면 Enter에서 초기화하고, Exit에서 정리한다. 전환 조건(Func 캡처)도 Awake에서 한 번만 만들고 프레임당 생성하지 않는다.
실수 2: Update에서 Rigidbody를 직접 움직여서 프레임레이트 의존 버그 발생
증상: 144Hz 모니터에서는 점프가 낮고, 30fps 환경에서는 점프가 높게 느껴진다. 벽에 부딪힐 때 튕김이 불안정하다.
원인: 물리 시뮬레이션은 FixedUpdate 타이밍(고정 간격)으로 진행되는데, Update에서 AddForce/velocity 변경을 하면 물리 스텝과 섞인다. 한 프레임에 FixedUpdate가 0회 또는 2회 호출되는 구간에서 결과가 달라진다.
해결: 상태 인터페이스에 FixedTick을 두고, Rigidbody 관련 변경(힘, 속도, MovePosition)은 FixedTick에서만 한다. 입력은 Update에서 수집하고, FixedTick에서는 ‘마지막 입력 스냅샷’을 사용한다.
실수 3: Exit에서 코루틴/이펙트 정리를 안 해서 이전 상태 로직이 살아남음
증상: 피격 깜빡임, 대시 잔상, 무적 타이머 같은 효과가 상태가 끝난 뒤에도 남는다. 가끔 ‘NullReferenceException: Object reference not set to an instance of an object’가 코루틴에서 터진다.
원인: 코루틴은 엔진이 IEnumerator를 저장해두고 다음 프레임에 재개한다. 상태가 바뀌어도 코루틴은 자동으로 멈추지 않는다. 상태 객체가 MonoBehaviour가 아니면 StopCoroutine을 호출할 주체도 애매해진다.
해결: 상태 머신(컨텍스트)에 코루틴 실행/정리 API를 두고, 상태는 핸들을 받아 Exit에서 정리 요청을 한다. 또는 상태 머신이 전환 시 ‘상태가 등록한 핸들 목록’을 일괄 StopCoroutine한다.
실수 4: GetComponent를 Tick에서 매 프레임 호출
증상: Profiler에서 Scripts 쪽 시간이 서서히 커지고, CPU Usage에서 GetComponent가 자주 보인다. 객체 수가 늘면 급격히 느려진다.
원인: GetComponent는 네이티브 쪽 컴포넌트 리스트를 검색하고, managed 래퍼로 다시 가져오는 과정이 있다. 호출 자체가 ‘상태 패턴’ 때문이 아니라, 매 프레임 네이티브 경계를 넘는 호출이 누적돼서 비용이 쌓인다.
해결: PlayerContext처럼 Awake에서 필요한 컴포넌트를 캐싱한다. 상태는 ctx.rb, ctx.animator 같은 캐시된 참조만 사용한다. 특히 Animator.StringToHash도 매 프레임 하면 비용이 쌓이니 정적 캐시로 둔다.
실수 5: 상태 전환 조건이 여러 곳에 흩어져서 전환 루프(왕복) 발생
증상: 콘솔에 Enter Idle/Enter Move가 같은 프레임에 연속으로 찍히거나, 캐릭터가 떨리면서 제자리에서 상태가 바뀐다.
원인: Idle.Tick에서 Move로 바꾸고, Move.Tick에서도 같은 프레임에 Idle로 바꾸는 식의 상호 조건이 생긴다. 입력/접지 값이 Update와 FixedUpdate에서 갱신되는 타이밍 차이 때문에 경계값에서 흔들리기도 한다.
해결: 전환은 한 프레임에 1회만 허용하는 정책을 둔다(TryTransitions가 true면 Tick을 스킵). 경계값에는 히스테리시스(예: sqrMagnitude > 0.02f로 진입, < 0.01f로 이탈)를 둔다. 전환 테이블을 한 곳에 모아 조건을 눈으로 검수한다.
1using UnityEngine;
2
3public class TransitionLoopGuard : MonoBehaviour
4{
5 private bool _transitionedThisFrame;
6
7 public bool CanTransition()
8 {
9 if (_transitionedThisFrame) return false;
10 _transitionedThisFrame = true;
11 return true;
12 }
13
14 private void LateUpdate()
15 {
16 _transitionedThisFrame = false;
17 }
18}이 가드는 전환이 같은 프레임에 연쇄로 일어나는 걸 막는다. LateUpdate에서 플래그를 리셋하는 이유는 PlayerLoop에서 Update가 끝난 뒤 프레임 마무리 단계에서 초기화하고 싶기 때문이다. 상태 머신이 복잡해질수록 이런 ‘정책 컴포넌트’가 버그를 잡는 데 시간을 아낀다.
성능 최적화 체크리스트
- Update에는 입력 수집과 상태 디스패치만 남기고, 물리 변경은 FixedUpdate/FixedTick으로 이동했다
- 상태 인스턴스는 전환마다 new 하지 않고 캐싱했다(전환이 잦은 상태일수록 중요)
- 상태 전환은 한 함수(ChangeState/SetState)로만 일어나게 만들었다(전환 로그/브레이크포인트 단일화)
- Exit에서 코루틴, 이펙트, 타이머를 정리하는 규칙을 강제했다(이전 상태 잔재 제거)
- GetComponent/Find 계열 호출은 Awake/Start에서 1회 캐싱하고 Tick에서 호출하지 않는다
- transform/Animator/Rigidbody 접근이 Tick에서 과도하지 않은지 Profiler Timeline으로 확인했다
- 전환 조건은 한 프레임 1회만 허용하거나, 히스테리시스로 경계 흔들림을 막았다
- Profiler에서 GC Alloc 컬럼을 켜고, 상태 전환/입력 반복 시 Alloc이 0B인지 확인했다
- 애니메이션 파라미터 키는 StringToHash로 캐싱하고, 문자열을 매 프레임 만들지 않는다
- 상태별로 Debug.Log를 상시 출력하지 않고, 샘플링(예: 60프레임마다)하거나 Conditional로 감쌌다
- Script Execution Order가 필요한 경우(Project Settings → Script Execution Order) 상태 머신/컨텍스트 갱신 순서를 고정했다
- 레이어마스크/레이캐스트는 필요한 레이어만 포함하고, 접지 체크 거리/원점이 캡슐 크기와 맞는지 확인했다
자주 묻는 질문
State 패턴을 쓰면 Update가 빨라지나?
Update 호출 자체는 빨라지지 않는다. Unity 엔진은 PlayerLoop의 ScriptRunBehaviourUpdate 단계에서 활성 MonoBehaviour를 순회하며 Update를 호출한다. State 패턴은 이 호출을 줄이는 기술이 아니라, Update 내부의 분기와 책임을 분리해 ‘어떤 상태에서 어떤 코드가 실행되는지’를 명확하게 만든다. 성능은 Tick 안에서 transform.position, Rigidbody, Animator 같은 네이티브 경계 호출을 얼마나 자주 하느냐와, 상태 전환 시 new/람다 캡처로 할당이 생기느냐에 달려 있다. 다음 학습 키워드는 Profiler Timeline, Managed-to-Native 비용, GC Alloc이다.
상태를 클래스로 만들면 new 때문에 무조건 GC가 생기나?
전환마다 new를 하면 GC 압력이 생긴다. 하지만 상태 인스턴스를 미리 만들어 캐싱하면 프레임당 할당은 0으로 만들 수 있다. 상태를 MonoBehaviour로 만들어서 컴포넌트를 켰다 껐다 하는 방식도 가능하지만, 그 경우엔 엔진이 관리하는 컴포넌트 활성/비활성 비용과 메시지 호출(OnEnable/OnDisable) 타이밍이 추가된다. 순수 C# 상태 객체 + MonoBehaviour 컨텍스트 조합이 흔한 이유는, 엔진 메시지 시스템과 상태 로직을 분리해 디버깅 포인트를 단순화하기 위해서다. 다음 학습 키워드는 오브젝트 풀링, ScriptableObject 상태, OnEnable 호출 타이밍이다.
왜 입력은 Update에서 받고 물리는 FixedUpdate에서 처리하나?
입력(Input.GetKeyDown 등)은 프레임 단위로 갱신되는 개념이고, 물리(Rigidbody)는 고정 시간 스텝으로 시뮬레이션된다. 엔진은 fixedDeltaTime 간격으로 물리 스텝을 돌리고, 그 구간에 ScriptRunBehaviourFixedUpdate를 호출한다. 프레임레이트가 변하면 한 프레임에 FixedUpdate가 0회 또는 2회 호출될 수 있다. 그래서 입력은 Update에서 ‘스냅샷’으로 저장하고, FixedUpdate에서는 그 스냅샷을 소비해 힘/속도를 적용하는 방식이 안정적이다. 다음 학습 키워드는 fixedDeltaTime, Physics.Simulate, 입력 버퍼링이다.
Coroutine을 상태에서 쓰면 왜 버그가 많이 나나?
코루틴은 상태와 생명주기가 다르기 때문이다. 코루틴은 엔진이 IEnumerator를 저장해두고 PlayerLoop의 특정 시점에서 재개한다. 상태가 바뀌어도 코루틴은 자동으로 멈추지 않아서, 이전 상태의 로직이 다음 프레임에도 실행된다. 특히 피격 무적, 깜빡임, 대시 쿨다운 같은 코루틴은 상태 전환과 충돌하기 쉽다. 해결은 Exit에서 StopCoroutine을 강제하거나, 상태 머신이 ‘상태가 시작한 코루틴 핸들’을 등록해 전환 시 일괄 정리하는 규칙을 두는 방식이다. 다음 학습 키워드는 Coroutine scheduler, yield instruction, CancellationToken(대체 패턴)이다.
Animator 파라미터는 상태 안에서 직접 만져도 되나?
가능하지만 강결합이 생긴다. Animator.SetTrigger/SetBool은 호출 즉시 포즈가 바뀌는 게 아니라, 애니메이션 시스템이 업데이트되는 단계에서 평가되어 결과가 반영된다. 그래서 전환 프레임에 물리 임펄스까지 같이 주면, 한 프레임 동안은 ‘이전 포즈 + 새로운 물리’ 조합이 나타날 수 있다. 실무에서는 상태 머신이 상태 변경 이벤트만 발행하고, 별도 브리지 컴포넌트가 Animator를 제어하는 형태가 변경에 강하다. 다음 학습 키워드는 Animator update mode, Root Motion, Animation Events다.
상태 전환 조건을 어디에 두는 게 유지보수에 유리하나?
규모가 작을 때는 상태 내부 Tick에 둬도 된다. 상태가 늘어나면 전환 규칙이 분산되어 ‘A에서 B로 가는 조건’과 ‘B에서 A로 돌아오는 조건’이 서로 충돌하기 시작한다. 전환 테이블(Transition Map)을 두면 규칙이 한 곳에 모이고, 한 프레임 1회 전환 정책 같은 제약도 쉽게 걸 수 있다. 다만 조건을 Func로 만들 때 프레임당 캡처 할당이 생기지 않게 Awake에서 한 번만 구성해야 한다. 다음 학습 키워드는 전환 우선순위, Any-state transition, 히스테리시스다.
State 패턴과 FSM, HFSM, BT(Behavior Tree)는 어떻게 구분하나?
State 패턴은 ‘상태 객체가 동작을 캡슐화’하는 구현 기법이고, FSM은 ‘상태와 전환의 모델’이다. HFSM은 상태 안에 또 상태 머신을 넣는 계층형 구조로, 이동 상태 안에 지상/공중, 전투 상태 안에 공격/방어 같은 세분화를 자연스럽게 만든다. BT는 조건-행동을 트리로 평가해 우선순위 기반 의사결정을 만드는 방식이라, AI에 자주 쓰인다. Unity 엔진 관점에서 셋 다 결국 Update/FixedUpdate에서 평가되며, 성능은 평가 빈도와 네이티브 호출/할당에 좌우된다. 다음 학습 키워드는 HFSM 설계, BT tick rate, blackboard 데이터 구조다.