공부/Unity

[최적화 스터디] 1. GC에 대해

BeepMaeae 2025. 11. 18. 17:16

* 본 게시물은 3D 유니티 프로젝트를 다룬다.

 

GarbageCollector.CollectIncremental

보스전 씬에서 Profiler를 돌려보던 중 GarbageCollector.CollectIncremental이 급격하게 높아지는 것을 확인했다. GarbageCollector가 뭔데 이렇게 갑자기 튀는걸까? 하는 궁금증이 생겨 GC에 대해서 검색해보았다.

 

일단 이미지는 유니티의 '증분형 가비지 컬렉션(Incremental Garbage Collection)'이 작동하고 있다는 뜻이다.

 

여기서 가비지 컬렉션 (GC)은 시스템이 자동으로 더 이상 사용되지 않는 메모리를 찾아내어 수거하고, 이 공간을 다시 사용할 수 있도록 정리하는 역할을 하며, CPU 자원을 소모한다.

  • 일반 GC vs. 증분형 GC (Incremental GC)
    • 일반 GC: 모든 작업을 멈춘 뒤 쓰레기를 한 번에 수거한다. 청소할 쓰레기가 많으면 멈추는 시간이 수십 ~ 수백 ms까지 길어진다고 표현할 수 있다.
    • 증분형 GC: 청소 작업을 여러 프레임에 걸쳐 잘게 나누어 조금씩 처리.

유니티는 증분형 가비지 컬렉션을 사용하는데, 이미지에서 증분형 CG가 스파이크로 보이는 이유는 다음과 같다.

  • 평소 프레임과의 대비: 만약 다른 프레임의 CPU 시간이 1ms에 불과했다면, 3.02ms짜리 작업이 실행되는 순간 해당 프레임의 총시간은 4.02ms로 4배가 됨.
  • 그래프상의 착시: 프로파일러 그래프는 이 상대적인 시간 증가를 스파이크나 핑이 튀는 것처럼 시각적으로 보여줌.

결론: GarbageCollector.CollectIncremental이 높아지는 건 코드 어딘가에 메모리 쓰레기를 만들고 있다는 뜻으로, 직접적인 원인은 다른 곳에 있다.

 

따라서 앞으로 profiler를 볼 땐 Overview 창에서 하단의 Hierarchy 탭을 선택하여 GC Alloc 열의 헤더를 클릭하여, 메모리 할당이 높은 순으로 정렬해야 한다.

 

프로젝트 내에서 GarbageCollecter 문제를 찾아보았다.

 

AerialMovementAction.cs - SetVelocityPerAxis

-            (float absVel, float absInput) = (Mathf.Abs(currentAxisSpeed), Mathf.Abs(axisInput));
-            (float signVel, float signInput) = (Mathf.Abs(currentAxisSpeed), Mathf.Abs(axisInput));
+            float absVel = Mathf.Abs(currentAxisSpeed);
+            float absInput = Mathf.Abs(axisInput);
+            float signVel = Mathf.Sign(currentAxisSpeed);
+            float signInput = Mathf.Sign(axisInput);

기존 구문은 튜플을 사용하여 힙 할당을 유발할 가능성이 있다. (그리고 왜인지는 모르겠지만 sign 구문에 절댓값이 부여되어 있는 오류가 있다.)

튜플을 사용하지 않는 방향으로 리팩토링하여 가비지를 방지하였다.

 

InteractionManager.cs

    private Dictionary<GameObject, LinkedListNode<Interaction>> 
        _interactionLookup = new Dictionary<GameObject, LinkedListNode<Interaction>>();
    
+    // Object pool for Interaction objects
+    private readonly Stack<Interaction> _interactionPool = new Stack<Interaction>();

    private void OnEnable()
    {
        _inputReader.PullEvent += OnPullInitiated;

 

        if (_interactionLookup.ContainsKey(obj))
            return;

-        // 새 Interaction 생성하여 LinkedList 앞에 추가
-        var newInteraction = new Interaction(io.m_Type, obj);
+        // Get an Interaction object from the pool or create a new one
+        Interaction newInteraction = GetInteractionFromPool(io.m_Type, obj);
        var node = _potentialInteractions.AddFirst(newInteraction);

        // Look-up dictionary에 노드 저장
        // Look-up에서 노드를 바로 꺼내기
        if (_interactionLookup.TryGetValue(obj, out var node))
        {
+            // Return the Interaction object to the pool
+            ReturnInteractionToPool(node.Value);
+
            // LinkedList에서 제거
            _potentialInteractions.Remove(node);
            // Look up dictionary에서도 키 제거
            _interactionLookup.Remove(obj);
        }
    }

+    private Interaction GetInteractionFromPool(InteractionType type, GameObject interactiveObject)
+    {
+        Interaction interaction = _interactionPool.Count > 0 ? _interactionPool.Pop() : new Interaction();
+        interaction.type = type;
+        interaction.interactiveObject = interactiveObject;
+        return interaction;
+    }
+
+    private void ReturnInteractionToPool(Interaction interaction)
+    {
+        interaction.type = InteractionType.None;
+        interaction.interactiveObject = null;
+        _interactionPool.Push(interaction);
+    }
+}

 

Interaction.cs

    public InteractionType type;
    public GameObject interactiveObject;

+    public Interaction() { }

 

Ineraction 자체가 클래스기 때문에, InterctionManager.cs의 new Interaction은 클래스를 자주 생성하여 가비지를 생성한다.

_potentialInteractions.AddFirst()가 객체를 LinkedList에 추가하고, RemovePotentialInteraction()를 호출하면 LinkedList에서 이 객체를 제거한다. 그런데 LinkedList에서 제거된 Interaction 객체는 아무 것도 참조하지 않는 쓰레기 상태가 된다.

따라서 Interaction 객체 풀을 사용해야 한다.

아래 과정을 통해 최적화를 진행해주었다.

  1. IneractionManager에 객체 풀 사용
    • Pool을 사용하면 처음에 만들어진 Interaction을 이후에 다시 사용할 수 있기 때문에, 무조건적으로 이득이라고 할 수 있다.
    • new를 통한 힙 메모리 할당은 그렇게 효율적인 작업이 아니다. 이를 최소한으로 줄여준다.
  2. AddPotentialInteraction, RemovePotentialInteraction에서 객체를 풀에 반환