24. Unity Profiler 창 구성요소와 기본 용어를 엔진 관점에서 이해하기
Unity Profiler의 모듈, Timeline, CPU/GPU/Memory 용어를 엔진 Player Loop·네이티브 바인딩·GC 관점에서 연결해 읽는 법을 설명한다. 초보도 병목을 재현·측정한다. 1프레임 예산도 잡는다. 60fps 기준 포함.
Unity Profiler 창 구성요소와 기본 용어를 엔진 관점에서 이해하기
게임 만들다 보면 ‘움직임은 단순한데 프레임이 갑자기 20fps로 떨어지는’ 사고가 터진다. 콘솔에는 에러도 없고, 씬에는 오브젝트 몇 개뿐인데도 Game 뷰가 뚝뚝 끊긴다. 이때 Profiler 창을 열면 그래프와 숫자가 쏟아지지만, 용어를 모르면 ‘어디가 문제인지’가 아니라 ‘무엇을 보고 있는지’부터 막힌다. Profiler은 엔진이 프레임을 어떻게 쪼개 처리하는지 그대로 드러내는 창이다. 용어를 Player Loop와 메모리 모델에 붙여 읽으면, 병목을 재현하고 고칠 수 있다.
핵심 개념
Profiler은 ‘느리다’라는 감각을 ‘프레임 시간(ms)’로 바꾸는 도구다. 60fps 목표면 1프레임 예산이 16.66ms이고, 30fps면 33.33ms다. Profiler 창의 모든 그래프는 결국 “이번 프레임에서 누가 예산을 얼마나 썼는가”를 보여준다. 초보가 흔히 하는 실수는 모듈 이름을 기능 설명으로만 외우는 것이다. 모듈은 Player Loop의 특정 구간과 네이티브 서브시스템(렌더러, 물리, 애니메이션, 스크립팅 VM)에 매핑된다.
용어 1) CPU Usage: 메인 스레드와 워커 스레드에서 소비한 CPU 시간을 합쳐 보여준다. Unity의 스크립트 메시지(Update 등)는 메인 스레드에서 호출되므로, 스파이크가 ‘Scripts’ 아래에 보이면 C#에서 원인을 찾을 확률이 높다. 반대로 Rendering, Physics가 튀면 C# 호출이 ‘트리거’였을 뿐 실제 비용은 네이티브에서 발생했을 수 있다.
용어 2) Timeline/Hierarchy: 같은 데이터를 두 관점으로 보여준다. Timeline은 시간축에서 스레드별로 어떤 마커가 언제 실행됐는지 보여주고, Hierarchy는 “어떤 호출이 자식을 얼마나 포함해 시간을 썼는지(포함 시간)”로 정렬한다. 스파이크 분석은 Timeline으로 ‘언제/어느 스레드’를 잡고, Hierarchy로 ‘무엇이 지배적인가’를 파고드는 흐름이 빠르다.
용어 3) GC Alloc: C#에서 관리 힙(Managed Heap)에 새 객체/배열/박싱 등이 발생한 바이트 수다. GC Alloc 자체는 ‘할당’이고, 프레임 드랍은 보통 그 다음 단계인 GC.Collect(마킹/스윕)에서 터진다. Profiler에서 GC Alloc이 꾸준히 쌓이면, 몇 초 뒤 GC 스파이크가 따라오는 패턴을 자주 본다.
용어 4) Self/Total(또는 Inclusive): 한 마커의 자기 비용(Self)과 자식 호출을 포함한 비용(Total)을 구분한다. 예를 들어 Update의 Total이 5ms인데 Self가 0.1ms면, Update 자체가 느린 게 아니라 Update 안에서 호출한 무언가가 시간을 먹고 있다는 뜻이다.
용어 5) Deep Profile: C# 함수 단위로 더 촘촘한 샘플링을 켜는 옵션이다. 내부적으로는 IL2CPP/Mono 쪽의 계측 포인트가 늘어나고 호출 오버헤드가 커진다. Deep Profile을 켠 상태에서 얻은 ms는 ‘실제보다 느리게 측정된 값’일 수 있으니, “어디를 의심할지”를 좁힐 때만 쓰고 끄는 습관이 필요하다.
1using UnityEngine;
2
3public class ProfilerWarmupScene : MonoBehaviour
4{
5 [SerializeField] private int spawnCount = 2000;
6 [SerializeField] private bool allocateGarbage = true;
7
8 private void Start()
9 {
10 for (int i = 0; i < spawnCount; i++)
11 {
12 var go = new GameObject("Dummy_" + i);
13 go.transform.position = new Vector3(i % 50, 0, i / 50);
14 }
15
16 if (allocateGarbage)
17 {
18 // 프레임마다 GC Alloc 스파이크를 만들기 위한 재료
19 InvokeRepeating(nameof(Allocate), 0.2f, 0.2f);
20 }
21 }
22
23 private void Allocate()
24 {
25 // string + boxing + 배열 할당
26 object boxed = Time.frameCount;
27 var arr = new byte[256 * 1024];
28 Debug.Log("Alloc tick " + boxed + " " + arr.Length);
29 }
30}이 스크립트를 실행하면 Console에 “Alloc tick …” 로그가 0.2초마다 찍히고, Profiler의 CPU Usage에서 Scripts/GC.Alloc이 보이기 시작한다. 왜 이런 재현이 필요하냐면, Profiler 용어는 ‘현상’과 붙어야 기억이 남기 때문이다. GC Alloc이 쌓이면 어느 순간 GC.Collect 스파이크가 따라오고, 그 프레임에서 16.66ms 예산을 초과하는지 눈으로 확인할 수 있다.
엔진 관점에서의 내부 동작
Profiler 데이터는 C#만의 기록이 아니다. Unity는 네이티브(C++) 런타임에서 프레임마다 여러 서브시스템을 실행하고, 그 경계에 프로파일링 마커(Profiler Marker)를 박아 샘플을 수집한다. C# 스크립트는 이 네이티브 루프 안에서 ‘스크립팅 단계’에 호출된다. 그래서 Profiler을 읽는 핵심은 “이 마커가 Player Loop의 어느 단계에 속하는가”다.
Player Loop 관점에서 자주 만나는 큰 덩어리는 EarlyUpdate, FixedUpdate, Update, PreLateUpdate, PostLateUpdate다. FixedUpdate는 물리 시뮬레이션(PhysX)이 고정 시간 스텝으로 돌기 때문에 분리돼 있다. 프레임이 느려지면 FixedUpdate가 한 프레임에 여러 번 실행되는 ‘catch-up’이 발생할 수 있고, Profiler Timeline에서 Physics.Simulate가 연속으로 여러 번 찍히는 패턴이 나온다.
C# → 네이티브 경계는 생각보다 자주 넘나든다. 예를 들어 Transform.position을 읽고 쓰는 것만으로도 내부적으로는 C# 래퍼(Transform)가 네이티브 Transform 컴포넌트 핸들을 통해 값을 가져오거나 설정한다. 이때 마샬링과 스레드 안전 체크, 변경 플래그(Dirty flag) 갱신이 얹힌다. Profiler에서 Scripts가 아니라 Transform/Animation/Rendering 쪽이 튀는 이유가 여기서 나온다.
Memory 모듈의 ‘Managed Heap’과 ‘Native’가 나뉘는 이유도 같은 맥락이다. Managed Heap은 Mono/IL2CPP 런타임이 관리하는 C# 객체 메모리이고, Native는 엔진의 C++ 할당(텍스처, 메시, 컴포넌트, 씬 오브젝트 등)이다. C#에서 Texture2D를 생성하면 C# 객체 껍데기(Managed)와 실제 픽셀 버퍼(Native)가 함께 생긴다. GC로 C# 객체가 정리돼도 Native 리소스가 즉시 반환되지 않는 케이스가 있어, Memory 모듈에서 둘을 분리해 본다.
CPU Usage 모듈에서 ‘EditorLoop’와 ‘PlayerLoop’를 구분해서 보는 이유도 있다. 에디터에서는 Inspector, SceneView, AssetDatabase 같은 편의 기능이 매 프레임 일을 한다. Play Mode에서 느리면 원인이 게임 코드인지, 에디터 오버헤드인지부터 분리해야 한다. Profiler 상단의 Target(Play Mode/Editor/Player)을 바꿔 보는 습관이 여기서 나온다.
처음에 나도 Profiler Timeline을 켜고 ‘Scripts가 3ms면 괜찮네’라고 착각한 적이 있다. 3시간 삽질 끝에 알아낸 건, 진짜 병목은 Scripts가 호출한 Camera.Render 트리거였고, 비용은 Rendering의 C++ 파이프라인에서 발생했다는 점이다. Hierarchy에서 Scripts 아래만 파고 있으면 놓친다. Timeline에서 메인 스레드의 큰 덩어리(예: RenderLoop.Draw)를 먼저 잡고 들어가야 한다.
1using UnityEngine;
2
3public class PlayerLoopOrderProbe : MonoBehaviour
4{
5 private void Awake() => Debug.Log("Awake frame=" + Time.frameCount);
6 private void OnEnable() => Debug.Log("OnEnable frame=" + Time.frameCount);
7 private void Start() => Debug.Log("Start frame=" + Time.frameCount);
8
9 private void FixedUpdate() => Debug.Log("FixedUpdate frame=" + Time.frameCount);
10 private void Update() => Debug.Log("Update frame=" + Time.frameCount);
11 private void LateUpdate() => Debug.Log("LateUpdate frame=" + Time.frameCount);
12
13 private void OnDisable() => Debug.Log("OnDisable frame=" + Time.frameCount);
14 private void OnDestroy() => Debug.Log("OnDestroy frame=" + Time.frameCount);
15}이 스크립트를 빈 GameObject에 붙이고 Play하면 Console 로그가 메시지 호출 순서를 그대로 보여준다. Profiler에서 CPU Usage를 켜고 같은 프레임을 클릭하면, Update/LateUpdate가 PlayerLoop의 어디에 배치되는지 감이 잡힌다. 왜 순서를 굳이 눈으로 확인하냐면, Profiler에서 보이는 마커 이름이 ‘내가 작성한 함수명’이 아니라 ‘엔진 단계명’인 경우가 많아서다. 순서를 머릿속에 갖고 있어야 Timeline에서 “이 구간이 물리인지 렌더링 준비인지”를 빠르게 분류한다.
1using UnityEngine;
2using UnityEngine.Profiling;
3
4public class ProfilerMarkerProbe : MonoBehaviour
5{
6 private static readonly ProfilerMarker Marker = new ProfilerMarker("Demo/HeavyWork");
7 [SerializeField] private int iterations = 200000;
8
9 private void Update()
10 {
11 using (Marker.Auto())
12 {
13 float acc = 0f;
14 for (int i = 0; i < iterations; i++)
15 acc += Mathf.Sqrt(i);
16
17 if (acc < 0) Debug.Log(acc);
18 }
19 }
20}Play 후 Profiler의 CPU Usage에서 Hierarchy를 열면 “Demo/HeavyWork”가 항목으로 잡힌다. 이 마커는 C#에서 시작하지만, Profiler 시스템 자체는 네이티브에 있고, C# 래퍼(ProfilerMarker)가 네이티브 이벤트 스트림에 샘플을 푸시한다. 그래서 마커를 많이 박으면 그 자체가 비용이 된다. 실제 프로젝트에서 마커는 ‘핵심 루프의 경계’에만 두고, 문제를 재현한 뒤 짧게 켜는 패턴이 안전하다.
실습하기
1단계: 프로젝트와 Profiler 타깃 세팅
Unity Hub → New Project → 3D(Core) 템플릿을 선택한다. 버전은 LTS(예: 2022.3.x 또는 2023.2+ LTS 계열)를 권장한다. 프로젝트가 열리면 Window → Analysis → Profiler를 클릭해 Profiler 창을 띄운다.
Profiler 상단에서 ‘Active Profiler’를 확인한다. 에디터에서만 측정하면 EditorLoop 오버헤드가 섞인다. File → Build Settings → PC, Mac & Linux Standalone 선택 → Add Open Scenes → Development Build 체크 → Autoconnect Profiler 체크 → Build And Run을 누르면 Player 타깃으로 측정 가능하다. 처음에는 에디터에서 재현하고, 원인이 좁혀지면 Player로 옮기는 흐름이 빠르다.
2단계: 씬 구성과 재현 장치 만들기
Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만들고 이름을 “ProfilerLab”로 바꾼다. Inspector에서 Add Component를 눌러 스크립트를 붙일 준비를 한다. 씬은 최대한 단순해야 그래프 해석이 쉬워진다.
Hierarchy 우클릭 → 3D Object → Plane을 추가한다. Main Camera는 그대로 둔다. Lighting 때문에 GPU가 흔들리면 분석이 흐려지므로, Window → Rendering → Lighting에서 Auto Generate를 끄고(체크 해제) 고정된 상태로 둔다. 이 상태에서 CPU 스파이크와 GC 스파이크를 의도적으로 만든다.
1using UnityEngine;
2
3public class ProfilerLabSpawner : MonoBehaviour
4{
5 [SerializeField] private int cubeCount = 5000;
6 [SerializeField] private bool randomizeMaterials = true;
7
8 private void Start()
9 {
10 for (int i = 0; i < cubeCount; i++)
11 {
12 var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
13 cube.name = "Cube_" + i;
14 cube.transform.position = new Vector3(i % 100, 0.5f, i / 100);
15
16 if (randomizeMaterials)
17 {
18 var r = cube.GetComponent<Renderer>();
19 r.material.color = new Color((i % 10) / 10f, (i % 20) / 20f, (i % 30) / 30f);
20 }
21 }
22 }
23}이 코드를 ProfilerLab 오브젝트에 붙이고 Inspector에서 Cube Count를 5000으로 둔다. Play하면 Scene/Game 뷰에 큐브가 격자로 깔린다. Profiler의 Rendering 모듈에서 Batches/SetPass Calls가 늘고, CPU Usage에서는 RenderLoop 쪽이 두꺼워지는 패턴이 나온다. 왜 material.color를 건드리냐면, Renderer.material은 내부적으로 머티리얼 인스턴스를 복제할 수 있어(SharedMaterial과 다름) Native 메모리와 드로우콜에 영향을 준다. Memory 모듈에서도 변화를 같이 본다.
1using System.Collections;
2using UnityEngine;
3
4public class ProfilerLabGC : MonoBehaviour
5{
6 [SerializeField] private float interval = 0.1f;
7
8 private void OnEnable()
9 {
10 StartCoroutine(AllocLoop());
11 }
12
13 private IEnumerator AllocLoop()
14 {
15 while (true)
16 {
17 // yield return null은 PlayerLoop의 다음 Update로 제어를 돌린다.
18 // 이 루프가 프레임을 독점하지 않게 만드는 장치다.
19 var tmp = new string('x', 1024);
20 Debug.Log(tmp + Time.frameCount);
21 yield return new WaitForSeconds(interval);
22 }
23 }
24}이 코드를 ProfilerLab에 추가로 붙이고 Interval을 0.1로 둔다. Play하면 0.1초마다 로그가 나오고, CPU Usage에서 Debug.Log와 string 관련 GC Alloc이 잡힌다. 왜 Coroutine이 yield를 요구하냐면, 코루틴은 별도 스레드가 아니라 PlayerLoop에서 ‘IEnumerator를 한 스텝씩 진행’하는 스케줄러이기 때문이다. yield return null은 다음 프레임 Update 타이밍으로 넘기는 의미라서, Profiler Timeline에서 해당 작업이 프레임 경계에 걸쳐 분산되는 모습을 볼 수 있다.
3단계: Profiler 창에서 ‘같은 프레임’을 제대로 읽기
Profiler 창에서 CPU Usage 모듈을 선택하고, 상단의 Record 버튼(빨간 원)이 켜져 있는지 확인한다. 그래프에서 튀는 프레임(스파이크)을 클릭한다. 그 다음 하단 뷰를 Timeline으로 바꾸고 Main Thread 트랙을 펼친다. “Scripts → BehaviourUpdate” 근처에서 길게 늘어진 구간이 보이면 C#이 트리거일 가능성이 크다.
같은 프레임을 Memory 모듈에서도 클릭한다. GC Alloc이 튀는 프레임이면, CPU Usage에서 GC.Collect가 같이 튀는지 확인한다. 둘이 같은 프레임에 같이 터지지 않을 수도 있다. 할당은 이전 프레임들에서 누적되고, 컬렉션은 임계치에 도달한 시점에 발생하기 때문이다. 이 지점에서 ‘원인 프레임(할당)’과 ‘증상 프레임(수집)’을 분리해 보는 감각이 생긴다.
심화 활용
패턴 1: 스파이크 프레임 고정(Freeze) + 원인 프레임 역추적
실무에서는 스파이크가 ‘가끔’ 터진다. 이때 Record를 계속 켜두면 데이터가 밀려서 찾기 어려워진다. Profiler 그래프에서 스파이크를 클릭한 뒤, 상단의 Freeze(또는 선택 프레임 고정 기능)를 사용하면 해당 프레임의 Timeline/Hierarchy가 유지된다. 그 프레임을 기준으로 앞뒤 프레임을 한 칸씩 이동하면서 “GC Alloc이 누적된 프레임”을 찾는 식으로 역추적한다.
1using System.Collections.Generic;
2using UnityEngine;
3
4public class SpikeReproPoolVsNew : MonoBehaviour
5{
6 [SerializeField] private int perFrame = 200;
7 private readonly Queue<GameObject> pool = new Queue<GameObject>(2000);
8
9 private void Start()
10 {
11 for (int i = 0; i < 2000; i++)
12 {
13 var go = GameObject.CreatePrimitive(PrimitiveType.Sphere);
14 go.SetActive(false);
15 pool.Enqueue(go);
16 }
17 }
18
19 private void Update()
20 {
21 for (int i = 0; i < perFrame; i++)
22 {
23 var go = pool.Dequeue();
24 go.SetActive(true);
25 go.transform.position = Random.insideUnitSphere * 10f;
26 go.SetActive(false);
27 pool.Enqueue(go);
28 }
29 }
30}이 코드는 매 프레임 활성/비활성을 반복하지만, New로 생성하지 않아서 GC Alloc과 Native 오브젝트 생성 비용이 거의 없다. 같은 로직을 Instantiate/Destroy로 바꾸면 CPU Usage에서 Object.Instantiate, Destroy, 그리고 메모리 쪽에서 Native allocation 변동이 늘어난다. Profiler에서 “스파이크가 줄었다” 같은 감상 대신, ‘Instantiate 관련 마커가 사라졌는지’로 확인하는 흐름이 안정적이다.
패턴 2: Rendering 병목과 Scripts 병목을 분리하는 읽기 순서
Rendering 병목은 C# 코드가 직접 느려서가 아니라, C#이 렌더 상태를 바꾸는 호출을 많이 만들어서 생기는 경우가 많다. 예를 들어 매 프레임 Renderer.material을 건드리면 머티리얼 인스턴싱이 늘고, SetPass Calls가 올라간다. 이때 CPU Usage에서 Scripts가 아니라 RenderLoop 쪽이 커진다. 그래서 먼저 Rendering 모듈에서 Batches/SetPass Calls/Tris를 보고, 그 다음 CPU Timeline에서 RenderLoop.Draw가 두꺼운 프레임을 클릭하는 순서가 빠르다.
1using UnityEngine;
2
3public class MaterialTouchCost : MonoBehaviour
4{
5 [SerializeField] private Renderer target;
6 [SerializeField] private bool touchMaterialEveryFrame = true;
7
8 private void Reset()
9 {
10 target = GetComponent<Renderer>();
11 }
12
13 private void Update()
14 {
15 if (target == null) return;
16
17 if (touchMaterialEveryFrame)
18 {
19 // Renderer.material은 필요 시 인스턴스를 만든다.
20 // Profiler에서 Rendering/Memory 변화를 같이 확인한다.
21 target.material.SetFloat("_Glossiness", Mathf.PingPong(Time.time, 1f));
22 }
23 }
24}Hierarchy 우클릭 → 3D Object → Cube를 하나 더 만들고, 이 스크립트를 붙인 뒤 Target에 Cube의 Renderer를 드래그한다. Play하면 겉보기 변화는 미미하지만 Profiler의 Rendering 통계가 흔들릴 수 있다. 왜냐하면 material 접근이 네이티브 리소스 생성/상태 변경으로 이어질 수 있기 때문이다. 이 케이스는 ‘스크립트 한 줄이 렌더 파이프라인 비용을 만든다’는 연결을 체감시키는 데 좋다.
처음에 나도 Memory 모듈에서 ‘Managed가 안정적이니 괜찮다’고 판단했다가, Native가 계속 증가하는 누수를 놓친 적이 있다. 증상은 10분 플레이 후 프레임이 서서히 떨어지고, 결국 에디터가 멈추는 형태였다. Profiler에서 GC Alloc이 없으니 안심했는데, 원인은 Texture2D를 매번 새로 만들고 Destroy를 안 하던 코드였다.
그때 콘솔에는 에러 대신 경고가 간헐적으로 섞였다: “A Native Collection has not been disposed, resulting in a memory leak” 같은 문구가 아니라, 더 교묘하게는 아무 메시지도 없었다. 3시간 동안 Timeline만 들여다보다가, Memory 모듈의 ‘Native Objects’ 카운트가 꾸준히 오르는 걸 보고 방향이 바뀌었다. 해결은 생성 빈도를 줄이고, 수명이 끝나는 시점에 Destroy를 명시하거나 풀링으로 바꾸는 방식이었다.
한 문단 요약: Profiler은 ‘C# 코드가 느리다/빠르다’를 판정하는 창이 아니라, Player Loop 단계별로 네이티브·매니지드 비용이 어디서 생겼는지 연결해 추적하는 창이다. Timeline으로 스레드/시점을 잡고, Hierarchy로 지배적인 호출을 찾고, Memory/Rendering 통계로 재발을 막는다.
자주 하는 실수
실수 1: Deep Profile 켠 상태의 ms를 그대로 성능 목표로 잡음
증상: Deep Profile을 켠 순간 CPU Usage가 2~10배로 늘고, Update가 8ms처럼 보인다. 최적화를 했는데도 수치가 잘 안 내려가서 방향을 잃는다.
원인: Deep Profile은 함수 단위 계측을 위해 호출 경계에 추가 비용을 넣는다. 특히 작은 함수가 많이 호출되는 코드(수학, 접근자, LINQ 대체 코드)에서 계측 오버헤드가 지배적이 된다.
해결: Deep Profile은 ‘의심 구간 찾기’용으로만 쓰고, 최종 판단은 일반 프로파일(Deep Profile 해제) + Player 빌드(Development + Autoconnect)에서 한다. 동일 장면에서 두 모드의 비율이 크게 차이나면, 계측 오버헤드가 크다는 신호다.
실수 2: Editor 타깃만 보고 Player 성능을 추정함
증상: 에디터에서는 25fps인데 빌드하면 60fps가 나오거나, 반대로 에디터에서는 멀쩡한데 모바일 빌드에서만 끊긴다. Profiler에서 EditorLoop가 두껍게 보이는데도 무시한다.
원인: 에디터는 SceneView 렌더링, Inspector 리플렉션, AssetDatabase, 도메인 리로드 등 게임과 무관한 비용이 섞인다. 반대로 Player는 플랫폼 드라이버/GPU 타이밍/해상도 영향이 커진다.
해결: Build Settings에서 Development Build와 Autoconnect Profiler로 Player를 붙여 측정한다. 모바일이면 USB 디바이스 프로파일링을 붙이고, 해상도/품질 설정을 고정한 뒤 비교한다.
실수 3: GC Alloc 스파이크만 보고 GC.Collect 프레임을 놓침
증상: Memory 모듈에서 GC Alloc이 0B인 프레임도 있는데, CPU Usage에서 갑자기 GC.Collect가 10ms 튄다. “왜 할당도 없는데 GC가 도나”라는 의문이 생긴다.
원인: 할당은 이전 프레임들에서 누적되고, 컬렉션은 임계치에 도달한 프레임에 실행된다. 또, 일부 할당은 다른 스레드/서브시스템에서 발생해 ‘증상 프레임’과 ‘원인 프레임’이 어긋난다.
해결: 스파이크 프레임만 보지 말고, 그 직전 수십 프레임의 GC Alloc 추이를 같이 본다. Profiler에서 프레임을 앞뒤로 이동하며 “Alloc 누적 → Collect” 패턴을 찾고, 원인 프레임의 호출 스택(특히 Debug.Log, string, LINQ, boxing)을 제거한다.
실수 4: Hierarchy의 Total만 보고 Self를 무시함
증상: BehaviourUpdate가 6ms라서 Update 자체를 줄이려 하지만 변화가 없다. 실제로는 자식 호출(예: Physics.Raycast, Camera.Render 트리거)이 시간을 먹는다.
원인: Total은 자식 비용을 포함한다. Self가 작은데 Total만 큰 경우, ‘부모 마커 이름’은 단지 컨테이너다. 컨테이너를 줄이려 해도 자식이 그대로면 수치가 유지된다.
해결: Hierarchy에서 Self 컬럼으로도 정렬해 본다. Total이 큰 노드를 펼쳐 가장 무거운 자식을 찾고, 그 자식이 네이티브 호출인지(C#에서 트리거만 한 것인지) 구분한다.
실수 5: Rendering 모듈 통계를 안 보고 CPU만 최적화함
증상: CPU Usage에서 Scripts를 1ms 줄였는데도 fps가 그대로다. 특히 카메라가 많은 씬, 투명 오브젝트가 많은 씬에서 흔하다.
원인: 병목이 GPU 또는 렌더 상태 변경(SetPass Calls), 오버드로우, 그림자 캐스팅에 있을 수 있다. CPU는 남는데 GPU가 꽉 차면 프레임은 GPU 타이밍에 묶인다.
해결: Rendering 모듈에서 Batches/SetPass Calls/Tris/Vtx를 먼저 확인하고, GPU Usage(가능한 플랫폼) 또는 Frame Debugger(Window → Analysis → Frame Debugger)로 드로우콜 원인을 좁힌다. 머티리얼 인스턴싱(Renderer.material)과 라이트/그림자 설정이 대표 원인이다.
1using UnityEngine;
2
3public class BoxingAllocExample : MonoBehaviour
4{
5 private void Update()
6 {
7 // object로 받는 순간 boxing이 발생할 수 있다.
8 object boxed = Time.frameCount;
9 Debug.Log("Frame=" + boxed);
10
11 // 문자열 결합은 임시 string을 만든다.
12 // Profiler에서 GC Alloc이 계속 뜨는지 확인한다.
13 string msg = "t=" + Time.time + ", dt=" + Time.deltaTime;
14 Debug.Log(msg);
15 }
16}이 코드를 잠깐 붙이면 Profiler에서 GC Alloc이 매 프레임 발생하는 패턴을 눈으로 확인할 수 있다. 실무에서는 Debug.Log가 원인이 되는 일이 정말 많다. 개발 중엔 괜찮다가, QA 빌드에서 Development + Script Debugging을 켠 상태로 로그가 폭발하면 프레임이 무너진다. 로그는 조건부로 제한하고, 문자열 생성이 많은 코드는 프로파일링 중에만 켜는 식으로 분리한다.
성능 최적화 체크리스트
- 목표 fps를 먼저 숫자로 고정한다(60fps=16.66ms, 30fps=33.33ms). 스파이크는 ‘예산 초과 프레임’으로 정의한다.
- Profiler Target을 Editor와 Player로 나눠 측정한다(Build Settings → Development Build + Autoconnect Profiler). EditorLoop가 두꺼우면 에디터 오버헤드가 섞인 상태다.
- 스파이크 프레임을 클릭한 뒤 CPU Usage에서 Timeline과 Hierarchy를 번갈아 본다(Timeline=언제/어느 스레드, Hierarchy=무엇이 지배적인가).
- CPU Usage에서 Self와 Total을 둘 다 본다. Total만 크고 Self가 작으면 ‘컨테이너’일 가능성이 높다.
- GC Alloc이 보이면 같은 프레임의 GC.Collect 유무를 확인하고, 앞 프레임들의 Alloc 누적 추이를 같이 본다(원인 프레임과 증상 프레임 분리).
- Deep Profile은 범인 찾기용으로만 짧게 켠다. Deep Profile ON/OFF에서 ms 비율이 급변하면 계측 오버헤드가 크다.
- Rendering 모듈에서 Batches/SetPass Calls/Tris/Vtx를 같이 본다. CPU 최적화 후에도 fps가 그대로면 GPU/렌더 병목을 의심한다.
- Renderer.material 접근을 점검한다. 매 프레임 접근은 머티리얼 인스턴싱과 SetPass Calls 증가로 이어질 수 있다(sharedMaterial과 구분).
- Instantiate/Destroy가 스파이크를 만드는지 확인한다. 가능하면 풀링으로 바꾸고, Profiler에서 Object.Instantiate/Destroy 마커가 사라졌는지로 검증한다.
- Update에서 GetComponent/Find 계열 호출을 점검한다. 캐싱 후 Profiler에서 Scripts 비용이 줄었는지 확인한다(호출 횟수 기반).
- FixedUpdate가 한 프레임에 여러 번 도는지 확인한다(Time.fixedDeltaTime, Physics.Simulate 구간). 물리 catch-up은 프레임을 더 느리게 만든다.
- Memory 모듈에서 Managed와 Native를 둘 다 본다. Managed가 안정적이어도 Native가 증가하면 텍스처/메시/머티리얼 누수 가능성이 있다.
자주 묻는 질문
Profiler의 CPU Usage에서 Scripts가 낮은데도 게임이 끊기는 이유가 무엇인가?
Scripts가 낮다는 건 C# 코드의 실행 시간이 짧다는 뜻일 뿐, 프레임이 빠르다는 뜻이 아니다. Unity 프레임은 C#이 끝난 뒤에도 네이티브 렌더 파이프라인(RenderLoop), 물리(Physics.Simulate), 애니메이션, 잡 시스템 등이 이어서 돈다. C#이 Camera 설정을 바꾸거나 머티리얼을 건드리면, 실제 비용은 C++ 렌더러에서 발생하고 CPU Usage에서 Rendering 쪽 마커가 두꺼워진다. 또 GPU가 병목이면 CPU가 대기하면서 프레임이 GPU 타이밍에 묶인다. 다음 학습 키워드는 Rendering 모듈의 Batches/SetPass Calls, Frame Debugger, GPU Usage(플랫폼별)이다.
Timeline 뷰와 Hierarchy 뷰는 언제 무엇을 먼저 봐야 하나?
스파이크 분석은 Timeline이 먼저다. Timeline은 스레드별로 어떤 마커가 언제 실행됐는지 보여주므로 “메인 스레드가 막혔는지”, “워커 스레드가 바쁜지”, “Render thread가 길게 늘어졌는지”를 한눈에 잡는다. 그 다음 Hierarchy로 넘어가 Total 기준 정렬을 하면 ‘지배적인 호출’이 위로 올라온다. Hierarchy에서 Total이 큰 노드는 컨테이너일 수 있으니 Self 정렬도 같이 보고, 가장 무거운 자식 마커까지 펼쳐야 한다. 다음 학습 키워드는 Main Thread vs Render Thread, Self/Total, Marker 트리 구조다.
GC Alloc이 0B인데도 GC.Collect가 뜨는 프레임이 있다. 왜 그런가?
GC Alloc은 ‘해당 프레임에 새로 할당된 바이트’이고, GC.Collect는 ‘누적된 할당을 회수하는 작업’이다. Unity/Mono(또는 IL2CPP의 관리 힙 모델)에서 컬렉션은 매 프레임 즉시 실행되지 않고, 힙이 특정 임계치에 도달하거나 내부 정책에 따라 실행된다. 그래서 원인 프레임(할당이 쌓인 프레임)과 증상 프레임(수집이 실행된 프레임)이 어긋난다. 또한 다른 스레드나 엔진 내부에서 발생한 관리 힙 활동이 뒤늦게 컬렉션을 유발할 수도 있다. 다음 학습 키워드는 Managed Heap, GC 세대(개념), Allocation 패턴(문자열/박싱/LINQ)이다.
Deep Profile은 왜 느려지고, 언제 켜야 하나?
Deep Profile은 C# 메서드 단위로 더 많은 샘플을 수집하려고 함수 진입/탈출 경계에 계측을 촘촘히 넣는다. 이 계측은 호출 오버헤드와 캐시 미스 가능성을 늘리고, 특히 작은 함수가 수만 번 호출되는 프레임에서 측정 자체가 병목이 된다. 그래서 Deep Profile에서 보이는 ms는 ‘실제 실행 시간 + 계측 비용’이다. 켜는 타이밍은 범인을 좁힐 때다. 예를 들어 Scripts 아래에서 Total이 큰데 자식이 뭉뚱그려 보이면 Deep Profile로 함수 단위까지 내려가고, 원인이 잡히면 즉시 끈 뒤 일반 모드/Player 빌드에서 다시 수치를 확인한다. 다음 학습 키워드는 Profiling 오버헤드, 샘플링 vs 계측, Player 측정이다.
Profiler의 Memory 모듈에서 Managed와 Native가 따로 있는 이유는 무엇인가?
Unity 오브젝트는 C# 객체만으로 끝나지 않는다. 예를 들어 Texture2D, Mesh, Material 같은 리소스는 C# 래퍼(Managed)와 실제 데이터 버퍼(Native)가 함께 존재한다. C# 참조가 끊겨도 Native 리소스가 즉시 해제되지 않거나, 반대로 Native는 남아 있는데 Managed는 정리되는 타이밍 차가 생길 수 있다. 그래서 Memory 모듈은 Managed Heap(가비지 컬렉터가 관리)과 Native(엔진이 직접 할당/해제)로 분리해서 보여준다. Native가 계속 증가하면 리소스 생성/해제 정책(Instantiate/Destroy, 머티리얼 인스턴싱, 텍스처 생성)이 의심 대상이다. 다음 학습 키워드는 Resources.UnloadUnusedAssets, Addressables 메모리, Native Object 수명이다.
Update와 FixedUpdate가 분리돼 있는 게 Profiler에서는 어떻게 보이나?
FixedUpdate는 물리 시뮬레이션이 ‘고정 시간 간격’으로 안정적으로 돌기 위해 분리돼 있다. 렌더 프레임은 16.66ms로 일정하지 않을 수 있지만, 물리는 일정한 dt로 적분해야 흔들림이 줄어든다. Profiler Timeline에서는 FixedUpdate 구간에 Physics.Simulate(또는 관련 마커)가 나타나고, 프레임이 느려지면 한 프레임 안에 FixedUpdate가 여러 번 실행되는 패턴이 보인다(물리 catch-up). 이때 체감 끊김은 Scripts가 아니라 Physics 누적으로 발생할 수 있다. 해결은 fixedDeltaTime 조정, 물리 연산량 감소(콜라이더 수/레이캐스트 빈도), 최대 물리 스텝 제한 점검이다. 다음 학습 키워드는 fixedDeltaTime, catch-up, Physics 비용 측정이다.
Profiler에서 ‘이 정도면 괜찮다’를 어떻게 숫자로 판단하나?
판단 기준은 목표 fps의 프레임 예산이다. 60fps면 16.66ms 안에 CPU+GPU가 모두 들어와야 하고, 120fps면 8.33ms다. Profiler에서 평균이 15ms여도 스파이크가 40ms면 체감은 ‘끊김’이다. 그래서 평균보다 99퍼센타일(거의 모든 프레임) 관점이 중요하다. 실무에서는 (1) 스파이크 프레임을 먼저 제거해 ‘최악 프레임’을 낮추고, (2) 그 다음 평균을 줄여 여유 시간을 만든다. 또한 Editor 수치로 확정하지 말고 Player 빌드에서 동일 장면, 동일 해상도, 동일 품질로 측정해야 한다. 다음 학습 키워드는 프레임 예산, 스파이크/퍼센타일, Player 프로파일링 워크플로다.