Version: 2023.1
언어: 한국어
가비지 컬렉션 비활성화
프로파일러 개요

가비지 컬렉션 베스트 프랙티스

가비지 컬렉션은 자동으로 작동하지만 프로세스하는 데 CPU 시간이 상당히 많이 소요됩니다.

C++처럼 할당하는 모든 메모리를 수동으로 추적하고 수동으로 비워야 하는 다른 프로그래밍 언어와 비교했을 때 C#의 자동 메모리 관리는 메모리 누수 위험을 줄여주며 기타 프로그래밍 오류도 줄여줍니다.

자동 메모리 관리를 사용하면 거의 오류 없이 빠르고 쉽게 코드를 작성할 수 있습니다. 하지만 이런 편리함도 성능에 영향을 미칠 수 있습니다. 성능에 맞게 코드를 최적화하려면 애플리케이션이 가비지 컬렉터를 많이 트리거하는 상황을 방지해야 합니다. 이 섹션에서는 애플리케이션이 가비지 컬렉터를 트리거할 때 영향을 미치는 몇몇 일반적인 문제와 해당 워크플로에 대한 개요를 살펴봅니다.

임시 할당

애플리케이션은 일반적으로 임시 데이터를 각 프레임마다 있는 관리되는 힙에 할당합니다. 하지만 이렇게 하면 애플리케이션의 성능에 영향을 미칠 수 있습니다. 예를 들면 다음과 같습니다.

  • 만일 프로그램이 각 프레임마다 1KB 만큼의 임시 메모리를 할당하고, 초당 프레임 수가 60인 경우, 프로그램은 1초당 60KB 만큼의 임시 메모리를 할당해야 합니다. 1분이 지나면 가비지 컬렉터가 사용할 수 있는 메모리가 3.6MB까지 추가됩니다.
  • 가비지 컬렉터를 매초마다 호출하는 것은 성능에 악영향을 줄 수 있습니다. 가비지 컬렉터가 1분에 한 번씩만 실행되면 수천 개의 개별 할당에 분산되어 있던 3.6MB를 정리해야 하므로 가비지 컬렉션에 시간이 상당히 소모될 수 있습니다.
  • 로딩 작업은 성능에 영향을 미칩니다. 에셋을 로딩하는 과중한 작업 시 애플리케이션이 많은 임시 오브젝트를 생성하고 Unity는 작업이 완료될 때까지 해당 오브젝트를 참조하는 경우 가비지 컬렉터는 해당 임시 오브젝트를 릴리스할 수 없습니다. 즉 Unity가 잠시 후에 포함되는 많은 오브젝트를 릴리스하더라도 관리되는 힙은 확장되어야 합니다.

이를 해결하려면 가능한 한 자주 관리되는 힙 할당량을 줄여야 합니다. 이상적으로는 프레임당 0바이트 또는 가능한 한 0에 가까우면 좋습니다.

재사용 가능 오브젝트 풀

가비지 생성을 방지하기 위해 애플리케이션이 오브젝트를 생성하고 파괴하는 횟수를 줄일 수 있는 경우가 많이 있습니다. 게임에서는 발사체 같은 타입의 오브젝트가 있는데 한 번에 적은 수이지만 반복에서 등장할 수 있습니다. 이런 경우에 이전 오브젝트를 파괴하고 새로운 오브젝트로 대체하기보다는 기존의 오브젝트를 다시 사용할 수 있습니다.

예를 들어 발사될 때마다 매번 프리팹에서 새로운 발사체 오브젝트를 인스턴스화하는 건 좋지 않습니다. 대신 게임플레이동안 동시에 존재할 수 있는 발사체의 최대 수를 계산하고 게임이 처음으로 게임플레이 씬에 들어갈 때 오브젝트 배열을 올바른 크기로 인스턴스화할 수 있습니다. 이렇게 하려면 다음을 따릅니다.

  • 모든 발사체 게임 오브젝트를 비활성화 상태로 설정하여 시작합니다.
  • 발사체가 발사되면 배열을 검색하여 배열에서 비활성화되어 있는 첫 번째 발사체를 찾아 필요한 포지션으로 이동하고 게임 오브젝트를 활성화된 상태로 설정합니다.
  • 발사체가 파괴되면 게임 오브젝트를 다시 비활성화로 설정합니다.

이렇게 재사용 가능한 오브젝트 풀 기법을 구현할 수 있게 하는 ObjectPool 클래스를 사용할 수 있습니다.

아래 코드는 스택 기반 오브젝트 풀을 단순하게 구현한 모습입니다. ObjectPool API가 포함되지 않은 Unity 이전 버전을 사용하는 경우 또는 커스텀 오브젝트 풀이 구현되는 방법에 대한 예를 보려는 경우 다음을 참조하는 편이 유용할 수 있습니다.

using System.Collections.Generic;
using UnityEngine;

public class ExampleObjectPool : MonoBehaviour {

   public GameObject PrefabToPool;
   public int MaxPoolSize = 10;
  
   private Stack<GameObject> inactiveObjects = new Stack<GameObject>();
  
   void Start() {
       if (PrefabToPool != null) {
           for (int i = 0; i < MaxPoolSize; ++i) {
               var newObj = Instantiate(PrefabToPool);
               newObj.SetActive(false);
               inactiveObjects.Push(newObj);
           }
       }
   }

   public GameObject GetObjectFromPool() {
       while (inactiveObjects.Count > 0) {
           var obj = inactiveObjects.Pop();
          
           if (obj != null) {
               obj.SetActive(true);
               return obj;
           }
           else {
               Debug.LogWarning("Found a null object in the pool. Has some code outside the pool destroyed it?");
           }
       }
      
       Debug.LogError("All pooled objects are already in use or have been destroyed");
       return null;
   }
  
   public void ReturnObjectToPool(GameObject objectToDeactivate) {
       if (objectToDeactivate != null) {
           objectToDeactivate.SetActive(false);
           inactiveObjects.Push(objectToDeactivate);
       }
   }
}

반복된 스트링 연결

C#의 문자열은 변경할 수 없는 레퍼런스 타입입니다. 여기서 레퍼런스 타입은 Unity가 관리되는 힙에 이를 할당하고 이 레퍼런스 타입이 가비지 컬렉션 대상이 된다는 의미입니다. 또한 변경할 수 없다는 의미는 문자열이 한 번 생성되면 변경될 수 없다는 의미입니다. 문자열을 수정하려고 하면 완전히 새로운 문자열이 생깁니다. 따라서 가능한 한 임시 문자열을 생성하지 않아야 합니다.

문자열 배열을 단일 문자열로 결합하는 다음 예시 코드를 살펴보십시오. 새로운 문자열이 루프 안에 추가될 때마다 결과 변수에 대한 이전 콘텐츠는 중복되고 코드는 완전히 새로운 문자열을 할당합니다.

// Bad C# script example: repeated string concatenations create lots of
// temporary strings.
using UnityEngine;

public class ExampleScript : MonoBehaviour {
    string ConcatExample(string[] stringArray) {
        string result = "";

        for (int i = 0; i < stringArray.Length; i++) {
            result += stringArray[i];
        }

        return result;
    }

}

입력 stringArray에 { "A", "B", "C", "D", "E" }가 포함되어 있는 경우 이 메서드는 다음 문자열에 대한 힙에 스토리지를 생성합니다.

  • "A"
  • "AB"
  • "ABC"
  • "ABCD"
  • "ABCDE"

이 예시에서는 최종 문자열만 필요하고 그 외 문자열은 중복된 할당입니다. 입력 배열에 더 많은 항목이 있을수록 이 메서드는 더 많은 문자열을 생성하고 각각은 마지막 문자열보다 더 길어집니다.

많은 문자열을 함께 연결해야 하는 경우 Mono 라이브러리의 System.Text.StringBuilder 클래스를 사용해야 합니다. 위의 스크립트에 대한 개선된 버전은 다음과 같습니다.

// Good C# script example: StringBuilder avoids creating temporary strings,
// and only allocates heap memory for the final result string.
using UnityEngine;
using System.Text;

public class ExampleScript : MonoBehaviour {
    private StringBuilder _sb = new StringBuilder(16);

    string ConcatExample(string[] stringArray) {
        _sb.Clear();

        for (int i = 0; i < stringArray.Length; i++) {
            _sb.Append(stringArray[i]);
        }

        return _sb.ToString();
    }
}

반복된 연결은 모든 프레임을 업데이트하는 것처럼 자주 호출되지 않으면 성능이 크게 저하되지 않습니다. 다음 예시는 업데이트가 호출될 때마다 새로운 문자열을 할당하고 가비지 컬렉터가 처리해야 하는 오브젝트의 연속적인 스트림을 생성합니다.

// Bad C# script example: Converting the score value to a string every frame
// and concatenating it with "Score: " generates strings every frame.
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

가비지 컬렉션에 대한 이러한 연속적인 요구가 발생하지 않도록 하려면 점수가 변경될 때만 텍스트가 업데이트되도록 코드를 설정할 수 있습니다.

// Better C# script example: the score conversion is only performed when the
// score has changed
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;
    
    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}

이를 좀 더 개선하기 위해 점수 타이틀("Score: "라고 표시된 부분)을 저장하고 점수는 두 가지 다른 UI.Text 오브젝트로 표시됩니다. 즉 문자열 연결이 필요하지 않습니다. 코드는 여전히 점수값을 문자열로 전환해야 하지만 이는 이전 버전에서 개선된 사항입니다.

// Best C# script example: the score conversion is only performed when the
// score has changed, and the string concatenation has been removed
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
   public Text scoreBoardTitle;
   public Text scoreBoardDisplay;
   public string scoreText;
   public int score;
   public int oldScore;

   void Start() {
       scoreBoardTitle.text = "Score: ";
   }

   void Update() {
       if (score != oldScore) {
           scoreText = score.ToString();
           scoreBoardDisplay.text = scoreText;
           oldScore = score;
       }
   }
}

배열 값 반환 메서드

간혹 새로운 배열을 생성하고 배열에 값을 넣어 반환하는 메서드를 작성하는 편이 더 편리할 수도 있습니다. 하지만 이 메서드가 반복적으로 호출되는 경우 새로운 메모리가 매번 할당됩니다.

다음 예시 코드는 호출될 때마다 새로운 배열을 생성하는 메서드에 대한 예시입니다.

// Bad C# script example: Every time the RandomList method is called it
// allocates a new array
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];
        
        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }
        
        return result;
    }
}

매번 메모리를 할당하는 경우를 피할 수 있는 한 가지 방법으로 배열이 레퍼런스 타입이라는 점을 이용하는 방법이 있습니다. 파라미터로써 메서드에 전달되는 배열을 수정하고 메서드가 반환된 후에도 그 결과를 유지할 수 있습니다. 이렇게 하려면 예시 코드를 다음과 같이 설정해야 합니다.

// Good C# script example: This version of method is passed an array to fill
// with random values. The array can be cached and re-used to avoid repeated
// temporary allocations
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}

이 코드는 배열의 기존 콘텐츠를 새로운 값으로 교체합니다. 이 워크플로에서는 배열의 초기 할당을 수행하기 위해 호출 코드가 필요하지만 함수는 호출될 때 새로운 가비지를 생성하지 않습니다. 그런 다음 배열을 재사용하고 다음에 이 메서드를 호출할 때 관리되는 힙에 새로 할당하지 않고 랜덤 숫자로 다시 채울 수 있습니다.

컬렉션과 배열 재사용

System.Collection 네임스페이스(예: 리스트 또는 딕셔너리)에서 배열이나 클래스를 사용할 때 할당된 컬렉션이나 배열을 재사용하거나 풀링하는 것이 효율적입니다. Collection 클래스는 Clear 메서드를 노출하므로 이는 컬렉션 값을 제거하지만 컬렉션에 할당된 메모리를 릴리스하지는 않습니다.

이는 복잡한 계산에 임시 “helper” 컬렉션을 할당하고자 하는 경우 유용합니다. 다음 코드 예시에 이에 대한 설명이 있습니다.

// Bad C# script example. This Update method allocates a new List every frame.
void Update() {

    List<float> nearestNeighbors = new List<float>();

    findDistancesToNearestNeighbors(nearestNeighbors);

    nearestNeighbors.Sort();

    // … use the sorted list somehow …
}

이 예시 코드는 프레임당 nearestNeighbors 리스트를 한 번씩 할당하여 데이터 포인트 세트를 수집합니다.

이 리스트를 메서드에서 포함하는 클래스로 옮기므로 코드가 다음과 같이 각 프레임에 새로운 리스트를 할당할 필요가 없습니다.

// Good C# script example. This method re-uses the same List every frame.
List<float> m_NearestNeighbors = new List<float>();

void Update() {

    m_NearestNeighbors.Clear();

    findDistancesToNearestNeighbors(NearestNeighbors);

    m_NearestNeighbors.Sort();

    // … use the sorted list somehow …
}

이 예시 코드에서는 여러 프레임에 걸쳐 리스트 메모리를 유지하고 재사용합니다. 코드는 리스트가 확장해야 할 때만 새로운 메모리를 할당합니다.

클로저 및 익명 메서드

일반적으로 가능하면 C#에서는 클로저를 피해야 합니다. 익명 메서드와 메서드 참조는 성능에 민감한 코드, 특히 프레임 단위로 실행되는 코드에서 사용을 최소화해야 합니다.

C#의 메서드 참조는 레퍼런스 타입이므로 힙에 할당됩니다. 즉 메서드 참조를 인수로 전달하는 경우 쉽게 임시 할당이 생성됩니다. 이 할당은 전달하는 메서드가 익명 메서드인지 아니면 미리 정의된 메서드인지에 관계없이 발생합니다.

또한 익명 메서드를 클로저로 전환하면 클로저를 메서드에 전달하는 데 필요한 메모리 양이 상당히 증가합니다.

아래의 코드 샘플에서는 랜덤인 숫자 리스트가 특정한 순서로 정렬되어야 합니다. 이 샘플은 익명 메서드를 사용하여 리스트의 정렬 순서를 제어하고 정렬은 할당을 생성하지 않습니다.

// Good C# script example: using an anonymous method to sort a list. 
// This sorting method doesn’t create garbage
List<float> listOfNumbers = getListOfRandomNumbers();


listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/2)) 

);

이 스니핏을 다시 사용할 수 있게 만들기 위해 로컬 범위의 변수에 대해 상수 2를 대체할 수 있습니다.

// Bad C# script example: the anonymous method has become a closure,
// and now allocates memory to store the value of desiredDivisor
// every time it is called.
List<float> listOfNumbers = getListOfRandomNumbers();


int desiredDivisor = getDesiredDivisor();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/desiredDivisor))

);

이제 이 익명 메서드는 메서드 범위 밖의 변수 상태에 접근할 수 있어야 하므로 클로저가 되었습니다. desiredDivisor 변수를 이 클로저 코드가 사용할 수 있도록 클로저에 반드시 전달해야 합니다.

올바른 값이 클로저에 전달되도록 하기 위해 C#은 클로저에 필요한 외부 범위 변수를 유지할 수 있는 익명 클래스를 생성합니다. 이 클래스의 사본은 클로저가 정렬 메서드에 전달될 때 인스턴스화되며, desiredDivisor 정수의 값으로 초기화됩니다.

이 클로저를 실행하려면 생성된 클래스 사본의 인스턴스화가 필요하고, 모든 클래스는 C# 참조 형식입니다. 이러한 이유로 클로저를 실행하기 위해서는 관리되는 힙에 오브젝트를 할당해야 합니다.

박싱

박싱은 Unity 프로젝트에서 의도하지 않은 임시 메모리 할당이 발생하는 가장 주된 원인입니다. 이는 값 타입의 변수가 자동으로 레퍼런스 타입으로 전환될 때 발생합니다. 특히 기본적인 값 형식 변수(정수 및 플로트 등)를 오브젝트 타입의 메서드로 전달할 때 주로 발생합니다. Unity용 C# 코드 작성 시 박싱을 피해야 합니다.

아래의 예시에서는 x의 정수가 object.Equals 메서드에 전달될 수 있도록 박싱됩니다. 오브젝트의 Equals 메서드는 오브젝트가 전달되어야 하기 때문입니다.

int x = 1;

object y = new object();

y.Equals(x);

C# IDE와 컴파일러는 박싱이 의도하지 않은 메모리 할당으로 이어지는 경우에도, 박싱에 대한 경고를 표시하지 않습니다. 이는 C#은 소규모 임시 메모리 할당이 효율적으로 세대 기반 가비지 컬렉터와 할당 크기에 민감한 메모리 풀을 활용하여 해결할 수 있을 것이라고 전제하기 때문입니다.

Unity 할당자는 크고 작은 메모리 할당을 해결하기 위해 서로 다른 메모리 풀을 활용하지만, Unity의 가비지 컬렉터는 세대 기반이 아니므로 박싱이 생성하는 소규모의 빈번한 임시 할당을 효율적으로 제거할 수 없습니다.

박싱의 식별

박싱은 CPU 트레이스에서, 사용하는 스크립팅 백엔드에 따라 여러 개의 메서드 중 하나로 호출되는 형식으로 나타납니다. 이는 다음과 같은 형식을 가집니다. 여기서 <example class>는 다른 클래스나 구조체의 이름을 의미하며, 는 인수의 수를 의미합니다.

<example class>::Box(…)
Box(…)
<example class>_Box(…)

박싱을 찾으려면 IL viewer tool built into ReSharper 또는 dotPeek decompiler와 같은 디컴파일러나 IL 뷰어 출력을 검색할 수도 있습니다. IL 명령어는 box입니다.

배열 기반 Unity API

배열을 반환하는 Unity API를 반복해서 액세스하면 미묘하지만 의도치 않은 할당 배열을 유발합니다. 배열을 반환하는 모든 Unity API는 액세스될 때마다 배열의 새로운 사본을 생성합니다. 따라서 배열 기반의 Unity API를 필요 이상으로 액세스하면 성능에 부정적인 영향을 미칠 수 있습니다.

예를 들어 아래의 코드는 루프 반복마다 네 개의 버텍스 배열 사본을 불필요하게 생성합니다. 이러한 할당 작업은 .vertices 프로퍼티에 액세스할 때마다 매번 실행됩니다.

// Bad C# script example: this loop create 4 copies of the vertices array per iteration
void Update() {
    for(int i = 0; i < mesh.vertices.Length; i++) {
        float x, y, z;

        x = mesh.vertices[i].x;
        y = mesh.vertices[i].y;
        z = mesh.vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

이 코드를 루프 반복 횟수와 무관하게 단일 배열 할당으로 리팩터링할 수 있습니다. 이렇게 하려면 아래와 같이 루프 전에 버텍스 배열을 캡처하도록 코드를 설정합니다.

// Better C# script example: create one copy of the vertices array
// and work with that
void Update() {
    var vertices = mesh.vertices;

    for(int i = 0; i < vertices.Length; i++) {

        float x, y, z;

        x = vertices[i].x;
        y = vertices[i].y;
        z = vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

이렇게 하는 더 나은 방법으로는 프레임 사이에서 캐싱되고 재사용되는 버텍스 리스트를 유지한 다음 Mesh.GetVertices를 사용하여 필요 시 리스트를 채워넣는 방법이 있습니다.

// Best C# script example: create one copy of the vertices array
// and work with that.
List<Vector3> m_vertices = new List<Vector3>();

void Update() {
    mesh.GetVertices(m_vertices);

    for(int i = 0; i < m_vertices.Length; i++) {

        float x, y, z;

        x = m_vertices[i].x;
        y = m_vertices[i].y;
        z = m_vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

프로퍼티를 한 번 액세스하는 것이 CPU 성능에 미치는 영향이 그렇게 크지는 않지만, 루프에 계속해서 액세스하면 CPU 성능을 크게 차지합니다. 반복적인 액세스는 관리되는 힙을 확장합니다.

이 문제는 모바일 디바이스에서 자주 나타나는데, 이는 Input.touches API가 위의 예시와 비슷하게 실행되기 때문입니다. .touches 프로퍼티가 액세스될 때마다 할당이 발생하는 것과 같이 프로젝트에 다음과 같은 코드가 있는 일도 마찬가지로 흔합니다.

// Bad C# script example: Input.touches returns an array every time it’s accessed
for ( int i = 0; i < Input.touches.Length; i++ ) {
   Touch touch = Input.touches[i];

    // …
}

다음과 같이 루프 조건에서 배열 할당을 빼도록 코드를 설정하여 이를 개선할 수 있습니다.

// Better C# script example: Input.touches is only accessed once here
Touch[] touches = Input.touches;

for ( int i = 0; i < touches.Length; i++ ) {

   Touch touch = touches[i];

   // …
}

다음 코드 예시는 이전 예시를 할당이 없는 Touch API로 전환합니다.

// BEST C# script example: Input.touchCount and Input.GetTouch don’t allocate at all.
int touchCount = Input.touchCount;

for ( int i = 0; i < touchCount; i++ ) {
   Touch touch = Input.GetTouch(i);

   // …
}

참고: 프로퍼티 액세스(Input.touchCount)는 프로퍼티의 get 메서드를 호출하는 CPU 영향을 절약하기 위해 루프 조건 외부에 위치해 있습니다.

대체 가능한 비할당 API

몇몇 Unity API에는 메모리 할당을 일으키지 않는 대체 버전이 있습니다. 가능하면 이 대체 버전을 사용해야 합니다. 다음 표에서는 일부 선별된 공통 할당 API와 비할당 대안을 보여줍니다. 리스트는 완전하지는 않아도 주의해야 할 API 종류를 나타내야 합니다.

할당 API 비할당 API 대안
Physics.RaycastAll Physics.RaycastNonAlloc
Animator.parameters Animator.parameterCountAnimator.GetParameter
Renderer.sharedMaterials Renderer.GetSharedMaterials

빈 배열 재사용

몇몇 개발팀은 배열 값 기반의 메서드가 빈 세트를 반환해야 할 때 null 값 대신에 빈 배열을 반환하는 것을 선호합니다. 이러한 코딩 방식은 C#이나 Java와 같은 다수의 관리되는 언어에서는 흔한 일입니다.

일반적으로 메서드에서 길이가 0인 배열을 반환할 때, 매번 새로운 배열을 생성하는 것보다 미리 할당된 정적 인스턴스로 반환하는 것이 더 효율적입니다.

추가 리소스

가비지 컬렉션 비활성화
프로파일러 개요