[Unity] GC를 돌려도 왜 메모리가 안 줄어드는 이유 — 관리되는 메모리, Clean/Dirty 메모리, 세그먼트 그리고 .NET VM
모바일 빌드 프로파일러를 보다가 이상한 걸 발견했습니다. GC.Collect()를 강제로 호출했는데, 프로파일러의 메모리 수치가 거의 안 줄어드는 겁니다. "GC가 청소를 했으면 메모리가 줄어야 하는 거 아닌가?" 싶어서 파고들었습니다.
결론부터 말하면, 이건 버그도 아니고 GC가 일을 안 한 것도 아니었습니다. iOS/Android 같은 OS가 메모리를 바라보는 방식(Clean/Dirty)과, Unity의 관리되는 힙이 메모리를 다루는 방식(세그먼트, free list)을 알아야 이 현상이 이해됩니다.
이 글의 목표는 세 가지입니다.
- OS가 메모리를 "지워도 되는 것/못 지우는 것"으로 나눈다는 걸 이해하고,
- Unity의 관리되는 힙(Managed Heap)이 세그먼트 단위로 동작하는 원리를 보고,
- 그래서 "GC를 돌려도 왜 Dirty 메모리는 안 줄어드는지"를 끝까지 설명합니다.
결론부터 3줄로 요약하면 다음과 같습니다.
- OS는 메모리를 "디스크에서 다시 만들 수 있는가"로 나눕니다. 만들 수 없는(Dirty) 메모리만 앱의 실제 위협으로 집계됩니다.
- Unity의 GC는 죽은 객체를 "논리적으로만" 정리합니다 — 그 메모리 공간을 재사용 목록(free list)에 올려둘 뿐, OS에 돌려주지 않습니다.
- 그래서 GC 직후에도 Dirty 메모리 수치는 거의 그대로고, 물리 메모리가 실제로 줄어드는 건 리맵(Remap)이라는 훨씬 드문 별도 이벤트에서입니다.
1. 사전 지식 — OS는 "프로세스 하나"만 보고, 그 안을 나누는 건 Unity 자신이다
본론(Clean/Dirty, GC)에 들어가기 전에, "OS가 Unity를 어떻게 보고, Unity는 그 안에서 메모리를 어떻게 쪼개는가"부터 정리하고 가겠습니다. 이걸 먼저 잡아야 뒤의 Clean/Dirty 이야기가 "어느 레벨에서 벌어지는 일인지" 헷갈리지 않습니다.
1.1 OS 입장: Unity는 그냥 프로세스 하나 (블랙박스)
Unity 프로파일러를 보면 메모리가 "관리되는 메모리(Managed)"와 "네이티브 메모리(Native)"로 나뉘어 표시됩니다. 처음엔 이게 OS 레벨의 구분인 줄 알았는데, 아니었습니다.
OS 입장에서 Unity 앱은 그냥 프로세스 하나입니다. OS는 "이 프로세스가 메모리를 얼마나 썼는지"만 알지, 그 메모리가 C# 객체를 위한 건지 렌더 텍스처를 위한 건지는 전혀 모릅니다.
@startuml
skinparam backgroundColor #111217
skinparam defaultFontColor #F5F5F5
skinparam ArrowColor #FFB86C
skinparam componentBorderColor #BD93F9
skinparam componentBackgroundColor #1E1B2E
skinparam componentFontColor #F5F5F5
skinparam packageBorderColor #FFB86C
skinparam packageFontColor #F5F5F5
skinparam noteBackgroundColor #1E1B2E
skinparam noteBorderColor #FFB86C
skinparam noteFontColor #F5F5F5
title OS가 보는 것 (블랙박스 1개)
package "OS" {
[Unity 프로세스] as Proc
}
note right of Proc : "이 프로세스가 메모리를\n얼마나 쓰는가"만 봄\n(Clean인지 Dirty인지만 구분,\n안에 뭐가 들었는지는 모름)
@enduml
1.2 Unity 내부: 그 "한 덩어리"를 다시 5개로 쪼갠다
OS에게는 그냥 한 덩어리로 보이는 가상 메모리를, Unity는 프로파일러에서 다시 확인할 수 있게 5개 카테고리로 내부 분류합니다. 이 분류는 순전히 Unity(와 그 위의 .NET VM)가 "누가 이 메모리에 책임이 있는가"로 나눈 소프트웨어 레벨 구분입니다.
@startuml
skinparam backgroundColor #111217
skinparam defaultFontColor #F5F5F5
skinparam ArrowColor #FFB86C
skinparam rectangleBorderColor #BD93F9
skinparam rectangleBackgroundColor #1E1B2E
skinparam rectangleFontColor #F5F5F5
skinparam packageBorderColor #FFB86C
skinparam packageFontColor #F5F5F5
title Unity 프로세스 내부 — 가상 메모리 할당 분포 (OS 입장에선 전부 "한 덩어리")
package "Unity 프로세스의 가상 메모리 주소공간" {
rectangle "바이너리" as Binary
rectangle "유니티\n네이티브 메모리" as NativeMem
rectangle "유니티\n관리되는 메모리" as ManagedMem
rectangle "그래픽스" as Graphics
rectangle "네이티브\n플러그인" as Plugin
}
Binary -[hidden]right-> NativeMem
NativeMem -[hidden]right-> ManagedMem
ManagedMem -[hidden]right-> Graphics
Graphics -[hidden]right-> Plugin
@enduml
각 칸이 뭘 의미하는지 정리하면 이렇습니다.
| 카테고리 | 정체 | 누가 관리하나 |
|---|---|---|
| 바이너리 | Unity 실행 파일 자체(IL2CPP라면 컴파일된 네이티브 코드 포함) | OS가 실행파일에서 그대로 매핑 — 디스크 원본과 동일해서 대체로 Clean |
| 유니티 네이티브 메모리 | Unity 엔진 C++ 코드가 런타임에 쓰는 내부 시스템/매니저용 메모리 | Unity 엔진이 직접 alloc/free |
| 유니티 관리되는 메모리 | new로 만든 C# 객체, 스크립팅 스택, VM 자체의 타입 메타데이터 |
.NET VM(Mono/IL2CPP)의 GC가 관리 |
| 그래픽스 | 텍스처, 렌더타겟, 버텍스버퍼 등 GPU 관련 리소스 | Unity 렌더링 파이프라인이 관리 |
| 네이티브 플러그인 | 개발자가 붙인 서드파티 네이티브(.dll/.so) 라이브러리 | Unity 엔진이 아니라 그 플러그인 코드 자신이 관리 |
핵심은 "관리되는 메모리"와 "네이티브 메모리"를 굳이 따로 뽑아놓은 이유입니다 — "이 메모리 문제, 내 C# 스크립트 탓인가 엔진/플러그인 탓인가"를 프로파일러에서 구분할 수 있게 하려는 겁니다. OS는 이 5개의 존재 자체를 모르고, 오직 Unity(그리고 Unity 위에서 도는 .NET VM)만 이 구분을 이해합니다.
이제 이 다섯 칸 중에서 이 글이 계속 파고들 곳은 하나, "유니티 관리되는 메모리"입니다. 그런데 이 칸을 이해하려면 먼저, OS가 진짜로 신경 쓰는 축이 뭔지부터 봐야 합니다.
2. OS가 진짜로 나누는 기준: Clean 메모리 vs Dirty 메모리
iOS/Android처럼 메모리가 빡빡한 환경에서, OS가 앱을 강제 종료(OOM Kill / iOS의 Jetsam)시킬지 말지를 판단하는 기준은 "이 메모리를 지워도 되는가"입니다.
| Clean 메모리 | Dirty 메모리 | |
|---|---|---|
| 정체 | 디스크의 원본과 내용이 완전히 동일한 페이지 (실행 코드, 안 건드린 압축 에셋 등) | 디스크에 원본이 없거나 수정된 페이지 |
| 메모리 부족 시 | OS가 그냥 버려도 됨 — 필요하면 디스크에서 다시 읽으면 되니까 | 못 버림 — 버리면 그 데이터는 영영 사라짐 |
| 회수 방법 | 그냥 evict (공짜) | 압축하거나, 앱이 직접 해제하거나 |
앱의 메모리 풋프린트(=OS가 앱을 죽일지 판단하는 기준값)는 대략 이렇게 계산됩니다.
메모리 풋프린트 ≈ (압축 안 된) Dirty 메모리 + (압축된) Dirty 메모리
Clean 메모리는 이 계산에 거의 안 들어갑니다. 그래서 최적화의 실질적 타겟은 "얼마나 많은 메모리를 할당했는가"가 아니라 "그중 Dirty가 얼마나 되는가"입니다.
Dirty를 만드는 대표적인 원인
- 힙 할당 + 값 쓰기:
new byte[1000]자체보다, 그 배열에 실제 값을 써넣는 순간 그 페이지가 Dirty로 전환됩니다. - 런타임에 생성/가공되는 그래픽 리소스: RenderTexture처럼 디스크 원본이 없는 것들.
- 심볼이 재배치된 프레임워크: 원래 여러 앱이 공유하는 Clean 코드 페이지가, 로드 시점에 주소 재배치(relocation)로 수정되면 그 순간부터 공유 불가 + Dirty.
- 스크립팅 백엔드(VM) 자체: 다음 절에서 자세히 다룹니다.
3. 압축된 Dirty 메모리 — 메모리 부족 시 무슨 일이 일어나나
메모리가 부족해지면 iOS는 이런 순서로 대응합니다.
- Clean 메모리는 그냥 버림 (공짜, evict)
- Dirty 메모리 중 최근에 안 건드린(=드물게 접근하는) 페이지를 찾아서 압축 알고리즘으로 실제 크기를 줄여 RAM에 그대로 둠
- 그래도 부족하면 앱을 Jetsam으로 강제 종료
압축해도 그 메모리는 여전히 "Dirty"입니다. 디스크에 원본이 없다는 사실 자체는 안 바뀌니까요. 다만 압축된 만큼만 실제 RAM을 차지하고, 나중에 그 메모리를 다시 건드리면 그 순간 압축이 풀립니다(CPU 비용 + 순간적인 끊김 발생). 이게 바로 앞서 말한 풋프린트 공식에서 "압축된 Dirty 메모리"가 별도 항으로 들어가는 이유입니다.
4. 왜 하필 IL2CPP/스크립팅 백엔드가 Dirty를 폭증시키나
여기서 헷갈렸던 부분이 하나 있습니다. IL2CPP는 C#을 C++로, 다시 네이티브 코드로 빌드 시점에 미리 컴파일하는 방식(AOT)인데, 왜 "가상머신(VM)"이라는 개념이 계속 따라붙는 걸까요.
C#은 애초에 "관리된 실행 환경" 없이는 못 돕니다
C#/.NET은 관리형(managed) 언어입니다. 코드가 CPU 명령어로 컴파일됐는지 여부와 별개로, 다음 서비스들이 런타임에 항상 필요합니다.
- 가비지 컬렉션(GC)
- 타입 시스템 / 리플렉션 (
Type,MethodInfo등) - 예외 처리 메커니즘
- 제네릭 타입 인스턴스 해석
이 서비스를 제공하는 계층이 바로 ".NET 가상머신(VM)"이고, JVM과 정확히 같은 위상의 개념입니다. "VM = 컴퓨터를 통째로 흉내내는 것"이 아니라 "관리된 코드가 실행되는 추상 환경"을 뜻합니다.
Mono와 IL2CPP는 "같은 VM 역할"의 다른 구현체
| Mono | IL2CPP | |
|---|---|---|
| 내 C# 코드 실행 방식 | IL 바이트코드를 런타임에 인터프리터/JIT로 직접 해석 | 빌드 시점에 이미 네이티브 코드로 컴파일 완료 |
| 엄밀한 의미의 "VM"인가 | ✅ 정확히 부합 (바이트코드 해석) | 🔺 느슨한 의미 (GC/타입시스템 서비스는 여전히 제공하지만, 코드 해석은 안 함) |
| GC/타입 메타데이터 제공 | Mono 런타임이 담당 | libil2cpp라는 자체 런타임 라이브러리가 담당 |
Unity가 공식적으로 "Scripting Backend"라는 중립적 용어를 쓰는 이유가 여기 있습니다. Mono와 IL2CPP는 실행 방식은 완전히 다르지만, "C# 객체가 .NET 객체로서 요구하는 서비스(GC/타입시스템)를 제공한다"는 같은 역할을 하기 때문에, 특정 실행 모델(인터프리터)을 전제하는 "VM"보다 "Scripting Backend"가 둘 다 포괄하는 더 정확한 표현입니다.
그래서 Dirty가 왜 폭증하나
앱 시작 시, 이 VM(Mono든 IL2CPP든)은 게임에서 쓰는 모든 C# 타입·메서드·제네릭 조합에 대한 메타데이터 테이블을 전부 만들어서 채워 넣어야 합니다. 이게 결국 "힙 할당 + 값 쓰기"를 대규모로 반복하는 것과 같아서, 게임이 크고 타입/제네릭이 많을수록 초기화 시점의 Dirty 메모리가 커집니다.
거기다 IL2CPP는 게임 로직 전체가 네이티브 함수로 컴파일되어 있어서, 로드 시점에 심볼 재배치(relocation)도 훨씬 격하게 일어납니다 — 원래 Clean이던 프레임워크 페이지들이 이 과정에서 대거 Dirty로 전환됩니다.
실무 팁: 네이티브 VM 메모리 사용량이 크다는 건 대개 불필요한 타입 정보가 많이 포함되어 있다는 신호입니다. 리플렉션 사용을 최소화하고, Managed Code Stripping으로 안 쓰는 코드를 제거하면 이 부분을 줄일 수 있습니다.
5. 관리되는 힙 내부 — 세그먼트, 그리고 GC가 메모리를 못 줄이는 진짜 이유
이제 처음 질문으로 돌아갑니다. "GC.Collect()를 불렀는데 왜 Dirty 메모리가 안 줄어드는가."
5.1 힙은 페이지가 아니라 "세그먼트" 단위로 관리된다
관리되는 힙에서 new로 객체를 만들면, 그 객체는 세그먼트(Segment)라는 더 큰 단위 안에 배치됩니다. 하나의 세그먼트는 여러 페이지(보통 4~16KB)로 이루어져 있고, 그 안에 여러 오브젝트가 들어갈 수 있습니다.
@startuml
skinparam backgroundColor #111217
skinparam defaultFontColor #F5F5F5
skinparam ArrowColor #FFB86C
skinparam activityBackgroundColor #1E1B2E
skinparam activityBorderColor #BD93F9
skinparam activityFontColor #F5F5F5
skinparam activityDiamondBackgroundColor #2D2442
skinparam activityDiamondBorderColor #FFB86C
skinparam noteBackgroundColor #1E1B2E
skinparam noteBorderColor #FFB86C
skinparam noteFontColor #F5F5F5
title 새 할당이 발생했을 때 GC가 공간을 찾는 순서
start
:new로 객체 할당 요청;
:기존 세그먼트 내부에서\n연속된 빈 공간을 검색;
if (충분한 연속 공간이 있는가?) then (예)
:그 공간을 즉시 재사용;
else (아니오)
:다른 세그먼트도 탐색;
if (어디에도 공간이 없는가?) then (예)
:OS에 새 세그먼트를 요청(예약)\n→ 여기서 가상 메모리가 늘어남;
endif
endif
:객체를 그 공간에 배치;
stop
@enduml
포인트는 "한 오브젝트는 반드시 연속된 페이지에 배치되어야 한다"는 제약입니다. 여러 위치에 흩어진 페이지로 객체 하나를 나눠 담을 수 없습니다. 이게 나중에 메모리 단편화(Fragmentation) 문제의 원인이 됩니다.
5.2 GC는 "논리적으로만" 청소한다 — 핵심
GC가 실행되면, 더 이상 참조되지 않는 오브젝트(unreachable object)를 찾아서 제거합니다. 그런데 이 "제거"가 OS에 메모리를 돌려주는 게 아닙니다.
@startuml
skinparam backgroundColor #111217
skinparam defaultFontColor #F5F5F5
skinparam ArrowColor #FFB86C
skinparam sequenceLifeLineBorderColor #BD93F9
skinparam sequenceLifeLineBackgroundColor #1E1B2E
skinparam sequenceParticipantBorderColor #BD93F9
skinparam sequenceParticipantBackgroundColor #1E1B2E
skinparam sequenceParticipantFontColor #F5F5F5
skinparam noteBackgroundColor #1E1B2E
skinparam noteBorderColor #FFB86C
skinparam noteFontColor #F5F5F5
title GC.Collect() 호출 후 실제로 벌어지는 일
participant "내 코드" as Code
participant "GC" as GC
participant "Free List" as FL
participant "OS" as OS
Code -> GC : GC.Collect()
GC -> GC : 참조 안 되는 객체 탐색
GC -> FL : 죽은 객체의 공간을\n"재사용 가능"으로 기록
note right of FL : 다음 할당 때 여기서\n공간을 먼저 찾음
GC --> Code : 완료
note over OS : OS는 아무것도 모름!\n그 페이지는 "한 번이라도 값이 써진 적 있음"\n→ 여전히 Dirty로 집계
@enduml
즉 GC는:
- 죽은 오브젝트를 찾고,
- 그 공간을 free list라는 내부 장부에 "재사용 가능"으로 기록할 뿐,
- OS에게 "이 페이지 이제 안 써요"라고 반납하지 않습니다.
그런데 그 페이지는 한 번이라도 값이 기록된 적이 있으므로, OS 관점에서는 여전히 Dirty 페이지입니다. GC가 논리적으로 "비었다"고 표시해도, 물리적으로는 여전히 그대로 RAM에 남아있는 겁니다. 그래서 GC 직후에도 프로파일러의 Dirty 메모리 수치는 거의 그대로입니다.
5.3 왜 이렇게 설계했나 — 즉시 반납하지 않는 이유
이게 버그처럼 보이지만, 사실은 의도된 설계입니다. 오브젝트가 죽을 때마다 그 페이지를 OS에 즉시 반납(디커밋)하면:
- 다음에 새 오브젝트를 할당할 때 다시 OS에 페이지를 요청해야 함
- 페이지 인/아웃은 비용이 결코 작지 않은 작업
- 게임처럼 매 프레임 오브젝트가 반복적으로 생성·파괴되는 환경에서는 이 비용이 누적되어 오히려 성능이 나빠짐
그래서 Unity를 포함한 대부분의 런타임은 "한 번 확보한 메모리는 최대한 재사용"하는 전략을 씁니다. 죽은 오브젝트의 Dirty 페이지도 즉시 반납 대신, 다음 할당을 위한 재사용 공간으로 유지합니다.
6. 그래도 언젠가는 줄어든다 — 리맵(Remap)
그렇다고 물리 메모리가 영원히 안 줄어드는 건 아닙니다. Unity의 GC는 여러 번의 GC 사이클을 거쳐도 계속 완전히 비어있는 세그먼트가 있으면, 그 세그먼트를 통째로 해제(언맵)한 뒤 동일한 크기의 완전히 새로운 빈 세그먼트로 재할당(리맵)합니다.
이 과정을 거쳐야만 그 세그먼트가 물고 있던 Dirty 페이지들이 진짜로 물리 메모리에서 해제됩니다. 다만 이건 매 GC마다 일어나는 게 아니라 훨씬 드문 주기(Unity 6 기준 GC 6회마다 1회)로만 발생합니다.
여기서 중요한 함정 하나: 가상 메모리 예약 크기는 이 과정과 무관하게 절대 줄어들지 않습니다. 런타임 동안 늘어나기만 할 뿐입니다. 리맵으로 줄어드는 건 오직 물리(상주) 메모리 사용량뿐입니다. 그래서 "가상 메모리 예약량"과 "실제 크래시를 유발하는 상주 메모리"는 서로 다른 지표이고, iOS Jetsam이 보는 건 후자입니다.
그래서 GC.Collect()를 남발하면 안 된다
리맵이 존재한다고 해서 "한 프레임에 GC.Collect()를 여러 번 부르면 메모리가 확 줄겠지?"라고 생각하면 오산입니다.
- GC 자체의 실행 비용이 크기 때문에, 과도하게 호출하면 앱이 장시간 정지된 것처럼 보일 수 있습니다.
- 리맵 로직은 "오래 계속 비어있던" 세그먼트만 대상으로 설계되어 있습니다. 한 번에 다수의 GC를 몰아서 실행하면, 일시적으로 잠깐 비어있는 세그먼트까지 강제로 해제됐다가, 다음 프레임에 새 할당이 생기면서 바로 다시 필요해져서 재로딩되는 경우가 흔합니다.
- 결과적으로 CPU만 태우고 실질적인 메모리 이득은 거의 없는 헛수고가 될 수 있습니다.
7. 정리
- "관리형 vs 네이티브"는 OS가 아니라, 하나의 OS 프로세스 안에서 .NET VM과 Unity 엔진이 "누가 해제 책임을 지는가"로 나눈 소프트웨어 내부 분류입니다.
- OS가 실제로 판단 기준으로 삼는 건 Clean(디스크에서 재생성 가능, 공짜로 버려짐) vs Dirty(원본 없음, 못 버림)이고, 앱의 메모리 풋프린트는 사실상 Dirty(+압축된 Dirty) 메모리의 합입니다.
- Mono와 IL2CPP는 실행 방식(인터프리터 vs AOT 컴파일)은 다르지만, 둘 다 GC/타입시스템을 제공하는 ".NET VM 역할"을 한다는 점에서 같습니다. 그래서 Unity는 이 둘을 아우르는 "Scripting Backend"라는 용어를 씁니다.
- 이 VM의 타입 메타데이터 초기화 과정 자체가 대량의 힙 할당+쓰기라, IL2CPP 빌드는 시작 시점에 Dirty 메모리가 크게 잡힙니다.
GC.Collect()는 죽은 오브젝트의 공간을 free list에 "재사용 가능"으로 표시할 뿐, OS에 즉시 반납하지 않습니다. 그 페이지는 이미 한 번 값이 써졌기 때문에 여전히 Dirty로 집계되고, 그래서 GC 직후에도 프로파일러 수치는 거의 그대로입니다.- 물리 메모리가 실제로 줄어드는 건 오래 비어있던 세그먼트를 통째로 해제·재할당하는 리맵을 통해서만이고, 이마저도 자주 일어나지 않도록 설계되어 있습니다.
"GC를 돌려도 메모리가 안 줄어든다"는 건 GC가 실패한 게 아니라, 페이지 인/아웃 비용을 아끼기 위해 의도적으로 메모리를 붙잡아두는 설계입니다. 이걸 모르고 GC.Collect()를 남발하면, 얻는 것 없이 프레임 드랍만 만들 수 있습니다.
참고자료 : 레트로의 유니티 최적화
'Unity' 카테고리의 다른 글
| [Unity] 코루틴은 프레임, yield 타이밍, 그리고 DLL 분석하기 (0) | 2026.07.13 |
|---|---|
| [Unity] 코루틴과 IEnumerator, Heap (0) | 2026.07.03 |
| [Unity] 직렬화 시스템 — [SerializeField], .asset, .meta가 하는 일 (0) | 2026.06.17 |
| [Unity] UnityEngine.Object의 이중 구조와 == null의 진짜 의미 (1) | 2026.06.11 |
| [Unity] C# 컴파일 과정과 IL, Mono, IL2CPP, asmdef 이해하기 (0) | 2026.06.10 |