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

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

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

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

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

게임 만들다 갑자기 프레임이 60에서 20으로 떨어지면, 화면에는 아무 변화가 없는데 조작이 끊기고 입력이 밀린다. 처음 Profiler를 열면 그래프가 움직이고 숫자가 쏟아지는데, 어디가 원인인지 감이 안 잡는다. Profiler는 ‘느린 코드 찾기 도구’가 아니라, 엔진이 한 프레임을 처리하는 순서를 기록해 원인-결과를 연결하는 도구다. 패널 구조와 샘플 수집 흐름을 잡으면, 초보도 “왜 여기서 ms가 튀는지”를 역추적할 수 있다. 내가 처음 Profiler를 제대로 못 써서 3시간 삽질한 적이 있다. Editor에서만 10ms가 튀길래 스크립트를 다 뜯었는데, 범인은 Game 뷰 우측 상단 Stats 오버레이와 Scene 뷰 렌더링이었다. CPU Usage에 EditorLoop가 크게 잡히는 걸 해석

핵심 개념

Profiler 창을 처음 열 때 가장 중요한 관점은 ‘프레임 단위 로그’라는 점이다. Unity는 매 프레임마다 Player Loop를 돌리면서 물리, 스크립트, 렌더링, 잡 시스템 등을 처리한다. Profiler는 그 과정에서 발생한 샘플(측정 이벤트)을 모아 타임라인으로 보여준다. 그래서 Profiler 그래프가 움직인다는 건 “지금 프레임들이 기록되고 있다”는 뜻이고, 멈춘다는 건 “샘플이 더 이상 들어오지 않는다”는 뜻이다.

기본 패널은 크게 3층으로 이해하면 편하다. 상단은 프레임 타임 차트(프레임 히스토리), 중단은 선택한 모듈(CPU Usage, Rendering, Memory 등)의 상세 타임라인, 하단은 선택한 샘플의 계층 뷰(Hierarchy/Timeline/Raw Hierarchy)다. 초보가 가장 많이 헷갈리는 지점은 ‘차트의 한 막대가 1프레임’이라는 감각이 없어서, 스파이크가 “어느 프레임에 무슨 일이 있었는지”로 연결되지 않는다는 점이다.

용어를 5개만 정확히 잡으면 읽는 속도가 확 올라간다. 1) Sample: 엔진이 측정한 이벤트 한 조각(예: ScriptRunBehaviourUpdate) 2) Marker: 샘플을 찍는 이름표(ProfilerMarker/내장 마커) 3) Module: CPU Usage, Rendering 같은 카테고리 패널 4) GC Alloc: C# 힙에 새 객체가 할당된 바이트 수(프레임당) 5) Overhead: 측정 자체가 추가하는 비용(특히 Deep Profile)

왜 Profiler가 필요하냐는 질문은 ‘눈으로는 원인을 못 찾기 때문’으로 끝나지 않는다. Unity는 C# 스크립트가 직접 CPU를 다 쓰는 구조가 아니다. 대부분의 시간은 네이티브(C++) 엔진이 물리, 애니메이션, 렌더링 준비, 드라이버 호출을 처리한다. C#은 그 네이티브 파이프라인에 명령을 넣는 계층이다. Profiler는 C#과 네이티브가 만나는 경계(바인딩)까지 같이 보여주기 때문에, “내 코드가 느린지, 엔진이 바쁜지, 에디터가 바쁜지”를 분리할 수 있다.

ProfilerWarmup.cs
1using UnityEngine;
2
3public class ProfilerWarmup : MonoBehaviour
4{
5    [SerializeField] int allocationsPerFrame = 200;
6    string[] temp;
7
8    void Update()
9    {
10        temp = new string[allocationsPerFrame];
11        for (int i = 0; i < temp.Length; i++)
12            temp[i] = "frame:" + Time.frameCount + " idx:" + i;
13    }
14}

이 스크립트를 빈 오브젝트에 붙이고 Play를 누르면, Profiler의 CPU Usage와 Memory(또는 GC Alloc 컬럼)에 매 프레임 할당이 찍힌다. 콘솔에 출력은 안 뜨지만, GC Alloc이 계속 누적되다가 특정 프레임에서 GC.Collect가 돌면서 CPU 스파이크가 생긴다. 왜냐하면 string 결합과 string[] 생성이 매 프레임 관리 힙을 늘리고, Unity의 Scripting GC가 임계치에서 수집을 실행하기 때문이다. 이 한 번의 체감이 Profiler 읽기의 기준점이 된다.

엔진 관점에서의 내부 동작

Profiler가 샘플을 수집하는 위치는 Player Loop의 각 단계에 박혀 있는 네이티브 마커들이다. 대표적으로 Update 단계에는 ScriptRunBehaviourUpdate 같은 마커가 있고, 그 안에서 MonoBehaviour.Update가 호출된다. 이때 C# 호출은 IL2CPP/Mono 런타임을 통해 실행되지만, 호출 자체를 트리거하는 쪽은 네이티브 엔진이다. 그래서 Profiler에서 ScriptRunBehaviourUpdate가 보인다는 건 “엔진이 스크립트 업데이트 구간에 들어왔다”는 의미다.

C# 스크립트에서 UnityEngine API를 호출하면 대개 C# 래퍼(Managed) → 내부 호출(InternalCall/ICall) → 네이티브(C++) 엔진 함수로 내려간다. 예를 들어 Transform.position을 읽고 쓰는 동작은 C# 프로퍼티처럼 보이지만, 실제 데이터는 네이티브 Transform 컴포넌트에 있다. 읽기/쓰기는 바인딩을 타고 네이티브 메모리에 접근한다. Profiler의 CPU Usage에서 ‘Scripts’ 아래에 보이는 시간이 전부 C# 계산이 아니라, 바인딩을 통해 네이티브로 내려가 실행된 시간까지 포함하는 경우가 많다.

왜 Editor에서 Profiler가 더 느리게 보이냐는 질문이 자주 나온다. Editor는 플레이어와 달리 EditorLoop가 추가로 돈다. Scene 뷰 렌더링, 인스펙터 리플렉션, 도메인 상태 유지 같은 작업이 같이 붙는다. Profiler 상단의 Target 드롭다운에서 ‘PlayMode’와 ‘Editor’의 구분을 놓치면, 원인 분석이 틀어진다. 내가 3시간 삽질한 케이스도 CPU Usage에서 큰 덩어리가 EditorLoop였는데, 스크립트를 의심하고 Update를 쪼개느라 시간을 버렸다.

실시간 측정 흐름은 ‘샘플 수집 → 링 버퍼 저장 → UI 렌더링’으로 이해하면 된다. 네이티브 엔진은 프레임마다 마커 이벤트를 기록하고, Profiler 커넥션(에디터 내부 또는 플레이어 연결)이 그 데이터를 받아 UI에 그린다. 이 과정 자체가 오버헤드라서, 특히 Deep Profile을 켜면 C# 함수 단위로 계측이 들어가고 프레임 타임이 확 늘어난다. Deep Profile이 켜진 상태에서 30ms가 찍혀도, 그 30ms가 실제 게임 비용인지 측정 비용인지 분리해야 한다.

메모리 관점에서 Profiler의 GC Alloc은 ‘관리 힙에 새 객체가 생긴 바이트’만 의미한다. 네이티브 메모리(텍스처, 메시, 네이티브 배열)는 별도다. 그래서 Memory 모듈에서 Total Used Memory가 늘어도 GC Alloc이 0일 수 있다. 반대로 GC Alloc이 매 프레임 1KB라도, 60fps면 초당 60KB가 쌓인다. 수집 주기와 힙 크기에 따라 2~10초마다 GC 스파이크가 생길 수 있고, 그 스파이크가 입력 지연으로 체감된다.

Profiler에서 특정 프레임을 클릭해 Hierarchy를 보면 ‘자식 합계’로 시간이 잡힌다. 이때 Time ms는 CPU 스레드에서 소비된 시간이며, GPU는 별도 모듈/프레임 디버깅이 필요하다. CPU Usage의 Timeline 뷰에서 메인 스레드, 렌더 스레드, 잡 워커 스레드가 나뉘는 이유는 Unity가 내부적으로 작업을 분산하기 때문이다. 잡 시스템이 켜진 프로젝트에서는 Worker Thread에 시간이 분산되며, 메인 스레드가 한가해 보여도 전체 프레임이 느릴 수 있다(예: 동기화 지점에서 대기).

PlayerLoopProbe.cs
1using UnityEngine;
2
3public class PlayerLoopProbe : MonoBehaviour
4{
5    void Awake()  { Debug.Log("Awake frame=" + Time.frameCount); }
6    void OnEnable(){ Debug.Log("OnEnable frame=" + Time.frameCount); }
7    void Start()  { Debug.Log("Start frame=" + Time.frameCount); }
8
9    void FixedUpdate(){ Debug.Log("FixedUpdate frame=" + Time.frameCount); }
10    void Update()     { Debug.Log("Update frame=" + Time.frameCount); }
11    void LateUpdate() { Debug.Log("LateUpdate frame=" + Time.frameCount); }
12}

이 스크립트를 붙이고 Play하면 Console에 호출 순서가 찍힌다. Profiler에서는 같은 프레임에서 ScriptRunBehaviourFixedUpdate, ScriptRunBehaviourUpdate, ScriptRunBehaviourLateUpdate 같은 마커가 순서대로 보인다. 왜 FixedUpdate가 frameCount 기준으로 같은 숫자에서 여러 번 찍히기도 하냐면, FixedUpdate는 ‘물리 시간(step)’을 따라가고 Update는 ‘렌더 프레임’을 따라가기 때문이다. 프레임이 느려지면 한 프레임에 물리 step을 여러 번 돌려 따라잡는다.

MarkerDemo.cs
1using UnityEngine;
2using Unity.Profiling;
3
4public class MarkerDemo : MonoBehaviour
5{
6    static readonly ProfilerMarker Marker = new ProfilerMarker("Demo/HeavyWork");
7    [SerializeField] int loop = 200000;
8
9    void Update()
10    {
11        using (Marker.Auto())
12        {
13            float s = 0f;
14            for (int i = 0; i < loop; i++)
15                s += Mathf.Sqrt(i);
16            if (s < 0) Debug.Log(s);
17        }
18    }
19}

이 코드를 실행하면 CPU Usage의 Hierarchy에서 Demo/HeavyWork 마커가 보인다. ‘왜 이게 필요한가’의 답은 명확하다. 내 코드가 엔진 내장 마커들 사이에 섞이면, 스파이크 프레임에서 내 코드가 원인인지 결과인지 분리하기 어렵다. 커스텀 마커를 박으면, Player Loop의 어느 구간(Update)에서 내 작업이 얼마나 먹는지 프레임 단위로 고정 좌표가 생긴다.

실습하기

1단계: 프로젝트와 Profiler 창 준비

Unity Hub → New project → 3D(Core) 템플릿을 선택한다. 버전은 LTS면 충분하다(2022 LTS/2023 LTS 계열). 프로젝트가 열리면 상단 메뉴 Window → Analysis → Profiler를 클릭한다. Profiler 창이 뜨면 좌측 상단 Record(빨간 원)과 Play Mode 연결 상태를 먼저 확인한다.

Profiler 창 상단에서 Target이 ‘Editor’인지 ‘PlayMode’인지 확인한다. Play를 누른 상태에서 Target이 PlayMode로 잡히면, 지금 실행 중인 게임 루프의 샘플이 들어온다. 자동으로 연결이 안 되면 Profiler 창의 Autoconnect 토글을 확인한다. 이 단계에서 Scene 뷰를 닫거나(탭 우클릭 → Close Tab) Game 뷰만 남기면 EditorLoop 노이즈가 줄어든다.

2단계: 씬 구성과 Inspector 설정

Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만든다. 이름을 ProfilerLab로 바꾼다. Inspector에서 Add Component를 눌러 ProfilerWarmup 스크립트를 붙인다. allocationsPerFrame을 200으로 둔 상태에서 시작하고, 스파이크가 잘 안 보이면 2000까지 올린다. 숫자를 올리면 GC Alloc이 커지고 GC 스파이크가 더 빨리 온다.

Hierarchy 우클릭 → 3D Object → Cube를 만든다. Cube에 아무 스크립트도 안 붙여도 된다. 이 오브젝트를 둔 이유는 Rendering 모듈에서 Batches/SetPass Calls 같은 수치가 최소로라도 찍히게 하려는 목적이다. Game 뷰 해상도는 16:9, Low Resolution Aspect 토글은 꺼둔다. 해상도 변경 자체가 렌더 비용을 바꾸기 때문이다.

ProfilerWindowGuide.cs
1using UnityEngine;
2
3public class ProfilerWindowGuide : MonoBehaviour
4{
5    [SerializeField] KeyCode pauseKey = KeyCode.P;
6    [SerializeField] KeyCode spikeKey = KeyCode.Space;
7    int burst;
8
9    void Update()
10    {
11        if (Input.GetKeyDown(pauseKey))
12            Time.timeScale = Time.timeScale > 0 ? 0 : 1;
13
14        if (Input.GetKeyDown(spikeKey))
15            burst = 20000;
16
17        if (burst-- > 0)
18            Debug.Log("burst " + burst);
19    }
20}

이 코드를 ProfilerLab에 추가로 붙이면, Space를 누른 프레임에 콘솔 로그가 폭발하면서 CPU 스파이크가 만들어진다. Profiler에서 스파이크 프레임을 클릭하면, CPU Usage의 Scripts 아래에 Debug.Log 비용이 잡힌다. 왜 이 실습이 유용하냐면 “내가 의도적으로 만든 스파이크”의 모양을 기억할 수 있기 때문이다. 나중에 실무에서 튀는 프레임을 봤을 때, 로그/문자열/에디터 콘솔이 원인인지 빠르게 감이 온다.

3단계: Profiler로 프레임을 고정하고 읽는 순서

Play를 누르고 Profiler 창에서 Record를 켠 상태로 2~3초 기다린다. 상단 차트에서 튀는 막대를 클릭하면 해당 프레임이 선택된다. 선택한 다음 Record를 끄면(Record 버튼 클릭) 데이터가 더 이상 움직이지 않고, 그 프레임을 안정적으로 읽을 수 있다. 초보는 Record를 켠 채로 분석하려다 타임라인이 계속 밀려서 눈이 미끄러진다.

읽는 순서는 고정한다. 1) CPU Usage 모듈에서 프레임 총 ms를 본다(60fps면 16.6ms). 2) Timeline으로 스레드 분포를 본다(Main Thread가 막히는지, Worker가 막히는지). 3) Hierarchy에서 큰 샘플을 펼친다. 4) 같은 프레임에서 GC Alloc 컬럼을 확인한다. 이 순서가 ‘원인 후보 좁히기’에 가장 빠르다.

GCAllocProbe.cs
1using UnityEngine;
2using Unity.Profiling;
3
4public class GCAllocProbe : MonoBehaviour
5{
6    static readonly ProfilerMarker Marker = new ProfilerMarker("Demo/GCAllocProbe");
7    readonly System.Text.StringBuilder sb = new System.Text.StringBuilder(256);
8
9    void Update()
10    {
11        using (Marker.Auto())
12        {
13            sb.Length = 0;
14            sb.Append("frame=").Append(Time.frameCount).Append(" t=").Append(Time.time);
15            if ((Time.frameCount & 31) == 0)
16                Debug.Log(sb.ToString());
17        }
18    }
19}

이 코드는 매 프레임 문자열을 만들지 않고 StringBuilder를 재사용한다. 32프레임마다 로그를 찍는데도 GC Alloc이 거의 안 찍히는 걸 확인할 수 있다. 왜냐하면 문자열 결합이 만드는 임시 객체가 줄어들고, StringBuilder의 내부 버퍼가 재사용되기 때문이다. Profiler에서 Demo/GCAllocProbe 마커를 클릭한 뒤 하단 Details에 GC Alloc이 0B에 가까운지 확인한다. 0B가 안 나오면, 콘솔 문자열 생성 외에 다른 할당이 섞였다는 뜻이라 다른 스크립트를 꺼서 분리한다.

심화 활용

한 문단 요약: Profiler는 ‘프레임 타임이 튄 지점’을 고정하고, Player Loop 구간(마커)과 GC Alloc을 같이 읽어 “C# 계산/네이티브 엔진/에디터 오버헤드”를 분리하는 도구다. Record를 끄고 한 프레임을 붙잡는 습관이 분석 속도를 바꾼다.

패턴 1: 에디터 노이즈 제거 후 Player에서 재측정

실무에서는 Editor에서만 느린 케이스가 너무 많다. Scene 뷰, 인스펙터, Gizmos, 콘솔이 성능을 흔든다. CPU Usage에서 EditorLoop가 크면, 그 프레임을 근거로 최적화하면 방향이 틀어진다. 그래서 ‘Editor에서 원인 후보 찾기 → Player에서 확정’ 흐름이 필요하다.

build-notes.sh
1# Windows 예시
2# File → Build Settings...
3# Development Build 체크
4# Autoconnect Profiler 체크
5# Build And Run 클릭
6
7# Android 예시
8# Development Build 체크
9# Autoconnect Profiler 체크
10# Script Debugging은 필요할 때만 체크
11# Build And Run

Development Build + Autoconnect Profiler로 실행하면, Profiler Target이 Player로 바뀌고 에디터 노이즈가 크게 줄어든다. 왜 Development Build가 필요하냐면, 릴리즈 빌드는 프로파일링 계측이 꺼지거나 제한되기 때문이다. 대신 오버헤드가 늘어 프레임이 조금 느려질 수 있다. 중요한 건 ‘상대 비교’다. Editor에서 튀던 10ms가 Player에서 2ms로 사라지면, 최적화 대상이 스크립트가 아닐 가능성이 높다.

패턴 2: 런타임에서 ProfilerRecorder로 수치 로그 남기기

Profiler 창을 열 수 없는 환경(테스트 기기, QA 빌드)에서는 프레임 타임과 GC Alloc을 텍스트로 남겨야 한다. Unity는 ProfilerRecorder를 제공한다. 이 값은 네이티브 계측 데이터를 ‘숫자’로 가져오는 통로라서, 실시간 HUD나 로그로도 쓸 수 있다. 단, 이것도 계측이므로 필요한 항목만 최소로 켠다.

RuntimeProfilerHUD.cs
1using UnityEngine;
2using Unity.Profiling;
3using Unity.Profiling.LowLevel.Unsafe;
4
5public class RuntimeProfilerHUD : MonoBehaviour
6{
7    ProfilerRecorder mainThreadTime;
8    ProfilerRecorder gcAlloc;
9
10    void OnEnable()
11    {
12        mainThreadTime = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Main Thread", 60);
13        gcAlloc = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame", 60);
14    }
15
16    void OnDisable()
17    {
18        mainThreadTime.Dispose();
19        gcAlloc.Dispose();
20    }
21
22    void OnGUI()
23    {
24        var ms = mainThreadTime.LastValue / 1000000.0;
25        GUILayout.Label("MainThread: " + ms.ToString("F2") + " ms");
26        GUILayout.Label("GC Alloc: " + gcAlloc.LastValue + " B");
27    }
28}

이 스크립트를 켜면 Game 뷰 좌상단에 Main Thread ms와 프레임당 GC Alloc 바이트가 찍힌다. 왜 OnGUI를 쓰냐면 초보 실습에서 UI 세팅 없이 바로 보이게 하려는 목적이다(실무에서는 uGUI/TMP로 바꾸는 편이 낫다). Main Thread 마커는 네이티브 엔진이 측정한 메인 스레드 프레임 시간을 의미하고, GC Allocated In Frame은 관리 힙 할당량이다. 숫자가 16.6ms를 넘는 프레임이 반복되면 60fps 유지가 깨진다.

내 흑역사 1: Deep Profile을 켠 채로 최적화를 진행한 적이 있다. CPU Usage에서 Update가 25ms로 찍혀서 코드를 갈아엎었는데, Deep Profile을 끄자 6ms였다. 원인은 계측이 C# 함수마다 들어가면서 호출 경로를 추적하느라 런타임이 느려진 것이다. 해결은 간단했다. Deep Profile은 ‘원인 위치를 좁힐 때만’ 잠깐 켜고, 평소에는 커스텀 ProfilerMarker로 좌표를 박는 방식으로 바꿨다.

내 흑역사 2: Profiler에서 GC Alloc이 0B라서 안심했는데도 5초마다 프리즈가 왔다. Memory 모듈을 보니 Texture memory가 계속 늘고 있었다. Addressables로 로드한 텍스처를 Release하지 않아 네이티브 메모리가 새고 있었고, 결국 OS 메모리 압박으로 드라이버 레벨에서 스톨이 생겼다. GC Alloc은 관리 힙만 보여준다. 네이티브 리소스 누수는 Memory 모듈과 로딩/언로딩 코드 경로를 같이 봐야 잡힌다.

자주 하는 실수

실수 1: Record를 켠 채로 분석하다가 프레임을 놓친다

증상: 스파이크가 보였는데 클릭하는 순간 그래프가 계속 움직여서, 같은 프레임을 다시 찾지 못한다. Hierarchy가 매번 다른 프레임으로 바뀌어 숫자가 흔들린다.

원인: Profiler는 실시간 스트림이라 Record가 켜져 있으면 선택 프레임이 계속 밀린다. 특히 Play 모드에서 부하가 걸리면 UI 갱신도 느려져 클릭이 더 튄다.

해결: 스파이크 막대를 클릭한 직후 Record를 끈다. 그 상태에서 Timeline/Hiearchy를 읽는다. 다시 재현이 필요할 때만 Record를 켠다.

실수 2: EditorLoop 시간을 스크립트 성능으로 착각한다

증상: CPU Usage에서 큰 덩어리가 보이는데, Scripts를 펼쳐도 별 게 없다. 최적화했는데도 Editor에서는 계속 느리다.

원인: Scene 뷰 렌더링, 인스펙터 갱신, 콘솔 로그 등 에디터 전용 비용이 EditorLoop로 잡힌다. PlayMode가 아니라 Editor 타겟을 보고 있을 때도 같은 착각이 난다.

해결: Profiler Target이 PlayMode인지 확인한다. Scene 뷰 탭을 닫고 Gizmos를 끈다(Game 뷰 우측 상단 Gizmos). 최종 판단은 Development Build Player에서 한다.

실수 3: Deep Profile을 상시 켜서 프레임을 망가뜨린다

증상: Deep Profile을 켜자마자 프레임이 반토막 난다. 스파이크가 더 심해져서 원인이 더 안 보인다.

원인: Deep Profile은 C# 함수 호출마다 계측을 추가한다. 호출 스택 수집과 마샬링이 늘어나고, 측정 오버헤드가 실제 비용을 덮는다.

해결: 평소에는 끈다. 위치가 필요할 때만 짧게 켠다. 자주 보는 구간은 ProfilerMarker로 직접 마커를 박는다.

실수 4: GC Alloc 0B면 메모리 문제가 없다고 믿는다

증상: GC Alloc이 0B인데도 몇 초마다 끊긴다. 기기에서만 더 심하다.

원인: 텍스처/메시/오디오 같은 네이티브 리소스는 관리 힙이 아니라 네이티브 메모리를 쓴다. 누수나 과도한 로딩은 GC Alloc에 안 잡힌다.

해결: Memory 모듈에서 Total Used/Texture/Mesh를 같이 본다. 로딩 시스템(Addressables/Resources)에서 Release/Unload 경로를 점검한다. 필요하면 Memory Profiler 패키지로 스냅샷 비교를 한다.

실수 5: FixedUpdate 스파이크를 Update 최적화로 해결하려 든다

증상: 프레임이 느려질 때 CPU Usage에서 FixedUpdate 구간이 길어지고, 한 프레임에 여러 번 반복된다. Update를 줄여도 여전히 끊긴다.

원인: FixedUpdate는 고정 시간 간격을 맞추기 위해 ‘따라잡기’가 발생한다. 물리 계산이 느리면 한 렌더 프레임에 물리 step이 여러 번 돌고, 그 자체가 스파이크가 된다.

해결: Project Settings → Time에서 Fixed Timestep을 확인한다. 물리 오브젝트 수, 충돌 레이어, Continuous Collision 같은 옵션을 조정한다. 물리 관련 코드는 FixedUpdate로 옮기되, 비용 자체를 줄인다.

FixedUpdateTrap.cs
1using UnityEngine;
2
3public class FixedUpdateTrap : MonoBehaviour
4{
5    Rigidbody rb;
6
7    void Awake()
8    {
9        rb = GetComponent<Rigidbody>();
10    }
11
12    void FixedUpdate()
13    {
14        // Update에서 AddForce를 호출하면 물리 step과 엇갈려 흔들림이 생긴다.
15        rb.AddForce(Vector3.forward * 5f, ForceMode.Acceleration);
16    }
17}

이 코드를 실행하면 물리 힘이 FixedUpdate에서만 들어가서, Profiler의 ScriptRunBehaviourFixedUpdate 구간에 비용이 모인다. Update에서 힘을 주면 프레임 레이트에 따라 힘 적용이 들쑥날쑥해지고, 물리 보정 때문에 더 큰 스파이크로 되돌아오는 경우가 있다.

성능 최적화 체크리스트

  • Profiler 분석은 Record OFF 상태에서 특정 프레임을 고정하고 진행한다(스파이크 클릭 → Record 끔).
  • Target이 Editor인지 PlayMode/Player인지 먼저 확인하고, EditorLoop가 큰 프레임은 최적화 근거로 쓰지 않는다.
  • 60fps 기준 프레임 예산 16.6ms를 상단 차트에 메모하고, 스파이크 프레임의 총 ms부터 본다.
  • CPU Usage는 Hierarchy와 Timeline을 번갈아 본다(원인 샘플 + 스레드 대기/동기화 확인).
  • GC Alloc 컬럼을 켜고(Profiler 모듈 상단 메뉴) 프레임당 할당이 0B에 가까운지 확인한다.
  • Deep Profile은 원인 위치를 좁힐 때만 잠깐 켠다. 평소에는 ProfilerMarker로 커스텀 마커를 박는다.
  • Console 로그 폭주가 의심되면 Collapse/Stack Trace 설정을 바꾸고, Development Build에서도 동일한지 확인한다.
  • FixedUpdate 반복이 보이면 Time.fixedDeltaTime, 물리 오브젝트 수, 충돌 레이어 매트릭스를 점검한다.
  • GetComponent/Find 계열 호출이 Update에 있으면 Awake/Start에서 캐싱하고, Profiler에서 호출 횟수와 ms 변화를 확인한다.
  • 네이티브 메모리 누수는 GC Alloc로 안 잡힌다. Memory 모듈과 로딩/언로딩 경로(Addressables Release 등)를 같이 점검한다.
  • 렌더링이 의심되면 Rendering 모듈에서 Batches/SetPass/Tris 변화를 보고, 해상도/포스트프로세싱 토글로 원인 범위를 줄인다.
  • Player에서 확정 측정이 필요하면 Development Build + Autoconnect Profiler로 기기/PC 플레이어에서 재현한다.

자주 묻는 질문

Profiler 창에서 CPU Usage의 ‘Scripts’ 시간이 전부 내 C# 코드 실행 시간인가?

아니다. Scripts 아래에 잡히는 시간은 ‘스크립트 구간에서 발생한 비용’에 가깝다. MonoBehaviour.Update 내부에서 순수 C# 연산만 돈 시간도 포함되지만, Transform 접근, Instantiate, Physics.Raycast 같은 UnityEngine API는 C# 래퍼를 통해 네이티브(C++)로 내려가 실행된다. 그 네이티브 비용이 다시 스크립트 구간의 자식 샘플로 합쳐져 보이기도 한다. 그래서 Hierarchy에서 특정 샘플을 클릭한 뒤, 하단 Callers/Details를 보고 어떤 API 호출이 자주 등장하는지 확인해야 한다. 다음 학습 키워드는 ‘ICall 바인딩’, ‘ScriptRunBehaviourUpdate’, ‘Profiler Timeline 스레드 분리’다.

Record를 끄면 데이터가 더 이상 안 들어오는데, 왜 그게 분석에 유리한가?

실시간 스트림은 프레임이 계속 추가되면서 타임라인이 이동한다. 초보는 스파이크 프레임을 클릭해도, 다음 순간 다른 프레임이 선택되거나 UI가 갱신되면서 숫자가 바뀐다. Record OFF는 ‘관측 대상 프레임을 고정’하는 기능이다. 프레임을 고정하면 Hierarchy를 여러 번 펼쳐도 같은 데이터가 유지되고, Timeline에서 스레드 대기 구간을 천천히 따라갈 수 있다. 실무에서는 “재현 → 스파이크 클릭 → Record OFF → 원인 후보 1개 찾기 → 다시 Record ON”을 반복한다. 다음 학습 키워드는 ‘Profiler frame selection’, ‘Hierarchy vs Timeline 차이’다.

Deep Profile을 켜면 왜 갑자기 프레임이 느려지고, 측정 결과를 믿기 어려운가?

Deep Profile은 C# 함수 호출 단위로 계측을 넣는다. 함수 진입/이탈마다 타임스탬프를 찍고, 호출 스택을 수집하거나 호출 경로를 기록한다. 이 작업은 원래 게임에 없던 추가 연산이며, 특히 호출 횟수가 많은 Update 루프에서는 오버헤드가 폭발한다. 그래서 Deep Profile 상태의 30ms는 ‘실제 게임 30ms’가 아니라 ‘게임 + 계측 30ms’일 수 있다. 해결은 두 단계다. 1) 평소에는 Deep Profile을 끄고, 큰 마커 단위로 원인 범위를 좁힌다. 2) 정말 함수 레벨이 필요할 때만 짧게 켜서 특정 프레임의 상위 몇 개 함수만 확인한다. 다음 학습 키워드는 ‘ProfilerMarker’, ‘Instrumentation overhead’, ‘Call Stacks’다.

GC Alloc이 프레임마다 200B 정도면 무시해도 되나?

무시하기 어렵다. 200B는 작아 보이지만 60fps면 초당 12KB, 1분이면 720KB가 쌓인다. GC는 누적된 관리 힙이 임계치에 도달하면 수집을 실행하고, 수집 프레임에서 메인 스레드가 멈추는 시간이 입력 지연으로 체감된다. 특히 모바일이나 저사양 PC에서는 2~10초 간격으로 5~30ms 스파이크가 나기 쉽다. Profiler에서 중요한 건 ‘평균 할당량’보다 ‘할당이 지속적으로 발생하는지’다. 지속 발생이면 언젠가 수집이 온다. 다음 학습 키워드는 ‘Incremental GC’, ‘managed heap’, ‘allocation-free patterns(StringBuilder, pooling)’이다.

Profiler에서 FixedUpdate가 한 프레임에 여러 번 도는 건 버그인가?

버그가 아니라 설계다. FixedUpdate는 고정 시간 간격(기본 0.02s)을 기준으로 물리 시뮬레이션을 진행한다. 렌더 프레임이 느려져서 한 프레임이 0.05s가 걸리면, 물리는 0.02s step을 2~3번 돌려서 시간을 따라잡는다. Profiler에서는 ScriptRunBehaviourFixedUpdate가 같은 렌더 프레임 안에서 반복된 것처럼 보인다. 이때 스파이크의 원인은 두 가지로 갈린다. 1) 물리 자체가 비싸서 step당 시간이 길다. 2) 프레임이 느려서 step 횟수가 늘었다. Timeline에서 FixedUpdate 블록이 반복되는지, 각 블록의 길이가 긴지로 구분한다. 다음 학습 키워드는 ‘fixedDeltaTime’, ‘physics cost’, ‘catch-up steps’다.

Editor에서는 괜찮은데 기기(Player)에서만 느리면 Profiler로 어떻게 접근해야 하나?

Editor와 Player는 실행 환경이 다르다. Editor는 JIT/도메인 상태, 에디터 UI, Scene 뷰 렌더링이 붙고, Player는 실제 플랫폼 드라이버와 스레드 스케줄링의 영향을 받는다. 접근 순서는 ‘Player에서 재현 가능한 측정 환경 만들기’가 먼저다. Development Build + Autoconnect Profiler로 기기에 붙이고, 동일 장면/동일 입력을 반복한다. 그 다음 CPU Usage에서 Main Thread가 긴지, Rendering에서 GPU bound 징후가 있는지(프레임이 길지만 CPU가 짧은 경우) 본다. 모바일이면 Thermal throttling도 고려해야 해서, 1분 이상 플레이 후 그래프가 서서히 늘어나는지도 체크한다. 다음 학습 키워드는 ‘Development Build profiling’, ‘GPU profiling’, ‘thermal throttling’이다.

Profiler에서 ‘ms’만 보고 최적화하면 왜 자주 실패하나?

프레임 타임(ms)은 결과이고, 원인은 호출 횟수·할당·동기화·대기·리소스 로딩처럼 다양하다. 예를 들어 Instantiate는 한 번이 2ms여도, 1초에 30번이면 프레임마다 스파이크가 된다. 또 Job Worker가 바쁘면 메인 스레드가 ‘WaitForJobGroup’ 같은 동기화 지점에서 대기하면서 ms가 튄다. 이때 메인 스레드의 스크립트를 줄여도 효과가 없다. 그래서 Profiler에서는 1) 스파이크 프레임 고정 2) Timeline에서 스레드 대기/동기화 확인 3) Hierarchy에서 상위 샘플의 호출 횟수(Calls)와 GC Alloc 확인 4) 로딩 이벤트(Async Upload, Resources/Addressables) 여부 확인 순으로 원인을 좁혀야 한다. 다음 학습 키워드는 ‘WaitForTargetFPS/WaitForJobGroup’, ‘Calls count’, ‘loading spikes’다.

관련 글

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

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

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

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

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

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

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

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

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