본문 바로가기
게임 엔진 - 유니티/[최적화] 유니티

[유니티 최적화] Garbage Collector

by 묻공러 2025. 6. 21.

# GC
더 이상 참조 하지 않는 쓰레기 메모리들을

GC가 알아서 수집하고 자동으로 해제해 주는 기능을 의미한다
프로파일러를 보다 보면 순간적으로 GC 때문에

성능이 하락하거나 게임이 끊기는 경우가 발생하는데

이를 GC spikes라고 부른다
GC를 최소화 하기위한 다양한 기법을 숙지해서 활용하는 것이 중요하다

# GC를 줄이기 위한 다양한 기법들
1. 객체 풀링 (Object Pooling)
개념: 

게임에서 자주 생성되고 파괴되는 객체들(예: 총알, 파티클 효과, 적 캐릭터)을 

미리 일정량 생성해두고 재활용하는 기법이다

객체를 파괴하는 대신 비활성화해서 풀에 반환하고, 필요할 때 풀에서 꺼내 활성화하여 사용한다


활용:
Instantiate와 Destroy는 GC 발생의 주요 원인이다

풀링을 사용하면 이 두 연산을 극적으로 줄일 수 있다
사운드 이펙트, UI 요소, 이펙트(폭발, 연기) 등 반복적으로 생성/소멸되는 모든 것에 적용한다


2. 문자열 조작 최소화 (String Manipulation Minimization)
개념: 

C#의 string은 불변(immutable) 객체이다

즉, 문자열을 수정하는 모든 연산(합치기, 대문자 변환 등)은 새로운 문자열 객체를 생성한다

이는 예상치 못한 GC를 유발한다


활용 1:
잦은 문자열 합치기에는 string.Format 대신 StringBuilder를 사용하면 된다
또한, 로그 메시지, UI 텍스트 업데이트 등을 자주 호출하는 경우 주의해야 한다
그리고 ToString() 호출을 최대한 줄이는 것이 좋다

특히 숫자를 문자열로 변환하는 작업은 GC를 발생시킨다

 

예시 1: 

using System;
using System.Text; // StringBuilder를 위해 필요
using UnityEngine; // MonoBehaviour 사용을 위해 (유니티 환경 가정)

public class StringOptimization : MonoBehaviour
{
    private StringBuilder _stringBuilder = new StringBuilder();

    void Update()
    {
        // 매 프레임 UI 텍스트를 업데이트하는 상황 가정
        // Debug.LogFormat이나 UI.Text.text = ... 에 사용될 문자열
        // Bad: 매 프레임 새로운 문자열 객체 3개 생성
        // string messageBad = "Score: " + 100 + ", Health: " + 50; 
        // Debug.Log(messageBad);

        // Good: StringBuilder를 사용하여 문자열 조작 시 GC 발생 최소화
        _stringBuilder.Clear(); // 이전 내용을 지움 (GC 발생 X)
        _stringBuilder.Append("Score: ");
        _stringBuilder.Append(100); // int -> string 변환 시 GC 발생하지만, 매번 새로운 StringBuilder 생성 X
        _stringBuilder.Append(", Health: ");
        _stringBuilder.Append(50);
        
        string messageGood = _stringBuilder.ToString(); // 최종 문자열 생성 (여기서만 GC 발생)
        // Debug.Log(messageGood); // 실제 UI 텍스트 업데이트에 사용
    }
}

 

활용 2:

딕셔너리 키로 string을 사용하는 것은 GC를 발생시키기에

Enum이나 다른 구조체를 사용하면 된다

 

예시 2:

using System;
using System.Collections.Generic;
using UnityEngine;

public class DictionaryKeyOptimization : MonoBehaviour
{
    // 게임 내 아이템 타입 Enum 정의
    public enum ItemType
    {
        Weapon,
        Armor,
        Potion,
        Material
    }

    // Bad: string 키를 사용하는 딕셔너리 (문자열 비교/해싱 오버헤드 + 잠재적 GC)
    private Dictionary<string, int> _itemCountsBad = new Dictionary<string, int>();

    // Good: Enum 키를 사용하는 딕셔너리 (값 타입이라 빠르고 GC 거의 없음)
    private Dictionary<ItemType, int> _itemCountsGood = new Dictionary<ItemType, int>();

    void Start()
    {
        // Bad 예시 (실제 게임에서는 itemTypeString에 변수가 들어갈 것)
        // _itemCountsBad["Sword"] = 5;
        // if (_itemCountsBad.ContainsKey("Sword")) { /* ... */ }

        // Good 예시
        _itemCountsGood[ItemType.Weapon] = 5;
        _itemCountsGood[ItemType.Potion] = 10;

        Debug.Log($"Weapon Count: {_itemCountsGood[ItemType.Weapon]}");
        if (_itemCountsGood.ContainsKey(ItemType.Armor))
        {
            Debug.Log("Player has Armor.");
        }
        else
        {
            Debug.Log("Player does not have Armor.");
        }
    }
}


3. 컬렉션 초기화 및 관리 최적화
개념: 

리스트(List<T>), 딕셔너리(Dictionary<TKey, TValue>) 같은 컬렉션도 

동적으로 크기가 조절될 때 내부적으로 메모리를 재할당할 수 있다

또한 foreach 루프는 값 타입이 아닌 경우 반복자(Enumerator) 객체를 할당할 수 있다

 

활용 1:
컬렉션을 미리 초기화할 때 적절한 초기 용량(new List<T>(capacity))을 

지정하여 런타임 중 재할당을 줄일 수 있다
또한, RemoveAt()이나 Remove()를 사용할 때 GC를 유발하기에

Clear()를 통해서 내부 배열을 유지하고 요소를 제거해 GC를 줄이는 것이 좋다

 

예시 1:

using System.Collections.Generic;
using UnityEngine;

public class CollectionOptimization : MonoBehaviour
{
    // Bad: 매번 새로운 List 생성 시 용량이 부족하면 내부적으로 배열 재할당 (GC)
    // private List<GameObject> _activeEnemies;

    // Good: 필드로 List를 미리 선언하고, 필요한 경우 초기 용량을 지정
    private List<GameObject> _activeEnemies = new List<GameObject>(100); // 100개까지는 재할당 없음

    void Awake()
    {
        // 시작 시 한 번만 생성하거나,
        // _activeEnemies = new List<GameObject>(initialCapacity);
    }

    public void AddEnemy(GameObject enemy)
    {
        _activeEnemies.Add(enemy);
    }

    public void ClearEnemies()
    {
        _activeEnemies.Clear(); // 내부 배열은 유지하고 요소만 제거 (GC 발생 X)
        // _activeEnemies = new List<GameObject>(); // Bad: 새 List 생성, 이전 List는 GC 대상
    }
}


활용 2:

foreach 루프 대신 for 루프를 사용하는 것이 좋다

특히 값 타입이 아닌 컬렉션(예: List<GameObject>)에 대해

foreach는 반복자 객체를 힙에 할당할 수 있다

최근 유니티 버전의 foreach는 컴파일러 최적화로 많이 개선되어

예전만큼 큰 문제는 아닐 수 있지만, 여전히 주의할 필요가 있다

 

예시 2:

using System.Collections.Generic;
using UnityEngine;

public class ForLoopOptimization : MonoBehaviour
{
    public List<GameObject> enemies = new List<GameObject>();

    void Update()
    {
        // Bad: GameObject와 같은 참조 타입 컬렉션에 대한 foreach는
        //     일부 이전 버전/플랫폼에서 반복자(Enumerator) 객체를 힙에 할당할 수 있음 (GC)
        // foreach (GameObject enemy in enemies)
        // {
        //     enemy.transform.Rotate(Vector3.up * Time.deltaTime * 10);
        // }

        // Good: for 루프는 인덱스를 사용하여 직접 접근하므로 반복자 객체를 할당하지 않음 (GC 발생 X)
        for (int i = 0; i < enemies.Count; i++)
        {
            if (enemies[i] != null) // null 체크는 항상 중요
            {
                enemies[i].transform.Rotate(Vector3.up * Time.deltaTime * 10);
            }
        }
    }
}


4. 람다식(Lambda Expression) 및 익명 메서드 사용 주의
개념: 

람다식이나 익명 메서드가 외부 스코프의 변수를 캡처(Closure)할 때, 

캡처된 변수들이 참조하는 객체는 힙에 할당될 수 있으며, 

이로 인해 GC가 발생할 수 있다

특히 delegate나 event에 람다식을 추가하고 제거하는 과정에서 발생하기 쉽다

 

활용:
매 프레임 호출되는 콜백에 람다식을 사용하는 것은 지양해야 한다

대신 미리 정의된 메서드를 사용하면 된다
Action이나 Func 타입의 델리게이트를 필드로 선언하여 재활용하고, 

여기에 람다식을 직접 할당하지 않고 특정 메서드를 할당하는 방식을 사용하면 된다

using System;
using UnityEngine;

public class LambdaOptimization : MonoBehaviour
{
    // Bad: Update 등에서 매 프레임 새로운 람다식을 이벤트에 추가
    // void Update()
    // {
    //     SomeManager.OnEvent += () => Debug.Log("Event Fired!"); // 매 프레임 새로운 람다 객체 생성 (GC)
    // }

    // Good: 람다식 대신 미리 정의된 메서드를 사용 (GC 발생 X)
    private Action _myAction;

    void Awake()
    {
        // 시작 시 한 번만 람다식을 할당하여 재활용하거나
        _myAction = () => Debug.Log("My Action Fired!");

        // 혹은 아예 미리 정의된 메서드 사용
        // _myAction = MyDefinedAction;
    }

    void OnEnable()
    {
        // 이벤트 구독 시에도 람다식 대신 미리 정의된 메서드 사용
        SomeManager.OnEvent += MyDefinedAction;
    }

    void OnDisable()
    {
        SomeManager.OnEvent -= MyDefinedAction;
    }

    private void MyDefinedAction()
    {
        Debug.Log("Defined action fired!");
    }

    // 예시용 매니저 (실제 게임에서는 별도 파일에 있을 것)
    public static class SomeManager
    {
        public static event Action OnEvent; // Action은 미리 정의된 델리게이트
        public static void TriggerEvent()
        {
            OnEvent?.Invoke();
        }
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // 이벤트를 발생시키면, 이미 등록된 MyDefinedAction이 호출됨 (GC 발생 X)
            SomeManager.TriggerEvent();
        }
        
        // 캐싱된 람다식 호출 (GC 발생 X)
        if (Input.GetKeyDown(KeyCode.A))
        {
            _myAction?.Invoke();
        }
    }
}


5. 캐싱(Caching) 및 반복 할당 피하기
개념: 

메서드 내에서 매번 new 키워드를 사용하여 새로운 객체를 생성하는 것을 피하고, 

한 번 생성한 객체를 변수에 캐싱하여 재활용하는 것이 좋다

 

활용:
GetComponent<T>()와 같이 자주 호출되는 메서드의 결과는 

Start()나 Awake()에서 캐싱하여 필드에 저장하고 재활용하면 된다

 

벡터 연산(Vector3.zero, Vector3.one 등)의 경우 new Vector3(0,0,0) 대신 

미리 정의된 정적 프로퍼티를 사용하면 된다

 

Physics.RaycastAll, Physics.OverlapSphere 등 배열을 반환하는 물리 관련 API는

Physics.RaycastNonAlloc, Physics.OverlapSphereNonAlloc과 같이

미리 할당된 배열을 인자로 받아 재사용하는 NonAlloc 버전을 사용하는 것이 좋다

using UnityEngine;

public class CachingOptimization : MonoBehaviour
{
    // Bad: Update()에서 매번 GetComponent 호출 (매번 컴포넌트 검색 및 잠재적 GC)
    // void Update()
    // {
    //     Transform myTransform = GetComponent<Transform>();
    //     myTransform.Rotate(Vector3.up * Time.deltaTime);
    // }

    // Good: Awake() 또는 Start()에서 컴포넌트를 캐싱
    private Transform _cachedTransform;
    private Renderer _cachedRenderer;

    void Awake()
    {
        _cachedTransform = transform; // GetComponent<Transform>()은 transform 프로퍼티로 이미 캐싱됨
        _cachedRenderer = GetComponent<Renderer>();
    }

    void Update()
    {
        _cachedTransform.Rotate(Vector3.up * Time.deltaTime); // 캐싱된 Transform 사용 (GC 발생 X)

        // Bad: 매번 새로운 Vector3 객체 생성 (GC)
        // transform.position = new Vector3(transform.position.x + 1, transform.position.y, transform.position.z);

        // Good: Vector3의 값을 직접 변경하거나, 정적 필드 사용 (GC 발생 X)
        Vector3 currentPos = _cachedTransform.position;
        currentPos.x += 1 * Time.deltaTime; // 값 타입이라 구조체 복사지만, 불필요한 new X
        _cachedTransform.position = currentPos;
        
        // Vector3.up 같은 정적 프로퍼티는 캐싱되어 있어서 GC 발생 안 함
        _cachedTransform.Rotate(Vector3.up * Time.deltaTime * 10);
    }
}

 

6. 코루틴(Coroutine) 종료 및 재시작 관리
개념: 

코루틴 내부에서 new WaitForSeconds(), new WaitForEndOfFrame() 등을 

호출할 때마다 새로운 객체가 생성되어 GC가 발생한다

 

활용:
WaitForSeconds와 같은 yield return 객체는 

미리 생성하여 캐싱하고 재활용하면 된다

private readonly WaitForSeconds _waitForOneSecond = new WaitForSeconds(1f);

IEnumerator MyCoroutine()
{
    yield return _waitForOneSecond; // 새로운 객체 생성 X
    // ...
}


또한, 코루틴을 시작할 때마다 StartCoroutine(MyCoroutine())을 호출하는 대신,

코루틴이 이미 실행 중인지 확인하고 StopCoroutine 후 StartCoroutine을 하거나,

코루틴 내부에서 yield break를 사용하여 명시적으로 종료하는 것이 좋다

 

7. 배열/리스트 재사용 및 지연 할당
개념: 

임시로 사용할 배열이나 리스트를 필요할 때마다 생성하지 않고, 

재사용 가능한 필드나 정적 변수로 선언해 두고 

Clear() 등으로 내용을 지운 후 재활용하는 것이 좋다

 

활용: 

런타임에 데이터가 추가될 수 있는 임시 버퍼 역할을 하는 컬렉션에 유용하다

using System.Collections.Generic;
using UnityEngine;

public class ArrayReuseOptimization : MonoBehaviour
{
    // Bad: 매번 새로운 List 또는 배열 생성 (GC)
    // private List<Enemy> _nearbyEnemies = new List<Enemy>();

    // Good: 재사용 가능한 List를 필드로 선언
    private List<Enemy> _nearbyEnemiesBuffer = new List<Enemy>();

    // Physics.OverlapSphereNonAlloc 등의 NonAlloc 버전 사용 시 필요
    private Collider[] _hitCollidersBuffer = new Collider[50]; // 최대 50개의 콜라이더를 미리 할당

    void Update()
    {
        // 예시: 주변 적 찾기
        FindNearbyEnemies();
    }

    void FindNearbyEnemies()
    {
        // 1. List 재사용
        _nearbyEnemiesBuffer.Clear(); // 내용을 비우고 재사용 (GC 발생 X)

        // 특정 범위 내 적들을 찾아 _nearbyEnemiesBuffer에 추가하는 로직
        // 예시: for 루프를 돌며 조건에 맞는 적을 _nearbyEnemiesBuffer.Add(enemy)

        // 2. Physics.OverlapSphereNonAlloc 사용 (GC 발생 X)
        // (LayerMask 설정 등은 생략)
        int numColliders = Physics.OverlapSphereNonAlloc(transform.position, 10f, _hitCollidersBuffer);

        _nearbyEnemiesBuffer.Clear(); // 이전 내용을 비움
        for (int i = 0; i < numColliders; i++)
        {
            Enemy enemy = _hitCollidersBuffer[i].GetComponent<Enemy>();
            if (enemy != null)
            {
                _nearbyEnemiesBuffer.Add(enemy);
            }
        }
        
        // _nearbyEnemiesBuffer를 활용하는 로직...
        // Debug.Log($"Found {_nearbyEnemiesBuffer.Count} nearby enemies.");
    }
}

// 예시용 Enemy 클래스
public class Enemy : MonoBehaviour { }