C# 런타임 기초 (3) - 가비지 컬렉션의 기초 - soo:bak
작성일 :
메모리를 자동으로 관리하는 대가
C# 런타임 기초 (2) - .NET 런타임과 IL2CPP에서는 C# 코드가 IL로 한 번 옮겨진 뒤 다시 기계어로 바뀌어 CPU 위에서 도는 과정을 따라갔습니다. 같은 IL이라도 빌드 시점에 C++로 풀어 네이티브로 미리 컴파일하면 IL2CPP의 AOT 방식이 되고, 실행 도중 그때그때 기계어로 옮기면 Mono의 JIT 방식이 됩니다.
그런데 런타임이 떠맡는 일은 코드를 돌리는 데서 그치지 않습니다. 그 핵심 역할 가운데 하나가 바로 메모리의 자동 관리입니다.
C# 런타임 기초 (1)에서 짚었듯, C# 코드에서 new로 참조 타입 객체를 만들면 런타임이 힙에 그만큼의 메모리를 잡아 줍니다. 그리고 그 객체를 가리키는 참조가 모두 사라지면, 런타임을 이루는 구성 요소인 가비지 컬렉터(Garbage Collector, GC)가 해당 메모리를 알아서 거두어 갑니다.
C나 C++에서는 이 일을 개발자가 손수 해야 합니다. 어디서 메모리를 비울지 직접 정해 주어야 하고, 그 판단이 어긋나면 메모리 누수나 댕글링 포인터처럼 프로그램을 무너뜨리는 버그로 이어집니다.
GC는 이 해제 작업을 대신 떠맡아, 개발자가 메모리를 언제 비울지 신경 쓰지 않아도 되게 합니다. 다만 공짜로 얻는 편의는 아닙니다. GC가 도는 동안에는 CPU 시간이 그쪽으로 쏠리고, Unity가 쓰는 Boehm GC는 힙 전체를 훑는 사이 C# 스크립트 실행을 통째로 멈추는 Stop-the-World를 일으킵니다.
이 글에서는 GC가 왜 필요한지부터 시작해, GC의 바탕이 되는 Mark-and-Sweep 알고리즘, Unity의 Boehm GC가 .NET GC와 어디서 갈리는지, 그리고 이 모든 것이 게임 성능에 어떻게 영향을 주는지를 차례로 살펴봅니다.
GC의 필요성
수동 메모리 관리의 위험
C나 C++에서는 메모리를 비우는 일까지 개발자의 몫입니다. malloc()이나 new로 필요한 만큼 메모리를 잡고, 다 쓰고 나면 free()나 delete로 직접 돌려줍니다.
언제 잡고 언제 돌려줄지를 개발자가 정하므로, 손에 익으면 군더더기 없이 효율적입니다. 다만 그 판단이 한 번 어긋나면 세 갈래의 위험이 비집고 들어옵니다.
첫 번째 위험인 메모리 누수는 메모리를 잡아 두고 돌려주는 일을 빠뜨릴 때 생깁니다. 한 번 빠뜨린 메모리는 다시 손댈 길이 없어, 프로그램이 오래 돌수록 쓰지도 않는 메모리가 차곡차곡 쌓여 갑니다.
이 문제는 메모리 여유가 빠듯한 모바일에서 특히 날카롭게 드러납니다. iOS는 메모리 압박이 심해지면 앱에 didReceiveMemoryWarning을 보내 메모리를 비우라고 재촉하지만, 앱이 제때 충분히 비우지 못하면 jetsam이 그 앱을 강제로 끊어 버립니다.
문제는 이 경고에 응하기가 쉽지 않다는 데 있습니다. 관리 힙의 메모리는 GC가 한 번 돌아야 비로소 회수되고, 설령 회수되더라도 Boehm GC는 비워 낸 공간을 OS에 되돌려주지 않으므로 프로세스가 차지한 메모리 총량 자체는 그대로 남습니다. Android 역시 메모리가 모자라면 백그라운드에 밀려난 앱부터 차례로 끊어 나갑니다.
두 번째 위험인 댕글링 포인터는 이미 돌려준 메모리를 여전히 가리키는 포인터가 남아 있을 때 생깁니다. 해제된 영역은 곧 다른 용도로 재사용되는데, 그 자리에 다른 데이터가 덮어써진 뒤 옛 포인터로 접근하면 전혀 엉뚱한 값을 읽게 됩니다.
게다가 그 자리에 무엇이 언제 덮어써지느냐에 따라 증상이 매번 달라지므로, 재현조차 들쭉날쭉합니다. 디버깅하기 까다로운 버그가 대개 여기서 비롯됩니다.
세 번째 위험인 이중 해제는 이미 돌려준 메모리를 또 한 번 돌려줄 때 생깁니다. 두 번째 해제가 메모리 할당자가 안에서 관리하던 자료구조를 헝클어뜨려, 그 뒤로는 할당이든 해제든 무엇 하나 예측대로 굴러가지 않게 됩니다.
세 위험 모두 코드가 복잡해질수록 빠지기 쉬워집니다. 객체끼리 참조가 얽히고설키면, 어느 객체를 어느 시점에 비워야 안전한지 가늠하기가 어려워지기 때문입니다.
가령 객체 X를 A가 아직 쓰고 있는데 B가 먼저 비워 버리면 A의 참조는 댕글링 포인터가 되고, 반대로 A도 B도 서로 미루다 아무도 비우지 않으면 그대로 메모리 누수로 남습니다.
GC의 역할
이 위험들의 뿌리는 메모리를 비울 책임이 개발자에게 있다는 데 있습니다. GC는 그 책임을 개발자에게서 런타임으로 옮겨 와 문제를 풀어냅니다. 개발자는 메모리를 잡기만 하고, 비우는 일은 GC가 알아서 떠맡습니다.
판단 기준은 단순합니다. 어떤 객체를 가리키는 참조가 더 이상 하나도 남지 않으면, GC가 그 객체의 메모리를 거두어 갑니다.
그래서 개발자는 free()나 delete를 직접 부를 일이 없습니다. 객체를 new로 만들어 쓰다가, 다 쓴 뒤 그 객체를 가리키던 참조만 끊어 두면 됩니다. 비우는 시점을 손으로 정하지 않으니, 이미 비운 메모리를 다시 가리키거나 한 번 더 비우는 일 자체가 생기지 않아 댕글링 포인터와 이중 해제는 처음부터 봉쇄됩니다. 참조가 끊긴 객체는 다음 번 GC가 돌 때 회수됩니다.
물론 GC라고 거저 얻는 것은 아닙니다. GC가 도는 동안에는 CPU 시간이 그쪽으로 들어가고, 때에 따라서는 프로그램 실행이 잠깐 멈추기도 합니다. 이 비용이 언제 얼마나 드는지 가늠하고 다스리려면, 결국 GC가 안에서 어떻게 움직이는지를 알아야 합니다.
Mark-and-Sweep 알고리즘
앞 절에서 보았듯 GC는 참조가 모두 끊긴 객체를 알아서 거두어 갑니다. 그러려면 먼저 지금 힙에 놓인 객체 가운데 어느 것이 아직 살아 있고 어느 것이 죽었는지부터 가려내야 합니다. 이 판정을 도맡는 가장 기본적인 알고리즘이 Mark-and-Sweep이며, 그 판정의 잣대로 삼는 것이 바로 도달 가능성(Reachability)입니다.
도달 가능성 (Reachability)
GC는 객체의 생존 여부를 그 객체가 쓸모 있느냐가 아니라 도달 가능성으로 가립니다. 프로그램이 지금 돌리는 코드에서 참조를 타고 따라가 닿을 수 있는 객체라면 살아 있는 것으로, 어떤 참조를 거쳐도 닿을 수 없는 객체라면 죽은 것으로 봅니다.
이 도달 가능성을 따질 때 출발점이 되는 것이 GC 루트(GC Root)입니다. GC 루트는 프로그램이 지금 직접 손에 쥐고 있는 참조의 진입점으로, 스택 변수와 정적 필드, CPU 레지스터가 여기에 해당합니다.
GC 루트가 직접 가리키는 객체는 도달 가능하며, 그 객체가 다시 가리키는 객체도 도달 가능합니다. 이렇게 참조를 한 단계씩 끝까지 타고 들어가면, 도달 가능한 객체 전체가 하나의 집합으로 추려집니다.
이 집합에 끝내 들지 못한 객체는 어떤 루트에서도 닿을 수 없으니 프로그램이 다시 손댈 길이 없고, 그래서 거두어 가도 아무 탈이 없습니다.
Mark 단계
도달 가능성을 가려내는 일이 GC가 가장 먼저 거치는 Mark(표시) 단계입니다. GC는 앞서 추린 GC 루트에서 출발해 참조 그래프를 타고 들어가며, 그렇게 닿은 객체마다 “살아 있음” 표시를 하나씩 남깁니다.
루트에서 시작한 탐색은 참조 그래프를 한 갈래씩 파고듭니다. 대부분의 GC 구현은 이 탐색에 마크 스택(mark stack)을 둔 깊이 우선 탐색을 택하는데, 여기에는 두 가지 이점이 맞물려 있습니다. 스택은 값을 인접한 메모리에 차곡차곡 쌓아 올려 캐시 적중률이 높고, 너비 우선 탐색이라면 따로 들고 있어야 할 큐보다 보조 메모리도 덜 잡아먹습니다.
GC는 이렇게 닿은 객체마다 “도달 가능” 표시를 남깁니다. 그래서 탐색이 다 끝나고 나면, 끝내 표시를 받지 못한 객체는 어느 루트에서도 닿지 못한, 곧 죽은 객체로 가려집니다.
Sweep 단계
살아 있는 객체를 다 표시했으니, 이제 표시가 없는 객체를 실제로 거두어 갈 차례입니다. 이것이 Mark에 뒤이은 Sweep(소거) 단계로, GC가 힙에 놓인 객체를 처음부터 끝까지 훑으며 Mark 표시가 붙지 않은 객체의 메모리를 하나씩 풀어 줍니다.
Sweep까지 마치고 나면 힙에는 살아 있는 객체만 남고, 죽은 객체가 비워 준 자리는 다음 할당에 그대로 내어 줄 수 있는 빈 공간으로 돌아갑니다. 도달 가능한 객체를 표시하는 Mark와 표시 없는 객체를 거두는 Sweep, 이 두 단계를 묶은 것이 Mark-and-Sweep 알고리즘이며, 이것이 GC가 죽은 객체를 가려내는 가장 기본적인 틀입니다.
죽은 객체를 가려내는 길이 Mark-and-Sweep 하나만 있는 것은 아닙니다. 대표적인 다른 길이 참조 카운팅(Reference Counting)입니다. 객체마다 자신을 가리키는 참조가 몇 개인지를 세어 두었다가, 그 수가 0으로 떨어지는 순간 곧바로 해당 객체를 해제합니다.
다만 이 방식에는 빈틈이 있습니다. A가 B를 가리키고 B가 다시 A를 가리키는 순환 참조가 끼어 있으면, 바깥에서는 이미 어느 쪽에도 닿을 수 없는데도 서로가 서로를 세어 주는 탓에 참조 수가 0으로 떨어지지 않아, 두 객체가 영영 해제되지 못한 채 힙에 눌러앉습니다.
반면 Mark-and-Sweep은 객체끼리 어떻게 얽혀 있든 따지지 않고 오직 루트에서 닿느냐만 잣대로 삼습니다. 그래서 이런 순환 참조도 루트에서 닿지 못하는 한 죽은 것으로 가려, 군더더기 없이 거두어 갑니다.
세대별 GC (Generational GC)
Mark-and-Sweep은 GC가 죽은 객체를 가려내는 기본 틀이지만, 세대를 나누지 않는 비세대(non-generational) 방식으로 돌리면 GC가 한 번 돌 때마다 힙에 놓인 객체를 빠짐없이 훑어야 합니다. Mark도 Sweep도 힙 전체를 대상으로 삼기 때문입니다.
그래서 힙에 객체가 10만 개 쌓여 있으면 GC는 매번 그 10만 개를 모두 표시하고 또 모두 소거합니다. 힙이 불어날수록 한 번의 GC에 드는 시간도 그만큼 늘어납니다.
데스크톱과 서버를 겨냥한 .NET 런타임은 이 비용을 덜기 위해 세대별 GC(Generational GC)를 들였습니다. 힙 전체를 매번 훑는 대신, 쓰레기가 자주 나오는 자리만 집중해서 살피자는 발상입니다.
세대 가설
세대별 GC가 딛고 선 토대가 바로 세대 가설(Generational Hypothesis)입니다. 객체의 수명을 두고 경험적으로 거듭 확인된 두 가지 경향을 묶어 부르는 말입니다.
첫째, 대부분의 객체는 수명이 짧습니다. 잠깐 쓰고 버리는 임시 문자열이나 루프를 돌며 생기는 중간 결과물, 메서드 안에서만 쓰이는 임시 객체처럼, 만들어지자마자 곧 쓸모를 잃는 객체가 전체의 대부분을 차지합니다.
둘째, 한 번 오래 살아남은 객체는 그 뒤로도 계속 살아남기 쉽습니다. 캐시나 설정 데이터, 게임이 도는 내내 자리를 지키는 매니저 클래스 인스턴스가 여기 듭니다. 초기에 한 번 만들어진 뒤로는 프로그램이 끝날 때까지 줄곧 살아 있는 객체들입니다.
분포를 이렇게 읽으면 GC가 어디에 힘을 쏟아야 하는지가 분명해집니다. 쓰레기의 대부분이 수명 짧은 객체에서 나온다면, 그런 객체가 갓 모여드는 영역만 자주 들여다봐도 쓰레기를 거의 다 걷어 낼 수 있습니다. 오래 살아남아 좀처럼 죽지 않는 객체까지 매번 힙 전체에 끼워 다시 훑을 까닭은 그만큼 줄어듭니다.
Gen 0, Gen 1, Gen 2
세대 가설을 실제 구조로 옮기기 위해, .NET의 세대별 GC는 힙을 Gen 0, Gen 1, Gen 2라는 세 영역으로 가릅니다. 객체가 살아온 시간에 따라 머무는 자리를 달리 정합니다.
갓 만들어진 객체는 모두 Gen 0에서 출발하고, GC 수집을 한 번씩 견뎌 살아남을 때마다 한 단계 위 세대로 옮겨 갑니다. 세대가 높을수록 영역은 더 넓게 잡아 두지만, GC가 들여다보는 빈도는 오히려 낮아집니다. 오래 살아남은 객체일수록 다시 죽을 가능성이 낮아 자주 검사할 까닭이 적기 때문입니다.
Gen 0은 갓 할당된 객체가 가장 먼저 들어서는 세대입니다. 영역을 작게(보통 수백 KB) 잡아 두고 GC가 가장 자주 들여다보므로, 짧게 살다 가는 객체 대부분이 여기서 생겨났다가 여기서 거두어집니다. 새 객체가 차곡차곡 쌓이다 Gen 0이 가득 차면 그 세대만 따로 떼어 수집하는데, 이때 Mark 단계에서 루트로부터 닿아 살아남은 객체만 한 단계 위인 Gen 1로 옮겨지고, 이렇게 세대를 올려 보내는 일을 승격(Promotion)이라 부릅니다.
Gen 1은 Gen 0 수집을 한 차례 견뎌 낸 객체가 머무는 영역입니다. 한 번 살아남았다는 것은 그만큼 더 오래 쓰일 객체라는 신호이므로, GC는 Gen 1을 Gen 0보다 뜸하게 들여다봅니다. 그러다 Gen 1 수집까지 다시 통과한 객체는 한 단계 더 올라 Gen 2로 승격됩니다.
Gen 2는 프로그램 내내 살아남는 장기 생존 객체가 자리 잡는 영역으로, 세 세대 가운데 GC가 가장 드물게 손대는 곳입니다. 다만 Gen 2를 수집하려면 그 아래 세대까지 한꺼번에 훑는 전체 힙 수집, 곧 Full GC가 되므로, 한 번 돌 때 드는 비용은 가장 무겁습니다.
이 흐름이 곧 세대 가설을 비용으로 환산한 결과입니다. 대부분의 객체가 수명이 짧다면, 크기가 작아 금세 끝나는 Gen 0 수집만 자주 돌려도 쓰레기의 대부분이 걸러집니다. 그래서 비용이 무거운 Full GC는 아래 세대에서 미처 거르지 못한 객체가 쌓일 때만, 그것도 어쩌다 한 번씩만 돌면 됩니다.
.NET의 세대별 GC는 여기에 더해 수집을 마친 뒤 압축(Compaction)까지 거칩니다. 살아남은 객체들을 힙 한쪽으로 차곡차곡 밀어붙여, 그 사이사이 비어 있던 자리를 한데 모아 연속된 빈 공간으로 정리하는 작업입니다.
이렇게 빈자리를 한 덩어리로 모아 두면, 새 객체를 할당할 때 그 연속된 공간을 곧바로 떼어 줄 수 있습니다. 작은 빈틈이 힙 곳곳에 흩어져 쓰지 못하게 되는 메모리 단편화도 이 과정에서 함께 풀립니다.
Unity의 Boehm GC
앞 절에서 본 .NET의 세대별 GC는 데스크톱과 서버를 겨냥한 런타임의 이야기입니다. Unity의 Mono 런타임은 정작 이 세대별 GC를 쓰지 않고, Boehm GC(Boehm-Demers-Weiser Garbage Collector)라는 다른 수집기를 얹어 돌립니다.
Boehm GC는 앞서 본 .NET GC와 세 군데에서 갈립니다. 세대를 따로 나누지 않아 수집할 때마다 힙을 통째로 훑고(비세대, Non-generational), 수집을 마쳐도 살아남은 객체를 옮기지 않으며(비압축, Non-compacting), 스택에 놓인 값이 객체를 가리키는 참조인지 그저 정수인지를 또렷이 가려내지 못합니다(보수적, Conservative).
이 세 성격이 곧 .NET GC와 Boehm GC를 가르는 핵심 차이이며, Unity에서 GC 비용이 유독 무거운 까닭의 뿌리이기도 합니다. 아래에서 하나씩 짚어 보겠습니다.
비세대 (Non-generational)
Boehm GC와 .NET GC가 처음으로 갈리는 지점은 힙을 세대로 나누느냐입니다. Boehm GC는 세대를 따로 두지 않으므로, .NET GC가 Gen 0만 떼어 살피던 부분 수집이라는 길 자체가 없습니다.
그래서 한 번 GC가 돌 때마다 힙에 놓인 객체를 처음부터 끝까지 빠짐없이 훑어야 합니다.
가령 힙에 객체가 1000개 쌓여 있다면, 그중 990개가 한참 전부터 자리를 지켜 온 장기 생존 객체라 해도 Boehm GC는 1000개를 빠짐없이 검사 대상에 올립니다. 오래 살아남은 객체만 따로 건너뛸 길이 없기 때문입니다.
여기서 Mark 단계에 드는 비용은 살아남은 객체 수를 따라가고, Sweep 단계에 드는 비용은 힙 전체 크기를 따라갑니다. 그래서 힙이 불어날수록 한 번의 GC에 걸리는 시간도 그만큼 길어지게 됩니다.
비압축 (Non-compacting)
두 번째 차이는 수집을 마친 뒤 살아남은 객체를 옮기느냐입니다. 앞서 .NET GC는 압축으로 객체를 한쪽에 차곡차곡 밀어붙인다고 했는데, Boehm GC는 Sweep을 끝내고도 객체를 제자리에 그대로 둡니다.
그러다 보니 죽은 객체를 해제한 자리는 빈틈으로 남고, 그 빈틈이 살아남은 객체 사이사이에 점점이 흩어집니다. 이렇게 작은 빈 공간이 힙 곳곳에 조각조각 끼어드는 것을 메모리 단편화(Fragmentation)라고 부릅니다.
위 그림처럼 빈자리가 조각나면 묘한 상황이 빚어집니다. 비어 있는 공간을 모두 더하면 120B에 이르는데도, 한 덩어리로 이어진 가장 큰 빈자리는 40B뿐이라 50B짜리 객체 하나 들여놓을 자리가 없습니다. .NET GC라면 압축으로 살아남은 객체를 한쪽에 몰아붙여 빈자리를 늘 한 덩어리로 모아 두므로 이런 일이 생기지 않지만, 객체를 옮기지 않는 Boehm GC에서는 빈 공간 총량이 넉넉해도 이어진 블록이 모자라면 새 객체를 받아 줄 수 없습니다. 결국 힙은 실제로 쓰는 양보다 더 부풀어 오릅니다.
더 까다로운 점은, 이렇게 한 번 넓어진 힙이 다시 좁아지지 않는다는 데 있습니다. GC가 죽은 객체를 거두어 메모리를 비워 내더라도 힙이 차지한 크기 자체는 그대로 유지되므로, 비세대 방식이 매번 훑어야 하는 검사 범위도 넓어진 채로 남습니다.
가령 게임 초반에 임시 객체를 한꺼번에 쏟아내 힙이 한 차례 넓어졌다면, 그 임시 객체를 모두 거두어 간 뒤에도 GC가 힙 전체를 훑는 시간은 줄지 않고 그대로 길게 남게 됩니다.
보수적 (Conservative)
세 번째 차이는 어떤 값이 객체를 가리키는 참조인지 가려내는 정확도입니다. Boehm GC는 본래 C와 C++ 같은 언어를 두루 받쳐 주려고 만든 범용 수집기라, 타입 정보가 주어지지 않아도 돌아가도록 설계되어 있습니다.
다만 타입 정보가 없으면, 메모리에 놓인 어떤 값이 객체를 가리키는 포인터인지 그저 평범한 정수인지를 가려낼 길이 없습니다.
이 한계가 특히 또렷하게 드러나는 자리가 스택과 레지스터입니다. 스택의 지역 변수나 레지스터에는 객체를 가리키는 포인터만 담기는 것이 아니라, 해시 코드나 연산 중간값 같은 평범한 정수도 함께 들어앉기 때문입니다.
보수적 GC는 이 슬롯이 포인터를 담았는지 정수를 담았는지 알려 주는 타입 정보를 갖고 있지 않습니다. 그래서 어떤 슬롯의 정수값이 마침 힙에 놓인 객체의 주소와 우연히 맞아떨어지면, GC는 그 값을 포인터로 받아들여 해당 객체를 아직 살아 있는 것으로 묶어 둡니다. 이렇게 멀쩡한 정수까지 참조일지 모른다며 안전하게 넘겨짚는 태도에서 보수적(Conservative)이라는 이름이 나왔습니다.
다만 Unity의 Mono는 이 보수적 스캔의 범위를 한쪽에서 좁혀 둡니다. Boehm GC에 타입 디스크립터를 건네주어, 힙 객체의 필드만큼은 정확하게 훑도록 다듬어 두었습니다. 객체의 어느 필드가 참조이고 어느 필드가 정수인지 타입 정보로 또렷이 가려낼 수 있기 때문입니다.
그러나 스택과 레지스터에는 이런 타입 정보가 끝내 주어지지 않아 여전히 보수적으로 훑을 수밖에 없으며, 뒤에서 볼 거짓 참조도 주로 이 자리에서 생겨납니다.
그림 속 객체 Y처럼, 이렇게 우연히 빚어지는 거짓 참조(False Reference) 탓에 정작 아무도 쓰지 않는 죽은 객체가 거두어지지 못한 채 힙에 눌러앉을 수 있습니다.
이런 거짓 참조가 곳곳에서 생기면 쓰지도 않는 쓰레기가 힙에 쌓여 크기가 군더더기로 불어나고, 앞서 본 비세대 특성과 맞물려 GC가 힙 전체를 훑는 시간까지 덩달아 길어지게 됩니다.
반면 .NET GC는 이런 넘겨짚기 없이 참조를 또렷이 가려내는 정확한(Precise) GC입니다. .NET 런타임은 JIT 컴파일 시점에 각 스택 프레임마다 타입 정보(GC Info)를 함께 만들어 두므로, 스택의 어느 슬롯이 객체 참조이고 어느 슬롯이 정수인지를 정확히 구분할 수 있습니다.
그래서 정수를 참조로 잘못 넘겨짚는 거짓 참조가 끼어들 여지가 없습니다. 앞 절에서 본 압축, 곧 살아남은 객체를 마음 놓고 옮겨 빈자리를 모으는 일이 가능한 것도 바로 이렇게 참조를 정확히 짚어 두는 덕분입니다.
.NET GC와 Boehm GC 비교
| 특성 | .NET GC (데스크톱/서버) | Boehm GC (Unity) |
|---|---|---|
| 세대 구분 | Gen 0/1/2 | 없음 (전체 검사) |
| 압축 | 수행 (단편화 없음) | 안 함 (단편화) |
| 참조 정확도 | 정확 (Precise) | 스택: 보수적 / 힙: 부분 정확 |
| Gen 0 수집 속도 | 빠름 | 해당 없음 |
| 힙 크기와 GC 시간 | 세대별 분리 | 비례 증가 |
| 힙 축소 | 가능 | 불가능 |
표를 보면 Boehm GC가 .NET GC에 견주어 거의 모든 칸에서 뒤처지는데, 그런데도 Unity가 이 수집기를 그대로 안고 가는 까닭은 성능보다 역사적 사정에 있습니다.
Unity가 Mono 런타임을 처음 들이던 시절(Unity 1.x, 2005년경)에 Boehm GC가 그 일부로 함께 따라 들어왔고, 그 뒤로 엔진의 네이티브 코드와 직렬화 시스템, 스크립팅 바인딩에 이르기까지 엔진의 여러 갈래가 이 GC를 발판으로 쌓여 올라갔습니다. 이제 와 .NET의 세대별 GC로 갈아 끼우려면 이렇게 얽힌 의존 관계를 통째로 다시 설계해야 합니다.
그래서 Unity는 이 교체를 멀리 둔 목표로만 잡아 둔 채, 지금까지도 Boehm GC를 그대로 쓰고 있습니다.
Stop-the-World와 GC 스파이크
세대를 나누지도, 살아남은 객체를 한쪽으로 모으지도, 무엇이 참조인지 단정하지도 않는 Boehm GC의 성격은 한 번 GC가 돌 때 드는 비용을 끌어올립니다. 매번 힙 전체를 보수적으로 훑어야 하니, 그 한 번이 가볍게 끝나기 어렵기 때문입니다.
이 비용은 코드 위에서만 머무는 추상이 아니라, 게임이 도는 현장에서 두 가지 모습으로 드러납니다. GC가 도는 동안 게임 로직이 잠시 멎는 Stop-the-World, 그리고 그 멈춤이 한 프레임의 시간을 위로 솟구치게 하는 GC 스파이크(GC Spike)입니다. 앞은 멈춤이라는 동작 자체를, 뒤는 그 동작이 프레임에 남기는 자국을 가리킵니다.
Stop-the-World
두 모습 가운데 먼저 짚을 것은 멈춤 그 자체입니다. Stop-the-World는 GC가 도는 동안 모든 C# 스크립트의 실행을 한꺼번에 멈춰 세우는 동작을 가리킵니다.
GC가 굳이 스크립트를 멈추는 까닭은 Mark 단계의 결과를 어긋나지 않게 지키기 위해서입니다. GC가 힙을 훑으며 어느 객체가 살아 있는지 표시하는 사이에 스크립트가 끼어들어 새 객체를 만들거나 객체끼리의 참조를 바꿔 버리면, 방금 표시해 둔 결과가 실제 상태와 어긋나게 됩니다. 그래서 GC는 힙 검사를 마칠 때까지 스크립트를 손대지 못하도록 잡아 둡니다.
한 프레임 안에서 일어나는 일은 입력 처리와 게임 로직, 렌더링 명령으로 정해져 있는데, GC가 끼어든 프레임에서는 그 사이에 Stop-the-World로 멎어 있던 시간이 고스란히 더해집니다. 위 그림에서 5ms 로직과 4ms 렌더링만으로 끝났을 프레임이, 15ms짜리 GC가 한가운데 들어서면서 24.5ms까지 불어나는 것이 바로 이 합산입니다.
이렇게 더해진 시간이 60fps 기준의 16.6ms 프레임 예산을 넘어서면, 그 프레임은 제때 화면에 그려지지 못합니다. 화면 갱신이 한 박자 늦어지는 셈이라, 플레이어는 매끄럽게 이어지던 화면이 순간 걸리는 끊김, 곧 스터터링(Stuttering)으로 이 지연을 느끼게 됩니다.
GC 스파이크
앞의 Stop-the-World가 프레임을 멎게 하는 동작이라면, 그 멈춤이 프레임 시간 그래프에 남기는 자국이 바로 GC 스파이크(GC Spike)입니다. GC가 끼어든 한 프레임만 시간이 유독 높이 솟아오르는 모양이라 이런 이름이 붙었습니다.
이 모양은 Unity Profiler로 프레임마다의 시간을 늘어놓고 보면 한눈에 드러납니다. 평소 10~15ms 안팎에서 고르게 이어지던 막대들 사이로, GC가 든 프레임 하나만 25~50ms까지 불쑥 솟아 다른 막대를 한참 웃돕니다.
막대가 얼마나 높이 솟느냐, 곧 스파이크의 크기를 가르는 것은 힙 크기와 살아 있는 객체의 참조 구조 두 가지입니다. 둘 다 GC가 한 번 도는 데 걸리는 시간을 좌우하는 요인입니다.
먼저 힙 크기에 따라 검사할 대상의 양이 달라집니다. 세대를 나누지 않는 Boehm GC는 GC가 돌 때마다 힙에 놓인 객체를 빠짐없이 훑으므로, 힙에 쌓인 객체가 많을수록 Mark 단계에서 살펴야 할 객체도 그만큼 불어나 한 번의 GC가 길어집니다. 여기에 객체끼리의 참조가 이리저리 얽혀 있으면, 그 참조를 한 갈래씩 타고 들어가는 그래프 탐색에도 더 많은 시간이 들어갑니다.
같은 크기의 힙이라도 어느 기기에서 도느냐에 따라 스파이크의 높이는 또 달라집니다. GC는 결국 CPU가 떠맡는 일이라, CPU 성능이 데스크톱보다 처지는 모바일에서는 똑같은 힙을 훑는 데에도 시간이 더 걸리기 때문입니다. 데스크톱에서 5ms로 끝나던 GC가 모바일에서는 15~20ms까지 늘어나기도 합니다.
Incremental GC
앞서 본 GC 스파이크는 한 프레임에 GC 작업이 통째로 몰리면서 그 프레임만 예산을 넘겨 버리는 데서 비롯됩니다. 그렇다면 그 작업을 한 프레임에 다 끝내려 들지 않고 여러 프레임에 잘게 나눠 흘려보내면, 프레임 하나가 솟구치는 일은 누그러뜨릴 수 있습니다. Unity가 2019.1부터 들인 Incremental GC(점진적 GC)가 바로 이 발상에서 나온 방식입니다.
GC 작업의 분산
Boehm GC의 기본 모드는 GC가 한 번 시작되면 Mark-and-Sweep 전체를 그 프레임 안에 끝까지 마쳐야 합니다. 그래서 힙이 무거운 순간에 GC가 끼어들면, 그 한 프레임에 GC 시간이 고스란히 얹혀 예산을 훌쩍 넘기게 됩니다.
Incremental GC는 같은 Mark-and-Sweep을 한 번에 몰아치지 않고, 프레임마다 일부만 떼어 조금씩 밀고 나가는 방식으로 풀어냅니다. 한 프레임에서는 GC 작업의 한 조각만 처리하고 나머지는 다음 프레임으로 넘기므로, 프레임 하나에 얹히는 GC 비용 자체가 작게 쪼개집니다.
위 그림에서 보듯, GC를 여러 프레임에 흩뿌리는 대신 치르는 값이 하나 있습니다. 뒤에서 다룰 쓰기 장벽 비용이 더해지면서 GC 총 작업량이 원래 20ms에서 ~26ms로 다소 불어난다는 점입니다. 다만 그렇게 늘어난 작업도 프레임마다 잘게 쪼개져 흘러가므로, 한 프레임이 떠안는 GC 비용은 5ms 안팎으로 가벼워집니다. 그래서 게임 로직과 합쳐도 프레임마다 15ms 안팎에 머물러 16.6ms 예산을 넘지 않고, 눈에 띄던 끊김도 가라앉게 됩니다.
쓰기 장벽 (Write Barrier)
GC 작업을 여러 프레임에 쪼개 놓으면 기본 모드에는 없던 빈틈이 하나 벌어집니다. GC가 한 프레임에서 객체 A를 검사해 두고 멈춘 뒤, 다음 프레임에서 다시 일을 잡기까지 그 사이에 스크립트가 멀쩡히 돌아간다는 점입니다.
이 틈에 스크립트가 A.child = newObject처럼 A의 참조를 바꿔 새 객체를 매달면 문제가 불거집니다. GC는 A를 이미 다 살펴본 뒤라 그 너머에 새로 붙은 newObject를 알 길이 없고, 어느 루트에서도 닿지 못하는 것으로 잘못 가려 멀쩡히 살아 있는 객체를 거두어 버릴 수 있습니다.
Incremental GC는 이 틈을 쓰기 장벽(Write Barrier)으로 메웁니다. 스크립트가 참조 필드를 고쳐 쓸 때마다 런타임이 그 자리에 끼어들어, 이 객체의 참조가 바뀌었다는 사실을 따로 기록해 둡니다.
그러다 다음 프레임에서 GC가 일을 다시 잡으면, 먼저 이 기록부터 들춰 봅니다. 바뀐 자리로 짚어 들어가 A를 한 번 더 검사하면, 그 사이 새로 매달린 newObject까지 빠짐없이 표시되므로 살아 있는 객체를 잘못 거두는 일이 막힙니다.
다만 이 안전장치는 공짜로 얻어지지 않습니다. 쓰기 장벽은 참조 필드를 고쳐 쓰는 자리마다 빠짐없이 끼어들어 기록을 남기므로, 참조를 자주 갈아 끼우는 코드일수록 그 기록 비용이 차곡차곡 더해집니다.
앞 그림에서 GC 총 작업량이 원래 20ms에서 ~26ms로 불어난 대목이 바로 이 쓰기 장벽 몫입니다. 한 프레임의 스파이크를 잘게 흩어 주는 대신, 그 대가로 총 작업량이 다소 늘어나는 맞바꿈인 셈입니다.
Incremental GC의 한계
여기까지 보면 Incremental GC가 스파이크를 다스리는 든든한 장치처럼 비치지만, 한 가지 선만큼은 분명히 그어 두어야 합니다. Incremental GC는 GC가 남기는 자국을 잘게 흩어 누그러뜨릴 뿐, GC라는 비용 자체를 걷어 내지는 못합니다.
그림의 오른쪽이 짚듯, Incremental GC가 손대는 것은 어디까지나 GC가 한 번 돌 때 남기는 자국의 모양일 뿐, GC가 돌아야 하는 상황 자체는 그대로 남습니다. 힙에 새 객체가 계속 쌓이면 GC는 변함없이 다시 돌고, 매 프레임 2~3ms의 GC 비용도 끊이지 않고 따라붙습니다.
이 한계가 가장 선명하게 드러나는 자리가 매 프레임 새로 할당을 일으키는 코드입니다. Update() 안에서 프레임마다 new string()이나 new List<>()로 객체를 찍어 내면, Incremental GC를 켜 두었더라도 할당이 해제를 앞질러 쌓이면서 GC 비용이 프레임마다 더해지고, 끝내 잘게 흩어 내지 못한 큰 스파이크가 다시 솟구치게 됩니다.
그래서 GC 문제를 뿌리째 잡으려면 힙 할당 자체를 줄여야 합니다. Incremental GC는 할당을 최대한 덜어 낸 뒤에도 끝내 남는 불가피한 GC 비용을, 한 프레임에 몰리지 않게 여러 프레임으로 흩어 주는 보조 장치로 두는 것이 맞습니다.
Incremental GC 활성화
Incremental GC는 Unity 에디터의 Project Settings > Player > Other Settings > Configuration에서 Use Incremental GC 체크박스로 켭니다. Unity 2019.3 이후 버전에서는 이 옵션이 처음부터 켜진 채로 들어가 있습니다.
한 가지 짚어 둘 것은, Incremental GC가 GC 알고리즘 자체를 갈아 끼우는 기능은 아니라는 점입니다. 세대를 나누는 세대별 GC로 바뀌는 것이 아니라, 앞서 본 Boehm GC 위에 얹혀 그 Mark-and-Sweep을 여러 프레임에 나누어 돌리도록 손보는 방식에 가깝습니다.
그러므로 비세대, 비압축, 보수적이라는 Boehm GC의 근본 성격은 Incremental GC를 켜도 그대로입니다. 달라지는 것은 같은 Mark-and-Sweep을 한 프레임에 몰아치느냐, 여러 프레임에 잘게 나누어 흘려보내느냐 하는 처리 시점뿐입니다.
GC.Collect()와 프로파일링
지금까지는 GC가 언제 어떻게 도는지를 런타임의 판단에 맡겨 둔 그림이었습니다. 그런데 C# 코드 쪽에서 GC를 직접 불러내는 손잡이도 하나 마련되어 있는데, 바로 System.GC.Collect()입니다. 이 메서드를 호출하면 Unity의 Boehm GC가 그 자리에서 전체 힙을 훑는 Mark-and-Sweep을 돌립니다.
.NET이라면 몇 세대까지 거둘지를 인자로 넘길 수 있지만, Unity의 Boehm GC는 애초에 세대를 나누지 않으므로 그런 인자는 받아도 무시한 채 언제나 힙 전체를 검사합니다.
다만 이 호출에는 Stop-the-World가 따라붙습니다. 부르는 순간 C# 스크립트가 통째로 멈추므로, 한창 플레이가 돌아가는 도중에 끼워 넣는 것은 피하는 편이 원칙입니다. 대신 씬을 새로 불러오거나 화면이 페이드 아웃되는 것처럼 플레이어가 잠깐의 멈춤을 알아채지 못하는 길목을 골라, 그 틈에 호출해 힙을 한 번 비워 두는 식으로 씁니다.
힙 할당이 어디서 얼마나 일어나는지를 손으로 가늠하기는 어렵고, 이를 짚어 주는 도구가 Unity Profiler입니다. CPU 모듈에 찍히는 GC.Alloc 마커를 따라가면, 매 프레임 어느 메서드가 힙을 얼마나 집어삼키는지 메서드 단위로 드러납니다. GC 스파이크를 다스리는 첫걸음은 결국 이 마커로 할당이 쏠리는 지점을 찾아내, 그 할당 자체를 덜어 내거나 아예 없애는 데 있습니다.
마무리
GC는 메모리를 알아서 거두어 가는 대신 메모리 누수와 댕글링 포인터, 이중 해제 같은 수동 관리의 위험을 개발자의 손에서 덜어 줍니다. 다만 그 일을 하느라 도는 시간이 그대로 프레임 시간을 갉아먹는데, Unity의 Boehm GC는 비세대·비압축·보수적이라는 성격 탓에 .NET의 세대별 GC보다 이 비용을 더 무겁게 치릅니다.
- Mark-and-Sweep은 GC 루트(스택 변수·정적 필드)에서 참조 그래프를 타고 도달 가능한 객체에 표시를 남긴 뒤, 표시가 없는 객체를 거두어 갑니다.
- .NET의 세대별 GC는 힙을 Gen 0/1/2로 가르고, 수명 짧은 객체가 모여드는 Gen 0만 자주 들여다봅니다.
- Unity의 Boehm GC는 매번 힙 전체를 훑는 비세대, 단편화를 남기는 비압축, 거짓 참조까지 살려 두는 보수적 성격을 함께 지녀 .NET GC보다 비용이 큽니다.
- GC가 도는 동안 모든 스크립트가 멈추는 Stop-the-World가 프레임 예산을 넘기면 GC 스파이크로 이어집니다.
- Incremental GC는 한 번의 GC를 여러 프레임에 잘게 나누어 스파이크를 누그러뜨리지만, 총 GC 시간 자체는 같거나 오히려 조금 늘기도 합니다.
GC.Collect()는 씬 전환처럼 잠깐의 멈춤이 허용되는 길목에서 불러 힙을 비워 두는 용도로 씁니다.- Unity Profiler의
GC.Alloc마커로 힙 할당이 쏠리는 지점을 짚어 내는 것이 최적화의 출발점입니다. - GC 문제를 뿌리에서 푸는 길은 결국 힙 할당 자체를 덜어 내는 데 있습니다.
이 항목들을 한 줄로 꿰면, GC 비용을 다스리는 일은 GC 알고리즘을 바꾸는 것이 아니라 GC가 거둘 거리를 애초에 적게 남기는 데로 모입니다. Boehm GC의 성격은 우리가 손댈 수 있는 영역이 아니지만, 매 프레임 얼마나 많은 객체를 새로 힙에 올리느냐는 코드를 쓰는 쪽의 몫이기 때문입니다.
이 글에서 짚은 GC의 원리는 실전에서 힙 할당을 덜어 내는 기법의 밑바탕이 됩니다. 메모리 관리 (1) - 가비지 컬렉션의 원리에서는 Unity 프로젝트의 GC 비용을 직접 재고 할당 패턴을 걷어 내는 방법을, 스크립트 최적화 (1) - C# 실행과 메모리 할당에서는 코드에 숨어 있는 힙 할당 패턴과 오브젝트 풀링을 다룹니다. 이어지는 다음 글 C# 런타임 기초 (4) - 스레딩과 비동기에서는 C# 런타임의 멀티스레딩과 비동기 프로그래밍으로 넘어갑니다.
관련 글
시리즈
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초 (현재 글)
- C# 런타임 기초 (4) - 스레딩과 비동기
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초 (현재 글)
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 버퍼 시스템
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요