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

25. Unity Profiler 창 구성과 Deep Profile·Call Stacks 설정 이유

Unity Profiler 창 구성 요소와 Deep Profile, Call Stacks를 엔진 호출 흐름 관점에서 설명한다. 느려진 프레임의 원인을 스택과 샘플링으로 추적한다. GC Alloc까지 연결한다. 60fps 기준으로 판단한다. 실습 포함.

25. Unity Profiler 창 구성과 Deep Profile·Call Stacks 설정 이유

Unity Profiler 창 구성과 Deep Profile·Call Stacks 설정 이유

게임 만들다 겪는 사고 중 제일 흔한 게 ‘어제는 60fps였는데 오늘은 20fps’다. 콘솔에는 에러가 없고, 씬도 그대로인데 Play를 누르는 순간 프레임이 무너진다. 이때 Profiler 창을 열어도 숫자만 잔뜩 보이고 어디를 눌러야 할지 막힌다. Deep Profile과 Call Stacks를 켜면 원인이 보이긴 하는데, 왜 켜는 순간 더 느려지고, 왜 어떤 함수는 스택이 안 나오고, 왜 Editor에서만 튀는지도 같이 따라온다. 이 글은 그 ‘왜’부터 잡는다.

핵심 개념

Profiler는 ‘느린 코드 찾기’ 도구가 아니라 ‘프레임 타임을 구성하는 샘플의 트리’를 수집하는 계측기다. Unity는 PlayerLoop의 각 단계에서 네이티브(C++)와 매니지드(C#) 경계를 오가며 일을 쪼개서 처리한다. Profiler 창은 그 조각들을 모듈 단위로 보여준다. 그래서 창 구성을 이해하지 못하면, 숫자를 봐도 원인을 못 잡는다.

Deep Profile은 매니지드 함수 호출을 더 촘촘히 계측한다. 촘촘하다는 말은 ‘함수 진입/이탈마다 샘플을 남긴다’는 뜻이고, 그 샘플을 남기는 행위 자체가 오버헤드다. Deep Profile을 켠 상태에서 프레임이 더 느려지는 건 버그가 아니라, 계측이 일을 더 시키기 때문이다. 그래서 Deep Profile은 “원인을 좁히는 단계”에서만 켠다.

Call Stacks는 특정 샘플이 어떤 경로로 호출됐는지 스택을 남긴다. C# 스택은 상대적으로 얻기 쉬운 편이지만, 네이티브 스택은 플랫폼/심볼/빌드 옵션에 따라 비어 있을 수 있다. 스택이 없으면 ‘누가 불렀는지’가 끊기고, 같은 샘플이 여러 경로에서 발생할 때 분리가 안 된다.

CPU Usage 모듈의 Timeline과 Hierarchy는 같은 데이터를 다른 관점으로 본다. Timeline은 “언제”를 본다. 특정 프레임에서 Main Thread가 막혔는지, Render Thread가 밀렸는지, Job Worker가 바쁜지 시간축으로 확인한다. Hierarchy는 “무엇이” 시간을 먹는지를 트리로 본다. Deep Profile/Call Stacks는 이 두 화면의 해상도를 올리는 옵션이다.

용어를 5개만 고정한다. Sample(샘플)은 Profiler가 기록한 한 조각의 작업 단위다. Marker(마커)는 샘플의 이름표이며, Unity 내장 마커와 사용자 마커가 있다. Self Time은 해당 노드 내부에서만 사용한 시간이고, Total Time은 자식까지 포함한 시간이다. GC Alloc은 매니지드 힙에 새 객체/배열/박싱이 생겨 할당된 바이트다. PlayerLoop는 Unity가 프레임마다 실행하는 고정된 단계 묶음이며 스크립트 메시지(Update 등)는 그 일부다.

ProfilerMarkerBasics.cs
1using UnityEngine;
2using UnityEngine.Profiling;
3
4public class ProfilerMarkerBasics : MonoBehaviour
5{
6    private static readonly ProfilerMarker Marker = new ProfilerMarker("Demo/SpinWork");
7
8    private void Update()
9    {
10        using (Marker.Auto())
11        {
12            transform.Rotate(0f, 180f * Time.deltaTime, 0f);
13        }
14    }
15}

이 스크립트를 붙이고 Profiler의 CPU Usage에서 Timeline을 보면 “Demo/SpinWork” 샘플이 Update 구간에 생긴다. 사용자 마커는 C#에서 네이티브로 “샘플 시작/종료” 호출이 들어가며, Profiler가 켜져 있을 때만 기록된다. 이게 중요한 이유는, 내장 마커만으로는 ‘내 코드의 어느 구간’인지 분리가 안 되는 상황이 자주 나오기 때문이다.

엔진 관점에서의 내부 동작

Unity에서 C# 스크립트는 곧바로 CPU에서 실행되는 게 아니라, 엔진이 만든 PlayerLoop 단계에서 호출된다. 대략적인 흐름은 Initialization → EarlyUpdate → FixedUpdate → PreUpdate → Update → PreLateUpdate → PostLateUpdate 순서로 이어지고, 그 안에 ScriptRunBehaviourUpdate 같은 서브 단계가 있다. Profiler의 CPU Usage는 이 단계들을 네이티브 마커로 끊어서 기록한다.

C# → C++ 경계는 대부분 internal call(또는 icall) 형태의 바인딩으로 넘어간다. 예를 들어 Transform.Rotate는 C# 메서드 호출처럼 보이지만, 실제로는 네이티브 Transform 컴포넌트의 상태를 바꾸는 호출로 연결된다. Profiler에서 “BehaviourUpdate” 아래에 “Transform.Rotate”가 보이거나, 어떤 경우엔 “Transform::Rotate” 같은 네이티브 마커로 보이는 이유가 이 경계 때문이다.

Deep Profile이 하는 일은 매니지드 함수 단위로 더 많은 샘플을 남기는 것이다. 일반 모드에서는 Unity가 선택한 일부 지점(내장 마커, 사용자 마커, 특정 엔진 호출)만 기록한다. Deep Profile은 IL 레벨에서 함수 진입/이탈을 계측하거나(버전에 따라 구현은 다르지만 결과적으로) 더 많은 샘플을 생성한다. 샘플이 많아지면 기록 버퍼가 더 빨리 차고, 기록/전송/정렬 비용이 프레임 타임에 섞인다.

Call Stacks는 샘플마다 ‘누가 호출했는지’를 저장해야 한다. 매니지드 스택은 런타임이 스택 프레임 정보를 가지고 있어서 비교적 수월하지만, 네이티브 스택은 디버그 심볼과 프레임 포인터/언와인딩 정보가 필요하다. 그래서 Editor에서는 어느 정도 나오다가도, Player 빌드에서는 Development Build + Script Debugging + 심볼 설정이 없으면 비어 있는 경우가 많다.

메모리 관점에서도 Deep Profile/Call Stacks는 비용이 있다. 샘플 이름, 스택 프레임, 스레드 타임라인 이벤트를 저장하는 버퍼가 늘어난다. 이 버퍼는 네이티브 쪽에 있고, Profiler 창이 데이터를 받아 화면에 그리면서 추가 비용이 생긴다. 그래서 “Profiler를 켜면 더 느려진다”는 현상은 자연스럽다. 측정 대상(게임)과 측정 도구(Profiler)가 같은 머신에서 경쟁하기 때문이다.

처음에 나도 Deep Profile을 켠 채로 최적화를 하다가 시간을 날린 적이 있다. 프레임이 35ms까지 튀어서 “진짜 병목이 여기인가?”라고 착각했는데, Deep Profile 오버헤드가 10ms 이상 섞여 있었다. 3시간 삽질 끝에 깨달은 건, Deep Profile은 ‘절대값’이 아니라 ‘상대 비교’와 ‘호출 경로 확인’에 쓰는 옵션이라는 점이다. 같은 조건에서 켜고 끄면서 차이를 봐야 한다.

PlayerLoopOrderProbe.cs
1using UnityEngine;
2
3public class PlayerLoopOrderProbe : MonoBehaviour
4{
5    private void Awake() => Debug.Log("Awake");
6    private void OnEnable() => Debug.Log("OnEnable");
7    private void Start() => Debug.Log("Start");
8    private void FixedUpdate() => Debug.Log("FixedUpdate");
9    private void Update() => Debug.Log("Update");
10    private void LateUpdate() => Debug.Log("LateUpdate");
11}

Play 모드에서 Console을 보면 Awake → OnEnable → Start 이후 프레임마다 FixedUpdate/Update/LateUpdate 로그가 찍힌다. Profiler Timeline에서는 이 호출이 ScriptRunBehaviourFixedUpdate, ScriptRunBehaviourUpdate, ScriptRunBehaviourLateUpdate 구간에 묶여 나타난다. 로그는 “순서”만 보여주고, Profiler는 “프레임 타임 안에서 어디에 걸렸는지”를 보여준다. 둘을 같이 보면 호출이 PlayerLoop 어느 단계에 매핑되는지 감이 생긴다.

GcAllocProbe.cs
1using System.Collections.Generic;
2using UnityEngine;
3
4public class GcAllocProbe : MonoBehaviour
5{
6    private readonly List<string> _list = new List<string>();
7
8    private void Update()
9    {
10        _list.Add(Time.frameCount.ToString());
11        if (_list.Count > 1000) _list.Clear();
12    }
13}

이 코드를 실행하면 CPU Usage의 GC Alloc 컬럼에서 Update 프레임마다 할당이 생긴다. Time.frameCount.ToString()이 문자열을 새로 만들기 때문이다. Deep Profile을 켜면 “어느 함수에서 할당했는지”가 더 잘게 쪼개져 보이지만, 동시에 프레임이 더 느려진다. GC Alloc 원인 추적은 Deep Profile이 유용한 편이지만, 측정은 짧게 끊어서 해야 한다.

한 문단 요약: Profiler 창은 PlayerLoop 단계에서 기록된 샘플을 모듈로 보여준다. Deep Profile은 매니지드 호출을 더 촘촘히 기록해서 원인 추적을 돕지만 오버헤드를 만든다. Call Stacks는 “누가 불렀나”를 연결해주지만 심볼/빌드 설정이 없으면 비어 있을 수 있다.

실습하기

1단계: 프로젝트와 Profiler 기본 연결

Unity Hub → New project → 3D(Core) 선택 후 생성한다. 버전은 2022.3 LTS 이상이면 화면 구성이 안정적이다. 씬은 기본 SampleScene 그대로 둔다. 성능 분석은 ‘재현’이 핵심이라, 처음엔 씬을 단순하게 유지하는 편이 원인 분리에 유리하다.

Window → Analysis → Profiler 클릭으로 Profiler 창을 연다. 상단의 Active Profiler가 “Editor”로 되어 있는지 확인한다. Play 버튼을 누르면 CPU Usage 모듈에 프레임 그래프가 흐른다. 이 상태는 Editor 자체 비용(Inspector, SceneView, 도메인 리로드 등)이 섞이므로, 나중에 Player로도 한 번 더 확인한다.

ProfilerSceneSetup.cs
1using UnityEngine;
2
3public class ProfilerSceneSetup : MonoBehaviour
4{
5    [SerializeField] private int spawnCount = 200;
6
7    private void Start()
8    {
9        for (int i = 0; i < spawnCount; i++)
10        {
11            var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
12            go.name = "Cube_" + i;
13            go.transform.position = new Vector3(i % 20, 0f, i / 20);
14            go.AddComponent<ProfilerMarkerBasics>();
15        }
16    }
17}

Hierarchy 우클릭 → Create Empty로 GameObject를 만들고 이름을 “Bootstrap”으로 바꾼다. Inspector → Add Component로 ProfilerSceneSetup을 추가하고 Spawn Count를 200으로 둔다. Play를 누르면 Game 뷰에 큐브 격자가 생기고, 모든 큐브가 회전한다. Profiler Timeline에서 ScriptRunBehaviourUpdate가 두꺼워지고, “Demo/SpinWork” 샘플이 여러 번 반복되는 게 보인다.

2단계: Profiler 창 구성(Modules, Timeline, Hierarchy)로 병목 위치 고정

Profiler 왼쪽 Modules에서 CPU Usage를 선택한다. 상단 드롭다운을 Timeline으로 두고, 오른쪽 상단의 톱니(⚙) 또는 모듈 옵션에서 Thread를 Main Thread로 고정한다. 여기서 목표는 “어느 스레드가 느린지”를 먼저 가르는 것이다. Main Thread가 꽉 차 있으면 스크립트/씬/물리/애니메이션이 의심 대상이고, Render Thread가 차 있으면 드로우콜/쉐이더/동기화가 의심 대상이다.

Timeline에서 프레임 하나를 클릭하면 아래에 선택된 프레임의 샘플이 시간축으로 펼쳐진다. 여기서 “EditorLoop”나 “Inspector” 같은 Editor 전용 샘플이 두껍게 보이면, Game 성능이 아니라 Editor UI 비용이 섞인 상황이다. Scene 뷰를 닫거나(탭 우클릭 → Close Tab), Game 뷰만 남기고 다시 측정하면 수치가 달라진다.

GetComponentHotPath.cs
1using UnityEngine;
2
3public class GetComponentHotPath : MonoBehaviour
4{
5    private void Update()
6    {
7        // 의도적으로 나쁜 패턴: 매 프레임 컴포넌트 탐색
8        var t = GetComponent<Transform>();
9        t.Rotate(0f, 90f * Time.deltaTime, 0f);
10    }
11}

Cube 하나를 클릭 → Inspector에서 GetComponentHotPath를 추가한다. Play를 누른 뒤 Profiler CPU Usage의 Hierarchy로 전환하면, 해당 오브젝트의 Update 아래에 GetComponent 관련 샘플이 보인다. 이 호출은 네이티브 쪽 컴포넌트 리스트를 순회하거나 타입 체크를 수행하고, C# 래퍼로 결과를 넘긴다. 프레임당 1회는 티가 안 나도, 200개 오브젝트가 하면 200회가 되고, 60fps 기준 16.6ms 예산에서 0.5ms만 먹어도 체감이 온다.

3단계: Deep Profile과 Call Stacks 적용 후 ‘누가 불렀는지’까지 추적

Profiler 상단 툴바에서 Deep Profile 토글을 켠다(버전에 따라 톱니 메뉴 안에 있다). 그리고 CPU Usage 모듈 옵션에서 Call Stacks(또는 Callstack) 기록을 켠다. 이 상태에서 Play를 누르면 프레임이 눈에 띄게 느려질 수 있다. 느려진 프레임 자체를 최적화 대상으로 착각하면 방향이 틀어진다. 목표는 “호출 경로”를 얻는 것이다.

Hierarchy에서 의심 샘플을 클릭한 뒤, 하단 Details 패널에서 Call Stack 탭을 본다. C# 스택이 보이면 “어느 MonoBehaviour의 어느 메서드가 이 엔진 호출을 유발했는지”가 연결된다. 스택이 비어 있으면 Player 빌드에서 심볼이 없거나, 해당 샘플이 네이티브만 기록되고 매니지드 프레임이 끼지 않은 경우다. 그때는 사용자 마커(ProfilerMarker)로 경계를 직접 만든다.

DeepProfileTarget.cs
1using UnityEngine;
2using UnityEngine.Profiling;
3
4public class DeepProfileTarget : MonoBehaviour
5{
6    private static readonly ProfilerMarker Marker = new ProfilerMarker("Demo/AllocAndSearch");
7
8    private void Update()
9    {
10        using (Marker.Auto())
11        {
12            var s = Time.frameCount.ToString();
13            var t = GetComponent<Transform>();
14            if (s.Length > 0) t.Rotate(0f, 30f * Time.deltaTime, 0f);
15        }
16    }
17}

Cube 하나에 DeepProfileTarget을 추가한다. Deep Profile + Call Stacks 상태에서 “Demo/AllocAndSearch” 샘플을 찍고 들어가면, 문자열 할당(ToString)과 GetComponent 호출이 같은 마커 안에서 발생한 사실이 한 덩어리로 보인다. 이 덩어리는 ‘내 코드가 만든 문제 구간’이므로, 엔진 내장 샘플이 난잡하게 섞여도 추적이 쉬워진다.

심화 활용

패턴 1: Deep Profile은 10초만 켜고, 원인 후보를 샘플 이름으로 고정한다

실무에서 Deep Profile을 계속 켜두면 ‘측정 때문에 느려진 게임’을 최적화하는 꼴이 된다. 그래서 루틴을 정한다. 1) 일반 모드에서 느린 프레임을 고른다. 2) 샘플 이름 후보 3개를 적는다(예: BehaviourUpdate, Physics.Simulate, GC.Collect). 3) Deep Profile을 켜고 5~10초만 재현한다. 4) Call Stack으로 호출자를 고정하고 바로 끈다.

이 방식이 통하는 이유는, Deep Profile의 가치가 ‘정밀한 호출자 정보’에 있고, 장시간 기록은 데이터 양과 오버헤드만 키우기 때문이다. Profiler는 기록 버퍼가 커질수록 전송과 UI 갱신 비용이 늘어나며, Editor에서는 그 비용이 게임 프레임에 섞인다.

ProfileGate.cs
1using UnityEngine;
2using UnityEngine.Profiling;
3
4public class ProfileGate : MonoBehaviour
5{
6    private static readonly ProfilerMarker Marker = new ProfilerMarker("Demo/OnlyWhenHot");
7    [SerializeField] private bool enableExpensivePath;
8
9    private void Update()
10    {
11        if (!enableExpensivePath) return;
12        using (Marker.Auto())
13        {
14            for (int i = 0; i < 2000; i++)
15                transform.Rotate(0f, 0.01f, 0f);
16        }
17    }
18}

Inspector에서 Enable Expensive Path를 껐다 켰다 하면서 Profiler 그래프가 어떻게 변하는지 확인한다. 특정 기능 토글로 프레임이 튀면, 그 기능의 샘플 이름이 고정된다. 실무에서 옵션 메뉴/이펙트 토글/디버그 플래그를 넣는 이유가 ‘재현 가능한 스위치’를 만들기 위해서인 경우가 많다.

패턴 2: Editor 측정과 Player 측정을 분리하고, Call Stacks는 Player에서 최종 확인한다

Editor에서 Profiler를 보면 SceneView 렌더링, Inspector 리페인트, 도메인 리로드 같은 비용이 섞인다. 그래서 “Editor에서만 느린 문제”와 “Player에서도 느린 문제”를 먼저 나눈다. File → Build Settings → Add Open Scenes로 씬을 추가하고, Development Build 체크, Autoconnect Profiler 체크 후 Build And Run을 누른다.

Player에서 Autoconnect가 붙으면 Active Profiler가 Editor에서 Player로 바뀐다. 이 상태의 CPU Usage는 Editor UI 비용이 빠진 값이라 의사결정이 쉬워진다. Call Stacks가 네이티브에서 비면, 심볼이 없는 상태일 가능성이 높다. Windows라면 PDB, Android라면 libunity.so 심볼 처리, iOS라면 dSYM 같은 개념이 엮인다.

EditorVsPlayerHint.cs
1using UnityEngine;
2
3public class EditorVsPlayerHint : MonoBehaviour
4{
5    private void Start()
6    {
7        Debug.Log($"isEditor={Application.isEditor}, platform={Application.platform}");
8        Debug.Log($"devBuild={Debug.isDebugBuild}");
9    }
10}

처음에 나도 “Editor에서 5ms인데 왜 기기에서 18ms지?” 때문에 하루를 날린 적이 있다. Profiler로 보면 기기에서는 Render Thread가 막혀 있었고, Editor에서는 SceneView가 병목이라 완전히 다른 그림이었다. 콘솔에는 아무것도 없고, 체감만 느린 상황에서, Autoconnect Profiler로 Player 데이터를 붙이니 원인이 즉시 바뀌었다. 그때 남은 힌트는 Call Stack이 아니라 스레드 분포였다.

또 한 번은 Call Stacks가 비어 있어서 “Profiler가 고장났다”라고 생각했다. 실제 원인은 Development Build를 끄고 릴리즈 빌드로 실행한 상태였다. Console에는 “Call stacks are not available in non-development builds” 비슷한 경고가 찍혔는데, 로그 필터에 묻혀서 못 봤다. Build Settings의 체크박스 하나가 ‘왜 추적이 안 되나’의 답이 되는 상황이 자주 나온다.

자주 하는 실수

실수 1: Deep Profile 켠 상태의 ms를 그대로 성능 목표로 잡는다

증상: Deep Profile을 켜는 순간 프레임이 16ms → 35ms로 튄다. Hierarchy에서 Update가 비정상적으로 커지고, ‘내 코드가 망가졌다’는 느낌이 든다.

원인: Deep Profile은 함수 단위 계측을 늘려 샘플 생성/저장/전송 비용이 프레임에 섞인다. 측정 도구가 부하를 만든다. 특히 Editor에서 Profiler UI가 실시간으로 데이터를 처리하면서 Main Thread 시간을 더 먹는다.

해결: 일반 모드에서 병목 프레임을 먼저 고르고, Deep Profile은 5~10초만 켠다. Deep Profile에서는 절대값보다 호출 경로와 상대 비율을 본다. 같은 조건에서 토글 전후를 비교하고, 최종 목표 ms는 Player(Development Build)에서 잡는다.

실수 2: Call Stacks가 비어 있는데도 스택을 찾느라 시간을 쓴다

증상: CPU Usage에서 샘플을 클릭해도 Call Stack 탭이 비어 있다. 어떤 샘플은 나오고 어떤 샘플은 안 나온다. 원인 추적이 끊긴다.

원인: Development Build가 아니거나 Script Debugging/심볼이 없는 상태일 수 있다. 네이티브 스택은 심볼 없으면 함수명이 주소로만 나오거나 아예 수집이 안 된다. 또 어떤 샘플은 네이티브에서만 기록되어 매니지드 프레임이 스택에 끼지 않는다.

해결: File → Build Settings에서 Development Build와 Autoconnect Profiler를 켠다. Player에서 Call Stacks를 재확인한다. 그래도 비면 사용자 ProfilerMarker로 경계를 만들고, 그 마커 기준으로 Hierarchy를 파고든다.

실수 3: Timeline을 안 보고 Hierarchy만 보고 최적화한다

증상: Hierarchy에서 가장 큰 샘플만 줄이려고 한다. 줄였는데도 프레임 드랍이 계속된다. 특정 순간에만 끊기는 현상이 남는다.

원인: 끊김은 ‘평균’이 아니라 ‘스파이크’다. 스파이크는 특정 프레임의 특정 시점(예: RenderThread 동기화, Asset 로드, GC.Collect)에서 발생한다. Hierarchy는 평균적 누적을 보기 쉬워서 스파이크 타이밍을 놓친다.

해결: Timeline에서 스파이크 프레임을 먼저 클릭하고, 그 프레임의 Main Thread/Render Thread/Job Worker 분포를 본다. 그 다음 해당 프레임에서만 커진 샘플을 선택해 Hierarchy로 내려간다. “언제”와 “무엇”을 분리해야 한다.

실수 4: Editor에서만 발생하는 비용을 게임 코드 문제로 착각한다

증상: Profiler에 EditorLoop, Inspector, SceneView.Repaint 같은 샘플이 커 보인다. Play 중에만 느리고, 빌드하면 괜찮거나 반대로 기기에서만 느리다.

원인: Editor는 게임 실행 외에도 편집기 UI, 리페인트, 에셋 임포트, 도메인 리로드 같은 작업을 한다. Profiler가 Editor를 대상으로 붙어 있으면 이 비용이 게임 프레임에 섞여 들어간다.

해결: Player(Development Build + Autoconnect Profiler)로 데이터를 한 번 더 본다. Editor 측정은 “원인 후보”를 좁히는 용도로 쓰고, 최종 판단은 Player에서 한다. SceneView 탭을 닫고 GameView만 남겨 Editor 비용을 줄인 뒤 재측정한다.

실수 5: GC Alloc 컬럼을 안 켜고 ‘갑자기 끊김’을 잡으려 한다

증상: 1~2초에 한 번씩 뚝 끊긴다. CPU Usage의 Total Time은 평소와 비슷한데, 특정 프레임만 튄다. 체감은 심각하다.

원인: 매 프레임 작은 할당이 누적되다가 특정 시점에 GC.Collect가 실행되면 스파이크가 생긴다. 문자열 생성, LINQ, 박싱, foreach(구조체 아닌 컬렉션) 같은 패턴이 흔한 원인이다. Deep Profile 없이도 GC Alloc 컬럼만 켜도 단서가 나온다.

해결: Profiler CPU Usage에서 GC Alloc 컬럼을 보이게 하고, 스파이크 프레임에서 GC.Collect/GC.Mark 같은 샘플을 찾는다. 할당이 발생한 Update를 찾으면 Call Stacks나 Deep Profile로 호출자를 좁힌다. 할당 제거 후 스파이크가 사라지는지 Timeline에서 확인한다.

GcAllocFixExample.cs
1using System.Text;
2using UnityEngine;
3
4public class GcAllocFixExample : MonoBehaviour
5{
6    private readonly StringBuilder _sb = new StringBuilder(64);
7
8    private void Update()
9    {
10        _sb.Clear();
11        _sb.Append("Frame=").Append(Time.frameCount);
12        if ((_sb.Length & 1) == 0) transform.Rotate(0f, 10f * Time.deltaTime, 0f);
13    }
14}

이 코드는 매 프레임 문자열을 새로 만들지 않고 StringBuilder 버퍼를 재사용한다. Profiler에서 GC Alloc이 0으로 떨어지는 걸 확인할 수 있다. 숫자가 0이 되면, 스파이크 원인이 GC였다면 Timeline에서 GC 관련 샘플이 사라지거나 빈도가 줄어든다.

성능 최적화 체크리스트

  • CPU Usage 모듈에서 Timeline과 Hierarchy를 번갈아 사용하며 ‘언제(스파이크 프레임)’와 ‘무엇(샘플 트리)’를 분리했다
  • Active Profiler가 Editor인지 Player인지 확인하고, 최종 판단은 Development Build Player 데이터로 했다
  • Deep Profile은 원인 후보가 좁혀진 뒤 5~10초만 켜고, 켠 상태의 ms를 목표치로 쓰지 않았다
  • CPU Usage에서 Main Thread/Render Thread/Job Worker 스레드를 각각 확인해 병목 스레드를 먼저 고정했다
  • Profiler에서 GC Alloc 컬럼을 켜고, 스파이크 프레임에 GC.Collect 계열 샘플이 있는지 확인했다
  • Call Stacks가 비면 Development Build, Script Debugging, 심볼(플랫폼별) 조건을 점검했다
  • 내 코드 구간을 분리하기 위해 ProfilerMarker(또는 CustomSampler)를 최소 2곳에 심었다
  • Update에서 GetComponent/Find/Camera.main 같은 탐색성 API 호출 횟수를 프레임당 수치로 적어봤다
  • FixedUpdate에서만 돌아야 하는 물리 로직과 Update에서 돌아야 하는 입력/카메라 로직을 분리했다
  • Instantiate/Destroy가 스파이크를 만들면 오브젝트 풀링으로 교체하고 Profiler에서 스파이크 감소를 확인했다
  • 레이어/레이어마스크 기반 충돌 필터링을 적용해 Physics.Simulate 비용을 줄였는지 확인했다
  • Editor 측정 시 SceneView 탭을 닫거나 Gizmos를 꺼서 편집기 렌더링 비용을 분리했다

자주 묻는 질문

Deep Profile을 켜면 왜 게임이 더 느려지나?

Deep Profile은 “느린 코드를 더 정확히 보여주는 모드”가 아니라 “더 많은 샘플을 기록하는 모드”다. 함수 진입/이탈 같은 더 촘촘한 지점에서 샘플을 남기면, 샘플 생성과 버퍼 기록 비용이 프레임 타임에 섞인다. Editor에서는 Profiler UI가 그 데이터를 실시간으로 정렬·렌더링하니 오버헤드가 더 커진다. 그래서 Deep Profile은 장시간 측정용이 아니라, 원인 후보를 좁힌 뒤 짧게 켜서 호출 경로를 고정하는 용도다. 다음 학습 키워드는 ProfilerMarker, CPU Timeline vs Hierarchy, 측정 오버헤드다.

Call Stacks가 비어 있는 이유가 뭔가?

Call Stacks는 ‘스택 프레임 정보’를 수집해야 해서 조건이 붙는다. 매니지드(C#)는 비교적 잘 나오지만, Player 빌드에서는 Development Build가 아니면 스택 수집이 제한될 수 있다. 네이티브(C++) 스택은 심볼(PDB/dSYM/so 심볼)과 언와인딩 정보가 없으면 함수명이 해석되지 않거나 수집 자체가 안 될 수 있다. 또 어떤 Profiler 샘플은 네이티브에서만 기록되어 매니지드 프레임이 스택에 끼지 않는다. 해결책은 Development Build + Autoconnect Profiler로 Player에서 재확인하고, 그래도 끊기면 ProfilerMarker로 내 코드 경계를 만들어 스택이 없어도 추적 가능한 기준점을 만드는 것이다. 다음 키워드는 Development Build, 심볼, native callstack이다.

Editor에서 Profiler로 본 수치를 그대로 믿어도 되나?

Editor 수치는 ‘원인 후보를 찾는 지도’로는 유용하지만, 최종 수치로는 위험하다. Editor는 SceneView 렌더링, Inspector 리페인트, 에셋 데이터베이스 작업, 도메인 리로드 등 게임과 무관한 비용을 함께 돌린다. Profiler가 Editor에 붙어 있으면 이 비용이 같은 머신의 Main Thread 시간을 공유하면서 게임 프레임에 섞인다. 그래서 Editor에서 10ms였던 것이 Player에서는 4ms가 되거나, 반대로 기기에서는 Render Thread 동기화 때문에 18ms가 되는 식으로 그림이 바뀐다. 실무에서는 Development Build Player로 한 번 더 측정해 “스레드 분포(Main/Render/Job)”가 같은지 확인한 뒤 최적화 우선순위를 정한다. 다음 키워드는 Autoconnect Profiler, EditorLoop, Player profiling이다.

Timeline과 Hierarchy 중 무엇을 먼저 봐야 하나?

끊김이 문제면 Timeline이 먼저다. 끊김은 평균이 아니라 특정 프레임의 스파이크이기 때문에, Timeline에서 튄 프레임을 클릭하고 그 프레임의 Main Thread/Render Thread/Job Worker가 어떤 순서로 막혔는지 본다. 그 다음 Hierarchy로 내려가 ‘그 프레임에서만 커진 샘플’을 트리로 추적한다. 반대로 꾸준히 느린 상황(항상 25ms 같은)이라면 Hierarchy에서 Total Time이 큰 루트를 먼저 잡고, 그 루트가 Timeline에서 프레임 내 어느 구간을 차지하는지 확인하는 흐름이 맞다. 둘은 경쟁 관계가 아니라 같은 데이터의 다른 투영이다. 다음 키워드는 spike frame, self time vs total time, thread timeline이다.

GC Alloc 컬럼이 0인데도 끊길 수 있나?

가능하다. 끊김 원인이 GC만 있는 건 아니다. Render Thread가 GPU를 기다리며 동기화되는 경우, Resources/Addressables 로드로 메인 스레드가 막히는 경우, 물리(Physics.Simulate)나 애니메이션 리빌드가 특정 프레임에 몰리는 경우도 스파이크를 만든다. 또 GC Alloc이 0처럼 보여도, 다른 스레드에서 할당이 발생하거나(모듈/버전 설정에 따라 표시가 제한될 수 있음), 네이티브 메모리 할당이 원인인 경우도 있다. 그래서 스파이크 프레임을 클릭해 Timeline에서 어떤 샘플이 튀는지 먼저 확인하고, GC 관련 샘플이 없으면 스레드 동기화(WaitForTargetFPS, Gfx.WaitForPresent 등)나 로딩 샘플을 의심해야 한다. 다음 키워드는 render thread sync, async loading, native allocation이다.

Deep Profile 없이도 ‘내 코드’ 구간을 추적하는 방법은?

ProfilerMarker로 경계를 직접 만드는 방식이 가장 안정적이다. 내 코드의 중요한 구간(예: AI 틱, 인벤토리 정렬, 네트워크 패킷 처리)에 Marker.Auto()를 감싸면 CPU Usage에서 그 이름이 고정된 샘플로 남는다. Deep Profile이 없어도 그 마커 아래로 들어가면, 그 구간에서 호출한 엔진 샘플들이 한 덩어리로 묶여 보인다. Call Stacks가 비어도 마커 이름은 남기 때문에, “누가 불렀는지” 대신 “어느 기능이 불렀는지”로 추적이 가능해진다. 실무에서는 기능 플래그(Inspector bool)와 마커를 같이 넣어 재현 스위치를 만든다. 다음 키워드는 ProfilerMarker, CustomSampler, feature flag profiling이다.

60fps 기준으로 프레임 예산을 Profiler에서 어떻게 판단하나?

60fps는 프레임당 약 16.6ms 예산이다. Profiler에서 CPU Usage의 Total이 16.6ms를 넘으면 CPU 병목 가능성이 크다. 다만 CPU Total이 16.6ms 아래인데도 끊기면, Render Thread나 GPU가 병목이거나 스레드 동기화로 인해 특정 구간이 기다리는 상황일 수 있다. 판단 흐름은 1) Timeline에서 스파이크 프레임을 고르고 2) Main/Render/Job 중 누가 길게 점유하는지 보고 3) 그 스레드의 가장 긴 샘플을 클릭해 Hierarchy로 내려가 Self Time이 큰 노드를 찾는 방식이 안정적이다. 그리고 Editor 측정은 오차가 크니 Player(Development Build)에서 동일 장면으로 재확인한다. 다음 키워드는 frame budget, thread bottleneck, GPU profiling이다.

관련 글

26. Unity Profiler 처음 열었을 때: 패널 구조와 실시간 측정 흐름

26. Unity Profiler 처음 열었을 때: 패널 구조와 실시간 측정 흐름

Unity Profiler 창의 기본 패널, Play 모드에서 샘플이 수집되는 타이밍, C# 호출이 네이티브로 넘어가는 지점과 GC Alloc 해석까지 연결한다. 초보도 원인 추적이 된다. 60fps 기준으로 읽는다. 60fps 기준으로 읽는다. 60fps 기준으로 읽는다.

24. Unity Profiler 창 구성요소와 기본 용어를 엔진 관점에서 이해하기

24. Unity Profiler 창 구성요소와 기본 용어를 엔진 관점에서 이해하기

Unity Profiler의 모듈, Timeline, CPU/GPU/Memory 용어를 엔진 Player Loop·네이티브 바인딩·GC 관점에서 연결해 읽는 법을 설명한다. 초보도 병목을 재현·측정한다. 1프레임 예산도 잡는다. 60fps 기준 포함.

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

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

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