31. Unity에서 힙(Heap)·스택(Stack) 메모리 차이, GC Alloc까지 코드로 확인
Unity C#에서 스택/힙 할당이 언제 발생하는지, 박싱·클로저·문자열로 생기는 GC Alloc을 코드와 Profiler로 재현하고 엔진 바인딩 관점에서 설명한다. 60fps 기준도 포함한다. 60fps 기준도 포함한다. 60fps 기준도 포함한다.
Unity에서 힙(Heap)·스택(Stack) 메모리 차이, GC Alloc까지 코드로 확인
게임 만들다 겪는 사고는 대체로 프레임 드랍에서 시작한다. 어제까지 60fps가 나오던 씬이 UI 한 장 추가하고 나서 45fps로 떨어지고, Profiler를 열면 GC.Collect가 8~20ms씩 튄다. 코드엔 new를 거의 안 썼는데도 GC Alloc이 매 프레임 찍힌다. 이때 필요한 감각이 힙과 스택의 차이, 그리고 Unity가 C# 코드를 네이티브 엔진으로 넘기는 순간 어떤 메모리 비용이 생기는지이다. 나도 처음에 이게 왜 이렇게 동작하는지 몰랐다. "문자열 한 번 붙였을 뿐"이라고 생각했는데, Profiler Timeline에서 Update마다 GC Alloc 160B가 찍히고 3시간 삽질 끝에 원인이 ToString과 박싱 조합이라는 걸 잡았다. 원인만 알면 해결은 단순했다. 문제는 원인을
핵심 개념
스택(Stack)은 함수 호출 단위로 생겼다 사라지는 임시 작업대에 가깝다. 지역 변수, 매개변수, 리턴 주소 같은 것이 쌓이고, 함수가 끝나면 한 번에 정리된다. 그래서 스택은 할당/해제가 매우 싸다. 반대로 힙(Heap)은 수명이 함수 범위를 넘어가는 데이터를 위한 공간이다. C#에서 new로 만든 참조 타입 객체, 배열, delegate, closure 캡처 객체가 여기에 들어간다.
Unity에서 이 구분이 중요한 이유는 GC(가비지 컬렉터) 때문이다. 힙에 잡힌 관리(Managed) 객체는 더 이상 참조되지 않으면 GC가 회수한다. 회수 자체가 공짜가 아니고, 특히 프레임 중간에 GC가 실행되면 메인 스레드가 멈추면서 프레임 드랍이 체감된다. 스택은 GC 대상이 아니어서 이런 스톨을 만들지 않는다.
초보가 가장 자주 헷갈리는 지점은 "값 타입(value type) = 스택"이 아니라는 점이다. 값 타입은 '복사되는 방식'을 말한다. 값 타입이라도 박싱(boxing)되면 힙에 올라간다. 예를 들어 int를 object로 담거나, interface로 넘기거나, string.Concat에 params object[]로 들어가면 힙 할당이 섞인다. Unity에서 Debug.Log가 대표적인 함정이다.
Unity 특유의 함정도 있다. GameObject, Transform, Component는 C# 객체처럼 보이지만 실체는 네이티브(C++)에 있다. C# 쪽은 네이티브 오브젝트를 가리키는 래퍼(핸들)이다. 그래서 힙/스택만으로 설명이 끝나지 않고, 관리 힙(Managed Heap)과 네이티브 힙(Native Heap) 사이를 오가는 비용까지 같이 봐야 한다.
용어를 5개만 고정한다. 1) Managed Heap: C# GC가 관리하는 힙. 2) Native Heap: Unity C++ 런타임이 malloc 계열로 잡는 힙. 3) Boxing: 값 타입을 object/interface로 포장하면서 Managed Heap에 객체를 만드는 과정. 4) Escape: 지역 데이터가 함수 밖으로 살아남아 힙으로 승격되는 상황(주로 closure). 5) Player Loop: Unity가 매 프레임 호출하는 고정된 단계 파이프라인.
1using UnityEngine;
2
3public class HeapStackPrimer : MonoBehaviour
4{
5 struct SmallStruct
6 {
7 public int a;
8 public int b;
9 }
10
11 void Update()
12 {
13 // 스택에 생성될 가능성이 큰 값 타입(단, 최적화/IL2CPP에 따라 달라질 수 있음)
14 SmallStruct s = new SmallStruct { a = 1, b = 2 };
15
16 // 박싱: 값 타입이 object로 변환되며 Managed Heap에 할당이 생긴다
17 object boxed = s;
18
19 // 문자열 결합: 새 string이 Managed Heap에 만들어진다
20 string msg = "a=" + s.a + ", b=" + s.b;
21
22 Debug.Log(msg + " boxedType=" + boxed.GetType().Name);
23 }
24}이 스크립트를 빈 씬에서 실행하면 Console에 매 프레임 로그가 쌓이고, Profiler(Analysis → Profiler)에서 CPU Usage/Timeline을 켜면 Update 아래에 GC Alloc이 찍힌다. 할당의 출처는 1) boxing(구조체를 object로 담는 순간) 2) 문자열 결합(새 string 생성) 3) Debug.Log 내부 포맷팅 경로에서 추가로 생기는 할당이다. 로그 스팸 자체가 느려서, 다음 섹션에서 할당만 분리해 관찰한다.
엔진 관점에서의 내부 동작
Unity에서 MonoBehaviour.Update는 C#이 자율적으로 호출하는 함수가 아니라, 네이티브 PlayerLoop가 '스크립트 업데이트 단계'에서 관리 코드로 콜백을 날리는 구조다. 실행 흐름은 대략 Native(C++) PlayerLoop → ScriptRunBehaviourUpdate → (C#) Update 호출이다. 그래서 Update 안에서 매 프레임 힙 할당을 만들면, 그 비용은 PlayerLoop의 핵심 경로에 그대로 누적된다.
C# 스크립트가 엔진 API를 호출할 때는 바인딩 계층을 지난다. 예를 들어 transform.position을 읽으면 C# Transform 래퍼가 내부적으로 네이티브 Transform 포인터를 들고 있고, get_position 같은 바인딩 함수(내부 호출/ICall)가 네이티브로 넘어가 값을 복사해 온다. 이 과정에서 값 타입(Vector3)은 보통 스택/레지스터로 복사되지만, 호출 자체는 경계를 넘는 비용이 있고, 특정 패턴에서는 임시 객체/배열이 Managed Heap에 생길 수 있다.
메모리를 두 층으로 나눠서 생각한다. 1) Managed Heap: C# new, boxing, string, 배열, delegate, LINQ, foreach(컬렉션 종류에 따라) 같은 것들이 만든다. 2) Native Heap: Instantiate로 만들어지는 GameObject/Component의 실제 데이터, Mesh/Texture 같은 리소스, PhysX 데이터 등이 만든다. Managed Heap은 GC가, Native Heap은 Unity의 메모리 매니저가 정리한다. 둘의 수명이 다르기 때문에 '메모리 누수처럼 보이는 현상'이 종종 생긴다.
GC가 언제 도는지도 PlayerLoop 관점에서 봐야 한다. Unity는 프레임 중간 아무 때나 GC를 강제로 돌리기보다는, 보통 할당 압박이 임계치를 넘었을 때 또는 명시적 GC.Collect 호출 때 컬렉션이 발생한다. 컬렉션이 발생하면 메인 스레드가 멈추고, 그 멈춤이 렌더링/물리/스크립트 전 단계에 영향을 준다. 그래서 '할당을 줄이는 것'이 곧 '스톨을 줄이는 것'이다.
스택은 함수가 끝날 때 한 번에 정리되니, 스택 메모리 자체가 병목이 되는 일은 드물다. 대신 스택에 너무 큰 데이터를 올리면(예: 큰 struct, 큰 배열을 stackalloc로) 호출 깊이가 깊을 때 스택 오버플로 위험이 생긴다. Unity C# 스크립트에서 흔한 문제는 이쪽이 아니라, 박싱/문자열/클로저로 인한 Managed Heap 압박이다.
처음에 나도 Coroutine이 왜 yield return null을 요구하는지 이해가 안 됐다. 엔진 쪽에서 코루틴 스케줄러가 IEnumerator를 보관하고, 매 프레임 PlayerLoop에서 MoveNext를 호출한다. yield return null은 '다음 프레임까지 대기'라는 신호 값이다. 문제는 코루틴 자체가 IEnumerator 객체를 만들고, yield 구문이 상태 머신 클래스를 생성하면서 Managed Heap 할당이 생긴다는 점이다. 이 패턴이 Update에서 매번 StartCoroutine으로 돌면 할당 폭탄이 된다.
1using System.Collections;
2using UnityEngine;
3
4public class PlayerLoopOrderProbe : MonoBehaviour
5{
6 IEnumerator Start()
7 {
8 Debug.Log("Start() begin frame=" + Time.frameCount);
9 yield return null;
10 Debug.Log("Start() after yield null frame=" + Time.frameCount);
11 }
12
13 void Awake() => Debug.Log("Awake frame=" + Time.frameCount);
14 void OnEnable() => Debug.Log("OnEnable frame=" + Time.frameCount);
15 void FixedUpdate() => Debug.Log("FixedUpdate frame=" + Time.frameCount);
16 void Update() => Debug.Log("Update frame=" + Time.frameCount);
17 void LateUpdate() => Debug.Log("LateUpdate frame=" + Time.frameCount);
18}이 스크립트를 빈 GameObject에 붙이고 Play를 누르면 Console에 Awake → OnEnable → Start begin → Update/LateUpdate → (다음 프레임) Start after yield null 순서가 찍힌다. FixedUpdate는 Project Settings → Time의 Fixed Timestep에 따라 0회 또는 여러 번 찍힌다. 이 출력은 PlayerLoop 단계가 분리되어 있다는 증거이고, 힙 할당을 어느 단계에서 만들었는지(특히 Update) 추적할 때 기준점이 된다.
1using System;
2using UnityEngine;
3using UnityEngine.Profiling;
4
5public class ManagedAllocProbe : MonoBehaviour
6{
7 [SerializeField] int iterations = 1000;
8
9 void Update()
10 {
11 long before = GC.GetTotalMemory(false);
12 Profiler.BeginSample("AllocProbeLoop");
13
14 for (int i = 0; i < iterations; i++)
15 {
16 // 1) 박싱 유발: int -> object
17 object o = i;
18 // 2) 문자열 생성 유발
19 string s = "i=" + i;
20 if (s.Length == 999999) Debug.Log(o);
21 }
22
23 Profiler.EndSample();
24 long after = GC.GetTotalMemory(false);
25 Debug.Log("Managed delta(bytes)=" + (after - before));
26 }
27}Profiler에서 AllocProbeLoop 샘플을 선택하면 해당 구간의 GC Alloc이 보인다. GC.GetTotalMemory(false)는 즉시 컬렉션을 강제하지 않으니 '대략적인 증가량'만 확인한다. iterations를 Inspector에서 1000, 10000으로 올리면 프레임 시간이 급격히 늘고, Console에는 Managed delta가 크게 튄다. 이 실험은 힙 할당이 프레임 타임에 직결된다는 걸 눈으로 확인시키는 용도다.
실습하기
1단계: 프로젝트 설정(Profiler가 잘 보이게)
Unity Hub → New Project에서 3D(Core) 템플릿을 고른다. 에디터 버전은 2022.3 LTS 이상을 권장한다. 스크립팅 백엔드는 Project Settings → Player → Other Settings에서 기본값(Mono/IL2CPP)을 그대로 두고 시작한다. Mono와 IL2CPP는 할당 패턴이 비슷하지만, 최적화 정도와 스택/레지스터 배치가 달라 결과 해석이 달라질 수 있다.
Analysis → Profiler를 열고, 상단에서 CPU Usage 모듈을 Timeline으로 바꾼다. 우측 상단 톱니에서 Call Stacks는 꺼도 된다(초보에겐 노이즈가 크다). GC Alloc 컬럼이 보이도록 Timeline 상세를 펼친다. 이 상태에서 Play를 누르면 Update에서 발생하는 할당이 프레임 단위로 보인다.
2단계: 씬 구성(실험용 오브젝트와 Inspector 값)
Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만들고 이름을 MemoryLab로 바꾼다. Inspector에서 Add Component를 눌러 ManagedAllocProbe 스크립트를 붙인다. iterations는 1000으로 시작하고, Play 중에 5000, 20000으로 올리며 프레임 타임 변화를 본다.
Console의 Collapse를 체크한다. 로그가 너무 많으면 에디터 자체가 느려져 결과가 왜곡된다. 로그를 줄이려면 ManagedAllocProbe에서 Debug.Log를 30프레임에 한 번만 찍게 바꿔도 된다. 실험의 목표는 'GC Alloc이 찍히는 순간'과 '프레임이 튀는 순간'을 같은 타임라인에서 보는 것이다.
1using System;
2using UnityEngine;
3using UnityEngine.Profiling;
4
5public class HeapVsStackExperiment : MonoBehaviour
6{
7 struct Pair
8 {
9 public int x;
10 public int y;
11 }
12
13 [SerializeField] int loops = 200000;
14 [SerializeField] bool enableBoxing;
15 [SerializeField] bool enableString;
16
17 void Update()
18 {
19 Profiler.BeginSample("HeapVsStackExperiment.Update");
20 int sum = 0;
21
22 for (int i = 0; i < loops; i++)
23 {
24 Pair p = new Pair { x = i, y = i + 1 }; // 값 타입
25 sum += p.x + p.y;
26
27 if (enableBoxing)
28 {
29 object boxed = p; // 박싱
30 if (boxed == null) Debug.Log("never");
31 }
32
33 if (enableString)
34 {
35 string s = "p=" + p.x + "," + p.y; // 문자열 힙 할당
36 if (s.Length == 0) Debug.Log("never");
37 }
38 }
39
40 Profiler.EndSample();
41 if ((Time.frameCount & 31) == 0) Debug.Log("sum=" + sum);
42 }
43}Inspector에서 loops를 200000 정도로 두고 enableBoxing, enableString을 토글한다. enableBoxing만 켜면 GC Alloc이 급증한다. enableString만 켜도 GC Alloc이 크게 찍힌다. 둘 다 끄면 CPU는 돌지만 GC Alloc은 거의 0에 가까워진다. 같은 for 루프인데도 '힙을 건드리는가'가 프레임 안정성을 갈라놓는 장면이다.
3단계: UnityEngine.Object 래퍼와 네이티브 메모리도 같이 보기
Hierarchy 우클릭 → 3D Object → Cube로 큐브를 하나 만든다. MemoryLab 오브젝트에 NativeObjectProbe 스크립트를 붙이고, Inspector에서 target에 Cube의 Transform을 드래그한다. Play를 누르면 Update에서 target.position을 읽고 쓰는 코드가 돌면서, 관리 힙 할당은 없는데도 엔진 바인딩 호출 비용이 생긴다.
Profiler에서 ScriptRunBehaviourUpdate 아래에 NativeObjectProbe.Update가 보이고, 그 안에서 Transform.get_position / set_position이 호출되는 비용이 누적된다. GC Alloc이 0이어도 느릴 수 있다는 점이 중요하다. 힙/스택은 '멈춤'을 만들고, 바인딩 호출은 '지속적인 비용'을 만든다.
1using UnityEngine;
2using UnityEngine.Profiling;
3
4public class NativeObjectProbe : MonoBehaviour
5{
6 [SerializeField] Transform target;
7 [SerializeField] int loops = 200000;
8
9 void Update()
10 {
11 if (target == null) return;
12
13 Profiler.BeginSample("NativeObjectProbe.TransformAccess");
14 Vector3 p = target.position;
15
16 for (int i = 0; i < loops; i++)
17 {
18 // 네이티브 Transform 접근(바인딩 호출) 반복
19 p.x += 0.000001f;
20 target.position = p;
21 }
22
23 Profiler.EndSample();
24 }
25}이 실험을 통해 두 종류의 비용을 분리한다. 1) Managed Heap 할당 → GC 스톨 위험. 2) 네이티브 바인딩 호출 반복 → 꾸준한 CPU 비용. 둘은 다른 문제라 처방도 다르다. 할당은 없애거나 줄이고, 바인딩 호출은 캐싱/배치 변경/루프 횟수 감소로 줄인다.
심화 활용
패턴 1: Update에서 힙 할당을 '금지 구역'으로 만들기
실무에서 가장 효과가 큰 규칙은 Update/LateUpdate/FixedUpdate에서 관리 힙 할당을 0으로 만드는 것이다. 이 규칙을 깨는 순간이 대부분 문자열, LINQ, foreach, params, boxing, 코루틴 생성이다. UI 갱신이 Update에서 매 프레임 일어나면 string.Format 한 번으로도 GC Alloc이 찍힌다.
1using System.Text;
2using UnityEngine;
3
4public class NoAllocHudText : MonoBehaviour
5{
6 [SerializeField] int score;
7 readonly StringBuilder sb = new StringBuilder(64);
8 string cached;
9
10 void Start()
11 {
12 // Start에서 한 번만 힙 할당을 허용한다
13 cached = "Score: 0";
14 }
15
16 void Update()
17 {
18 // 점수가 변할 때만 문자열을 만든다(매 프레임 생성 금지)
19 int newScore = Mathf.FloorToInt(Time.time * 10f);
20 if (newScore == score) return;
21
22 score = newScore;
23 sb.Length = 0;
24 sb.Append("Score: ");
25 sb.Append(score);
26 cached = sb.ToString();
27
28 // 여기서 실제 UI(TextMeshPro 등)에 cached를 넣는다
29 if ((Time.frameCount & 63) == 0) Debug.Log(cached);
30 }
31}실행하면 Console에 Score가 가끔 찍히고, Profiler에서 Update의 GC Alloc이 '점수 변경 프레임'에만 발생한다. 매 프레임 160B씩 쌓이던 프로젝트에서 이런 식으로 UI 문자열을 이벤트 기반으로 바꿔서 GC 스파이크를 없앤 적이 있다. 원인은 Text 갱신이 아니라 문자열 생성 빈도였다.
패턴 2: GetComponent/Find류 호출의 비용을 '바인딩 호출 + 배열 순회'로 분해하기
GetComponent는 C#에서 한 줄이지만, 내부적으로는 네이티브 GameObject가 가진 컴포넌트 리스트를 탐색한다. 그 리스트는 네이티브 메모리에 있고, C# 제네릭 호출은 바인딩을 통해 네이티브 쪽 탐색 함수를 부른다. Update에서 매 프레임 GetComponent를 호출하면 1) 바인딩 호출 2) 리스트 탐색이 프레임마다 반복된다.
1using UnityEngine;
2using UnityEngine.Profiling;
3
4public class GetComponentCachingDemo : MonoBehaviour
5{
6 Rigidbody cached;
7
8 void Awake()
9 {
10 // Awake에서 한 번만 네이티브 탐색을 수행
11 cached = GetComponent<Rigidbody>();
12 }
13
14 void Update()
15 {
16 Profiler.BeginSample("GetComponentEveryFrame");
17 var rb1 = GetComponent<Rigidbody>();
18 if (rb1 != null) rb1.AddForce(Vector3.up * 0.01f);
19 Profiler.EndSample();
20
21 Profiler.BeginSample("GetComponentCached");
22 if (cached != null) cached.AddForce(Vector3.up * 0.01f);
23 Profiler.EndSample();
24 }
25}Rigidbody가 붙은 오브젝트에서 실행하면 Profiler에 두 샘플이 나란히 찍힌다. GetComponentEveryFrame은 프레임마다 비용이 나오고, GetComponentCached는 거의 0에 가깝다. GC Alloc은 둘 다 0일 수 있다. 느린 이유가 GC가 아니라 네이티브 탐색이라는 뜻이다. 그래서 캐싱이 효과가 있다.
한 문단 요약: Unity 성능 문제는 '힙 할당으로 인한 GC 스톨'과 '네이티브 바인딩/탐색 호출의 누적 비용'이 섞여 터진다. Profiler에서 GC Alloc과 CPU 샘플을 분리해서 원인을 먼저 고정해야 처방이 맞는다.
내 흑역사 1. 모바일에서 2~3초마다 화면이 멈췄고, Profiler에서 GC.Collect가 25ms씩 튀었다. 원인을 찾으려고 Update의 new를 다 지웠는데도 멈췄다. Timeline을 확대하니 GC Alloc이 UI 갱신 프레임마다 3~5KB씩 쌓였고, 범인은 string.Format과 LINQ였다. 특히 Where/Select가 iterator 객체와 closure를 만들면서 힙을 계속 밀어 올렸다.
내 흑역사 2. 'GC Alloc 0이면 안전'이라고 믿고 Transform 접근을 루프에서 50만 번 돌렸더니 프레임이 30fps로 떨어졌다. GC는 0인데 CPU가 12ms를 먹었다. Transform.get_position/set_position이 네이티브 경계를 넘는 호출이라는 걸 그때 몸으로 배웠다. 해결은 position을 지역 변수에 캐싱하고, 루프 밖에서 한 번만 set_position을 호출하는 방식으로 바꿨다.
자주 하는 실수
실수 1: Debug.Log에 값 타입을 직접 붙여서 박싱을 만든다
증상: Profiler에서 Update마다 GC Alloc이 16B~수백 B씩 찍히고, Console 로그를 끄면 프레임이 갑자기 안정된다. 모바일에서는 1~3초 간격으로 GC.Collect 스파이크가 보인다.
원인: 문자열 결합("x=" + value) 과정에서 value가 object로 승격되거나, params object[] 경로로 들어가면서 박싱과 배열 할당이 생긴다. Debug.Log 자체도 에디터에서는 추가 비용이 크다.
해결: 로그를 프레임마다 찍지 않는다. 숫자/구조체 출력은 조건부(예: 60프레임에 한 번)로 제한한다. 문자열은 캐시하거나, 개발 빌드에서만 로그를 켠다. Profiler에서 GC Alloc이 0이 되는지 확인한다.
실수 2: foreach가 항상 할당이 없다고 믿는다
증상: List<T>를 foreach 돌린다고 생각했는데, 실제로는 IEnumerable을 반환하는 API를 foreach로 돌리면서 매 프레임 GC Alloc이 생긴다. 특히 LINQ 결과를 foreach로 순회할 때 자주 터진다.
원인: 컬렉션 타입에 따라 enumerator가 struct일 수도 있고 class일 수도 있다. IEnumerable/iterator 블록(yield return) 기반 열거자는 대체로 힙 객체를 만든다. Unity API 중에도 내부에서 iterator를 만드는 것이 있다.
해결: Update 경로에서는 LINQ와 iterator 기반 IEnumerable 사용을 피한다. List<T>는 for 루프로 돌려서 확실히 한다. Profiler에서 GC Alloc이 0인지로 판정한다.
실수 3: 코루틴을 Update에서 매 프레임 StartCoroutine 한다
증상: GC Alloc이 프레임마다 일정량 찍히고, 몇 초에 한 번씩 GC.Collect가 튄다. 동작은 정상인데 프레임이 규칙적으로 끊긴다.
원인: IEnumerator 상태 머신 객체가 Managed Heap에 만들어진다. yield 구문이 많을수록 상태 머신이 커지고, 매 프레임 생성하면 할당이 누적된다. 엔진은 그 IEnumerator를 보관하고 매 프레임 MoveNext를 호출한다.
해결: 코루틴은 이벤트 기반으로 시작하고, 한 번 시작한 코루틴을 재사용하거나 루프 내부에서 yield로 반복한다. 반복 타이머는 Update에서 float 누적 방식으로 바꾸는 것도 방법이다.
실수 4: 큰 struct를 무심코 복사해 스택/레지스터 압박을 만든다
증상: GC Alloc은 0인데도 CPU가 올라가고, Burst/Jobs 없이 순수 C# 계산이 느리다. 프로파일링하면 값 복사 비용이 생각보다 크다.
원인: 값 타입은 대입/인자 전달 때 복사된다. 크기가 큰 struct(예: 많은 필드를 가진 커스텀 struct)를 루프에서 계속 복사하면 스택/레지스터 이동이 늘어난다. 힙 문제는 아니지만 프레임 타임을 갉아먹는다.
해결: 큰 struct는 readonly ref 전달(in 키워드) 같은 패턴을 고려한다. Unity 초보 단계에서는 우선 struct를 작게 유지하고, 루프에서 불필요한 대입을 줄인다.
실수 5: UnityEngine.Object를 C# 객체처럼 new/GC로만 생각한다
증상: Destroy를 호출했는데도 메모리(특히 텍스처/메시)가 바로 안 줄어든다. GC를 돌려도 효과가 없고, 씬 전환 후에도 메모리 사용량이 높게 유지된다.
원인: UnityEngine.Object의 실체는 네이티브에 있고, C# 래퍼가 그 핸들을 들고 있다. Destroy는 네이티브 오브젝트 파괴 예약이고, 실제 해제 시점은 엔진 루프의 특정 단계에서 처리된다. 관리 힙 GC와는 별개다.
해결: Resources.UnloadUnusedAssets 같은 네이티브 리소스 정리 흐름을 이해한다. Addressables/AssetBundle을 쓰면 해제 타이밍을 더 명시적으로 관리한다. Profiler의 Memory 모듈에서 Managed/Native를 분리해 본다.
1using UnityEngine;
2
3public class BoxingTrapExample : MonoBehaviour
4{
5 interface ITag { }
6 struct Tag : ITag
7 {
8 public int id;
9 }
10
11 void Update()
12 {
13 // interface로 받는 순간 박싱이 발생한다(값 타입 -> 참조 타입)
14 ITag t = new Tag { id = Time.frameCount };
15
16 // 로그를 줄여도 GC Alloc이 찍히는지 Profiler로 확인한다
17 if ((Time.frameCount & 63) == 0)
18 Debug.Log("tag=" + t.GetType().Name);
19 }
20}이 코드는 Update에서 interface에 struct를 대입하는 순간 박싱이 생긴다. GC Alloc이 '로그를 적게 찍는데도' 계속 발생하는 케이스를 재현한다. 실무에서는 이벤트 시스템이나 상태 머신을 interface 기반으로 설계할 때 이 함정이 자주 나온다.
성능 최적화 체크리스트
- Profiler(Analysis → Profiler)에서 CPU Usage를 Timeline으로 두고, ScriptRunBehaviourUpdate 아래의 GC Alloc 컬럼을 먼저 본다
- Update/LateUpdate/FixedUpdate에서 new, string 결합, string.Format, ToString 호출을 금지 규칙으로 둔다
- Debug.Log는 60프레임에 1회 같은 샘플링 로그로 바꾸고, 에디터 전용 로그는 #if UNITY_EDITOR로 감싼다
- 값 타입을 object/interface로 넘기는 코드(박싱)를 검색한다: object, IEnumerable, params object[] 경로
- LINQ(Where/Select/ToList)와 iterator(yield return) 사용 위치를 점검하고, Update 경로에서 제거한다
- GetComponent/Find 계열 호출은 Awake/Start에서 캐싱하고, Update에서는 캐시된 참조만 사용한다
- Transform 접근은 루프 안에서 get/set 반복을 피하고, 지역 변수로 누적 후 루프 밖에서 한 번만 set한다
- 코루틴은 Update에서 매 프레임 생성하지 않고, 한 번 시작한 루프 코루틴을 유지하거나 타이머로 대체한다
- Instantiate/Destroy 남발을 피하고, 오브젝트 풀링으로 Native Heap 변동을 줄인다
- Memory Profiler 또는 Profiler Memory 모듈에서 Managed/Native/Graphics 메모리를 분리해서 본다
- GC.Collect를 임의로 호출하지 않고, 로딩 화면 등 안전 구간에서만 필요성을 검토한다
- FixedUpdate에서 할당이 생기면 물리 스텝 횟수만큼 누적되므로, FixedUpdate의 GC Alloc을 별도로 체크한다
자주 묻는 질문
Unity에서 스택과 힙을 구분하면 프레임 드랍 원인을 더 빨리 찾는 이유는 뭔가?
프레임 드랍이 두 패턴으로 나타나기 때문이다. 첫째는 GC.Collect로 인한 '뚝 끊김'이다. 이건 Managed Heap에 할당이 누적돼 임계치를 넘을 때 발생한다. 둘째는 바인딩 호출/탐색 호출 누적으로 인한 '계속 느림'이다. 이건 GC Alloc이 0이어도 발생한다. Profiler에서 GC Alloc이 찍히면 힙 문제로 좁혀지고, GC Alloc이 0인데 CPU가 높으면 Transform 접근, GetComponent, 물리/렌더 호출 같은 네이티브 경계 비용을 의심한다. 다음 학습 키워드는 Profiler Timeline 읽기, Managed vs Native 메모리, boxing/closure 패턴이다.
값 타입(struct)은 무조건 스택에 잡히나? Vector3도 스택인가?
값 타입은 '참조가 아니라 값이 복사된다'는 의미이지, 항상 스택이라는 의미가 아니다. JIT/IL2CPP 최적화에 따라 레지스터에 잡히거나, 호출 규약에 따라 스택에 복사될 수 있다. 더 중요한 건 박싱과 escape다. struct가 object나 interface로 변환되는 순간 Managed Heap에 박싱 객체가 만들어진다. 또한 struct를 캡처하는 클로저가 생기면 캡처 객체가 힙에 생긴다. Vector3 자체는 값 타입이라 보통 힙 할당을 만들지 않지만, Vector3를 object로 넘기거나, params object[]로 전달하거나, LINQ 캡처에 들어가면 힙이 섞인다. 다음 학습 키워드는 boxing, escape analysis, IL2CPP 최적화 차이이다.
Profiler에서 GC Alloc이 0인데도 Update가 느릴 수 있는 이유는 뭔가?
GC Alloc은 Managed Heap 할당만 보여준다. Unity에서 느림의 큰 비중은 네이티브 엔진 호출 비용이다. Transform.get_position/set_position, GetComponent, Physics.Raycast, Renderer 접근 같은 API는 C#에서 네이티브로 넘어가는 바인딩 호출을 한다. 이 호출은 메모리 할당이 없어도 비용이 들고, 특히 루프 안에서 수십만 번 반복하면 프레임 타임을 그대로 먹는다. 해결은 호출 횟수를 줄이는 구조 변경이다. Transform 값은 지역 변수로 캐싱하고, GetComponent는 Awake에서 캐싱한다. 다음 학습 키워드는 Unity 바인딩(ICall), PlayerLoop 단계, API 호출 비용 측정이다.
왜 GetComponent를 캐싱하면 빨라지나? C# 변수에 넣는 게 뭐가 다른가?
차이는 '탐색을 매번 하느냐'다. GetComponent<T>() 호출은 C# 제네릭처럼 보여도, 내부적으로 네이티브 GameObject가 가진 컴포넌트 리스트를 검색하는 경로로 들어간다. 리스트는 네이티브 메모리에 있고, 타입 매칭을 하며 순회한다. Update에서 매 프레임 호출하면 바인딩 호출 + 리스트 순회가 매 프레임 반복된다. Awake/Start에서 한 번만 호출해 캐싱하면, 이후에는 C# 필드에 든 참조를 쓰는 것이라 탐색 경로가 사라진다. GC Alloc이 0이어도 CPU 비용이 줄어드는 대표 사례다. 다음 학습 키워드는 UnityEngine.Object 래퍼, 컴포넌트 검색 비용, 캐싱 패턴이다.
왜 코루틴은 yield return null을 해야 하고, 그게 메모리와 무슨 관계가 있나?
코루틴은 엔진이 IEnumerator를 보관하고 PlayerLoop에서 매 프레임 MoveNext를 호출하는 스케줄링 모델이다. yield return null은 '다음 프레임까지 대기'라는 신호로 쓰인다. 문제는 코루틴이 공짜가 아니라는 점이다. C# 컴파일러가 yield 문을 상태 머신 클래스로 바꾸면서 IEnumerator 인스턴스가 Managed Heap에 만들어진다. Update에서 매 프레임 StartCoroutine을 호출하면 매 프레임 상태 머신 객체가 생성되고 GC Alloc이 누적된다. 해결은 코루틴을 한 번 시작한 뒤 내부에서 루프를 돌리거나, Update 타이머로 대체해 할당을 없애는 방식이다. 다음 학습 키워드는 iterator state machine, coroutine scheduler, allocation-free update loop이다.
IL2CPP 빌드에서는 GC가 덜 돈다던데, 그럼 힙/스택 최적화가 필요 없나?
IL2CPP는 C# 코드를 C++로 변환해 네이티브로 컴파일하지만, Managed Heap과 GC 개념이 사라지지 않는다. 여전히 관리 객체는 관리 힙에 있고, GC가 돌면 메인 스레드 스톨이 발생한다. 다만 JIT 대신 AOT 컴파일이라 최적화 양상이 다르고, 어떤 경우엔 박싱/문자열 경로가 조금 달라 보일 수 있다. 최적화의 방향은 동일하다. Update에서 할당을 만들지 않고, 바인딩 호출을 줄이며, 풀링으로 Native Heap 변동을 낮춘다. 다음 학습 키워드는 IL2CPP 메모리 모델, incremental GC, 플랫폼별 GC 튜닝이다.
GC Alloc을 없애려면 어떤 순서로 코드를 고쳐야 하나?
순서는 측정 기반이어야 한다. 1) Profiler Timeline에서 GC Alloc이 찍히는 스크립트/함수를 고정한다. 2) 그 함수 안에서 string 결합, ToString, string.Format, LINQ, foreach(특히 IEnumerable), params object[], boxing(object/interface 대입), 코루틴 생성(StartCoroutine) 같은 패턴을 찾는다. 3) 문자열은 이벤트 기반 갱신으로 바꾸고, 컬렉션 처리는 for 루프와 재사용 버퍼로 바꾸며, 박싱은 제네릭/구체 타입으로 바꾼다. 4) GC Alloc이 0이 된 뒤에도 느리면 Transform/GetComponent/Physics 같은 네이티브 호출 횟수를 줄이는 단계로 넘어간다. 다음 학습 키워드는 Profiler 사용법, allocation patterns, API call budgeting(프레임 예산)이다.