| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- Unity
- 마개조
- 가비지 컬렉션
- 개발공부
- Garbage Collecter
- 에디터 개조
- GC 원리
- 가비지 컬렉터
- 할로우 나이트 엔딩
- 유니티
- 할나 후기
- 유니티 렉
- Zenocide
- CustomEditor
- Hollow Knight
- 할나 엔딩
- 커스텀에디터
- 커스텀프로퍼티드로어
- 유니티 세이브
- SerializeReference
- C#
- 유니티 #공전 #유니티회전고정
- Gc
- CustomPropertyDrawer
- 유니티 #UI 늘리기 #이미지 재사용
- 유니티 에디터
- 할로우 나이트 분석
- 개발지식
- 할로우 나이트
- SerializeField
- Today
- Total
가을의 개발 일지
[Unity/GC] 가비지 컬렉션(Garbage Collection), 왜 쓰고 어떻게 쓰는데? 본문
서론
게임 개발을 하다보면, 꼭 한번쯤은 최적화에 관심을 갖게 된다. 내가 처음 최적화를 파던 건 2019년이었는데, 당시 만들던 2D 러닝 게임이 고사양 3D 게임에 버금갈 만큼 버벅였기 때문이다.
그러다보면 꼭 프로파일러를 켜게 되고, 갑작스런 프레임 드랍에서 렌더링, 물리 Vsync, 그리고 가비지 컬렉션(이하 GC)을 만나게 된다.


오늘은 그때의 기억도 되살릴 겸, GC에 대해 다시 공부도 할 겸 포스트를 작성한다. 이번 포스트에선 GC를 사용하는 이유, 정말 간단한 동작 원리, 어떻게 다뤄야 안정적인가에 대해 살펴보자.
가비지 컬렉션(GC). 그거 왜 쓰는데?
현재 많이 쓰이는 언어들 중, C와 C++을 제외한 많은 언어(Java, Kotlin, 파이썬, C# 등)들이 GC를 채택하고 있다. 그래서 처음엔 C, C++에서 메모리 관리하는 게 귀찮아서 생겨난 줄 알았는데, 의외로 첫 등장은 1958년 Lisp였고, 이는 1972년 탄생한 C보다도 무려 14년이 빠르다. (그렇다고 최초는 아니고, 최초의 고급 언어는 포트란이라고.)
여튼, GC를 쓰는 이유는 명확하다. 메모리 관리를 자동화하는 것. C와 C++에서 malloc, free를 다루다보면, 객체의 생성 및 소멸 과정에서 프로그래머가 실수할 확률도 높다. 그리고 이렇게 누수된 메모리는, 평생 지워지지 않는다. 우리가 프로그램을 실행하는데, 하는 것도 없으면서 RAM만 6기가씩 잡아먹으면 어떻게 되겠는가?
이에 객체의 생성은 기존처럼 사용자가 원하는 타이밍에 하되, 소멸을 간단히 호출하거나, 혹은 호출하지 않아도 사용하지 않는 메모리는 자동으로 지워주는 존재가 등장했고, 그것이 곧 GC다.
그리고, 자동화의 개념에서 알 수 있듯, GC는 프로그래머에게 편리함을 제공해주지만 C, C++에 비해 메모리 관리가 수동적이라는 단점이 있다. 저점은 보장되지만 고점은 뽑기 힘든, 이를 테면 지금의 Chat-GPT 같은 존재랄까. 그래서 최적화가 중요한 부문들엔 아직도 C와 C++이 사용된다하고, GC를 쓰면서도 비슷한 성능을 뽑기 위해선 결국 메모리 지식이 필요하다.
GC는 어떻게 동작하는데?
이 부분은 파고 들면 정말 자세하게 설명할 수 있지만(stop-the-world, mark-and-sweep, 유니티만의 독자적인 GC 등...), 이번 포스트는 상세 원리보단 개요, 언제, 어떻게, 왜 쓰는지에 집중하기에 여기선 겉만 핥고 넘어가겠다. 자세한 동작 원리는 다음 포스트에 작성하겠다.
더이상 쓰지 않는 건 치워버립시다.
GC의 개념은 위 한 줄로 설명 가능하다. 프로그램 내에 GC라는 친구를 두고, 이 친구가 메모리를 관리하게 한다. 메모리를 계속 지켜보다가 꽉 차는 순간, 더이상 사용하지 않는 객체들은 해제해버린다.
좀 더 자세하게 살펴보자면, 각 객체가 참조되고 있는지를 검사하며, 누구도 참조하지 않는 객체는 지운다. 예를 들면 Player가 Gun이라는 객체를 생성해서 사용하다가, 무기를 knife로 바꾼다면 Gun은 더이상 참조되지 않는다. 이런 객체들은 메모리가 꽉 찼을 때 제거되며, 그렇게 주기적으로 메모리를 휴지통마냥 비우면서, 프로그램의 동작 내내 메모리가 꽉 차 뻗는 일이 생기지 않게 관리해주는 것이다.
근데 왜 GC가 문제가 돼요?
GC는 우리가 일일이 객체를 소멸시키지 않아도, 자동으로 해제해주는 고마운 친구다. 그럼 왜 이런 GC가 개발자들에게 악명이 높을까? 그 이유는, GC가 동작하는 순간 게임은 멈추기 때문이다.
메모리가 가득차게 되면, GC는 게임 전체를 정지시켜버린다. 메모리를 해제하는 과정에서, 참조가 변경될 수도, 새로운 객체가 생겨나고 참조가 사라질 수도 있기 때문이다. 이렇게 멈춰놓고 모든 객체를 검사하며 현재 사용 중인지, 앞으로 쓸 일이 없는지 체크하고, 안 쓴다 판단한 객체들은 전부 해제해버린다. 이렇게 멈추는 시간이 짧으면 모르겠는데, 누수를 얼마나 시켰냐에 따라 플레이어가 체감할 정도로 오래 걸리기도 한다. 만약 프레임과 반응 속도가 중요한 FPS에서, 적이 나타난 순간 컴퓨터가 잠깐 멈춘다면? 당신이 롤을 하다가 중요한 한타를 시작했는데, 하필 그 순간에 게임이 잠깐 멈춘다면? 아마 상상도 하기 싫을 것이다.
그렇기에, GC가 메모리를 관리해주는데 GC를 안 돌릴수록 이득인 아이러니한 상황이 발생하고, 결국 메모리 누수를 막기 위해 메모리에 대해 공부하게 된다. 차라리 직접 관리하겠다며 C++로 선회하는 사람도 있을 정도니.
그럼 이제부터, 어떻게 하면 유니티에서 메모리를 관리할 수 있을지 간단히 살펴보자.
메모리 관리. 어떻게 하는 게 좋을까?
여기엔 다양한 방법들이 있고, 또 모두 유효한 방법들이다. 상황에 맞게 적용한다면 최적화에 큰 도움이 될 것이다. 각 방법별로 어떤 목표를 갖는지, 어떻게 메모리 누수를 막는지 가볍게 살펴보자.
1. 그냥 GC 쓰지 말죠?
처음은 극단적인 방법으로 시작해보자. GC의 가장 큰 문제점은, 언제 동작할지 모른다는 것이다. 마치 시한폭탄처럼, 메모리가 쌓이길 기다리다가 갑자기 빵! 하고 돌아가는 게 GC니까.
그렇다보니, 아예 GC를 갖다버리고 직접 메모리를 관리하는 게 첫번째 방법 되겠다. 유니티에선 설정에서 GC를 비활성화하는 방법은 없지만, 런타임에 GC를 비활성화하는 코드는 있다.
// 출처: Unity Documention
// GC를 즉시 실행한다.
System.GC.Collect();
// GC를 활성화한다.
GarbageCollector.GCMode = GarbageCollector.Mode.Enabled;
// GC를 완전히 비활성화한다. System.Gc.Collect를 사용하여도 실행되지 않는다.
GarbageCollector.GCMode = GarbageCollector.Mode.Disabled;
// GC가 자동적으로 실행되진 않으나, System.GC.Collect를 사용하여 실행할 수 있다.
GarbageCollector.GCMode = GarbageCollector.Mode.Manual;
// 점진적 GC를 실행한다. 모드가 점진적 GC로 바뀌는 게 아니니 주의. Collect와 동일.
GarbageCollection.CollectIncremental();
위는 유니티 공식 문서에서 제공하는 GC 관련 함수들이다. GC를 직접 수행하거나, 비활성화 하는 방법 등이 존재한다.
다만, GC를 비활성화하게 된다면, 당연히 게임은 메모리 누수에 취약해진다. 프로그래머 모르게 누수되는 코드가 하나라도 있다면, 게임이 오래 실행될수록 점점 누수되어 결국 게임이 버티지 못할 것이다. 그렇기에, 만약 이 방법을 꼭 써야겠다면 메모리 관리는 반드시 도가 틀 정도로 공부하고, 두번 세번 확인하자.
2. 객체가 소멸되는 경우를 줄여버리죠?
한 번 생성된 객체를 즉시 소멸하는 대신, 재사용하는 방법이다. 마치 일회용품을 줄이고 재사용품을 쓰자는 느낌이다.
아마 여러분이 최적화에 조금이나마 공부해봤다면, 오브젝트 풀링을 한 번은 써봤을 것이다. 이 풀링 방법이 해당 의견에서 비롯되었다. FPS 게임을 개발할 때, 총을 쏠 때마다 총알을 생성/무언가에 닿을 시 소멸한다면, 라이플만 구현해도 1초에 6개는 우습게 생길 것이다. 사람이 많아진다면... 끔찍하다.
그럴 때 총알을 미리 100개 정도 준비해두고, 사라지는 타이밍에 소멸하는 대신 비활성화, 생성할 때 다시 활성화한다면 이 객체들은 소멸할 일이 없다. 당연히, GC도 돌지 않고 누수도 적어진다.
단, 해당 객체들은 소멸하지 않는다. 즉, 항상 메모리에 상주하게 된다. 물론 ScriptableObject를 쓰는 등 최적화 기법은 많이 있지만, 오브젝트를 미리 충분히 생성해두고, 게임 한 판 동안 사라지지 않는다는 건 주의할만하다. 그렇기에, 풀의 크기를 어떻게 잡을 것인가가 중요해진다.
3. GC를 빨리 수행하면 되지 않나요?
이번엔 GC가 호출되는 건 막을 수 없으니, 실행되는 시간을 줄여보자는 아이디어가 있다. 이를 위해 최대 힙의 크기를 작게 만들어, GC가 빈번하지만 재빠르게 수행되도록 유도한다.
이는 게임 단위가 긴 게임에서, 프레임을 안정적으로 유지하는데 도움을 준다. 8~10시간씩 이어지는 게임에서 평균 프레임이 60이라면, 가끔씩 30프레임으로 떨어지는 대신 자주 50프레임으로 떨어트리자는 생각이다. 스토리 위주의 게임이거나, 템포가 빠르지 않은 오픈월드 등의 게임이라면 이 방법이 효율적일 수 있다.
그러나, 실행 시간이 짧다한들 결국 GC가 자주 실행되기에, 만일 짧은 시간이어도 플레이어가 체감 가능한 영역이라면, '이 게임 좀 자주 버벅이네'라는 느낌을 줄 수도 있다.
4. GC가 자주 호출되지 않으면 되지 않나?
이번엔 3과 반대로, GC의 실행 시간을 늘리되 빈도를 줄여버리는 전략이다. 즉, 힙의 크기를 대폭 늘려버려 한 번 멈출 때 크게 멈추더라도, 그런 일이 거의 없게 하자는 생각이다.
이것만 놓고보면 '뭐야, 오래 멈추면 플레이어들이 더 불편해하는 거 아냐?' 싶겠지만, 만약 한 판의 길이가 길지 않거나, 잠깐 렉이 걸리더라도 크게 신경쓰지 않을 수 있는, 퍼즐이나 힐링, 방치형 등이라면 꽤나 효과적일 수 있다.
다만, 이 경우엔 잡고 있는 메모리가 크다는 단점이 있다. 힙의 크기를 늘렸다는 건, 그만큼 게임 실행 중 사용되는 메모리가 많다는 걸 뜻한다. 속도와 메모리는 항상 Trade-off 관계에 있으니, 본인의 판단 하에 힙의 크기를 적절히 정하도록 하자.
5. 안 들키면 장땡 아냐?
이번엔 조금 틀어서, GC 자체를 해결하려 하지 말고 들키지만 말자는 전략이 있다.
이전에, 이런 글을 본 적이 있다. 엘리베이터 속도가 느리다는 피드백에, 속도를 높이는 것보다 효과적인 건 거울을 설치하는 것이었다. 즉, 문제가 되는 대상에 집착하지 말고, 사용자가 느끼는 불편에 초점을 맞춰보는 것이다.
GC가 문제가 되는 건, 어디까지나 플레이하는 도중에 게임이 멈추기 때문이다. 그런데, 잘 생각해보면 우린 한 가지, 잘못 생각하던 게 있다. 바로 게임이 모든 순간, 영원히 연속적일 거란 믿음이다.
실제로는, 우리가 하는 많은 게임들에 끊기는 순간이 있다. 포탈을 통해 맵을 이동할 때, 유저가 일시정지를 할 때, 죽은 직후, 상점에 들를 때 등등, 게임은 생각보다 많은 순간에 흐름이 멈추게 된다.
이를 역이용해, 흐름이 멈추는 순간 GC를 수동으로 돌려버리는 방법이 있다. 대표적인 게 바로 싱글 게임의 일시정지로, 유저가 ESC를 누르고 UI가 나타나기 전까지, 설정을 변경한 후 저장하는 순간 등, 게임 도중 자주 발생하고, 멈춰도 자연스러운 구간들이 꽤나 존재한다. 이 사이사이에 몰래 GC를 돌려서, 게임이 멈추는 순간을 조절해보고자 하는 게 마지막 방법이 되겠다.
개인적으론, 이 방법이 가장 게임스럽다고 생각한다. 탈 것을 구현 못해 NPC 머리에 열차를 붙인 폴아웃3처럼, 유도 레이저를 구현하기 위해 플레이어 옆에 투명 토끼를 둔 WOW처럼. 원래 게임이라는 게, 작동 원리야 어떻든 플레이만 잘 되면 장땡 아니겠는가? 세세한 방식을 따지기보단 오로지 사용자에게 포커스를 맞춘 방법이라, 가장 마음에 든다.
당연하지만, 위 방법들은 하나만 택해야하는 오지선다가 아니다. 상황에 맞게, 본인의 취향에 맞게 여러 방법들을 섞어 쓰는 것이 가장 좋지 않겠는가?
거기 유니티 켜는 당신, 잠깐만 멈춰보십쇼.
이 글을 통해 GC의 존재를 처음 접했다면, 당장 여러분의 게임에 이 방법들을 적용시키고 싶을 것이다. 특히 게임이 버벅이고 있다면 더더욱.
그러나, 꼭 명심할 것이 있다. 잠재적인 위험을 제거하지 말고, 실제 문제가 되는 부분을 판별한 후 제거하라는 것이다.
유니티에서 Window - Analysis - Profiler를 누르거나, 혹은 ctrl + 7을 누르면 프로파일러를 열 수 있다. 이외에도 Frame Debugger 등, 유니티는 이미 여러분의 최적화를 위한 많은 도구들을 제공하고 있다.
만약 여러분이 최적화를 처음 시도해본다면, 높은 확률로 프레임 세팅을 하지 않았거나 Rendering이 발목을 잡고 있을 것이다. 나도 버벅임을 해결하려 정말 많은 최적화 기법을 사용해봤는데, 알고보니 설정에서 타겟 프레임이 30 fps였다는 웃지 못할 사건도 있었다. 이를 60 fps로 올리니 게임이 그리 쾌적할 수가 없더라.
최적화 기법은 정말 많다. 그 이유는, 병목을 일으키는 요소 또한 그만큼 많기 때문이다. 그렇기에, 지금 여러분이 최적화를 원한다면, 프로파일러부터 켜서 원인이 무엇인지부터 찾아봐라. 디버깅도 안 해보고 버그를 고칠 순 없지 않겠는가?
보너스) 함수 내 지역 변수는 GC에 영향을 줄까?
포스트를 작성하며 문득 의문이 들어 공부해봤는데, 알아두면 좋을 듯한 결과가 나와 덤으로 추가해본다.
결론부터 말하자면, 함수 내에서 사용하는 지역 변수도 메모리 해제의 대상이다. 그러나, 그 변수가 어떤 종류인가에 따라 GC에서 처리하는지 아닌지는 조금 달라진다.
GC는 힙 영역에서 동작한다. 이는, 힙이 아닌 스택에서 관리되는 메모리는 해당 사항이 없다는 뜻이다. 무엇이 힙이고, 무엇이 스택에 들어가는지 헷갈린다면 Call by Value, Call by Reference를 생각해보자. 당신이 사용하려는 변수는 값 타입인가, 참조 타입인가?
int, Vector3 등 값 타입은 스택에 복사된다. 따라서, 함수 내에서 int를 선언하고 자주 호출한다해서 GC가 자주 돌진 않는다.
그러나 GameObject를 위시한 Class나 자료구조들, 참조 타입은 힙에 주소값이 복사된다. 이 변수들은 함수가 끝난다고 해제되지 않는다. 힙에 계속해서 남아 있다가, GC가 동작하는 순간 다른 메모리들과 함께 해제된다.
따라서, 함수 내에서 List<>를 새로 만들고, 값을 채워 반환하는 함수처럼 함수 내에서 힙 메모리 할당이 이뤄진다면, 매개 변수로 원본 리스트를 넘겨받고 그 안에 채우는 식으로 바꾼다면 메모리 낭비를 줄일 수 있다.
마무리
이번 시간엔 가비지 컬렉션(GC)에 대해 왜 쓰는지, 어떻게 쓰는지, 단점과 개선 방법에 대해 살펴보았다. 요즘 들어 세부적인 내용은 생략하고 이런 배경 위주 포스트가 많은데, 이는 단순히 처음부터 자세한 설명으로 시작하면 "그래서 그게 뭔데" 소리가 나오기 때문이다. 배경을 이해했다면 내부를 세세히 파도 잘 이해되는 법. 다음 시간엔 유니티에서 GC가 어떻게 동작하는지, C# GC와 함께 원리까지 자세히 살펴보겠다.
참고 자료
- r/rust, "A brief history of Garbage Collection, culminating with Rust", Reddit, 2024. 7. 30., https://www.reddit.com/r/rust/comments/1eft52h/a_brief_history_of_garbage_collection_culminating/
- Unity, "가비지 컬렉션 비활성화", Unity Documentation Version. 2021.3, https://docs.unity3d.com/kr/2021.3/Manual/performance-disabling-garbage-collection.html
- 석영, "[Unity] 스택 메모리 vs 힙 메모리", Tistory, 2024. 7. 9., https://milkyquartz.tistory.com/293
- MangKyu, "[Java] Garbage Collection(가비지 컬렉션)의 개념 및 동작 원리 (1/2), Tistory, 2021. 1. 27., https://mangkyu.tistory.com/118
- sam0308, "[C#] 가비지 컬렉터(Garbage Collector), Tistory, 2023. 2. 20., https://sam0308.tistory.com/22
- 코더 제로, "[유니티 최적화] 자동 메모리 관리 이해", Tistory, 2021. 2. 24., https://coderzero.tistory.com/entry/유니티-최적화-자동-메모리-관리-이해
'Unity > 최적화' 카테고리의 다른 글
| [Unity/C#/GC] 유니티와 C#의 가비지 컬렉션(Garbage Collection)은 어떻게 동작하는가 (0) | 2025.09.22 |
|---|