19. Unity State 패턴으로 이동/대기/공격 상태 구현과 엔진 동작 원리
Unity에서 State 패턴으로 이동/대기/공격을 구현하고, Player Loop·C#→C++ 바인딩·GetComponent 비용·GC 포인트를 엔진 관점에서 설명한다. 초보자 실습 포함. 60fps 기준 측정 팁 제공. 60fps 기준 측정 팁 제공.
Unity State 패턴으로 이동/대기/공격 상태 구현과 엔진 동작 원리
게임 만들다 겪는 사고는 대개 상태가 섞일 때 터진다. 이동 키를 누른 채 공격 버튼을 연타했는데 캐릭터가 미끄러지며 공격 애니메이션이 끊기고, 콘솔에는 "MissingReferenceException: The object of type 'Animator' has been destroyed" 같은 로그가 뜬다. Update 안에서 if 문이 늘어난 만큼, 프레임마다 GetComponent를 다시 찾고, 상태 전환 타이밍이 Player Loop의 다른 단계와 충돌한다. State 패턴은 기능 분리가 아니라 엔진 호출 흐름에 맞춘 안전장치다.
핵심 개념
State 패턴이 필요한 이유는 "조건문 정리"가 아니라 "프레임 기반 실행 모델" 때문이다. Unity는 매 프레임마다 MonoBehaviour의 Update를 호출한다. Update 안에서 이동/대기/공격을 모두 처리하면, 입력·물리·애니메이션이 서로 다른 타이밍에 갱신되는 문제를 한 함수에서 억지로 맞추게 된다. 상태를 객체로 분리하면, 각 프레임에 실행되는 코드가 상태별로 고정되고, 전환 시점에만 부작용(속도 초기화, 트리거 리셋 등)을 모아서 처리한다.
핵심 용어 1) 상태(State): 현재 프레임에서 허용되는 행동의 집합이다. 예를 들어 공격 상태에서는 이동 입력을 무시하거나 감속만 허용한다. 2) 전환(Transition): 입력/시간/애니메이션 이벤트 같은 조건이 만족되면 상태를 교체하는 동작이다. 3) 컨텍스트(Context): 상태가 공유하는 데이터(Transform, Animator, 속도, 타겟 등)와 엔진 콜백(Update/FixedUpdate)을 제공하는 주체다. 4) 진입/유지/종료(Enter/Tick/Exit): 전환 시 한 번 실행되는 코드와 매 프레임 실행되는 코드를 분리하는 규칙이다.
초보가 가장 빨리 체감하는 효과는 버그 재현이 쉬워진다는 점이다. 이동 중 공격이 이상하면 MoveState만 보면 된다. 반대로 if-else 덩어리에서는 "이 프레임에 이동 처리 먼저 했나, 공격 처리 먼저 했나"가 섞인다. 같은 프레임 안에서도 Update에서 위치를 바꾸고 LateUpdate에서 카메라가 따라가며, Animator는 내부적으로 다른 단계에서 평가된다. 상태 분리는 이 순서를 눈에 보이게 만든다.
성능 관점에서도 상태는 도움이 된다. Update에서 매번 GetComponent를 호출하거나, string 기반 Animator 파라미터를 매번 SetTrigger("Attack")로 호출하면 비용이 누적된다. 상태 객체는 필요한 참조를 시작 시 캐싱하고, Tick에서 최소한의 호출만 한다. 60fps에서 프레임 예산은 16.6ms이고, 캐릭터 50마리면 Update 호출 횟수 자체가 50배가 된다. 상태별로 호출을 줄이는 구조가 프레임 예산 관리에 유리하다.
1using UnityEngine;
2
3public interface IState
4{
5 void Enter();
6 void Tick();
7 void Exit();
8}
9
10public class StateMachine
11{
12 public IState Current { get; private set; }
13
14 public void ChangeState(IState next)
15 {
16 if (Current == next) return;
17 Current?.Exit();
18 Current = next;
19 Current?.Enter();
20 }
21
22 public void Tick()
23 {
24 Current?.Tick();
25 }
26}이 코드가 해결하는 문제는 "전환 시 한 번만 실행돼야 할 코드"가 매 프레임 반복되는 사고다. Enter에서 속도 초기화, 애니메이션 트리거 설정, 타겟 락 같은 작업을 모아두면 Tick에서 불필요한 SetTrigger 반복 호출이 사라진다. 실행하면 상태가 바뀌는 프레임에만 Enter/Exit가 호출되고, 그 외 프레임에는 Tick만 호출되는 구조가 된다.
엔진 관점에서의 내부 동작
Unity에서 C# 스크립트는 네이티브(C++) 엔진 위에 얹힌 관리 코드 계층이다. MonoBehaviour의 Update 같은 메시지는 C#이 자발적으로 호출하는 게 아니라, 엔진의 Player Loop가 프레임마다 "이 컴포넌트는 Update가 필요한가"를 스캔하고, 필요하면 C# 메서드를 호출한다. 이때 호출 경로는 대략 C++ PlayerLoop → Scripting(바인딩) → C# 메서드 디스패치 순서다.
Player Loop 관점에서 상태 머신은 Update 단계의 "스크립트 실행" 슬롯에 들어간다. 입력(Input System/Legacy Input)은 Update 이전에 갱신되고, 물리(Physics)는 FixedUpdate 타이밍에 별도 스텝으로 진행된다. 그래서 Rigidbody 기반 이동은 FixedUpdate에서 처리해야 물리 스텝과 동기화된다. 반대로 Transform 기반 이동은 Update에서 처리하면 렌더 프레임과 맞아 떨어져 부드럽다. 상태별로 Tick을 Update/FixedUpdate로 나누는 이유가 여기서 나온다.
GetComponent가 느린 이유도 엔진 계층 구조로 설명된다. GameObject와 Component 목록은 네이티브 메모리에 저장된다. C#에서 GetComponent<T>()를 호출하면, 내부적으로 네이티브 쪽 컴포넌트 배열/리스트를 순회하고 타입 매칭을 수행한 뒤, 해당 네이티브 오브젝트를 가리키는 C# 래퍼(UnityEngine.Object)를 반환한다. 호출 시점마다 바인딩 경계를 넘고, 순회 비용이 생긴다. Update에서 매 프레임 호출하면 캐릭터 수만큼 누적된다.
메모리 관점에서 상태 객체는 순수 C# 힙에 할당된다. 상태를 new로 만들면 관리 힙에 객체가 생기고, 더 이상 참조가 없으면 GC 대상이 된다. 전환마다 new를 반복하면 GC Alloc이 쌓이고, 어느 순간 GC.Collect가 프레임을 끊는다. 실무에서는 상태를 시작 시 한 번만 생성해 재사용하거나, ScriptableObject로 상태 데이터를 분리해 런타임 할당을 줄인다.
Coroutine이 yield return null을 해야 하는 이유도 Player Loop에 있다. 코루틴은 "다음 프레임까지 실행을 미룬다"는 예약이다. yield return null은 내부적으로 "다음 Update 루프에서 재개" 큐에 등록된다. 즉 yield는 스레드가 멈추는 게 아니라, 엔진이 관리하는 코루틴 스케줄러에 의해 다음 루프에서 MoveNext가 호출되는 구조다. 그래서 코루틴은 상태 전환의 타이밍 제어(공격 쿨타임, 경직 시간)에 쓰기 좋지만, 남발하면 스케줄러 오버헤드가 생긴다.
처음에 나도 Update/FixedUpdate를 섞어 쓰다가 3시간 삽질한 적이 있다. Rigidbody로 이동시키면서 공격 상태에서만 Update로 위치를 보정했더니, 콘솔에 "Rigidbody interpolation is not supported when using MovePosition" 같은 경고가 뜨고 캐릭터가 순간이동했다. 원인은 물리 스텝이 FixedUpdate에서 진행되는데, Update에서 Transform을 건드리면 네이티브 물리 월드와 렌더 트랜스폼이 서로 다른 타임라인을 갖기 때문이다. 상태 패턴을 쓰면서 Tick을 FixedTick/UpdateTick으로 분리하니 재현이 바로 끊겼다.
1using UnityEngine;
2
3public class PlayerLoopProbe : MonoBehaviour
4{
5 private int _fixedCount;
6 private int _updateCount;
7
8 private void Awake()
9 {
10 Debug.Log("Awake");
11 }
12
13 private void OnEnable()
14 {
15 Debug.Log("OnEnable");
16 }
17
18 private void Start()
19 {
20 Debug.Log("Start");
21 }
22
23 private void FixedUpdate()
24 {
25 _fixedCount++;
26 if (_fixedCount % 50 == 0)
27 Debug.Log($"FixedUpdate count={_fixedCount} time={Time.time:F2}");
28 }
29
30 private void Update()
31 {
32 _updateCount++;
33 if (_updateCount % 120 == 0)
34 Debug.Log($"Update count={_updateCount} time={Time.time:F2}");
35 }
36
37 private void LateUpdate()
38 {
39 if (_updateCount % 120 == 0)
40 Debug.Log("LateUpdate after Update in same frame");
41 }
42}이 코드를 빈 GameObject에 붙이고 Play를 누르면 콘솔 로그가 섞여 찍힌다. FixedUpdate는 Time.fixedDeltaTime(기본 0.02s) 기준으로 여러 번 호출되거나, 프레임이 빠르면 한 번도 호출되지 않을 수 있다. Update/LateUpdate는 렌더 프레임마다 1회다. 상태 머신에서 이동을 어디에 넣어야 하는지, 공격 쿨타임을 Time.deltaTime으로 계산하면 어떤 흔들림이 생기는지 감이 잡힌다.
1using UnityEngine;
2
3public class GetComponentCostProbe : MonoBehaviour
4{
5 private Animator _cached;
6
7 private void Awake()
8 {
9 _cached = GetComponent<Animator>();
10 }
11
12 private void Update()
13 {
14 // 의도적으로 비교용 호출
15 var a1 = GetComponent<Animator>();
16 var a2 = _cached;
17
18 if (a1 != a2)
19 Debug.Log("Animator reference mismatch");
20 }
21}Profiler(Windows: Window → Analysis → Profiler)에서 CPU Usage를 켜고, Deep Profile은 끈 상태로 Play하면 Update에서 GetComponent가 잡힌다. 캐싱한 _cached는 단순 필드 접근이라 비용이 사실상 0에 가깝다. 반면 GetComponent는 네이티브 컴포넌트 검색과 바인딩 경계를 넘는 비용이 있다. 캐릭터 100개가 동일 코드를 돌리면, 이 한 줄이 프레임당 100회 호출로 바뀐다.
실습하기
1단계: 프로젝트 설정
Unity Hub → Projects → New project에서 3D(Core) 템플릿을 선택한다. Unity 버전은 2022.3 LTS 계열을 권장한다(Profiler와 패키지 호환이 안정적이다). 프로젝트 이름은 StatePatternPractice로 둔다.
Project 창에서 Assets 폴더 우클릭 → Create → Folder로 Scripts 폴더를 만든다. Edit → Project Settings → Time에서 Fixed Timestep이 0.02인지 확인한다. 이 값이 바뀌면 FixedUpdate 호출 빈도가 달라져 상태 전환 테스트가 흔들린다.
2단계: 씬 구성
Hierarchy 우클릭 → 3D Object → Plane로 바닥을 만든다. Hierarchy 우클릭 → 3D Object → Capsule로 플레이어를 만든다. Capsule 이름을 Player로 바꾼다. Main Camera는 Player를 바라보게 위치를 (0, 6, -8), Rotation을 (25, 0, 0) 정도로 맞춘다.
Player(Capsule) 선택 → Inspector → Add Component에서 CharacterController를 추가한다. CharacterController의 Center는 (0, 1, 0), Height는 2, Radius는 0.5 기본값이면 충분하다. Add Component로 Animator도 추가한다(애니메이션이 없어도 트리거 테스트는 가능하다). Animator Controller가 없으면 경고가 뜨지만 상태 전환 로그는 정상 동작한다.
1using UnityEngine;
2
3public class PlayerContext : MonoBehaviour
4{
5 [Header("Tuning")]
6 public float moveSpeed = 4f;
7 public float attackDuration = 0.35f;
8
9 [Header("Runtime (read only)")]
10 public Vector3 moveInput;
11
12 public CharacterController controller { get; private set; }
13 public Animator animator { get; private set; }
14
15 private void Awake()
16 {
17 controller = GetComponent<CharacterController>();
18 animator = GetComponent<Animator>();
19
20 if (controller == null)
21 Debug.LogError("CharacterController missing on Player");
22 }
23}이 코드를 Player 오브젝트에 붙이면, Awake에서 CharacterController와 Animator를 한 번만 찾는다. Play를 눌렀을 때 콘솔에 "CharacterController missing"이 뜨면 Inspector에서 컴포넌트를 빠뜨린 상태다. Update에서 GetComponent를 반복 호출하지 않으니, 상태 Tick이 늘어도 바인딩 비용이 추가되지 않는다.
1using UnityEngine;
2
3public class PlayerStateMachineDriver : MonoBehaviour
4{
5 private StateMachine _sm;
6 private PlayerContext _ctx;
7
8 private IdleState _idle;
9 private MoveState _move;
10 private AttackState _attack;
11
12 private void Awake()
13 {
14 _ctx = GetComponent<PlayerContext>();
15 _sm = new StateMachine();
16
17 _idle = new IdleState(_sm, _ctx);
18 _move = new MoveState(_sm, _ctx);
19 _attack = new AttackState(_sm, _ctx);
20 }
21
22 private void Start()
23 {
24 _sm.ChangeState(_idle);
25 }
26
27 private void Update()
28 {
29 _ctx.moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
30
31 if (Input.GetKeyDown(KeyCode.Space))
32 _sm.ChangeState(_attack);
33
34 _sm.Tick();
35 }
36}Inspector에서 Player에 PlayerStateMachineDriver를 추가한다. Play를 누르고 WASD를 누르면 MoveState로, 아무 입력이 없으면 IdleState로 전환되는 로그가 찍히게 만들 예정이다. Space를 누르면 AttackState로 전환된다. 입력을 Update에서 읽는 이유는 입력 시스템이 프레임 단위로 갱신되기 때문이다. FixedUpdate에서 Input.GetKeyDown을 읽으면 놓치는 프레임이 생길 수 있다.
1using UnityEngine;
2
3public class IdleState : IState
4{
5 private readonly StateMachine _sm;
6 private readonly PlayerContext _ctx;
7
8 public IdleState(StateMachine sm, PlayerContext ctx)
9 {
10 _sm = sm;
11 _ctx = ctx;
12 }
13
14 public void Enter()
15 {
16 Debug.Log("State=Idle Enter");
17 if (_ctx.animator != null) _ctx.animator.ResetTrigger("Attack");
18 }
19
20 public void Tick()
21 {
22 if (_ctx.moveInput.sqrMagnitude > 0.01f)
23 _sm.ChangeState(new MoveState(_sm, _ctx));
24 }
25
26 public void Exit()
27 {
28 Debug.Log("State=Idle Exit");
29 }
30}Play 후 WASD를 누르면 콘솔에 Idle Exit, Move Enter 순서로 찍혀야 한다. 그런데 이 코드는 일부러 함정을 넣었다. Tick에서 new MoveState를 매번 생성한다. 입력을 누르고 있는 동안 매 프레임 new가 발생해 GC Alloc이 누적된다. Profiler의 GC Alloc 컬럼에서 Update마다 바이트가 늘어나는 걸 확인할 수 있다. 다음 단계에서 상태 인스턴스를 재사용하는 형태로 고친다.
1using UnityEngine;
2
3public class MoveState : IState
4{
5 private readonly StateMachine _sm;
6 private readonly PlayerContext _ctx;
7
8 public MoveState(StateMachine sm, PlayerContext ctx)
9 {
10 _sm = sm;
11 _ctx = ctx;
12 }
13
14 public void Enter()
15 {
16 Debug.Log("State=Move Enter");
17 }
18
19 public void Tick()
20 {
21 if (_ctx.moveInput.sqrMagnitude < 0.01f)
22 {
23 _sm.ChangeState(new IdleState(_sm, _ctx));
24 return;
25 }
26
27 Vector3 dir = _ctx.moveInput.normalized;
28 Vector3 motion = dir * (_ctx.moveSpeed * Time.deltaTime);
29 _ctx.controller.Move(motion);
30 }
31
32 public void Exit()
33 {
34 Debug.Log("State=Move Exit");
35 }
36}Game 뷰에서 Capsule이 바닥 위를 움직이면 CharacterController.Move가 네이티브 쪽에서 충돌 계산을 수행한 결과다. C#에서 Move를 호출하면 내부적으로 캐릭터 컨트롤러(네이티브 오브젝트)의 Sweep 테스트와 접촉 해결이 실행된다. Transform.position을 직접 바꾸는 것과 달리, 충돌 해결이 엔진 물리 쪽과 일관되게 처리된다. Space를 누르면 AttackState로 전환되도록 이어진다.
1using UnityEngine;
2
3public class AttackState : IState
4{
5 private readonly StateMachine _sm;
6 private readonly PlayerContext _ctx;
7 private float _t;
8
9 public AttackState(StateMachine sm, PlayerContext ctx)
10 {
11 _sm = sm;
12 _ctx = ctx;
13 }
14
15 public void Enter()
16 {
17 _t = 0f;
18 Debug.Log("State=Attack Enter");
19 if (_ctx.animator != null) _ctx.animator.SetTrigger("Attack");
20 }
21
22 public void Tick()
23 {
24 _t += Time.deltaTime;
25 if (_t >= _ctx.attackDuration)
26 {
27 if (_ctx.moveInput.sqrMagnitude > 0.01f)
28 _sm.ChangeState(new MoveState(_sm, _ctx));
29 else
30 _sm.ChangeState(new IdleState(_sm, _ctx));
31 }
32 }
33
34 public void Exit()
35 {
36 Debug.Log("State=Attack Exit");
37 }
38}Play 중 Space를 누르면 콘솔에 Attack Enter가 찍히고, 0.35초 뒤 Exit가 찍힌다. Animator Controller가 없어도 SetTrigger 호출 자체는 가능하지만, 컨트롤러가 없으면 애니메이션 변화는 없다. 여기서도 new 상태 생성이 반복된다. 다음 섹션에서 상태 인스턴스를 Driver에서 재사용하도록 바꾸고, GC Alloc을 0으로 만든다.
심화 활용
한 문단 요약: 상태를 new로 매 전환마다 만들면 GC Alloc이 누적되고, Update에서 GetComponent나 string 기반 Animator 호출을 반복하면 바인딩 비용이 누적된다. 상태 인스턴스 재사용 + 참조 캐싱 + 전환 시점에만 부작용 처리로 프레임 예산을 지킨다.
패턴 1: 상태 인스턴스 재사용 + 전환 조건을 Driver로 모으기
실무에서는 상태가 10개를 넘어가면서 전환 조건이 서로 꼬인다. 그래서 상태 내부에서 다른 상태를 new로 만들지 않고, Driver가 미리 만든 상태 인스턴스로만 전환한다. 이렇게 하면 런타임 할당이 사라지고, 전환 그래프를 한 곳에서 추적할 수 있다. Profiler에서 GC Alloc이 0으로 떨어지는 걸 확인할 수 있다.
1using UnityEngine;
2
3public class PlayerStateMachineDriver2 : MonoBehaviour
4{
5 private StateMachine _sm;
6 private PlayerContext _ctx;
7
8 private IdleState2 _idle;
9 private MoveState2 _move;
10 private AttackState2 _attack;
11
12 private void Awake()
13 {
14 _ctx = GetComponent<PlayerContext>();
15 _sm = new StateMachine();
16
17 _idle = new IdleState2(_ctx);
18 _move = new MoveState2(_ctx);
19 _attack = new AttackState2(_ctx);
20 }
21
22 private void Start()
23 {
24 _sm.ChangeState(_idle);
25 }
26
27 private void Update()
28 {
29 _ctx.moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
30
31 if (Input.GetKeyDown(KeyCode.Space))
32 _sm.ChangeState(_attack);
33 else if (_sm.Current == _attack)
34 {
35 // 공격 상태는 내부 타이머로 종료한다
36 }
37 else if (_ctx.moveInput.sqrMagnitude > 0.01f)
38 _sm.ChangeState(_move);
39 else
40 _sm.ChangeState(_idle);
41
42 _sm.Tick();
43 }
44}
45
46public class IdleState2 : IState
47{
48 private readonly PlayerContext _ctx;
49 public IdleState2(PlayerContext ctx) { _ctx = ctx; }
50 public void Enter() { if (_ctx.animator != null) _ctx.animator.ResetTrigger("Attack"); }
51 public void Tick() { }
52 public void Exit() { }
53}
54
55public class MoveState2 : IState
56{
57 private readonly PlayerContext _ctx;
58 public MoveState2(PlayerContext ctx) { _ctx = ctx; }
59 public void Enter() { }
60 public void Tick()
61 {
62 Vector3 dir = _ctx.moveInput.sqrMagnitude > 0.01f ? _ctx.moveInput.normalized : Vector3.zero;
63 _ctx.controller.Move(dir * (_ctx.moveSpeed * Time.deltaTime));
64 }
65 public void Exit() { }
66}
67
68public class AttackState2 : IState
69{
70 private readonly PlayerContext _ctx;
71 private float _t;
72
73 public AttackState2(PlayerContext ctx) { _ctx = ctx; }
74 public void Enter()
75 {
76 _t = 0f;
77 if (_ctx.animator != null) _ctx.animator.SetTrigger("Attack");
78 }
79 public void Tick() { _t += Time.deltaTime; }
80 public void Exit() { }
81}Driver2로 교체하려면 Player에서 기존 PlayerStateMachineDriver를 Disable 체크하거나 컴포넌트를 제거한다. Play 후 Profiler에서 GC Alloc이 0인지 확인한다. 상태 전환이 매 프레임 실행되는데도 할당이 없다는 점이 핵심이다. 상태 객체를 재사용하면 C# 힙 압력이 줄고, GC가 프레임을 끊는 빈도가 줄어든다.
패턴 2: Animator 파라미터 해시 + 전환 시점에만 호출
Animator.SetTrigger("Attack")는 string을 해시로 변환하는 비용이 들어간다. 내부적으로는 파라미터 이름을 ID로 바꿔 테이블을 찾는다. 매 프레임 호출하면 CPU가 쓸데없는 일을 반복한다. 전환 시점(Enter)에서 한 번만 호출하고, 파라미터는 StringToHash로 미리 정수 ID를 만든다. 이 방식은 캐릭터 수가 늘수록 차이가 커진다.
1using UnityEngine;
2
3public class AnimatorHashDemo : MonoBehaviour
4{
5 private static readonly int AttackHash = Animator.StringToHash("Attack");
6 private Animator _anim;
7
8 private void Awake()
9 {
10 _anim = GetComponent<Animator>();
11 }
12
13 public void FireAttackTrigger()
14 {
15 if (_anim == null) return;
16 _anim.SetTrigger(AttackHash);
17 }
18
19 public void ResetAttackTrigger()
20 {
21 if (_anim == null) return;
22 _anim.ResetTrigger(AttackHash);
23 }
24}이 컴포넌트를 Player에 붙이고, AttackState Enter에서 FireAttackTrigger를 호출하도록 연결하면 string 기반 호출이 사라진다. Profiler Timeline에서 Animator.SetTrigger가 차지하는 시간이 아주 작아 보여도, 캐릭터 200마리면 누적이 된다. 특히 모바일에서 스크립트 CPU가 4~6ms만 넘어도 60fps가 흔들린다.
처음에 나도 Animator 트리거를 Tick에서 매 프레임 호출했다가 애니메이션이 재시작되는 버그를 봤다. 증상은 공격 모션이 0프레임에서 계속 끊기는 것이고, 콘솔에는 별 로그가 없었다. Profiler에서 Animator.Update가 비정상적으로 길게 잡혔고, 원인은 매 프레임 SetTrigger가 들어가면서 상태 머신이 계속 재진입한 것이었다. Enter에서 1회만 호출하게 바꾸니 바로 사라졌다.
또 한 번은 상태 전환 직후 Destroy된 오브젝트를 참조해 "MissingReferenceException"이 났다. 공격 이펙트를 Instantiate 후 0.2초 뒤 Destroy했는데, 상태 Exit에서 이펙트의 Transform을 다시 만졌다. UnityEngine.Object는 C# 레퍼런스가 남아 있어도 네이티브 오브젝트가 파괴되면 '가짜 null'로 동작한다. Exit에서 null 체크를 해도, 타이밍에 따라 이미 파괴된 핸들을 건드리면 예외가 난다. 이펙트 레퍼런스는 상태가 아니라 컨텍스트에서 수명 관리(풀링)로 옮기는 게 맞았다.
자주 하는 실수
실수 1: Tick에서 new 상태를 생성해 GC Alloc이 쌓임
증상: Play 후 이동 키를 누르고 1~2분 지나면 미세한 끊김이 생긴다. Profiler에서 GC.Collect가 간헐적으로 튀고, CPU Usage에 Scripting GC가 보인다.
원인: Tick에서 new IdleState/new MoveState를 반복 생성한다. 관리 힙에 객체가 계속 쌓이고, 일정 임계치에서 GC가 실행된다. 상태 전환 자체가 많을수록 더 빨리 터진다.
해결: 상태 인스턴스를 Awake에서 한 번만 만들고 재사용한다. 전환은 Driver가 미리 만든 객체로만 수행한다. Profiler의 GC Alloc이 0인지 확인한다.
실수 2: Update에서 Rigidbody를 직접 움직여 물리와 충돌
증상: 공격 상태에서만 캐릭터가 순간이동하거나 바닥을 뚫는다. 콘솔에 "Non-convex MeshCollider" 경고가 섞여 나오기도 하고, 재현이 프레임레이트에 따라 달라진다.
원인: 물리 월드는 FixedUpdate 스텝에서 진행된다. Update에서 Transform.position을 바꾸면 네이티브 물리 상태와 렌더 트랜스폼이 어긋난다. 특히 Rigidbody는 MovePosition/velocity를 FixedUpdate에서 다루는 설계다.
해결: Rigidbody 기반 캐릭터면 상태에 FixedTick을 두고 FixedUpdate에서만 이동을 수행한다. 입력은 Update에서 읽고, FixedUpdate에서 마지막 입력 값을 사용한다. Project Settings의 Fixed Timestep도 함께 점검한다.
실수 3: Animator 트리거를 매 프레임 SetTrigger 해서 애니메이션이 재시작됨
증상: 공격 모션이 시작 프레임에서 떨리거나, 전혀 진행되지 않는다. 콘솔에는 에러가 없고, 애니메이터 창에서 상태가 계속 재진입하는 것처럼 보인다.
원인: Tick에서 SetTrigger를 매 프레임 호출하면, 트리거가 계속 켜진 상태로 평가되어 전이가 반복될 수 있다. Animator는 내부적으로 그래프를 평가하고 전이를 결정하는데, 매 프레임 같은 이벤트를 던지면 상태 머신이 안정화되지 않는다.
해결: Enter에서 1회만 SetTrigger를 호출하고, Exit에서 ResetTrigger가 필요하면 한 번만 호출한다. 파라미터는 StringToHash로 캐싱한다. 애니메이션 종료는 normalizedTime이나 Animation Event로 처리한다.
실수 4: GetComponent를 Update에서 반복 호출
증상: 캐릭터 수가 늘수록 프레임이 떨어진다. Profiler에서 Scripts 항목에 GetComponent가 자주 보이고, Deep Profile을 켜면 더 크게 보인다.
원인: GetComponent는 네이티브 컴포넌트 목록을 순회하고 바인딩 경계를 넘는다. 매 프레임 호출하면 호출 횟수만큼 누적된다. 특히 Update는 모든 활성 MonoBehaviour에 대해 호출되므로 폭발한다.
해결: Awake/Start에서 컴포넌트를 캐싱한다. 상태 객체에는 캐싱된 참조(PlayerContext)를 주입한다. 상태 Tick에서는 필드 접근만 남긴다.
실수 5: 상태 전환 중 Destroy된 오브젝트를 참조해 MissingReferenceException 발생
증상: 콘솔에 "MissingReferenceException: The object of type 'X' has been destroyed but you are still trying to access it"가 뜬다. 공격 이펙트나 타겟이 사라지는 타이밍에만 발생한다.
원인: UnityEngine.Object는 네이티브 핸들을 감싸는 래퍼다. C# 레퍼런스가 살아 있어도 네이티브가 Destroy되면 비교 연산에서 null처럼 보이지만, 접근 시점에 따라 예외가 난다. 상태 Exit에서 이펙트를 만지는 코드가 대표적이다.
해결: 오브젝트 수명은 컨텍스트나 별도 시스템(풀링/이펙트 매니저)에서 관리한다. 상태는 요청만 하고 직접 Destroy된 참조를 들고 있지 않는다. 필요한 경우 OnDisable에서 정리하고, null 체크는 Unity의 가짜 null 특성을 고려해 분기한다.
1using UnityEngine;
2
3public class UnityNullProbe : MonoBehaviour
4{
5 public GameObject target;
6
7 private void Update()
8 {
9 if (Input.GetKeyDown(KeyCode.K) && target != null)
10 {
11 Destroy(target);
12 Debug.Log("Destroyed target");
13 }
14
15 // Destroy 직후에도 C# 레퍼런스는 남아 있다
16 if (target == null)
17 Debug.Log("target looks null (Unity fake null)");
18 else
19 Debug.Log($"target instance id={target.GetInstanceID()}");
20 }
21}K를 누르면 Destroy가 예약되고, 다음 프레임부터 target이 null처럼 보이는 로그가 찍힌다. 이 타이밍 차이가 상태 전환과 겹치면 MissingReferenceException이 튀기 쉽다. 이펙트 레퍼런스를 상태가 들고 있으면 Exit에서 접근하는 순간이 위험해진다.
성능 최적화 체크리스트
- Profiler에서 CPU Usage 모듈을 켜고, Scripts 항목에서 Update 비용 상위 5개를 확인한다(프레임당 16.6ms 예산 기준).
- GC Alloc 컬럼을 켜고, 상태 전환/입력 연타 시 프레임당 할당이 0B인지 확인한다(new 상태 생성, LINQ, string concat 의심).
- GetComponent/Find 계열 호출을 Update에서 금지하고 Awake/Start에서 캐싱한다(캐싱 누락은 Profiler에서 GetComponent가 반복 노출).
- Animator 파라미터는 StringToHash로 정수화하고, Enter/Exit에서만 SetTrigger/ResetTrigger를 호출한다(Tick 반복 호출 금지).
- Rigidbody 이동은 FixedUpdate에서만 처리하고, 입력은 Update에서 읽어 마지막 입력 값을 저장한다(물리 스텝과 렌더 프레임 분리).
- CharacterController.Move 호출은 Time.deltaTime을 곱해 프레임 독립적으로 만든다(속도 단위 m/s 유지).
- 상태 객체는 Awake에서 한 번 생성해 재사용하고, 전환 그래프는 Driver에서만 수행한다(전환 중 new 금지).
- 공격 지속시간/쿨타임은 Time.time 기반(절대시간) 또는 누적 타이머로 처리하고, 프레임 드랍에도 종료가 보장되는지 테스트한다.
- 레이어 충돌은 Project Settings → Physics의 Layer Collision Matrix로 줄이고, Raycast는 LayerMask를 명시한다(불필요한 충돌 쿼리 감소).
- 이펙트/투사체는 Instantiate/Destroy 반복 대신 풀링을 사용한다(상태 Enter에서 Spawn 요청, Exit에서 반환 요청).
- 코루틴은 공격/경직 같은 짧은 타이밍 제어에만 쓰고, 캐릭터 수가 많으면 Update 타이머로 대체한다(코루틴 스케줄러 오버헤드 확인).
- Development Build + Autoconnect Profiler로 디바이스에서 측정한다(Editor 성능과 디바이스 성능은 다르게 나온다).
자주 묻는 질문
State 패턴을 쓰면 Update 호출 자체가 줄어드나?
Update 호출 횟수는 줄지 않는다. 활성화된 MonoBehaviour가 있으면 엔진의 Player Loop가 매 프레임 스크립트 메시지를 디스패치한다. State 패턴이 줄이는 것은 Update 안에서 실행되는 분기와 호출 수다. 예를 들어 공격 중에는 이동 계산과 충돌 쿼리를 아예 실행하지 않게 만들 수 있고, Animator 트리거도 Enter에서 1회만 호출하게 된다. 다음 학습 키워드는 Player Loop, MonoBehaviour message dispatch, CPU Usage Profiler의 Scripts breakdown이다.
상태를 클래스로 만들지 말고 enum + switch로 하면 더 빠르지 않나?
enum + switch는 분기 비용만 보면 빠를 수 있다. 문제는 규모가 커질수록 전환 부작용(속도 초기화, 트리거 리셋, 이펙트 스폰)이 switch 곳곳에 흩어져 버그가 된다. 상태 클래스로 분리하면 Enter/Exit에 부작용을 고정할 수 있고, 참조 캐싱도 상태 생성 시점에 끝낼 수 있다. 성능은 "new를 반복하지 않는다"는 전제가 중요하다. enum 방식으로도 할당 0을 만들 수 있지만, 유지보수 비용이 커진다. 다음 학습 키워드는 data-oriented state, transition table, allocation-free design이다.
왜 GetComponent를 캐싱하면 빨라지나? 캐싱도 결국 참조 하나 저장하는 것 아닌가?
캐싱은 C# 필드 접근으로 끝나지만, GetComponent는 네이티브 컴포넌트 컨테이너를 탐색하는 과정이 포함된다. 호출 시 C#→네이티브 바인딩 경계를 넘고, 타입 매칭을 위해 컴포넌트 목록을 순회한다. 이 비용이 한 번이면 무시되지만, Update에서 매 프레임 호출하면 캐릭터 수 × 프레임 수로 누적된다. Profiler에서 GetComponent가 0.01ms라도, 200개면 2ms가 된다. 다음 학습 키워드는 native binding cost, component lookup, caching patterns이다.
Update와 FixedUpdate를 왜 분리했나? 하나로 통일하면 더 단순하지 않나?
물리는 일정한 시간 간격으로 적분해야 안정적이다. 프레임레이트는 30~144fps처럼 변동하므로, 물리까지 프레임에 묶으면 결과가 달라진다. Unity는 FixedUpdate를 고정 시간 스텝으로 두고, 네이티브 물리 월드를 그 스텝에 맞춰 진행한다. Update는 렌더 프레임에 맞춰 입력·카메라·UI를 처리한다. 상태 패턴에서 이동이 Rigidbody 기반이면 FixedUpdate 쪽에, CharacterController/Transform 기반이면 Update 쪽에 두는 이유가 여기 있다. 다음 학습 키워드는 fixed timestep, interpolation, physics integration이다.
Coroutine에서 yield return null은 정확히 무엇을 의미하나?
yield return null은 코루틴을 다음 프레임까지 중단하라는 신호다. 코루틴은 별도 스레드가 아니라, 엔진이 관리하는 스케줄러가 IEnumerator.MoveNext를 특정 Player Loop 단계에서 다시 호출하는 구조다. null을 반환하면 "다음 Update 루프에서 재개" 큐에 들어간다. 그래서 공격 쿨타임을 코루틴으로 만들면 코드가 짧아지지만, 코루틴 개수가 많아지면 스케줄러가 처리해야 할 작업이 늘어난다. 다음 학습 키워드는 coroutine scheduler, player loop timing, allocation in IEnumerator이다.
Animator Controller가 없는데 SetTrigger를 호출하면 왜 경고만 나고 크래시는 안 나나?
Animator 컴포넌트는 네이티브 쪽에 애니메이션 그래프를 평가하는 런타임을 가진다. 컨트롤러가 없으면 그래프가 비어 있어 전이가 발생하지 않는다. SetTrigger 호출은 파라미터 테이블에 값을 쓰는 요청인데, 테이블이 없거나 매칭이 안 되면 내부적으로 무시되거나 경고로 끝난다. Unity는 에디터 환경에서 이런 실수를 크래시로 만들기보다 로그로 알려주는 쪽을 택했다. 다음 학습 키워드는 animator parameter binding, controller runtime, warning vs error policy이다.
상태 전환을 Animation Event로 하면 더 정확하지 않나?
Animation Event는 특정 프레임에 C# 함수를 호출해 주므로 타이밍이 직관적이다. 대신 애니메이션 클립에 이벤트가 박혀 있어 코드 변경 없이도 동작이 바뀌고, 클립 교체/리타게팅 시 이벤트가 누락되기 쉽다. 또 이벤트 호출도 결국 Player Loop의 애니메이션 평가 이후에 스크립트로 디스패치되는 흐름이라, 상태 전환이 다른 시스템(입력, 물리)과 엇갈릴 수 있다. 실무에서는 공격 판정 시작/끝 같은 포인트만 이벤트로 두고, 상태 종료는 normalizedTime(예: 0.9 이상)이나 타이머로 이중 안전장치를 둔다. 다음 학습 키워드는 animation event pitfalls, normalizedTime polling, gameplay sync.