| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Hollow Knight
- 유니티 렉
- GC 원리
- 유니티
- 유니티 세이브
- 개발공부
- Garbage Collecter
- SerializeField
- 할로우 나이트
- 개발지식
- 할로우 나이트 분석
- Unity
- 할나 후기
- 마개조
- 할나 엔딩
- 커스텀프로퍼티드로어
- CustomPropertyDrawer
- 유니티 #공전 #유니티회전고정
- 커스텀에디터
- Zenocide
- CustomEditor
- 에디터 개조
- 가비지 컬렉션
- 가비지 컬렉터
- 할로우 나이트 엔딩
- C#
- 유니티 #UI 늘리기 #이미지 재사용
- SerializeReference
- 유니티 에디터
- Gc
- Today
- Total
가을의 개발 일지
[Unity/C#/GC] 유니티와 C#의 가비지 컬렉션(Garbage Collection)은 어떻게 동작하는가 본문
서론
[Unity/GC] 가비지 컬렉션(Garbage Collection), 왜 쓰고 어떻게 쓰는데?
서론게임 개발을 하다보면, 꼭 한번쯤은 최적화에 관심을 갖게 된다. 내가 처음 최적화를 파던 건 2019년이었는데, 당시 만들던 2D 러닝 게임이 고사양 3D 게임에 버금갈 만큼 버벅였기 때문이다.
autumncat.tistory.com
지난 시간에 가비지 컬렉션(Garbage Collection, GC)의 개념, 사용 이유, 단점과 해결책을 간단하게 알아보았다.
아직 위 포스트를 읽지 않았다면 먼저 읽고 오는 것을 추천한다. 이번 포스팅에서는 GC의 정확한 동작 원리를 알아볼 텐데, 머릿속에 개요가 짜여있지 않다면 이해가 본질적은 이해는 어려울 것이다.
C#(.NET)의 GC
유니티는 C# 언어를 사용하니, 먼저 C#의 GC에 대해 알아보자. 정확히는, C#은 언어일 뿐 GC가 구현되어 있지 않고, .NET 환경의 GC라고 해야 정확할 것이다. 그러니 앞으로는 .NET GC라고 표현하겠다.
.NET GC는 아래와 같은 특징을 갖는다.
- 세대별 가비지 컬렉션
- Stop the World
- Mark and Sweep
- 백그라운드 가비지 컬렉션
GC의 동작 방식을 간단히 살펴보고, 이후 각각의 방식이 무엇인지 하나씩 살펴보도록 하자.
.NET GC 동작 방식
.NET에서 GC는 다음 과정을 따른다.
- 표시 단계: 모든 활성 개체를 찾아 목록을 만든다.
- 재배치 단계: 압축될 개체에 대해 참조를 업데이트한다.
- 압축 단계: 비활성 개체의 공간을 회수하고, 살아남은 개체를 압축한다.
원리 자체는 간단하다. 정확히 어떻게 구현되는가는 각 특징에서 자세히 살펴볼 것이다.
그전에, 활성 개체는 어떻게 찾을 수 있을까?
.NET은 먼저 루트 스캔 단계를 통해, 참조 탐색을 시작할 루트를 찾는다. 루트엔 정적 필드, 스레드 스택의 지역 변수, CPU 레지스터, GC 핸들, finalize 큐가 포함된다.
GC가 수행되면, GC를 호출한 스레드를 제외한 모든 스레드가 일시 중단된다. 그것이 유니티 개발자들이 GC를 기피하고, 최대한 안 만나려고 애쓰는 이유가 된다.
다만, .NET의 GC는 이러한 문제들을 효율적인 방법들로 개선해냈다. 그 방법들을 지금부터 차례차례 살펴보자.
1. 세대별 가비지 컬렉션
.NET GC는 세대별 가비지 컬렉션을 사용한다.
크게 0세대, 1세대, 2세대, 3세대(Large Object Heap, LOH)로 구분된다. 이름부터 다르듯, 3세대는 완전 별개로 보면 된다.
작동 원리는 간단하다.
- 생성된 객체를 0세대 힙에 할당한다.
- 0세대 힙이 가득 차면 0세대 힙 안에서 GC를 수행한다. 이때 살아남은 객체들은 압축 후 1세대로 승격한다.
- 1세대 힙이 가득 차면 1세대 힙 안에서 GC를 수행한다. 이때 살아남은 객체들을 압축 후 2세대로 승격한다.
- 2세대 힙마저 가득 찼다면 전체 가비지 수집, 즉 모든 세대(LOH도 포함한다.)를 대상으로 GC를 수행한다.
0, 1, 2세대를 나누고, 0이 차면 GC 후 1로 승격, 1이 차면 GC 후 2로 승격하는 식이다. GC는 각 세대별 따로 수행되고, 살아남은 자들은 윗 세대로 승격된다.
0세대가 가장 작고, 2세대가 가장 크다. 0세대가 몇 MB에 그친다면, 2세대는 수백 MB까지 갈 수도 있다. 이는 0세대는 자주 실행되는 만큼 빠르게 처리하고, 2세대는 드물게 실행되는 만큼 빈도를 더 줄이기 위해서라 볼 수 있다. (애초에 2세대까지 왔으면 죽었을 확률도 낮다.)
대부분 0세대에서 메모리가 해제되며, 0세대에서 살아남는 객체는 의외로 드물다. 임시 변수 등이 많이 할당되기 때문.
원리 자체는 그렇게 어려운 개념은 아니다.
그럼, 2세대 GC를 수행한 후에도 메모리가 가득차 있다면 어떻게 될까?
당연히 OutOfMemoryException이 발생하며 프로그램이 뻗는다. 다만, 이 지경까지 왔다면 그건 더이상 GC가 어떻게 할 문제는 아닐 것이다. (살아있는 객체들만으로 힙을 가득 채웠다면... 그건 GC를 탓할 때가 아닌 것 같다.)
그럼 3세대(LOH) 힙은 무엇인가?
위에서 보니, 3세대 힙은 2세대에서 승격되지도 않는다. 그럼 이 공간은 왜 존재할까?
3세대 힙(LOH)는 크기가 큰 객체들을 따로 모아 관리하는 힙으로, 85,000 B 이상의 객체들이 할당된다. (임계치 변경 가능)
이 정도로 크기가 큰 객체들은 세대를 넘나들 때 압축 및 복사되는 것조차 부담스러운 크기다. 그렇기에 따로 모아서 관리한다.
3세대 힙은 물리적 세대로, 논리적으로는 2세대의 일부로 간주된다.
LOH는 가득차면 Full GC가 수행되어, 2세대 힙과 함께 수행된다. 즉, LOH만 독자적으로 GC가 수행되는 일은 없으며, 항상 2세대 힙과 묶여서 동작한다.
세대를 나누는 이유는?
일반적으로 GC에서 살아남은 객체는, 오래 살아있을 확률이 높다. 만약 세대 구분 없이, 하나의 힙에서만 GC를 수행한다면, 이 객체들은 GC가 수행될 때마다 계속 남아 있을 확률이 높다. 그런데 GC가 수행될 땐, 이 객체들이 살았는지, 죽었는지를 계속 판별해야하기에 불필요한 검색이 많이 동반되는 것이다. 이에 .NET에서는 세대를 구분해, 조금 더 효율적은 GC를 구현한다.
하나 더 흥미로운 점으로, .NET에선 해당 세대의 잔존율이 높다면 임계치를 늘린다. 이를 통해 불필요한 GC가 자주 수행되는 것을 방지한다.
2. Stop the World

GC가 수행되면 프로그램은 멈춘다. GC가 수행되는 도중 프로그램이 동작하면, 객체의 생존 여부 또한 계속해서 바뀔 것이고, 프로그램에 예상치 못한 오류가 날 수도 있으니. 위처럼 한 스레드가 GC를 호출하면, 다른 스레드는 GC가 끝날 때까지 하던 일을 멈춘다.
3. Mark and Sweep
앞서 우린, GC를 수행할 때 활성 개체를 찾고, 그 목록을 만든다.고 하였다. 이는 전통적 방식대로 Mark and Sweep 알고리즘을 따른다.
먼저 Mark and Sweep 방식이 무엇인지부터 살펴보자.
마킹하고, 치운다.
사실 특징이랄 것도 없는 게, 단어 뜻 그대로의 의미다. 활성 개체를 찾아 표시하고, 표시되지 않은 개체들은 해제한다.
해제하는 방법이야 문제되지 않을 테고, 활성 개체를 어떻게 찾는지를 알아보자.
먼저, 루트를 찾는다. 앞서 설명했듯, 여기엔 정적 필드, 스레드 스택의 지역 변수, CPU 레지스터, GC 핸들, finalize 큐가 포함된다.
이들은 프로그램의 외부에서 메모리에 접근하기에, 해당 루트가 참조한 객체가 비활성화일 순 없다.
이후, 루트를 시작으로 참조하고 있는 개체들을 찾아나간다. 보통 DFS를 많이 사용하는데, BFS도 구현은 가능하다고.
여튼, 루트를 기점으로 본인이 참조하는 개체들에 표시하고, 또 그 개체가 참조중인 개체를 표시하며 꼬리에 꼬리를 물고 하나씩 표시해나간다. 만약 이게 2세대에서 작동한다면, 이때 표시된 목록은 스냅샷으로 저장될 것이다.
모든 개체에 표시가 끝나면, 표시되지 않은 개체를 회수한다. 이게 전부다.
4. 백그라운드 가비지 컬렉션
0세대 및 1세대는 힙의 크기가 작기에, GC가 수행되도 멈추는 시간이 길지 않다. 해봐야 1ms(0.001초) 미만, 1세대쯤 와도 몇 ms(0.00몇 초) 정도일까.
문제가 되는 건 2세대다. 힙의 크기 자체가 큰 만큼, 운이 나쁘면 초 단위로 프로그램이 멈출 수도 있다. 즉, .NET 역시 이후 설명할 유니티와 마찬가지로, Stop the World를 피해갈 순 없다.
그러나, Microsoft는 2세대 GC에 대해 혁신적인(?) 방법을 개발해냈으니, 바로 백그라운드 가비지 컬렉션이다.
워크스테이션 GC에선 .NET Framework 4 이상, 서버 GC에선 .NET Framework 4.5부터 이 기능을 지원하며, 그 이전 버전에선 동시 가비지 수집을 지원한다. 이 기능의 반대(0세대와 1세대에서 실행되는, 우리가 알던 GC)는 포그라운드 가비지 컬렉션이라고 한다.
이는 말 그대로, GC를 수행하면서도 다른 스레드의 실행을 보장하는 것이다. 이게 어떻게 가능한 것일까?
시간을 멈춘다. 단, 최소한의 시간 동안만.
.NET 역시 완전하게 Stop the World를 피해가진 못한다. 그러나, 멈추는 시간을 최소화해 이 문제를 해결한다.
우리는 직관적으로, GC가 수행될 땐 프로그램이 멈춰야 한다고 생각한다. 하지만, 자세히 뜯어보면 멈춰야 하는 순간은 GC 중에서도 일부에 불과하다. 바로 활성 개체를 찾아서 마킹하고, 목록을 만드는 순간. 즉, 표시 단계만 시간을 멈춘다.
.NET의 백그라운드 GC는 GC의 시작 단계인, 루트 스캔과 객체 참조의 스냅샷에서만 시간을 멈추고, 실제로 개체를 해제하는 순간엔 다른 스레드를 활성화해버린다. 물론 그 과정에서 활성화 정보가 변할 수 있다. 그러나, 이는 2세대 힙에서만 적용된다. 그 사이 새로 생기는 개체들은 0세대에 저장될 것이고, 아무리 빨리 차올라도 1세대일 테니, GC를 백그라운드에서 돌리는 놀라운 일이 가능해진다. (만약 그마저도 넘어서서 빨리 차오른다면, 그건 이번에도 GC 잘못이 아니라 당신 잘못이다.)
따라서, .NET에서 GC는 유니티와 달리 무서워하지 않아도 된다. 일반적인 해결책 만으로도 당신의 프로그램이 못 써먹게 되진 않는다.
Unity의 가비지 컬렉션
지금까지 .NET의 가비지 컬렉션 방식에 대해 알아보았다. 그런데, 가만보니 GC가 그렇게 문제가 되나? 싶다. 맞다. 만약 당신이 웹이나 앱 개발자라면, 메모리 관리를 그냥 GC에 맡겨둬도 성능상 큰 문제가 없을 것이다.
문제는 게임에, 그리고 유니티에 존재한다.
실시간성과 안정적인 프레임을 요구하는 게임 자체의 특성도 분명 있지만, 사실 그보다도 유니티 자체의 문제가 참... 악명 높다. 왜 그런지 이유를 살펴보자.
유니티는 가장 원시적인 GC를 사용한다.
유니티의 가장 큰 단점이자, 유니티 개발자들이 GC라면 치를 떠는 이유. 유니티는 위처럼 잘 만들어진 .NET의 GC와 다르게, 아주 원시적이고 기초적인 GC를 사용한다.
Boehm-Demers-Weiser 가비지 컬렉터를 사용한다. 이게 뭐냐면, C와 C++에서 사용하기 위헤 1992년 만들어진 가비지 컬렉터다. 그렇다. 1992년. 유니티는 아직 2000년대로 넘어오지 못했다.
Boehm-Demers-Weiser GC는 위에 설명한 .NET GC에서, Mark and Sweep과 Stop the World만 존재하는 GC라고 보면 된다. 세대별 구분을 이용한 최적화도, 여기서 발전한 백그라운드 GC도 지원하지 않는다.
앞서 우린 세대별 구분에서, 얼마나 GC가 빠르고 효율적으로 동작하는지 살펴보았다. 그런데, 0세대 힙만 존재한다면 어떻겠는가?
살아남은 개체는 압축도, 승격도 되지 않고 메모리를 계속 점유하며, 이들은 매 GC마다 살아있는지 검사받게 될 것이다. 가용 공간이 줄어드는 만큼 GC는 더 빨리 차오를 것이고, 이를 회피하려 힙 크기를 늘렸다간 GC가 돌 때마다 1초씩은 멈출 것이다. 세대 구분이 없기에, 백그라운드로 GC를 돌리지도 못하고, 오로지 포그라운드에서, GC가 시작되는 순간부터 GC가 끝나는 순간까지가 렉이 된다.
그렇기에 유니티 개발자들에게 GC는 회피해야 하는 것, 안 돌아야 하는 것이 된 것이다.
그럼 유니티는 왜 최신 기술을 지원하지 않나요?
이는 아쉽게도, 유니티의 구조적 문제에서 기인한다. 아래는 2023년 StackOverflow 글에서 찾은 답변을 요약한 것이다.
유니티는 2008년 초, Mono와 협업하며 Mono 런타임 라이선스(사용에 대한 GPL)를 부여받았다. 이때 Mono의 주요 GC는 Boehm GC였다.
Mono는 .NET 프레임워크의 오픈소스 버전으로, 윈도우 외 리눅스, macOS, 심지어는 콘솔에서도 돌아간다는 장점이 있다. 이는 유니티의 핵심인 멀티플랫폼과도 직결된다.
시간이 흘러 Mono는 4.x / 5.x 버전이 나오며 세대별 GC(SGen GC)를 탑재했지만, 유니티는 라이선스 비용을 주기 싫어서 라이선스를 부여받을 당시의 Mono를 계속 사용 중이고, 따라서 새 GC 대신 기존의 Boehm GC를 유지 중이다.
다행히 2016년, 마이크로소프트가 자마린을 인수하고, MIT 라이선스 하에서 코드베이스를 재출시해 문제가 해결되었다. 유니티는 .NET 재단에 합류해 최신 Mono 런타임을 엔진에 통합 중이지만, 아직 완료되지 않았다.
또한, Microsoft는 .NET GC를 오픈소스로 공개하지 않았고, 대신 .NET Core 버전의 GC를 공개했다. 이는 Mono GC와 다르기에 통합하는데 더 큰 노력이 필요하다. 2023년 현재 Unity에 Mono 5가 통합되었고, 앞으로 .NET Core로 마이그레이션 할 수도 있다.
다만, 더블 체크를 하지 않아 정보가 틀렸을 수도, 2025년 현재엔 달라졌을 가능성도 있다. 이 점을 염두에 두고 유니티의 뉴스레터를 주의깊게 살펴보자.
유니티도 마냥 놀고 있진 않았다.
그렇다면, 유니티는 이 문제를 해결할 의사가 없는가? 하면 그건 또 아니다. 유니티는 Scripting Backend로 Mono 대신 IL2CPP를 지원하기도 하고, 점진적 GC(여러 프레임에 걸쳐 GC를 수행해, 스파이크를 낮추는 기법)를 개발하기도 했다.
다만 IL2CPP는 "테스트는 Mono로, 빌드는 IL2CPP로!"라는 풍문과 달리, 버그가 많고 플랫폼 별로 따로 테스트를 돌려줘야 하기에 웬만하면 Mono를 쓰는 게 낫다고 한다. 그나마 점진적 GC가 2019년에 도입되었으나, 이 역시 문제를 완벽히 해결하진 못했다.
결국 우리 게임은 우리 손에 달렸다.
그러니, 우린 오브젝트 풀링, GC 호출 시점 조정, StringBuilder 사용, LINQ, foreach, 람다식 사용 최소화 등등 코드를 짤 때 신경써서 짜야 한다. Update 문에서 GetComponent를 호출하지 말고, Find를 호출하지 말고, new를 호출하지 말고... 같은 기본만 지켜도, 사실 여러분이 GC 때문에 최적화에 실패할 일은 없을 것이다. (대부분 문제는 렌더링입니다.)
언젠가 GC 문제가 완전히 해결되는 그날까지, 다들 파이팅이다.
마무리
그동안 GC를 회피하는 방법은 많이 익혔는데 정작 동작 원리는 몰랐기에 흥미롭게 공부한 시간이었다. 그리고, 역시 공식 문서가 제일 낫다. 이 블로그로 GC의 개념을 익히려는 여러분도, 꼭 한번은 아래 참고 자료에 적어둔 Microsoft 공식 문서를 읽어봤으면 한다. 차근차근 읽어보면 그리 어려운 개념도 아니다. 몇 가지 궁금한 부분은 GPT에도 질문해봤는데(세대별 메모리 크기 등), 신빙성과는 별개로 학습 중 생기는 궁금증을 쉽게 해결할 수 있다는 건 확실한 장점이다. 뭐, 물론 팩트체크를 위한 더 큰 시간이 걸리겠지만.
참고 자료
- Microsoft, "쓰레기 수거", Microsoft Learn, 2025. 6. 18.,
https://learn.microsoft.com/ko-kr/dotnet/standard/garbage-collection/ - Microsoft, "가비지 컬렉션 기본 사항", Microsoft Learn, 2023. 4. 8.,
https://learn.microsoft.com/ko-kr/dotnet/standard/garbage-collection/fundamentals - Microsoft, "워크스테이션 및 서버 가비지 수집", Microsoft Learn, 2025. 6. 17.,
https://learn.microsoft.com/ko-kr/dotnet/standard/garbage-collection/workstation-server-gc - Microsoft, "백그라운드 가비지 수집", Microsoft Learn, 2025. 6. 18.,
https://learn.microsoft.com/ko-kr/dotnet/standard/garbage-collection/background-gc - Microsoft, "Windows 시스템의 대규모 객체 힙", Microsoft Learn, 2025. 6. 18.,
https://learn.microsoft.com/ko-kr/dotnet/standard/garbage-collection/large-object-heap - Unity, "가비지 컬렉터 개요", Unity Documention 2021.3,
https://docs.unity3d.com/kr/2021.3/Manual/performance-garbage-collector.html - Unity, "자동 메모리 관리 이해", Unity Documention 2021.1, https://docs.unity3d.com/kr/2021.1/Manual/UnderstandingAutomaticMemoryManagement.html
- NGVI, "유니티 메모리 관리, Garbage Collector, GC 최적화 접근방식, 유니티 원죄의 GC 스파이크의 답답함이란... 점진적 GC, Incremental GC는 해결책이 될까?", 티스토리, 2021. 6. 21., https://gdev.tistory.com/56
- 새벽_글쓴이, "[Unity] 유니티 C#) Garbage Collector ( 가비지 컬렉터 )", 티스토리, 2025. 1. 6., https://tears2am.tistory.com/54
- Smith, "Unity's garbage collector - Why non-generational and non-compacting?", stackoverflow, 2017. 10. 4., https://stackoverflow.com/questions/46574407/unitys-garbage-collector-why-non-generational-and-non-compacting
- shaboom96, "Mono 아니면 IL2CPP?", reddit, 2022. 12. 2., https://www.reddit.com/r/Unity3D/comments/zag4ka/mono_or_il2cpp/?tl=ko
- Wikipedia, "Boehm Garbage Collector", https://en.wikipedia.org/wiki/Boehm_garbage_collector
- Wikipedia, "모노 (소프트웨어)", https://ko.wikipedia.org/wiki/모노_(소프트웨어)
'Unity > 최적화' 카테고리의 다른 글
| [Unity/GC] 가비지 컬렉션(Garbage Collection), 왜 쓰고 어떻게 쓰는데? (8) | 2025.08.26 |
|---|