30. Unity에서 스택과 힙 차이: 메모리 배치·수명·GC까지
Unity에서 스택과 힙이 실제로 어디에 잡히는지, C# 래퍼와 C++ 엔진 메모리가 어떻게 연결되는지, GC Alloc이 왜 생기는지까지 엔진 관점으로 설명한다. (스택/힙/GC/PlayerLoop)))))))))))))))))))))))))))))))))))
Unity에서 스택과 힙 차이: 메모리 배치·수명·GC까지
게임 만들다 갑자기 60fps가 40fps로 떨어지고, Profiler에 GC.Collect가 12ms로 찍혀서 캐릭터가 순간이동하듯 끊기는 사고가 난다. 콘솔에는 에러가 없는데, Update에서 문자열 로그를 찍거나 GetComponent를 반복 호출한 뒤부터만 재현된다. 이런 유형은 대부분 “스택/힙/네이티브” 경계에서 생긴다. 어디에 할당되고 언제 살아남는지 모르면, 고친다고 고쳤는데 더 느려지는 일이 생긴다.))))))))))))))))))))))))))))))))))
핵심 개념
스택(Stack)과 힙(Heap) 구분은 “어디에 저장되나”보다 “누가 수명을 관리하나”에서 갈린다. 스택은 함수 호출(스택 프레임) 단위로 자동 정리되고, 힙은 런타임(GC)이 살아있는 객체 그래프를 추적해서 정리한다. Unity에서는 여기에 C++ 네이티브 힙(엔진이 직접 malloc/new로 잡는 영역)이 하나 더 끼어들어, C# 객체 하나가 ‘관리 힙 + 네이티브 오브젝트’ 두 군데에 나뉘어 존재하는 경우가 많다.
용어를 게임 코드 맥락으로 잡아두면 디버깅이 빨라진다. 1) 관리 힙(Managed Heap): C# new로 만든 참조형, 박싱된 값형, delegate/closure 등이 올라간다. 2) 스택 프레임(Stack Frame): Update 같은 메서드 안의 지역 변수와 인자, 그리고 JIT/AOT가 만든 호출 정보가 쌓인다. 3) 네이티브 오브젝트(Native Object): Transform, Mesh, Texture 같은 UnityEngine.Object의 실제 데이터가 C++ 쪽에 존재한다. 4) 핸들/포인터(IntPtr/InstanceID): C# 래퍼가 네이티브 오브젝트를 가리키는 연결 고리다.
왜 이 구분이 필요한가. 프레임 드랍의 원인 중 상당수가 ‘힙 할당 → GC 마킹/스윕 → 메인 스레드 스톱’ 흐름에서 나온다. 반대로 스택은 할당/해제가 사실상 포인터 이동이라 빠르지만, 스택에 큰 배열을 올리거나(unsafe/stackalloc) 재귀를 깊게 타면 스택 오버플로우가 난다. Unity에서는 스택이 빠르다고 해서 모든 걸 스택으로 옮길 수도 없다. 엔진 API 대부분이 참조형/네이티브 오브젝트와 묶여 있기 때문이다.
값형(struct)과 참조형(class)도 여기서 자주 오해가 생긴다. “struct는 스택”이라는 문장은 절반만 맞다. struct는 ‘값’이라서 변수에 담긴 위치(스택/힙/배열/다른 객체 필드)에 따라 같이 이동한다. 예를 들어 class 안의 struct 필드는 그 class가 힙에 있으면 struct도 힙에 ‘포함’된다. 성능 사고는 이 복사 특성 때문에 생긴다. 큰 struct를 Update에서 매 프레임 복사하면 CPU가 먼저 터진다.
1using UnityEngine;
2
3public class StackHeapPrimer : MonoBehaviour
4{
5 private class RefObj { public int x; }
6 private struct ValObj { public int x; }
7
8 void Update()
9 {
10 int localOnStack = 10;
11 RefObj a = new RefObj { x = localOnStack }; // 관리 힙에 객체 1개
12 ValObj b = new ValObj { x = localOnStack }; // 값 자체는 현재 스택 프레임의 지역 변수
13
14 Debug.Log($"stack local={localOnStack}, heap obj x={a.x}, val x={b.x}");
15 }
16}이 스크립트를 빈 씬에서 실행하면 Console에 매 프레임 문자열이 찍힌다. 그리고 Profiler에서 GC Alloc이 프레임마다 발생한다. 원인은 new RefObj 자체도 있지만, 더 큰 원인은 Debug.Log의 문자열 보간($"...")이 새 string을 만들기 때문이다. 여기서 중요한 포인트는 “스택/힙 차이”가 단순 이론이 아니라, Update에서 힙 할당이 생기면 PlayerLoop의 다음 프레임 어딘가에서 GC가 끼어들 수 있다는 점이다.
엔진 관점에서의 내부 동작
Unity 스크립트 한 줄은 보통 C# → 내부 호출(바인딩) → C++ 엔진으로 내려간다. C# 쪽은 관리 힙과 스택을 쓰고, C++ 쪽은 네이티브 힙과 엔진 전용 메모리 풀을 쓴다. UnityEngine.Object 계열(Transform, GameObject, Material 등)은 C# 객체가 ‘진짜 데이터’를 들고 있지 않고, 네이티브 오브젝트를 가리키는 핸들만 들고 있다.
PlayerLoop 관점에서 메모리 이벤트가 언제 튀는지 감이 필요하다. Update/LateUpdate에서 힙 할당이 누적되면, GC는 보통 “그 프레임의 특정 지점”에서 즉시 도는 게 아니라, 임계치에 도달했을 때 다음 프레임들 중 한 타이밍에서 메인 스레드를 멈추고 마킹/스윕을 수행한다. 그래서 원인 코드는 Update인데, 끊김은 몇 초 뒤에 발생하는 패턴이 흔하다.
GetComponent<T>()가 왜 느려지나. 호출 자체는 C# 제네릭처럼 보이지만, 내부적으로는 GameObject가 가진 컴포넌트 목록을 네이티브 쪽에서 조회하고, 해당 컴포넌트를 C# 래퍼로 다시 포장해 반환한다. 이 경로에서 네이티브↔관리 경계(마샬링/핸들 확인)가 반복된다. Update에서 1000번 호출하면, 그 1000번이 전부 경계를 넘는다.
처음에 나도 “Transform은 그냥 필드 접근이니까 공짜”라고 착각했다. 실제로는 transform 프로퍼티 접근 자체가 네이티브 오브젝트 핸들을 따라가며 유효성 체크를 한다. 예전에 모바일에서 Update마다 transform.position을 여러 번 읽는 코드가 있었는데, Profiler에서 Script.Update는 0.2ms인데 PlayerLoop 안쪽의 EngineLoop가 1ms씩 튀었다. 3시간 삽질 끝에 Transform 캐싱과 position 읽기 횟수 줄이기로 해결했고, 그때 ‘래퍼 프로퍼티도 경계 비용이 있다’는 걸 몸으로 배웠다.
IL2CPP 빌드에서는 JIT 대신 AOT로 C++ 코드가 생성된다. 스택 프레임과 값형 복사 비용은 결국 C++ 코드로 내려가며, boxing 같은 힙 할당은 여전히 관리 힙(IL2CPP 런타임의 GC 힙)에 생긴다. 에디터(Mono)와 디바이스(IL2CPP)에서 GC 타이밍과 비용이 달라 보일 수 있는 이유가 여기 있다.
Unity의 직렬화도 힙/네이티브를 흔든다. Inspector에 보이는 public 필드/SerializeField는 에디터에서 값이 바뀔 때마다 직렬화 데이터가 갱신된다. 런타임에서는 그 값이 관리 객체에 들어가지만, Texture/Mesh 같은 리소스는 네이티브 메모리에 큰 덩어리로 존재한다. 그래서 “C#에서 null로 바꿨는데 메모리가 안 줄어든다” 같은 현상은 네이티브 리소스의 참조 카운트/언로드 타이밍 문제인 경우가 많다.
1using UnityEngine;
2
3public class PlayerLoopOrderProbe : MonoBehaviour
4{
5 void Awake() => Debug.Log("Awake");
6 void OnEnable() => Debug.Log("OnEnable");
7 void Start() => Debug.Log("Start");
8
9 void FixedUpdate() => Debug.Log("FixedUpdate");
10 void Update() => Debug.Log("Update");
11 void LateUpdate() => Debug.Log("LateUpdate");
12
13 void OnDisable() => Debug.Log("OnDisable");
14 void OnDestroy() => Debug.Log("OnDestroy");
15}이 스크립트를 빈 GameObject에 붙이고 Play를 누르면 Console에 호출 순서가 쌓인다. FixedUpdate는 물리 타임스텝에 맞춰 0~여러 번 호출될 수 있고, Update/LateUpdate는 렌더 프레임에 맞춰 1번 호출된다. 메모리 관점에서 중요한 점은, FixedUpdate에서 힙 할당이 생기면 물리 프레임 수만큼 더 빨리 누적된다는 점이다. 같은 코드라도 Update에 있을 때보다 GC 임계치에 더 빨리 닿는다.
1using UnityEngine;
2
3public class GetComponentCostProbe : MonoBehaviour
4{
5 [SerializeField] private int callsPerFrame = 2000;
6 private Transform cached;
7
8 void Awake()
9 {
10 cached = GetComponent<Transform>();
11 }
12
13 void Update()
14 {
15 for (int i = 0; i < callsPerFrame; i++)
16 {
17 var t = GetComponent<Transform>();
18 _ = t.position;
19 }
20
21 for (int i = 0; i < callsPerFrame; i++)
22 {
23 _ = cached.position;
24 }
25 }
26}callsPerFrame을 2000으로 두고 Profiler를 열면, 스크립트 쪽에서 GetComponent가 잡히거나(버전에 따라) Engine 쪽 호출 비용이 늘어난다. 같은 프레임에서 cached.position 루프는 상대적으로 덜 튄다. 이유는 첫 루프가 매번 네이티브 컴포넌트 검색과 래퍼 반환 경계를 타는 반면, 두 번째 루프는 이미 잡아둔 래퍼가 같은 네이티브 오브젝트를 가리키며 검색 단계를 건너뛰기 때문이다.
실습하기
1단계: 프로젝트 설정과 Profiler 준비
Unity Hub → Projects → New project에서 3D(Core) 템플릿을 선택한다. Unity 버전은 2022.3 LTS 또는 2023.2 이상을 권한다(Profiler 모듈과 GC 관련 표시가 안정적이다). 프로젝트 이름은 MemoryLab로 둔다.
상단 메뉴 Window → Analysis → Profiler를 연다. Profiler 창에서 CPU Usage와 Memory 모듈을 켠다. 오른쪽 상단 Record 버튼이 켜져 있는지 확인한다. Play 모드에서 GC Alloc 컬럼이 보이도록 CPU Usage 모듈의 Timeline/Hierarchy를 번갈아 확인한다.
2단계: 씬 구성(오브젝트 배치, Inspector 값 입력)
Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만들고 이름을 MemoryTest로 바꾼다. Inspector에서 Add Component를 눌러 스크립트 2개를 붙일 예정이라, 먼저 Project 창에서 Scripts 폴더를 만든다(Project 우클릭 → Create → Folder).
MemoryTest 오브젝트에 붙일 스크립트는 1) 힙 할당을 유발하는 케이스, 2) 스택에서만 끝나는 케이스를 같은 프레임에 비교한다. Inspector에서 숫자를 바꿔가며 GC Alloc이 언제부터 생기는지 확인한다. 특히 callsPerFrame, arraySize 같은 필드는 Play 중에도 바꿀 수 있으니, 값 변경 후 Profiler 그래프가 즉시 바뀌는지 본다.
1using System.Text;
2using UnityEngine;
3
4public class HeapAllocLab : MonoBehaviour
5{
6 [SerializeField] private int logsPerSecond = 30;
7 [SerializeField] private int stringLength = 64;
8
9 private float acc;
10
11 void Update()
12 {
13 acc += Time.deltaTime;
14 if (acc < 1f / logsPerSecond) return;
15 acc = 0f;
16
17 // 힙 할당: StringBuilder 내부 버퍼 + 최종 문자열 생성
18 var sb = new StringBuilder(stringLength);
19 for (int i = 0; i < stringLength; i++) sb.Append((char)('a' + (i % 26)));
20 Debug.Log(sb.ToString());
21 }
22}Play를 누르면 Console에 일정 주기로 긴 문자열이 출력된다. Profiler CPU Usage에서 Scripts 쪽에 Debug.Log가 보이고, Memory/GC Alloc이 주기적으로 증가한다. logsPerSecond를 120으로 올리면 같은 1초 동안 더 많은 힙 할당이 누적되고, 몇 초 뒤 GC.Collect 스파이크가 보이기 시작한다. “원인 코드가 실행되는 시점”과 “끊김이 터지는 시점”이 어긋나는 걸 눈으로 확인하게 된다.
1using UnityEngine;
2
3public class StackOnlyLab : MonoBehaviour
4{
5 [SerializeField] private int iterations = 200000;
6
7 void Update()
8 {
9 int sum = 0;
10 for (int i = 0; i < iterations; i++)
11 {
12 int a = i;
13 int b = a * 2;
14 sum += b;
15 }
16
17 if ((Time.frameCount & 127) == 0)
18 Debug.Log("sum=" + sum);
19 }
20}이 스크립트는 루프가 크면 CPU는 쓰지만, 루프 자체는 스택 지역 변수만 사용한다. Profiler에서 Scripts의 시간이 늘어도 GC Alloc은 거의 늘지 않는다(단, Debug.Log("sum=" + sum)에서 문자열 결합이 발생하므로 128프레임마다 소량 할당이 생긴다). Debug.Log를 완전히 제거하면 GC Alloc이 0에 가까워진다. 같은 Update라도 “CPU 바운드”와 “GC 바운드”가 분리된다는 감각이 생긴다.
3단계: 힙을 줄이는 코드로 바꿔서 프레임 안정성 확인
Hierarchy에서 MemoryTest를 복제(Ctrl+D)하고 이름을 MemoryTest_NoAlloc로 바꾼다. HeapAllocLab 대신, 캐시된 StringBuilder를 재사용하는 스크립트를 붙인다. Inspector에서 logsPerSecond를 동일하게 맞춘 뒤, 두 오브젝트를 번갈아 활성/비활성 체크박스로 비교한다.
Inspector에서 GameObject 활성 체크박스를 끄면(On/Off) 해당 MonoBehaviour의 Update 호출 자체가 PlayerLoop에서 빠진다. 같은 씬에서 A/B 테스트가 가능하다. Profiler에서 GC Alloc 그래프가 “계단식으로 누적되는지” “거의 평평한지”가 차이로 드러난다.
1using System.Text;
2using UnityEngine;
3
4public class HeapAllocLab_NoAlloc : MonoBehaviour
5{
6 [SerializeField] private int logsPerSecond = 30;
7 [SerializeField] private int stringLength = 64;
8
9 private float acc;
10 private readonly StringBuilder sb = new StringBuilder(256);
11
12 void Update()
13 {
14 acc += Time.deltaTime;
15 if (acc < 1f / logsPerSecond) return;
16 acc = 0f;
17
18 sb.Clear();
19 for (int i = 0; i < stringLength; i++) sb.Append((char)('a' + (i % 26)));
20 Debug.Log(sb.ToString());
21 }
22}여기서도 마지막에 ToString은 새 string을 만든다. 그래서 GC Alloc이 0이 되지는 않는다. 대신 StringBuilder 자체의 내부 버퍼 재할당이 사라져, 할당량이 줄어든다. 로그 출력이 목적이 아니라 UI 텍스트 갱신이라면, 프레임마다 ToString을 만들지 않도록 설계를 바꾸는 게 더 큰 차이를 만든다(예: 변경될 때만 갱신, 숫자 포맷 캐시 등).
심화 활용
한 문단 요약: Unity 성능 사고의 상당수는 ‘관리 힙 할당(특히 Update/FixedUpdate) → 몇 초 뒤 GC 스파이크’ 패턴이다. 스택은 자동 정리되지만, UnityEngine.Object는 네이티브 메모리까지 얽혀 있어 C#에서 null 처리만으로 끝나지 않는다.
패턴 1: “관리 힙 0”에 가까운 Update 만들기(캐시 + 풀링 + 이벤트)
UI나 전투 로그처럼 매 프레임 문자열을 만드는 코드는 GC 스파이크를 부른다. 실무에서는 Update에서 문자열 생성 자체를 없애고, 데이터 변경 이벤트가 발생했을 때만 UI를 갱신한다. 그리고 List/Dictionary 같은 컬렉션은 재사용 풀을 둔다. 이유는 할당량을 줄이는 목적도 있지만, 더 중요한 건 “할당 패턴을 예측 가능하게” 만드는 데 있다.
1using System.Collections.Generic;
2using UnityEngine;
3
4public class ListPoolExample : MonoBehaviour
5{
6 private static readonly Stack<List<int>> pool = new Stack<List<int>>();
7
8 List<int> Rent()
9 {
10 if (pool.Count > 0) return pool.Pop();
11 return new List<int>(256);
12 }
13
14 void Return(List<int> list)
15 {
16 list.Clear();
17 pool.Push(list);
18 }
19
20 void Update()
21 {
22 var list = Rent();
23 for (int i = 0; i < 1000; i++) list.Add(i);
24 // list를 사용한 뒤 반환
25 Return(list);
26 }
27}이 코드를 실행하면 Update에서 매 프레임 List를 new 하지 않는다. Profiler에서 GC Alloc이 줄어든다. 내부적으로는 List의 배열 버퍼가 관리 힙에 남아 재사용된다. 단점은 풀에 쌓인 버퍼가 메모리를 계속 점유한다는 점이다. 그래서 모바일에서는 풀 사이즈 상한, 씬 전환 시 풀 비우기 같은 정책이 필요하다.
패턴 2: UnityEngine.Object는 ‘관리 힙’이 아니라 ‘네이티브’가 본체라는 전제로 다루기
Texture2D를 new로 만들거나 Instantiate를 반복하면, 관리 힙보다 네이티브 메모리가 먼저 터질 수 있다. Profiler의 Memory 모듈에서 GC Heap이 평평한데도 앱이 죽는 케이스가 여기서 나온다. C# 래퍼가 살아있으면 네이티브 리소스가 유지되는 경우가 많고, 반대로 C# 래퍼가 죽어도 네이티브가 즉시 해제되지 않는 경우도 있다(언로드 타이밍, 참조, 내부 캐시).
1using UnityEngine;
2
3public class NativeMemoryTrap : MonoBehaviour
4{
5 [SerializeField] private int width = 1024;
6 [SerializeField] private int height = 1024;
7 [SerializeField] private int perSecond = 10;
8
9 private float acc;
10
11 void Update()
12 {
13 acc += Time.deltaTime;
14 if (acc < 1f / perSecond) return;
15 acc = 0f;
16
17 // 네이티브 메모리 큰 덩어리 생성(RGBA32면 대략 4MB)
18 var tex = new Texture2D(width, height, TextureFormat.RGBA32, false);
19 tex.SetPixel(0, 0, Color.red);
20 tex.Apply();
21
22 // 참조를 버려도 즉시 네이티브가 회수된다는 보장은 없다.
23 // Destroy를 호출하지 않으면 누적되는 케이스가 흔하다.
24 Destroy(tex);
25 }
26}Game 뷰에는 변화가 없는데 Memory 모듈에서 Unity Objects/Native가 출렁인다. Destroy(tex)를 빼면 상승 추세가 더 뚜렷해진다. 여기서 스택/힙만 알고 있으면 “new 했으니 GC가 치우겠지”로 끝나지만, UnityEngine.Object는 네이티브가 본체라 Destroy/UnloadUnusedAssets 같은 엔진 경로를 타야 한다.
처음에 나도 Addressables로 로드한 Texture를 C# 참조에서만 끊으면 메모리가 내려갈 거라 믿었다. 실제 현상은 Android에서 10분 플레이 후 ‘java.lang.OutOfMemoryError: Failed to allocate a 16777216 byte allocation’이 터졌다. Profiler에서 GC Heap은 40MB로 멀쩡했고, Native가 1.2GB까지 올라가 있었다. 원인은 “Release 호출 누락 + 텍스처 인스턴스화(복제) + 씬 전환 시 풀에 남은 참조” 3종 세트였다. 그날은 로그로 InstanceID를 찍고, Resources.FindObjectsOfTypeAll로 살아있는 텍스처를 세면서 원인을 좁혔다.
스택/힙 지식이 실무에서 먹히는 지점은 ‘어떤 메모리 그래프를 믿어야 하는가’다. GC Heap이 안정적이어도 네이티브가 새고 있으면 크래시가 난다. 반대로 네이티브가 안정적인데 GC가 튀면 입력이 끊긴다. 둘을 분리해서 보는 습관이 필요하다.
자주 하는 실수
실수 1: “struct는 스택”이라고 믿고 큰 struct를 마구 복사함
증상: Update에서 값 몇 개 계산하는데도 CPU Usage에서 Scripts가 2~5ms까지 올라간다. GC Alloc은 0인데 프레임이 떨어져서 원인을 못 찾는다. 특히 List<T>.ForEach나 LINQ 없이도 느리다.
원인: 큰 struct(예: 64바이트 이상)를 메서드 인자로 전달하거나 프로퍼티로 반환하면서 복사가 반복된다. struct는 위치가 스택이든 힙이든 ‘값 복사’가 기본 동작이다. Unity의 Vector3는 작아서 괜찮은 편이지만, 사용자 정의 struct가 커지면 급격히 비싸진다.
해결: 큰 struct는 readonly ref(in) 전달을 고려하거나, 내부에 배열/참조를 넣는 설계를 피한다. 프로퍼티 반환 대신 out 파라미터로 채우는 방식도 효과가 있다. 그리고 Profiler에서 GC가 아니라 CPU가 문제인지 먼저 분리한다.
실수 2: boxing을 모르고 interface/Debug.Log로 값형을 계속 박싱함
증상: Profiler에 GC Alloc이 프레임마다 몇십 바이트씩 쌓인다. 시간이 지나면 GC.Collect가 5~20ms로 튀고 조작이 끊긴다. Console에는 에러가 없고, 코드도 new를 안 쓴 것처럼 보인다.
원인: 값형이 object나 interface로 업캐스팅되면 박싱이 발생해 힙에 새 객체가 생긴다. 예: Debug.Log(someInt), string.Format, 인터페이스 리스트에 struct를 넣는 경우. “new를 안 썼는데 할당된다”는 느낌의 대표 원인이다.
해결: Profiler의 GC Alloc 컬럼에서 호출 스택을 열어 박싱 지점을 찾는다. Debug.Log는 개발 빌드에서만 켜고, 런타임 로그는 링버퍼로 쌓아 한 번에 출력한다. interface 대신 제네릭/구체 타입을 쓰거나, 값형을 참조형으로 바꾸는 것도 선택지다.
1using UnityEngine;
2
3public class BoxingTrap : MonoBehaviour
4{
5 void Update()
6 {
7 int score = Time.frameCount;
8 object boxed = score; // boxing: 힙 할당
9 Debug.Log(boxed);
10
11 // boxing은 이런 형태에서도 숨어든다
12 Debug.Log(string.Format("score={0}", score));
13 }
14}이 코드를 실행하면 GC Alloc이 프레임마다 발생한다. 특히 string.Format은 내부적으로 object[]를 만들면서 할당이 커진다. Console 출력이 목적이면 에디터에서만 켜고, 디바이스에서는 끄는 쪽이 안전하다.
실수 3: Update에서 GetComponent/transform을 반복 호출하고 “힙이 아니라서 괜찮다”고 판단함
증상: GC Alloc은 0인데도 CPU가 늘고, 특정 기기에서만 프레임이 떨어진다. Profiler Hierarchy에서 Scripts.Update는 작게 보이는데, PlayerLoop 하위의 Engine 항목이 커진다.
원인: GetComponent/transform 접근은 네이티브 경계를 타며 검색/유효성 체크가 들어간다. 관리 힙 할당이 없어도 네이티브 호출 비용은 누적된다. 특히 Update에서 수천 번 반복하면 0.1ms 단위로 쌓여 2~3ms가 된다.
해결: Awake/OnEnable에서 컴포넌트를 캐싱하고, 루프 안에서는 캐시된 참조를 쓴다. Transform 값은 한 프레임에 여러 번 읽지 말고 지역 변수로 받아 재사용한다. Profiler에서 GC만 보지 말고 CPU Timeline에서 네이티브 호출을 같이 본다.
실수 4: UnityEngine.Object를 C# 참조에서만 끊고 메모리가 내려가길 기대함
증상: 씬을 여러 번 로드/언로드하면 메모리가 계속 증가한다. GC Heap은 안정적인데 앱이 크래시하거나, iOS에서 jetsam으로 종료된다. 혹은 “MissingReferenceException: The object of type 'Texture2D' has been destroyed but you are still trying to access it.”가 뜬다.
원인: UnityEngine.Object는 네이티브 메모리가 본체다. C#에서 null로 만들거나 스코프를 벗어나도 네이티브가 즉시 해제되지 않는다. 반대로 Destroy를 했는데도 C# 래퍼를 잡고 있으면 MissingReferenceException이 난다(네이티브는 죽었는데 래퍼는 살아있음).
해결: 런타임 생성 리소스는 Destroy를 호출하고, Addressables/AssetBundle은 Release/Unload 경로를 지킨다. 씬 전환 시 풀/싱글톤이 리소스 참조를 계속 잡고 있지 않은지 확인한다. Memory Profiler 패키지로 Native 오브젝트 수를 추적한다.
실수 5: 코루틴/람다로 캡처를 만들고 할당이 없다고 착각함
증상: 평소에는 괜찮다가 전투 시작 같은 이벤트 이후부터 GC Alloc이 프레임마다 생긴다. 코드를 보면 new를 안 쓰고, yield return null만 있다. Profiler에서 Scripts 아래에 “GC.Alloc”이 작은 단위로 반복된다.
원인: 코루틴은 상태 머신 객체가 힙에 생성된다. 람다/익명 메서드는 캡처 클래스(closure)가 힙에 생성될 수 있다. 특히 Update에서 매 프레임 새 코루틴을 StartCoroutine하면 상태 머신이 계속 쌓인다.
해결: 코루틴은 1회 시작 후 내부 루프에서 yield return null로 유지하고, 중복 시작을 막는다. 람다는 캐시하거나, 캡처가 없는 정적 메서드/명시적 메서드로 바꾼다. Profiler에서 GC Alloc 콜스택을 열어 “DisplayClass/MoveNext”가 보이면 캡처/코루틴이 원인이다.
성능 최적화 체크리스트
- Profiler(CPU Usage)에서 GC Alloc 컬럼을 켜고, Update/FixedUpdate에서 0B를 목표로 측정한다
- Profiler(Memory)에서 GC Heap과 Native(또는 Unity Objects) 그래프를 분리해서 본다
- Update에서 Debug.Log, 문자열 보간($""), string.Format 호출을 제거하거나 개발 빌드에서만 활성화한다
- GetComponent/Find/transform 접근은 Awake/OnEnable에서 캐싱하고, 프레임 루프에서는 캐시만 사용한다
- LINQ(Where/Select/ToList)와 foreach 캡처가 Update에서 돌지 않게 검색/필터링을 사전 계산한다
- boxing 지점을 찾기 위해 interface/object 업캐스팅과 params(object[]) API 호출을 점검한다
- 코루틴은 중복 StartCoroutine을 막고, 필요하면 단일 코루틴을 유지하며 상태만 바꾼다
- 런타임 생성 Texture2D/Mesh/Material은 Destroy를 호출하고, Addressables/AssetBundle은 Release/Unload 규칙을 지킨다
- List/Dictionary 재사용이 필요한 구간은 풀링하되, 풀 상한과 씬 전환 시 정리 정책을 둔다
- FixedUpdate에서 할당이 생기면 물리 스텝 수만큼 누적되므로, 물리 루프는 특히 할당 0을 강제한다
- 대형 struct는 복사 비용을 의심하고, in/ref 전달과 데이터 레이아웃을 점검한다
- 프레임 예산을 수치로 고정한다: 60fps면 16.6ms, 스크립트는 기기 기준 2~4ms 내로 제한한다
자주 묻는 질문
Unity에서 ‘스택에 잡히는 변수’는 정확히 무엇인가? MonoBehaviour 필드는 스택인가?
스택에 잡히는 것은 “현재 실행 중인 함수 호출”이 가진 지역 변수/인자/임시값이다. Update 안의 int, Vector3 같은 지역 변수는 그 Update 호출의 스택 프레임에 들어간다. 반면 MonoBehaviour의 필드는 스택이 아니다. MonoBehaviour 인스턴스 자체가 관리 힙에 존재하고(또는 IL2CPP 런타임의 관리 힙), 그 필드들은 그 객체 메모리 안에 포함된다. 그래서 필드는 메서드가 끝나도 남아 있고, 씬에서 오브젝트가 파괴되거나 참조가 끊겨 GC 대상이 될 때까지 살아남는다. 다음 학습 키워드는 stack frame, managed object layout, field lifetime이다.
‘struct는 스택’이라는 말이 왜 계속 틀리게 들리나? Vector3는 어디에 저장되나?
struct는 ‘저장 위치가 고정’된 타입이 아니라 ‘값 복사 규칙’을 가진 타입이다. Vector3가 지역 변수면 스택 프레임에 들어가고, class의 필드면 그 class가 있는 힙에 함께 들어가며, 배열 요소면 배열 버퍼(힙)에 들어간다. 그래서 Vector3를 프로퍼티로 반환할 때 복사가 발생할 수 있고, 큰 struct를 반복 반환하면 CPU가 늘어난다. Unity의 Transform.position은 Vector3를 값으로 반환하므로, 읽을 때마다 값 복사 자체는 생긴다. 다음 학습 키워드는 value type semantics, copying cost, in/ref parameters이다.
UnityEngine.Object는 C# 객체인데 왜 Destroy를 해야 하나? GC가 치우면 안 되나?
UnityEngine.Object 계열은 C# 객체가 ‘래퍼’이고, 실제 리소스(텍스처 픽셀, 메시 버텍스, 컴포넌트 데이터)는 C++ 네이티브 메모리에 있다. GC는 관리 힙만 관리하므로, 네이티브 리소스의 해제 타이밍을 직접 보장하지 못한다. Unity는 Destroy를 통해 “이 네이티브 오브젝트를 엔진 쪽에서 파괴 목록에 넣어달라”는 요청을 받는다. Destroy 이후에도 C# 래퍼 참조는 남을 수 있어 MissingReferenceException이 발생한다. 다음 학습 키워드는 native object lifetime, wrapper/handle, Destroy vs GC이다.
GC Alloc이 0인데도 프레임이 떨어진다. 스택/힙 문제는 아닌가?
GC Alloc이 0이라는 사실은 ‘관리 힙 할당이 없다’는 뜻이지, 비용이 없다는 뜻이 아니다. 네이티브 경계를 자주 넘는 호출(GetComponent, transform 접근, Physics 쿼리, 렌더 상태 변경)은 힙 할당 없이도 CPU를 많이 쓴다. 또 큰 struct 복사, 캐시 미스, 과도한 루프 같은 순수 CPU 문제도 GC와 무관하게 프레임을 깎는다. Profiler Timeline에서 PlayerLoop 하위의 Engine 영역과 Scripts 영역을 분리해 보고, Deep Profile은 짧게만 켜서 원인을 좁힌다. 다음 학습 키워드는 native call overhead, profiling timeline, CPU-bound vs GC-bound이다.
왜 Update와 FixedUpdate가 분리되어 있고, 메모리 관점에서 뭐가 달라지나?
FixedUpdate는 물리 시뮬레이션의 안정성을 위해 고정된 시간 간격으로 돌고, Update는 렌더 프레임에 맞춰 변동 간격으로 돈다. 메모리 관점에서 차이는 ‘호출 횟수’다. 프레임이 느려지면 한 렌더 프레임 동안 FixedUpdate가 2~3번 호출될 수 있어, 같은 할당 코드라도 누적 속도가 더 빠르다. 그래서 FixedUpdate에서의 작은 할당(예: 작은 배열, boxing, 문자열)이 몇 초 만에 GC 임계치를 넘기는 경우가 있다. 다음 학습 키워드는 fixed timestep, physics loop, allocation rate(alloc/sec)이다.
코루틴에서 yield return null을 쓰면 왜 다음 프레임으로 넘어가나? 그게 스택/힙과 무슨 관련이 있나?
코루틴은 C# 컴파일러가 상태 머신 클래스로 바꾼다. 즉, 실행 상태(현재 어디까지 실행했는지, 지역 변수 값)를 담는 객체가 힙에 생긴다. yield return null은 Unity의 코루틴 스케줄러에 “다음 Update 타이밍에 다시 MoveNext를 호출해 달라”는 신호로 쓰인다. 스택 프레임은 한 번의 호출이 끝나면 사라지므로, 여러 프레임에 걸친 실행을 스택만으로 유지할 수 없다. 그래서 코루틴은 힙 객체로 상태를 저장한다. 다음 학습 키워드는 iterator state machine, MoveNext, coroutine scheduler이다.
IL2CPP 빌드에서 스택/힙 동작이 Mono 에디터와 다르게 느껴진다. 무엇을 믿어야 하나?
에디터(Mono)는 JIT 기반이고, 디바이스(IL2CPP)는 AOT로 C++ 코드가 생성된다. 스택 프레임 구성, 인라이닝, 값형 복사 비용 같은 CPU 특성이 달라질 수 있고, GC 구현과 튜닝도 차이가 난다. 그래서 에디터에서 GC Alloc이 작아 보여도 디바이스에서 스파이크가 더 커질 수 있다. 성능 판단은 목표 기기에서 Development Build + Autoconnect Profiler로 측정한 값을 기준으로 한다. 그리고 Memory Profiler로 Native/Managed를 함께 본다. 다음 학습 키워드는 IL2CPP AOT, Mono vs IL2CPP profiling, device profiling workflow이다.