지금까지 C#의 GC를 공부하면서 자연스럽게 이런 생각을 했다.
C#에서 객체를 생성하면 GC가 알아서 세대별로 관리하겠지?
보통 .NET의 GC를 공부하면
세대 0
↓
세대 1
↓
세대 2
와 같은 구조를 접하게 된다. 그래서 Unity에서도 당연히 GC가 돌 때 객체가 0세대 -> 1세대 -> 2세대로 승격되겠지? 라고 생각을 했다.
그런데 오늘 Unity의 GC를 공부하면서 지금까지의 생각과 다른 부분을 발견했다.
Unity의 GC는 최신 .NET GC와 같은 세대별 GC 구조를 사용하지 않는다.
그렇다면 자연스럽게 이런 질문이 생길 수 밖에 없다.
왜 Unity는 세대별 GC를 사용하지 않을까?
그래서 이번 글에서는 이 부분을 중심으로
Unity의 GC가 어떻게 동작하는지, .NET의 GC와 무엇이 다른지를 정리해보려한다.
먼저 비교 대상인 최신 .NET GC부터 살펴보자.
.NET의 GC는 객체의 생존 기간에 따라 세대를 나눈다.
Heap
세대 0
↓ 살아남음
세대 1
↓ 살아남음
세대 2
핵심 아이디어는 간단하다.
대부분의 객체는 생성된 지 얼마 지나지 않아 죽는다.
예를 들어 어떤 메서드에서 임시 객체를 만들었다고 가정해보자.
void Update()
{
var data = new MyClass();
}
data가 이후에 아무 곳에서도 참조되지 않는다면
해당 객체는 오래 살아남을 필요가 없다.
이런 객체를 계속 전체 힙에서 찾는 것은 비효율적이다.
그래서 .NET은 객체의 생존 기간을 기준으로 세대를 나눈다.
<세대 0>
새롭게 생성된 객체가 들어간다.
new Object() -> 세대 0
대부분의 객체는 세대 0에서 바로 수거된다.
<세대 1>
세대 0의 GC에서 살아남은 객체가 들어간다.
세대 0 -> 세대 1
생존
<세대 2>
오랫동안 살아남은 객체는 세대 2로 승격된다.
세대 0 -> 세대 1 -> 세대 2
즉, 오래 살아남은 객체를 매번 검사하지 않기 위해 세대를 나누는 것 이라고 할 수 있다.
여기서부터 조금 다른 이야기가 나온다.
Unity에서도 당연히 C# 객체를 관리하기 위한 GC가 존재한다.
하지만 Unity의 Mono/IL2CPP 런타임에서 사용해온 GC는 최신 .NET Generational GC와는 다른 방식으로 동작한다.
대표적으로 Boehm-Demers-Weiser GC 계열의 GC를 사용해왔다.
<Boehm-Demers-Weiser GC란?>
Boehm-Demers-Weiser GC는 대표적인 보수적 GC이다.
여기서 보수적이라는 것은 메모리 안의 값을 확인했을 때,
해당 값이 객체를 가리키는 포인터일 가능성이 있다면, 해당 객체를 살아있는 것으로 간주할 수 있다.
는 의미이다.
예를 들어 메모리 안에 다음과 같은 값이 있다고 생각해보자.
0x12345678
GC 입장에서는 이 값이 단순한 숫자인지, 어떤 객체를 가리키는 주소인지 정확하게 판단하기 어려울 수 있다.
이때 해당 값이 객체를 가리킬 가능성이 있다면 안전하게 살아있는 객체로 판단하는 방식이 보수적 GC의 특징이다.
즉, 객체일 가능성이 있다면 일단 살아있다고 판단하는 것이다.
이러한 방식은 GC가 객체의 모든 정보를 정확하게 알지 못하는 상황에서도 메모리를 안전하게 관리할 수 있다는 장점이 있다.
<그렇다면 Incremental GC는 무엇일까?>
여기서 Incremental GC라는 개념이 등장한다.
Incremental GC는 새로운 종류의 GC가 아니라
GC 작업을 한 번에 수행하지 않고 여러 단계로 나누어 처리하는 방식이다.
즉, Boehm 계열 GC가 어떤 GC 구현을 사용하고 객체를 어떻게 추적하는지에 대한 개념이라면, Incremental GC는 "GC 작업을 한 번에 처리할 것인가, 여러 번에 나누어 처리할 것인가?"에 대한 개념이다.
예를 들어 GC를 한 번 실행하는 데 많은 시간이 필요하다고 해보자.
일반적으로 한 번에 처리한다면
Frame 1
-> GC 작업 8
이처럼 특정 프레임에 GC 작업이 집중될 수 있다.
반면 Incremental GC를 사용하면
Frame 1
-> GC 작업 2
Frame 2
-> GC 작업 2
Frame 3
-> GC 작업 2
Frame 4
-> GC 작업 2
이처럼 GC 작업을 여러 프레임에 걸쳐 나누어 실행할 수 있다.
따라서 Incremental GC는 GC 작업을 여러 프레임에 분산하여 한 번에 발생하는 GC 작업으로 인한 지연과 스파이크를 줄이는 것이 목적이다.
<Generational GC와 Incremental GC>
두 개념의 관계를 정리하면 다음과 같다.
Garbage Collector
│
┌─────────────┴───────────────────────┐
│ │
객체를 어떻게 관리하는가? 어떻게 실행하는가?
│ │
┌─────┴────────────┐ │
│ │ │
Boehm GC Generational GC Incremental GC
│ │ │
Non-Generational Gen 0/1/2 여러 프레임에 분산
즉, Boehm-Demers-Weiser GC와 Incremental GC는 서로 대체되는 개념이 아니다.
Boehm GC는 어떤 GC 구현을 사용하는지에 대한 것이고, Incremental은 GC 작업을 어떤 방식으로 수행할지에 대한 것이다.
Unity에서는 이러한 GC 환경에서 Incremental GC를 통해 GC 작업을 여러 프레임에 나누어 수행할 수 있도록 지원한다.
일반적인 프로그램에서는 잠깐의 GC 지연이 크게 문제가 되지 않을 수도 있다.
하지만 게임에서는 얘기가 다르다.
예를 들어 게임이 60 FPS로 동작한다고 가정해보자.
한 프레임에 사용할 수 있는 시간은 약 16.67ms이다.
그런데 어떤 프레임에서 GC 작업에 30ms가 소요된다면? 프레임 하나가 크게 밀릴 수 있다.
결과적으로
GC -> 프레임 지연 -> 순간적인 끊김
이 발생할 수 있다.
그래서 Unity에서는 GC Allocation을 줄이는 것이 중요하다.
Unity에서는 다음과 같은 코드가 반복적으로 실행될 수 있다.
void Update()
{
string text = "Hp : " + hp;
}
또는
void Update()
{
var list = new List<Enemy>();
}
또는
void Update()
{
var result = GetEnemies().Where(x => x.IsAlive);
}
이런 코드들은 상황에 따라 Managed Heap Allocation을 발생시킬 수 있다.
그리고 이 할당이 계속 누적되면
Allocation -> Managed Heap 증가 -> GC 필요 -> GC 실행 -> Frame Spike 가능
이라는 흐름으로 이어질 수 있다.
그래서 Unity 개발에서는
Update같은 반복되는 게임 로직에서 불필요한 Heap 할당을 줄여야한다.
라는 얘기를 자주 듣게 되는 것이다.
그렇다면 이런 생각을 할 수 있다.
"Unity도 결국 C#인데 왜 최신 .NET의 GC를 그대로 사용하지 않는걸까?"
여기서 Unity가 "게임 엔진이기 때문에 Generational GC를 사용할 수 없다"고 이해해서는 안 된다는 것이다.
Generational GC와 Incremental GC는 서로 다른 개념이기 때문에, 기술적으로는 세대별 GC를 사용하면서 GC 작업을 분산하는 방식도 가능하다.
Unity 공식 문서에서는 Mono와 IL2CPP 모두 Boehm GC를 사용한다고 설명하고 있지만, 왜 최신 .NET의 Generational GC를 채택하지 않았는지에 대한 단 하나의 명확한 이유를 제시하고 있지는 않다.
따라서 여기서는 Unity의 GC가 왜.NET GC와 다른 구조를 가지고 있는지에 초점을 맞추는 것이 적절하다.
그럼 이어서 진행해보면
Unity는 Mono와 IL2CPP라는 스크립팅 백엔드를 사용하며, 이 환경에서 Unity 자체적으로 통합된 GC를 사용해왔다.
Unity는 게임 엔진이라는 특성상
이 중요하다.
이런 특성 때문에 GC로 인한 순간적인 프레임 지연을 줄이는 것이 중요하고, Unity에서도 Incremental GC와 같은 방식을 제공하고 있다.
따라서 단순히 .NET에서 사용하는 GC가 좋으니까 Unity에서도 그대로 사용하면 되지 않아?라고 생각하면 안된다.
여기서 중요한 것은 "Unity에는 GC가 없다."가 아니다.
당연히 Unity에서도 Managed Heap의 객체를 수집하기 위한 GC가 존재한다.
핵심은
Unity의 GC가 최신 .NET의 Generational GC와 동일한 구조는 아니라는 것이다.
그리고 Unity에서는 GC로 인한 프레임 지연을 줄이기 위해
Incremental GC 같은 기능을 제공한다.
즉,
.NET
-> Generational GC
-> 객체를 세대별로 관리
-> Background GC 등을 통해 GC 작업을 백그라운드에서 수행Unity
-> Boehm GC
-> 비세대별 구조
-> Incremental GC를 통해 GC 작업을 여러 프레임에 분산
즉, .NET과 Unity의 차이는 Generational과 Incremental 중 하나를 선택한 것이 아니라, 사용하는 GC 구현과 GC 작업을 수행하는 방식이 다르다는 점에 있다.
이번 글을 작성하기 전에 크게 잘못 알고 있었던 부분은
C#이면 당연히 Generational GC를 사용하는 것 아닌가?
라는 생각이었다.
하지만 C#이라는 언어와 그 코드를 실행하는 런타임의 GC 구현은 별개의 문제였다.
정리하면
- C#은 프로그래밍 언어이다.
- .NET은 C# 코드를 실행할 수 있는 런타임/플랫폼이다.
- GC는 런타임에서 관리 메모리를 회수하는 역할을 한다.
- 최신 .NET의 GC는 Generational GC를 사용한다.
- *Unity의 Boehm GC는 .NET의 Generational GC처럼 세대별로 객체를 관리하지 않는다.
- Unity는 Incremental GC를 통해 GC 작업을 여러 프레임에 나누어 수행할 수 있다.
- 따라서 Unity는 GC Allocation을 줄이는 것이 여전히 중요하다.
이번 글에서 배울 점은 단순히
Unity는 세대별 GC를 사용하지 않는다.
라는 사실이 아니라
같은 C# 코드라도 어떤 런타임에서 실행되는지에 따라 메모리 관리 방식이 달라질 수 있다.
는 것이다.