본문 바로가기

유니티

유니티 최적화 - Garbage Collection

게임에서의 최적화는 정말 중요하다. 만약 유저가 재밌게 플레이하고 있을 때 게임이 잠깐 멈추는 현상이 일어나게 되면 유저에게 부정적 영향을 주게 된다.

 

지금부터 알아보는건 유니티엔진을 통해 게임을 제작하였을 때 최적화 기법 또는 C# 내 최적화 이다.

 

[ Garbage Collection ] 

객체지향 프로그래밍 언어는 메모리를 동적으로 할당한다는 개념이 존재하고, 런타임에 동적으로 할당된 메모리를 해제해줄 필요가 있다. C++에서는 Modern C++이 되어서야 "스마트 포인터" 라는 기술의 등장으로 이를 비교적 나은 형태로 처리할 수 있게 되었지만 "자동"으로 해준다고 보기는 어렵다.

 

C#으로 작성한 코드는 .NET 위에서 실행되며 CLR을 통해 자동으로 필요없는 동적 메모리를 해제하는

GC(Garbage Collection)를 지원한다. 

 

C#의 GC

각 어플리케이션의 Managed Heap은 그 공간이 유한하기 때문에 메모리 영역을 관리해주어야 한다. 

C# 어플리케이션의 메모리는 주어진 공간을 순차적으로 채우는 특징 때문에 할당을 반복하면 힙의 마지막 주소에 도달하게 된다. 이때 GC가 필요없는 메모리를 수집하여 해당 공간의 할당을 해제하는데, 이것이 GC (Garbage Collection)이다.

 

GC 호출 시 다른 스레드들을 일시정지하고 GC가 실행되기 때문에, 너무 잦은 GC 호출은 프로그램의 성능을 하락시킬 수 있습니다.

 

Managed Heap (관리되는 힙)

힙 메모리는 프로세스 별로 고유하게 가진다. 새 프로세스가 생성될 때 인접한 공간의 메모리를 할당받게 되는데 이를 managed heap이라 부른다.

 

그럼 Garbage란 무엇인가?

Reference type 변수들을 살펴보면 스택 메모리에 주소 값을 담아두고 힙 메모리에 접근하는 방식으로 저장되어 있다. 여기서 함수가 종료되고 지역 변수들이 제거되면서 스택 메모리에서 힙 메모리를 가리키고 있는 일부 주소값들이 같이 pop된다. 그러면 해당 주소값에 있는 힙 메모리의 데이터에는 더 이상 접근할 수 없는 상태가 된다. 이렇게 참조할 수 없게 된 객체를 garbage라고 부른다.

 

아래 내용으로 살펴보자

void static Main()
{
	int i = 123;      // a value type
	object o = i;     // boxing
	int j = (int)o;   // unboxing
}

여기서 Main함수가 끝나게 되면 o는 stack 메모리에서 pop된다. 그럼 이것이 가리키는 boxing된 i는 힙 메모리에서 Garbage로 남아있게 되는 것이다.

그렇게 Garbage로 남은 메모리는 어떠한 시점에서 Garbage Collection이라는 알고리즘이 메모리를 해제 시켜준다.

 

어떠한 방식으로 메모리 할당과 해제를 하는지 마이크로 소프트 공식 홈페이지의 말을 빌려오겠다.

microsoft--

메모리 할당

사용자가 새 프로세스를 시작하면 런타임에서는 인접한 주소 공간 영역을 이 프로세스에 예약합니다. 이 예약된 주소 공간을 관리되는 힙이라고 합니다. 관리되는 힙에서는 힙에 있는 다음 개체가 할당될 주소의 포인터를 관리합니다.

관리되는 힙에서 메모리를 할당하면 관리되지 않는 힙에서 메모리를 할당하는 것보다 속도가 더 빠릅니다. 런타임에서는 포인터에 값을 더하여 개체에 메모리를 할당하기 때문에, 스택에서 메모리를 할당하는 속도만큼 빠릅니다. 또한 연속으로 할당된 새 개체는 관리되는 힙에 인접하여 저장되므로 애플리케이션에서 개체에 빠른 속도로 액세스할 수 있습니다.

메모리 해제

가비지 컬렉션은 애플리케이션의 루트를 검사하여 더 이상 사용되지 않는 개체를 결정합니다.

애플리케이션 루트에는 정적 필드, 스레드 스택의 지역 변수, CPU 레지스터, GC 핸들, finalize 큐가 포함됩니다. 각 루트는 관리되는 힙에 있는 개체를 참조하거나 Null로 설정됩니다. 가비지 컬렉션은 나머지 런타임에 이러한 루트를 요청할 수 있습니다. 가비지 컬렉션은 이 목록을 사용하여 루트에서 접근할 수 있는 모든 개체를 포함하는 그래프를 만듭니다.

그래프에 없는 개체는 애플리케이션 루트에서 연결할 수 없습니다. 가비지 컬렉션은 연결할 수 없는 개체를 가비지로 간주하고 이 개체에 할당된 메모리를 해제합니다.

 

-----------------

조금 더 효율적으로 관리하기 위해 GC 세대라는게 있다. (유니티에서는 GC 세대는 사용 안함)

 

GC 세대

GC는 효율적인 수집을 위해 객체들에 세대를 매깁니다. 최근에 생성되어 가비지 컬렉팅을 아직 겪은 적이 없는 객체를 0세대, 가비지 컬렉팅을 1번 겪었지만 해제되지 않은 객체를 1세대, 2번 겪었지만 해제되지 않은 객체를 2세대라 합니다.

세대가 높아질수록, 즉 가비지 컬렉팅을 겪고도 생존한 객체들일수록 중요한 객체로 판단하는 방식입니다. 기본적으로 가비지 컬렉팅은 0세대를 대상으로 먼저 시행되며, 그러고도 필요한 메모리를 확보할 수 없을 경우에 1세대까지 포함하여 시행, 1세대까지 포함하여도 메모리 확보가 불가할 때 2세대까지 포함하게 됩니다. 2세대까지 포함한 모든 가비지 컬렉팅 수행 시 아예 프로세스를 일시정지하고 우선적으로 수행하기 때문에, 순간적인 프레임 드랍 등이 일어나게 됩니다.

 

 또한 GC는 객체를 그 크기에 따라 85,000바이트(85kb)보다 작으면 SOH(Small Of Heap), 크거나 같으면 LOH(Large Of Heap)로 구분합니다. SOH는 위에서 언급한대로 할당 직후 0세대부터 시작하며, LOH의 경우 처음부터 2세대로 등록됩니다. 가비지 컬렉팅을 겪고 생존한 객체를 중요하다고 판단하듯이, 크기가 큰 객체는 중요한 객체라고 판단하는 방식입니다. 또한 가비지 컬렉팅 후 SOH들은 메모리 단편화를 없애기 위해 재배치되지만, LOH의 경우 옮기는 과정에 발생하는 오버헤드가 크기 때문에 가비지 컬렉팅 후 이동되지 않습니다. 

 

 

그러면 자동으로 관리 해주니까 좋은 것이 아니냐 라고 생각할 수 있다.

반은 맞고 반은 틀리다라고 말할 수 있을 것 같다. 

 

Garbage Collection의 문제가 뭐가 있을까?

우리는 런타임에 메모리를 할당과 해제를 반복적으로 사용할 것이다. 하지만 이렇게 되면 Heap에 할당된 메모리가 해제되면서 Heap에 듬성듬성 메모리가 배치되어 메모리 단편화가 발생하고 이는 해당 듬성듬성으로 새로 할당 받을 수 있는 메모리 크기와 맞지 않아 메모리 할당이 어려워지고, 할당 속도를 느리게 하는 원인이 된다. 

 

그렇다면 어떻게 코드를 짜야 GC를 줄일 수 있을까?

 

1. 불필요한 List 사용

List<T>는 동적으로 크기를 조절할 수 있는 편리한 자료구조이지만, 메모리 효율에 있어서는 좋지 않은 편입니다. 단적으로 말하자면, 일반 배열로 충분하다면 배열을 사용하는 것이 낫습니다.

 

2. string 변수에 무분별한 + 연산

 string 변수에는 + 연산을 사용하여 간단하게 텍스트를 추가할 수 있지만, 이 기능은 사실 사용할 때마다 가비지를 생성하게 됩니다. 그래서 StringBuilder 클래스를 사용을 권장합니다.

 

3. 불필요한 큰 사이즈의 오브젝트 생성

85kb 이상의 객체는 LOH로 구분되어 시작부터 2세대로 등록되며, LOH 객체를 불필요하게 많이 만들면 힙 영역에 2세대 객체가 가득하여 가비지 컬렉팅 효율이 떨어지게 되며, 메모리 단편화가 발생할 수 있습니다.

 

4. 캐싱 사용

우리 코드가 힙 할당으로 이어지는 함수를 반복적으로 호출하고 결과를 폐기하면 불필요한 쓰레기가 생성됩니다.

대신, 우리는 이러한 객체에 대한 참조를 저장하고 재사용하는 기술을 캐싱이라고 한다.

 

5. 오브젝트 풀링 사용

계속 재사용될 오브젝트에는 풀에서 넣고, 가져오는 방식을 사용하면 불필요한 쓰레기가 생기지 않는다. (이미 처음에 생성시킬때 할당이 되며, 해당 오브젝트를 사용하지 않게 되었을 때만 해제 하기 때문에)

 

6. 함수 사용

 1) GameObject.tag -> GameObject.CompareTag 사용

 2) Input.GetTouch, Input.touchCount -> Input.touches 사용

 3) Physics.SphereCastNonAlloc -> Physics.SphereCastAll 사용

 

7. 코루틴 함수 사용

 1) StartCoroutine 사용 시 약간의 가비지 발생

 2) yield new return 에서 new 할 때마다 가비지 발생

되기 때문에 Unitask 적극 활용!!

 

!. 예전엔 foreach 문에서 enumerator 부분에서 박싱해서 사용되서 가비지가 생겼지만 최신 c# 컴파일러에서는 가비지를 만들지 않는다.

 

이렇게 이러한 정보들을 알고 코드를 작성하는 것과 모르고 작성하는 것이 차이가 날 수 있기 때문에

C# 혹은 JAVA를 사용한다면 꼭 알고 가야하는 내용이다.!! ><

 

+

[GC]

메모리를 자동으로 관리해주는 기술이다.

사용되지 않는 메모리(가비지) 를 자동으로 수거해갑니다.

mark.and.sweep → root를 기반으로 가지를 뻗어나가면서 살아있는 메모리를 체크한다.

root : 메모리에서 직접 접근 가능한 객체들을 말합니다. 예를 들어, 전역 변수, 스택 변수, 레지스터에 있는 변수, 현재 실행 중인 함수의 지역 변수

  • Stop-the-world가 완전히 사라진 것은 아님 (하지만 크게 개선됨)
    • *"메인 스레드를 포함한 모든 스레드가 잠시 멈추고, GC가 메모리를 정리하는 동안 기다린다"*는 뜻입니다.
    • 최신 GC는 최대한 그 시간과 영향도를 줄이려고 많은 기술적 개선
  • 세대별(Generational) GC 적용
  • 메모리 압축(정렬, Compaction) 없음

세대별 GC가 메모리가 꽉 차지 않았을 때도 실행되는 이유

  1. 📈 할당 임계치(Allocation Threshold) 초과
    • GC는 특정 세대의 **할당량(임계치)**을 넘어서면 메모리 부족이 아니더라도 예방적으로 실행됩니다.
    • 예: 세대 0에 너무 많은 객체가 짧은 시간에 할당되면 GC가 발생해 청소합니다.
  2. 🧠 메모리 압박 판단
    • 시스템이 메모리 부족 가능성을 감지하면, 미리 GC를 수행할 수 있습니다.
    • 특히 Unity나 서버 앱에서 큰 객체를 반복적으로 할당하면 "미리 정리해둬야겠다"는 판단이 트리거가 될 수 있어요.
  3. 명시적 호출
    • 개발자가 GC.Collect()를 호출하여 강제로 실행할 수도 있음
    • 단, Unity에서는 이걸 자주 쓰면 성능에 나쁜 영향을 줄 수 있음
  4. 🔁 **대형 객체 힙(LOH)**의 압박 (85,000 바이트(약 83.0 KB) 이상)
    • 큰 배열이나 Texture 같은 대형 객체는 세대 2에 바로 할당됨
    • 이 경우 LOH가 꽉 차지 않아도 세대 2 GC가 유발될 수 있어요

Unity에서 메모리 압축을 하지 않는 이유

  • GC가 객체의 메모리 주소를 변경하면 안 되는 환경
    • Unity에서는 객체의 메모리 주소를 직접 참조하는 경우가 많음.
    • 특히, 네이티브 코드와 연동된 객체는 주소가 바뀌면 문제가 발생할 수 있음.
      • 만약 GC가 C# 객체의 메모리 주소를 압축(compact) 하면서 변경해버리면…
      • Unity C++ 코드에서 참조하고 있던 포인터(주소)는 더 이상 유효하지 않게 됩니다.
    • 따라서 Unity의 GC는 객체를 이동(압축)하지 않음.
  • 실시간 게임 환경에서 GC의 추가적인 부하 방지
    • 압축 GC는 메모리 정리를 위해 Stop-the-World 시간을 증가시킬 수 있음.
    • Unity에서는 프레임 드롭을 최소화하기 위해 Incremental GC를 적용하여 Stop-the-World 시간을 줄이는 방식으로 최적화함.
      • Incremental GC : Garbage Collection(가비지 수집) 과정이 한 번에 모든 작업을 하지 않고, 여러 프레임에 나눠서 조금씩 수행되도록 만든 방식
728x90

'유니티' 카테고리의 다른 글