유니티 입문2026년 03월 03일· 9 min read

20. Unity Animator State 초보자 가이드: 상태 만들기와 Transition 전환 원리

Animator Controller에서 State와 Transition을 만들고, 파라미터로 전환을 제어하는 법을 엔진 내부(네이티브 그래프/PlayerLoop/성능) 관점으로 설명한다. 초보자용 실습 포함. 2021+ 기준 실무 팁 제공. 60fps 기준 최적화까지.

20. Unity Animator State 초보자 가이드: 상태 만들기와 Transition 전환 원리

Unity Animator State 초보자 가이드: 상태 만들기와 Transition 전환 원리

게임 만들다 보면 ‘걷기 애니메이션이 갑자기 멈추고 Idle로 튀거나, 공격 버튼을 눌렀는데 한 박자 늦게 나가거나, Transition이 끝나기도 전에 다른 상태로 덮여서 캐릭터가 덜컥거리는’ 사고가 자주 난다. Animator 창에서 선 몇 개 그으면 해결될 것 같지만, 실제로는 파라미터 평가 타이밍과 네이티브 애니메이션 그래프 업데이트 순서 때문에 증상이 반복된다. 이 글은 State/Transition을 만드는 손동작보다, 엔진이 왜 그렇게 전환을 결정하는지에 초점을 둔다.

핵심 개념

Animator Controller의 State는 ‘클립을 재생하는 단위’가 아니라, 런타임에서 애니메이션 그래프의 한 노드로 컴파일되는 설계 단위다. 에디터에서 보이는 박스는 저장 데이터(에셋)이고, Play를 누르는 순간 네이티브(C++) 쪽에서 Mecanim 그래프가 만들어져 매 프레임 평가된다. 그래서 State를 많이 만든다고 곧바로 느려지는 게 아니라, 전환 조건(Condition)과 레이어/블렌드 트리 평가가 늘어날 때 비용이 늘어난다.

Transition은 ‘상태 이동’처럼 보이지만 실제로는 두 상태의 포즈를 섞는 블렌딩 구간을 의미한다. Transition Duration이 0이면 즉시 스위치처럼 보이고, 0.1~0.2초면 두 클립의 샘플 결과를 가중치로 섞는다. 이때 Has Exit Time은 “현재 상태가 특정 normalized time을 지나야 전환 조건을 평가”하는 게 아니라, “전환이 허용되는 창(window)을 추가”하는 옵션에 가깝다. 그래서 Trigger를 쏴도 Exit Time이 남아 있으면 바로 안 넘어가는 상황이 생긴다.

Parameter(Bool/Int/Float/Trigger)는 C#에서 값을 넣는 것처럼 보이지만, 내부적으로는 Animator 컴포넌트가 들고 있는 파라미터 테이블(네이티브 메모리)에 기록된다. 같은 프레임 안에서 여러 번 SetBool을 호출해도 최종 값만 남고, Transition 조건 평가는 애니메이션 업데이트 단계에서 한 번에 처리된다. 그래서 “버튼 누른 순간 바로 전환”을 원하면 Update에서 SetTrigger를 던지는 것만으로는 부족하고, 어떤 업데이트 단계에서 전환 평가가 일어나는지 감을 잡아야 한다.

용어를 5개만 딱 잡고 가면 삽질이 줄어든다. State(클립 재생 노드), Transition(두 상태 블렌딩 규칙), Condition(파라미터 기반 전환 조건), Exit Time(전환 허용 시점/창), Interruption Source(다른 전환이 현재 전환을 끊을 수 있는지)이다. ‘왜 공격이 씹히지?’는 대부분 Exit Time + Interruption 조합에서 터진다.

AnimatorParamDemo.cs
1using UnityEngine;
2
3public class AnimatorParamDemo : MonoBehaviour
4{
5    [SerializeField] private Animator animator;
6
7    private static readonly int Speed = Animator.StringToHash("Speed");
8    private static readonly int Attack = Animator.StringToHash("Attack");
9
10    private void Reset()
11    {
12        animator = GetComponent<Animator>();
13    }
14
15    private void Update()
16    {
17        float move = Input.GetAxisRaw("Horizontal");
18        animator.SetFloat(Speed, Mathf.Abs(move));
19
20        if (Input.GetKeyDown(KeyCode.Space))
21            animator.SetTrigger(Attack);
22    }
23}

이 코드를 실행하면 콘솔에는 아무 것도 찍히지 않지만, Animator 창에서 Speed 파라미터가 0↔1로 바뀌고 Space를 누른 순간 Attack 트리거가 켜졌다가 소비(consumed)되는 걸 확인할 수 있다. 트리거가 ‘켜진 상태로 유지’되지 않는 이유는, 전환 평가가 끝난 뒤 엔진이 같은 프레임의 애니메이션 업데이트 단계에서 트리거를 자동으로 리셋하는 설계를 택했기 때문이다. 트리거를 이벤트처럼 쓰게 하려는 의도다.

엔진 관점에서의 내부 동작

Animator Controller는 에디터에서 YAML/바이너리 형태의 에셋으로 저장되지만, 런타임에서는 그대로 해석하지 않는다. Play 시작 시점에 C# 래퍼(Animator, RuntimeAnimatorController)가 네이티브 바인딩을 통해 “이 컨트롤러로 애니메이션 그래프를 만들어라”를 요청하고, C++ 쪽 Mecanim 시스템이 상태 머신/블렌드 트리/전환 조건을 내부 표현으로 컴파일한다. 에디터에서 Transition 선을 몇 개 더 긋는 행위가 런타임에서 매 프레임 조건 평가 분기(branch)를 늘리는 이유가 여기 있다.

Player Loop 관점에서 애니메이션은 대략 Script Update 이후, Animation Update 단계에서 평가된다. 그래서 Update에서 SetFloat/SetTrigger를 호출하면 ‘그 프레임의 애니메이션 평가’에 반영되는 경우가 많다. 다만 Animator Update Mode가 Normal/Animate Physics/Unscaled Time인지에 따라 평가 시점이 달라진다. Animate Physics면 FixedUpdate 타이밍에 맞춰 평가되어, Update에서 트리거를 던져도 실제 전환은 다음 FixedUpdate에서 일어나 체감 지연이 생긴다.

C#의 Animator.SetFloat 같은 API는 managed 메모리에서 값을 바꾸는 게 아니라, 내부적으로 네이티브 오브젝트(Animator의 C++ 인스턴스)에 있는 파라미터 버퍼에 기록한다. 이 호출은 P/Invoke처럼 보이는 경계를 넘는 ‘내부 호출(icall)’ 형태로 구현되어 있고, 경계 비용은 작지만 1프레임에 수천 번 반복하면 눈에 띄는 시간이 된다. 그래서 파라미터 이름 문자열을 매번 넘기지 말고 StringToHash로 정수 해시를 캐싱하는 패턴이 널리 쓰인다.

전환 조건 평가는 ‘상태 머신 그래프’에서 현재 상태의 outgoing transition들을 순서대로 검사하는 방식에 가깝다. 조건이 많을수록 비교 연산이 늘고, Any State 전환을 많이 걸면 거의 매 프레임 전역 조건을 검사하게 된다. “왜 Any State 남발하면 느려지지?”의 답은 단순하다. 현재 상태에 상관없이 후보 전환이 늘어나는 구조라서, 매 프레임 검사할 목록이 커진다.

메모리 관점에서 Animator는 그래프 인스턴스(네이티브), 파라미터 테이블(네이티브), 바인딩된 스켈레톤/Avatar 데이터(네이티브), 그리고 C# 래퍼 객체(매니지드)를 함께 가진다. 전환이 일어날 때마다 클립을 새로 로드하는 게 아니라, 이미 로드된 AnimationClip의 샘플링을 다른 가중치로 평가한다. 그래서 Transition 자체는 GC를 만들지 않는 편이지만, 스크립트 쪽에서 매 프레임 GetCurrentAnimatorStateInfo 같은 구조체를 박싱하거나 문자열 기반 API를 남발하면 GC Alloc이 생길 수 있다.

처음에 나도 “SetTrigger를 눌렀는데 왜 공격이 한 번씩 씹히지?”를 이해 못 해서 3시간을 날렸다. Profiler에서 CPU Usage를 열고 Timeline으로 들어가면 Animator.Update가 보이고, 그 아래에 Transition Evaluate 같은 구간이 찍힌다. 거기서 원인은 Has Exit Time이 켜진 상태에서 Exit Time이 0.9로 잡혀 있어서, 트리거가 들어와도 전환이 ‘허용 창’에 들어갈 때까지 대기하던 것이었다. 트리거는 다음 평가 때 소비되니, 타이밍이 어긋나면 씹힌 것처럼 보인다.

PlayerLoopAnimatorTrace.cs
1using UnityEngine;
2
3public class PlayerLoopAnimatorTrace : MonoBehaviour
4{
5    [SerializeField] private Animator animator;
6
7    private static readonly int Attack = Animator.StringToHash("Attack");
8
9    private void Reset()
10    {
11        animator = GetComponent<Animator>();
12    }
13
14    private void Update()
15    {
16        if (Input.GetKeyDown(KeyCode.Space))
17        {
18            Debug.Log("Update: SetTrigger(Attack)");
19            animator.SetTrigger(Attack);
20        }
21    }
22
23    private void LateUpdate()
24    {
25        var info = animator.GetCurrentAnimatorStateInfo(0);
26        Debug.Log($"LateUpdate: stateHash={info.shortNameHash} normalized={info.normalizedTime:0.00}");
27    }
28}

이 스크립트를 붙이고 Space를 누르면, 콘솔에 Update 로그가 먼저 찍히고 LateUpdate에서 stateHash/normalizedTime이 찍힌다. LateUpdate에서 상태가 바로 바뀌지 않는 경우가 생기는데, 애니메이션 평가는 LateUpdate보다 뒤에서 일어나는 프레임도 있기 때문이다(프로젝트 설정/업데이트 모드/에디터 상황에 따라). 이 차이를 체감하면 “왜 상태 정보 조회가 한 프레임 늦어 보이지?”가 납득된다. 상태 정보는 ‘마지막으로 평가된 결과’를 돌려준다.

AnimatorHashCostDemo.cs
1using UnityEngine;
2
3public class AnimatorHashCostDemo : MonoBehaviour
4{
5    [SerializeField] private Animator animator;
6
7    private int speedHash;
8
9    private void Awake()
10    {
11        animator = GetComponent<Animator>();
12        speedHash = Animator.StringToHash("Speed");
13    }
14
15    private void Update()
16    {
17        // 나쁜 패턴: 문자열 기반 호출을 매 프레임 반복
18        animator.SetFloat("Speed", 1f);
19
20        // 좋은 패턴: 해시 캐싱
21        animator.SetFloat(speedHash, 1f);
22    }
23}

Profiler에서 Deep Profile을 켜고 이 스크립트를 돌리면, 문자열 기반 SetFloat 쪽이 내부적으로 문자열→해시 변환과 바인딩 탐색을 더 자주 타는 걸 확인할 수 있다. 프레임당 0.01ms 차이라도 200개 캐릭터면 2ms가 된다. 엔진은 ‘문자열 API도 제공’하지만, 실무에서는 해시 캐싱을 기본으로 깔고 가는 이유가 수치로 드러난다.

실습하기

1단계: 프로젝트와 애니메이션 클립 준비

Unity Hub → New project → 3D(Core) 템플릿을 고른다. 버전은 2021.3 LTS 이상을 권장한다(Animator 창 UI와 Write Defaults 관련 옵션이 안정적이다). 프로젝트가 열리면 Project 창에서 Animations 폴더를 만들고, 테스트용으로 Idle/Walk/Attack 3개 클립이 필요하다. 외부 FBX가 없다면 Asset Store의 무료 캐릭터나 Mixamo 클립을 받아도 된다.

Hierarchy 우클릭 → 3D Object → Capsule로 캐릭터 대역을 만든다. Capsule에 Animator 컴포넌트를 Add Component로 추가한다. Inspector에서 Animator의 Controller 필드는 비어 있을 텐데, Project 창 우클릭 → Create → Animator Controller로 컨트롤러를 만들고 Capsule의 Animator.Controller에 드래그한다. 이 순간 C# 오브젝트가 바뀌는 게 아니라, 네이티브 Animator가 참조할 컨트롤러 에셋 포인터가 직렬화로 연결된다.

2단계: Animator Controller에서 State와 Transition 만들기

Project에서 만든 Animator Controller를 더블클릭해 Animator 창을 연다. Animator 창 빈 공간 우클릭 → Create State → Empty를 세 번 만들고 이름을 Idle, Walk, Attack으로 바꾼다. 각 State를 클릭한 뒤 Inspector에서 Motion에 해당 AnimationClip(Idle/Walk/Attack)을 드래그한다. Idle을 우클릭 → Set as Layer Default State로 기본 상태를 지정한다.

Parameters 탭에서 + 버튼을 눌러 Float Speed, Trigger Attack을 만든다. Idle에서 Walk로 우클릭 → Make Transition을 만든 뒤 Walk를 클릭해 연결한다. Transition을 클릭하고 Inspector에서 Conditions에 Speed Greater 0.1을 추가한다. Walk에서 Idle로도 Transition을 만들고 Speed Less 0.1을 건다. Any State → Attack 전환도 만들고 Condition에 Attack(Trigger)을 단다. Attack 상태에서 Idle로 돌아오는 전환은 Has Exit Time을 켜고 Exit Time 0.9, Transition Duration 0.05로 둔다. 공격이 끝나기 전에 끊기지 않게 만드는 기본 형태다.

AnimatorStatePracticeInput.cs
1using UnityEngine;
2
3public class AnimatorStatePracticeInput : MonoBehaviour
4{
5    [SerializeField] private Animator animator;
6    [SerializeField] private float speedScale = 1f;
7
8    private static readonly int Speed = Animator.StringToHash("Speed");
9    private static readonly int Attack = Animator.StringToHash("Attack");
10
11    private void Reset()
12    {
13        animator = GetComponent<Animator>();
14    }
15
16    private void Update()
17    {
18        float h = Input.GetAxisRaw("Horizontal");
19        float speed = Mathf.Abs(h) * speedScale;
20        animator.SetFloat(Speed, speed);
21
22        if (Input.GetKeyDown(KeyCode.Space))
23            animator.SetTrigger(Attack);
24    }
25}

Play를 누르면 Game 뷰에서 Capsule은 가만히 있지만, Animator 창에서 파란색 활성 상태가 Idle↔Walk로 바뀌는 게 보인다. Space를 누르면 Any State에서 Attack으로 전환되며, Attack이 끝날 즈음 Idle로 돌아온다. 여기서 확인할 포인트는 두 가지다. Speed가 0.1 근처에서 흔들리면 Idle/Walk가 깜빡거리며 왔다 갔다 한다. 이건 전환 조건이 매 프레임 평가되기 때문에 생기는 현상이다.

3단계: 전환이 왜 씹히는지 재현하고 고치기

Attack 전환이 씹히는 상황을 의도적으로 만든다. Animator에서 Attack 상태로 들어가는 Any State → Attack Transition을 클릭하고, Transition Duration을 0.25로 늘린다. 그리고 Interruption Source를 None으로 둔다. 이 상태에서 Space를 연타하면, 공격 입력이 들어와도 이미 전환 중이라서 새 전환이 끼어들지 못해 ‘입력이 무시된 느낌’이 난다.

이 문제를 고치는 방법은 UX 목표에 따라 다르다. “공격 연타를 콤보로 받겠다”면 Interruption Source를 Current State 또는 Current State then Next State로 바꾸고, Ordered Interruption을 꺼서 최신 입력이 전환을 끊고 들어오게 만든다. “공격은 한 번 시작하면 끝까지”가 목표면, 오히려 Any State를 쓰지 말고 Idle/Walk에서만 Attack으로 가는 전환을 만들어 입력 창을 제한한다.

AnimatorTransitionRepro.cs
1using UnityEngine;
2
3public class AnimatorTransitionRepro : MonoBehaviour
4{
5    [SerializeField] private Animator animator;
6
7    private static readonly int Attack = Animator.StringToHash("Attack");
8
9    private void Awake()
10    {
11        if (!animator) animator = GetComponent<Animator>();
12    }
13
14    private void Update()
15    {
16        if (Input.GetKeyDown(KeyCode.Space))
17        {
18            animator.SetTrigger(Attack);
19            Debug.Log("Attack trigger fired");
20        }
21
22        if (Input.GetKeyDown(KeyCode.R))
23        {
24            animator.Rebind();
25            Debug.Log("Animator.Rebind called");
26        }
27    }
28}

Space 연타 중에 R을 누르면 애니메이터가 리바인드되며 상태가 초기화되는 걸 볼 수 있다. 리바인드는 네이티브 그래프 인스턴스를 재초기화하고 바인딩을 다시 잡는 동작이라, 런타임에서 자주 쓰면 프레임 드랍이 난다. 디버깅 목적으로만 잠깐 쓰는 게 안전하다. 공격 씹힘을 Rebind로 ‘고치는’ 식의 접근은 원인을 숨기는 쪽이다.

심화 활용

패턴 1: Any State 최소화 + ‘상태군’ 단위로 전환 비용 줄이기

실무에서 Any State는 편하지만, 조건 평가 비용과 디버깅 난이도를 동시에 올린다. 캐릭터가 50마리만 돼도 Any State에 6개 전환이 달린 컨트롤러는 매 프레임 300개 조건을 검사하게 된다(대략적인 감각). 대신 Locomotion(Idle/Walk/Run) 같은 상태군에서만 공통 전환을 두고, 공격/피격은 서브 스테이트 머신으로 분리하는 편이 런타임 평가가 예측 가능해진다.

AnimatorLayerGuard.cs
1using UnityEngine;
2
3public class AnimatorLayerGuard : MonoBehaviour
4{
5    [SerializeField] private Animator animator;
6    [SerializeField] private int combatLayerIndex = 1;
7
8    private static readonly int Attack = Animator.StringToHash("Attack");
9
10    private void Awake()
11    {
12        if (!animator) animator = GetComponent<Animator>();
13    }
14
15    private void Update()
16    {
17        if (Input.GetKeyDown(KeyCode.Space))
18        {
19            // 레이어 가중치가 0이면 트리거를 쏴도 시각적으로 아무 일도 안 일어난다.
20            // 이 상황을 코드에서 가드하면 디버깅 시간이 줄어든다.
21            if (animator.GetLayerWeight(combatLayerIndex) <= 0.001f)
22            {
23                Debug.LogWarning("Combat layer weight is 0. Attack ignored.");
24                return;
25            }
26
27            animator.SetTrigger(Attack);
28        }
29    }
30}

레이어를 쓰는 순간 “트리거는 찍히는데 애니메이션이 안 바뀐다”가 자주 터진다. 원인은 레이어 가중치가 0이거나 AvatarMask가 비어 있는 경우가 많다. 엔진은 레이어별로 포즈를 계산해 합성하는데, 가중치가 0이면 계산 결과가 최종 포즈에 기여하지 않는다. 트리거 자체는 정상 동작하니 더 헷갈린다.

패턴 2: StateMachineBehaviour로 전환 타이밍을 엔진 이벤트로 받기

공격 판정/사운드/이펙트를 Update에서 normalizedTime으로 폴링하면, 프레임 드랍 시 타이밍이 흔들린다. 대신 StateMachineBehaviour의 OnStateEnter/OnStateExit을 쓰면, 상태 진입/이탈 시점을 애니메이션 시스템이 결정한 결과에 맞춰 받을 수 있다. 이 콜백은 C++ 애니메이션 업데이트가 상태 변화를 확정한 뒤 C#로 역호출되는 구조라, “내가 생각한 상태”가 아니라 “엔진이 확정한 상태”를 기준으로 동작한다.

AttackStateEvents.cs
1using UnityEngine;
2
3public class AttackStateEvents : StateMachineBehaviour
4{
5    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
6    {
7        Debug.Log($"Enter Attack. layer={layerIndex} time={stateInfo.normalizedTime:0.00}");
8    }
9
10    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
11    {
12        Debug.Log($"Exit Attack. layer={layerIndex} time={stateInfo.normalizedTime:0.00}");
13    }
14}

Animator 창에서 Attack 상태를 클릭하고 Inspector의 Add Behaviour로 AttackStateEvents를 붙인다. Play 후 Space를 누르면 콘솔에 Enter/Exit 로그가 찍힌다. 이 로그는 Update에서 찍는 것보다 신뢰도가 높다. 상태 전환이 일어나는 프레임에 엔진이 확정한 이벤트이기 때문이다. 단, 이 콜백에서 무거운 작업(Instantiate, Resources.Load)을 하면 애니메이션 업데이트 단계에 부하가 걸린다.

처음에 나도 Write Defaults 때문에 반나절을 날린 적이 있다. 공격 상태에서만 손에 무기가 보이게 하려고 Renderer.enabled를 애니메이션으로 껐다 켰는데, 다른 상태로 돌아오면 손이 계속 사라졌다. 콘솔 에러는 없고, 씬 뷰에서만 이상했다. 원인은 Write Defaults가 켜진 상태에서, 해당 상태가 “명시하지 않은 프로퍼티를 기본값으로 써버리는” 식으로 동작해 다른 상태의 값을 덮어쓴 것이었다.

해결은 두 단계였다. 첫째, Animator Controller 전반에서 Write Defaults를 끄고(프로젝트/팀 규칙에 따라 다르지만 일관성이 핵심), 둘째, 무기 표시 같은 토글은 애니메이션 커브 대신 코드/이벤트로 제어해 ‘어떤 상태가 어떤 값을 쓰는지’를 명시했다. 그 뒤 Profiler에서 Render/Animation 타임라인을 확인하니, 상태 전환 때 불필요한 프로퍼티 쓰기도 줄어들었다.

한 문단 요약: State/Transition은 에디터 UI가 아니라 런타임 네이티브 그래프에서 조건 평가와 블렌딩 규칙으로 실행된다. 전환 씹힘의 대부분은 Exit Time, Interruption, 업데이트 모드(Physics/Normal), 레이어 가중치에서 원인이 나온다.

자주 하는 실수

실수 1: Has Exit Time 때문에 트리거가 씹힌다고 착각

증상: Space를 눌렀는데 공격이 바로 안 나가고, 어떤 프레임에서는 아예 안 나간 것처럼 보인다. 콘솔에 SetTrigger 로그는 찍히는데 상태가 안 바뀐다.

원인: Any State → Attack 전환에 Has Exit Time이 켜져 있거나, Attack에서 빠져나가는 전환의 Exit Time이 너무 뒤에 잡혀 있다. 트리거는 평가 시점에만 소비되므로, 허용 창 밖이면 다음 평가까지 대기한다.

해결: 공격처럼 즉시성이 필요한 전환은 Has Exit Time을 끄고 Condition만으로 전환시키거나, Exit Time을 0.0~0.2로 앞당긴다. ‘공격은 끝까지’가 목표면 Attack → Idle 전환만 Exit Time을 쓰고, 진입 전환은 Exit Time을 쓰지 않는다.

실수 2: Any State 남발로 디버깅 불가능 상태

증상: Idle에서 Walk로 가는 줄 알았는데 갑자기 Hit로 가고, 다시 Idle로 돌아오는 등 상태가 튄다. Animator 창에서 선이 너무 많아 원인을 못 찾는다.

원인: Any State에 여러 전환이 걸려 있고, 조건이 겹치거나(예: Speed>0.1과 IsMoving=true), Interruption 설정 때문에 전환이 서로 끊는다. 엔진은 후보 전환을 순서대로 검사하므로, 조건 충돌이 있으면 ‘먼저 만족한 전환’이 잡힌다.

해결: Any State는 “정말 현재 상태와 무관한 전환(죽음, 강제 피격)”만 남긴다. 이동/공격 같은 일반 전환은 서브 스테이트 머신이나 상태군 내부 전환으로 제한한다. Transition 리스트를 줄이면 조건 평가 비용도 같이 줄어든다.

실수 3: Update Mode가 Animate Physics인데 입력이 늦게 반응

증상: 키를 눌러도 전환이 한 박자 늦고, 특히 Fixed Timestep이 0.02가 아닌 프로젝트에서 더 심해진다. 30fps 환경에서 공격이 더 늦다.

원인: Animator Update Mode가 Animate Physics면 애니메이션 평가가 FixedUpdate 리듬에 맞춰 돈다. Update에서 SetTrigger를 넣어도 전환 평가는 다음 FixedUpdate에서 일어난다.

해결: 캐릭터 시각 애니메이션은 Normal 업데이트를 기본으로 두고, 물리 기반 리깅이 정말 필요할 때만 Animate Physics를 쓴다. 물리와 맞춰야 한다면 입력 버퍼링(최근 입력을 몇 프레임 저장)으로 체감 지연을 줄인다.

실수 4: 레이어 가중치 0 또는 AvatarMask 설정 누락

증상: 트리거도 조건도 맞는데 상체 공격이 안 보인다. Animator 파라미터는 변하고 상태도 바뀌는데, 모델 포즈는 그대로다.

원인: 상체 레이어의 Weight가 0이거나 AvatarMask가 비어 있어서 레이어 결과가 최종 포즈에 합성되지 않는다. 엔진은 레이어별 포즈를 계산한 뒤 가중치로 합성한다.

해결: Animator Inspector에서 Layers 탭의 해당 레이어 Weight를 1로 올리고, AvatarMask에 상체 본이 포함돼 있는지 확인한다. 디버깅 때는 잠깐 마스크를 제거해 레이어 포즈가 나오는지부터 확인한다.

실수 5: Write Defaults 혼용으로 상태 전환 후 값이 남음

증상: 공격 상태에서만 켠 이펙트가 Idle로 돌아와도 계속 켜져 있거나, 반대로 계속 꺼져 있다. 특정 상태를 한 번 지나간 뒤부터만 문제가 생긴다.

원인: 어떤 상태는 Write Defaults가 켜져 있고, 어떤 상태는 꺼져 있어 프로퍼티 기본값/기록 방식이 섞인다. 엔진이 상태가 쓰는 프로퍼티를 어떻게 초기화할지 일관성이 깨진다.

해결: 컨트롤러 전체에서 Write Defaults 정책을 하나로 통일한다. 토글성 값(Renderer.enabled, GameObject active)은 애니메이션 커브보다 코드/이벤트로 제어해 상태 간 잔상을 없앤다.

WriteDefaultsDebugToggle.cs
1using UnityEngine;
2
3public class WriteDefaultsDebugToggle : MonoBehaviour
4{
5    [SerializeField] private GameObject weapon;
6    [SerializeField] private Animator animator;
7
8    private void Awake()
9    {
10        if (!animator) animator = GetComponent<Animator>();
11        if (!weapon) Debug.LogWarning("Weapon reference missing");
12    }
13
14    public void SetWeaponVisible(int visible)
15    {
16        if (!weapon) return;
17        weapon.SetActive(visible != 0);
18        Debug.Log($"Weapon active={weapon.activeSelf}");
19    }
20}

애니메이션 이벤트로 SetWeaponVisible(1/0)을 호출하면, 어떤 상태에서 무기 표시를 바꾸는지 코드 레벨에서 로그로 남는다. 상태가 값을 ‘몰래’ 덮어쓰는 상황이 줄어든다. 토글을 커브로 처리할 때보다 디버깅이 빠른 편이다.

성능 최적화 체크리스트

  • Animator 파라미터 이름 문자열 대신 Animator.StringToHash를 static readonly로 캐싱한다(문자열 해시/탐색 비용 제거).
  • Any State 전환 개수를 3~5개 이하로 제한하고, 공통 전환은 서브 스테이트 머신으로 묶는다(조건 평가 분기 감소).
  • 공격/회피 같은 즉시 반응 전환은 Has Exit Time을 끄고, 종료 전환(Attack→Idle)에만 Exit Time을 둔다(입력 씹힘 감소).
  • Transition Duration을 의미 있게 짧게 잡는다(0.05~0.15). 0.25 이상이면 입력 연타 시 ‘전환 중 전환 불가’가 체감된다.
  • Interruption Source와 Ordered Interruption을 팀 규칙으로 통일한다. 같은 컨트롤러 안에서 섞이면 재현이 어려운 버그가 나온다.
  • Animator Update Mode가 Animate Physics인지 확인한다. 입력 지연이나 Fixed Timestep 변경 시 체감이 크게 변한다.
  • 레이어를 쓰면 Layer Weight와 AvatarMask를 먼저 점검한다. 상태/파라미터가 정상인데 시각 결과가 없으면 1순위 원인이다.
  • Profiler에서 CPU Usage 타임라인으로 Animator.Update 비용을 확인한다. 캐릭터 수를 늘려가며 60fps(16.6ms)에서 여유를 계산한다.
  • GC Alloc 컬럼을 켜고, Update에서 GetCurrentAnimatorStateInfo/IsName 문자열 비교를 남발하지 않는지 본다(문자열/박싱 유발).
  • Animator.Rebind/RuntimeAnimatorController 교체는 런타임에서 빈번히 하지 않는다(그래프 재초기화로 스파이크 가능).
  • Write Defaults 정책을 컨트롤러 전체에서 통일한다. 토글성 값은 이벤트/코드로 명시적으로 제어한다.
  • 입력 버퍼링(최근 0.1초 입력 저장)을 넣어 FixedUpdate/프레임 드랍 상황에서도 트리거 유실을 줄인다.

자주 묻는 질문

Animator에서 State가 바뀌는 건 언제 확정되나? Update에서 SetTrigger 하면 그 프레임에 바로 바뀌나?

SetTrigger/SetBool/SetFloat 호출은 C# 쪽 변수 변경이 아니라, 네이티브 Animator 인스턴스의 파라미터 테이블에 값을 기록하는 요청이다. 상태 전환의 ‘확정’은 애니메이션 시스템이 그래프를 평가하는 단계에서 일어난다. 보통은 Script Update 이후 Animation Update에서 조건을 검사하고, 전환이 성립하면 블렌딩을 시작한다. 그래서 Update에서 트리거를 던지면 같은 프레임에 반영되는 경우가 많지만, Update Mode가 Animate Physics면 FixedUpdate 리듬에서 평가되어 다음 물리 틱까지 지연된다. 다음 학습 키워드는 PlayerLoop, Animator Update Mode, Fixed Timestep이다.

Has Exit Time을 켜면 정확히 무엇이 달라지나? 트리거가 있어도 왜 바로 안 넘어가나?

Has Exit Time은 ‘조건을 무시하고 무조건 나간다’가 아니다. 전환이 허용되는 시점을 현재 상태의 normalized time 기준으로 제한하는 옵션이다. 예를 들어 Exit Time이 0.9면 현재 상태가 90% 재생되기 전에는 전환 조건이 만족돼도 전환을 시작하지 못한다. 트리거는 평가 시점에 소비되는 이벤트성 값이라, 허용 창 밖에서 들어오면 다음 평가까지 대기하면서 체감상 씹힌 것처럼 보인다. 해결은 진입 전환에서는 Exit Time을 끄고, 종료 전환에만 Exit Time을 쓰는 식으로 역할을 분리하는 것이다. 다음 학습 키워드는 normalizedTime, transition window, trigger consumption이다.

Any State를 쓰면 왜 디버깅이 어려워지나?

Any State 전환은 현재 상태와 무관하게 항상 후보로 올라간다. 엔진은 현재 상태의 전환들 + Any State 전환들을 함께 검사하고, 조건이 겹치면 먼저 만족한 전환이 잡힌다. 전환이 많아질수록 “왜 이 프레임에 저기로 갔지?”의 원인이 조건 충돌, 전환 우선순위, 인터럽트 설정으로 분산된다. 특히 Speed 같은 연속값과 Bool/Trigger가 섞이면 프레임 경계에서 조건이 동시에 참이 되는 순간이 생긴다. 실무에서는 죽음/강제 피격 같은 정말 전역적인 것만 Any State로 두고, 나머지는 상태군 내부 전환으로 제한해 재현성을 확보한다. 다음 학습 키워드는 transition priority, interruption, sub-state machine이다.

Animator.SetFloat("Speed", ...)처럼 문자열로 써도 되는데, 왜 해시 캐싱을 강하게 권하나?

문자열 기반 API는 호출할 때마다 파라미터 이름을 찾아야 한다. 내부적으로는 문자열을 해시로 바꾸거나, 이름 테이블에서 인덱스를 찾는 과정이 들어간다. 한 번 호출은 미미하지만, 캐릭터 수가 늘면 누적된다. 예를 들어 200개 캐릭터가 매 프레임 Speed/Turn/IsGrounded 3개만 문자열로 세팅해도 600회/프레임이고, 여기에 상태 정보 조회까지 더해지면 CPU가 애니메이션 쪽에서 꾸준히 새는 형태가 된다. Animator.StringToHash로 int를 캐싱하면 경계 호출 자체는 남아도 이름 탐색 비용이 줄어든다. 다음 학습 키워드는 Profiler Timeline, icall, string hashing이다.

Transition Duration을 길게 주면 왜 입력이 ‘먹통’처럼 느껴지나?

전환 중에는 ‘현재 상태’와 ‘다음 상태’가 블렌딩되고, 동시에 다른 전환이 끼어들 수 있는지는 Interruption Source 설정에 의해 제한된다. Duration이 0.25~0.4처럼 길면 그만큼 전환 상태가 오래 유지되고, Interruption Source가 None이면 새 트리거가 와도 전환을 끊고 들어오지 못한다. 사용자는 버튼을 눌렀는데 반응이 없는 것처럼 느낀다. 해결은 UX 목표를 먼저 정하는 것이다. 연타를 받으려면 인터럽트를 허용하고, 한 번 시작하면 끝까지라면 전환 Duration을 짧게 하거나 Any State 진입을 제한해 입력 창을 명확히 만든다. 다음 학습 키워드는 interruption source, ordered interruption, combo buffering이다.

레이어를 추가했더니 상태는 바뀌는데 애니메이션이 안 보인다. 왜 파라미터는 정상인데 결과가 없나?

Animator 레이어는 ‘별도의 포즈 계산 결과’를 만든 뒤 최종 포즈에 합성한다. 합성 비율이 Layer Weight다. Weight가 0이면 해당 레이어는 계산돼도 최종 결과에 기여하지 않는다. AvatarMask가 비어 있으면 적용할 본이 없어서 결과가 사실상 무시된다. 이때 파라미터 값과 상태 머신은 정상적으로 움직이니, 디버깅이 더 헷갈린다. 해결 순서는 Weight를 1로 올리고, 마스크를 잠깐 제거해 전체 본에 적용되는지 확인한 뒤, 다시 상체/하체로 범위를 좁히는 방식이 빠르다. 다음 학습 키워드는 layer blending, avatar mask, additive layer이다.

Write Defaults는 왜 문제를 만들고, 팀에서는 어떻게 다루는 게 안전한가?

Write Defaults는 상태가 활성화될 때 ‘그 상태가 애니메이션으로 쓰는 프로퍼티 외의 것’을 기본값으로 써버리는 동작과 엮여 예기치 않은 덮어쓰기를 만든다. 특히 어떤 상태는 켜져 있고 어떤 상태는 꺼져 있는 혼용 상황에서, 특정 상태를 한 번 거치면 Renderer.enabled나 IK 관련 값이 돌아오지 않는 현상이 나온다. 팀에서 안전하게 다루려면 컨트롤러 전체 정책을 하나로 통일하고, 토글성 값은 애니메이션 커브가 아니라 이벤트/코드로 명시 제어하는 편이 재현성과 리뷰가 좋다. 이미 커브로 많이 썼다면, 상태별로 어떤 프로퍼티를 쓰는지 목록화하고, 문제가 나는 프로퍼티부터 코드로 빼는 식으로 단계적으로 정리한다. 다음 학습 키워드는 write defaults, property binding, animation curves vs events이다.

관련 글

13. Rigidbody 질량·중력·드래그로 물리 움직임 만들기와 엔진 내부 흐름

13. Rigidbody 질량·중력·드래그로 물리 움직임 만들기와 엔진 내부 흐름

Unity Rigidbody의 mass, useGravity, drag 설정이 FixedUpdate에서 네이티브 물리로 어떻게 반영되는지, 성능·GC·설계 이유까지 함께 설명한다(초보자용). 154자 내외 구성. 154자 내외 구성. 154자 내외 구성.

3. Unity Raycast로 클릭·터치 입력 감지 구현과 엔진 내부 흐름

3. Unity Raycast로 클릭·터치 입력 감지 구현과 엔진 내부 흐름

Unity Raycast로 클릭·터치 입력을 감지하는 방법과 C#→네이티브 물리 쿼리 흐름, PlayerLoop 타이밍, GC/성능 함정을 함께 다룬다. 레이어 마스크와 UI 충돌도 포함한다. 수치 기반 최적화 기준 제공한다. 실습 코드 포함한다. 모바일 대응 포함.

29. Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기

29. Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기

Unity Prefab Variant를 엔진 직렬화·오버라이드 관점에서 설명하고, 캐릭터/아이템 파생 프리팹을 공통 설정 유지하며 운영하는 실습과 성능 함정을 다룬다. 2022 LTS 기준 실무 패턴 포함.