개발/공부

메모리 관리에 대한 탐구 (3) - 가비지 컬렉터(C#)

메피카타츠 2023. 5. 15. 20:28

오늘은 이전에 계획했던대로 가비지 컬렉터의 작동 방식과, 가비지 혹은 GC Alloc을 줄이는 방법에 대해서 정리해보겠다.

 

가비지 컬렉터란?

가비지 수집 기본 사항 | Microsoft Learn

 

가비지 수집 기본 사항

가비지 수집기의 작동 원리와 최적 성능으로 구성하는 방법에 대해 알아봅니다.

learn.microsoft.com

 

가비지 컬렉터에 대한 내용은 마이크로소프트 문서에 자세히 잘 정리되어있다. 요약해보자면, 가비지 컬렉터는 메모리의 할당과 해제를 관리해주기 때문에 개발자에게 편의성을 제공하며, 메모리 보안 또한 제공한다. 가비지 컬렉터는 메모리가 추가로 필요하거나, 프로그래머가 직접 호출할 때 실행되는데, 애플리케이션 루트에서 접근할 수 없는 개체들을 가비지로 간주하고 해당 메모리를 해제한다. 이 과정에서 단편화가 발생하기 때문에 접근 가능한 개체들끼리 붙이는 형식으로 메모리를 압축한다. 그런데 큰 개체(배열인 85,000바이트 이상의 개체)의 경우, 메모리 내에서 이동하는 비용이 크기 때문에 이것을 피하기 위해 별도의 힙에 할당하며, 일반적으로 큰 개체의 메모리는 압축되지 않는다.

 

또한, 가비지 컬렉터는 성능 향상을 위해 힙의 메모리들을 세대 단위로 나눈다. 가비지 컬렉션을 수행하는 메모리의 크기가 클수록 가비지 컬렉션이 오래 걸리는데, 가비지가 될 가능성이 낮은 개체들과 가비지가 될 가능성이 높은 개체들을 다른 세대로 나누는 셈이라고 할 수 있겠다. 이렇게 관리되는 힙은 0세대, 1세대, 2세대 총 3개의 세대로 나뉜다. 새 개체는 0세대에 저장되며, 0세대 가비지 컬렉션에서 수집되지 않은 개체들이 1세대로 승격된다. 0세대 가비지 컬렉션을 수행해도 메모리가 부족하면 1세대 가비지 컬렉션을 수행한다. 1세대 가비지 컬렉션에서 수집되지 않은 개체들은 2세대로 승격된다. 만약 1세대 가비지 컬렉션을 수행해도 메모리가 부족하다면 2세대 메모리 컬렉션을 수행한다.

즉, 새로 할당되어 가비지일 가능성이 높은 개체는 0세대에 할당되어 다음 가비지 컬렉션을 수행할 때 바로 수집될 수 있는 것이고, 이미 최소 2번의 가비지 컬렉션을 거쳤음에도 가비지가 되지 않은 개체들은 후순위로 두어 전체 메모리의 크기를 줄여 가비지 컬렉터의 성능 향상을 도모할 수 있는 것이다. 단, 아까 언급한 큰 개체는 처음부터 3세대라고도 하는 LOH(큰 개체 힙)로 이동하는데, 2세대 가비지 컬렉션을 할 때 같이 수집된다.

 

가비지 컬렉션 성능 문제와 해결 방법

가비지 컬렉션 및 성능 | Microsoft Learn

 

가비지 컬렉션 및 성능

가비지 수집 및 메모리 사용량과 관련된 문제에 대해 알아봅니다. 가비지 수집이 애플리케이션에 미치는 영향을 최소화하는 방법을 알아봅니다.

learn.microsoft.com

위에 포함된 내용들은 물론이고, 이외에도 여러 번거로운 작업들을 가비지 컬렉터가 알아서 처리해주기 때문에 굉장히 편리하다고 생각한다. 개발자가 신경을 쓸만한 부분이 많지는 않은 것 같은데, 마이크로소프트의 문서에서 참고할만한 성능 관련된 문제와 해결 방법, 성능 검사 절차 등을 제시하고 있다.

 

점진적 가비지 컬렉션

점진적 가비지 컬렉션 - Unity 매뉴얼

 

점진적 가비지 컬렉션 - Unity 매뉴얼

점진적 가비지 컬렉션(GC)은 가비지 컬렉션 프로세스를 여러 프레임에 분산시킵니다. 이는 Unity에서 가비지 컬렉션 동작의 기본값입니다.

docs.unity.cn

Unity에서는 2019.1 버전 이후로 점진적 가비지 컬렉션이라는 기능을 지원한다. 가비지 컬렉션 작업을 할 때 원래는 다른 스레드들을 중지하여 가비지 컬렉터에 걸리는 시간만큼 프로그램이 일시정지 되는데, 점진적 가비지 컬렉션 기능은 작업을 여러 프레임에 분할하여 처리하기 때문에 이런 일시정지같은 현상을 막을 수 있다. 다만 아무래도 멈춰놓고 한 번에 처리하는 것 보다 많은 시간이 걸리며, 처리하는 과정에서 프레임이 떨어질 수도 있을 것 같다. 

 

최적화 방법

자동 메모리 관리 이해 - Unity 매뉴얼 (unity3d.com)

 

자동 메모리 관리 이해 - Unity 매뉴얼

오브젝트나 문자열, 배열을 생성한 이후 저장하려면 메모리 공간이 필요합니다. 필요한 공간은 heap이라고 하는 중심 풀에서 할당됩니다. 메모리 공간을 할당받은 항목이 더 이상 사용되지 않게

docs.unity3d.com

Unity Documentation의 자동 메모리 관리 이해에서는 최적화와 관련된 몇 가지 예시들도 보여주고 있다. 최적화를 위한 첫 번째 방법은 먼저 문자열 접합 반복을 피하는 것이다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void ConcatExample(int[] intArray) {
        string line = intArray[0].ToString();
        
        for (i = 1; i < intArray.Length; i++) {
            line += ", " + intArray[i].ToString();
        }
        
        return line;
    }
}


//JS script example
function ConcatExample(intArray: int[]) {
    var line = intArray[0].ToString();
    
    for (i = 1; i < intArray.Length; i++) {
        line += ", " + intArray[i].ToString();
    }
    
    return line;
}

예시에서 보여주는 코드는 위와 같은데, for문에서 i가 1씩 증가할 때마다 line에 문자열이 추가로 붙는 것이 아니라, 이전 내용이 삭제되고 새로운 문자열이 할당된다고 한다. 즉, 이 작업을 할 때마다 계속 새로운 string 개체가 생성되며 가비지가 엄청나게 많이 생성된다고 할 수 있다. 문자열을 동시에 여러 개 연결해야 한다면 System.Text.StringBuilder를 활용하는 것이 좋다고 한다.

 

//C# script example
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;
    }
}


//JS script example
function RandomList(numElements: int) {
    var result = new float[numElements];
    
    for (i = 0; i < numElements; i++) {
        result[i] = Random.value;
    }
    
    return result;
}
//C# script example
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;
        }
    }
}


//JS script example
function RandomList(arrayToFill: float[]) {
    for (i = 0; i < arrayToFill.Length; i++) {
        arrayToFill[i] = Random.value;
    }
}

두 번째로는 배열 값을 반환하는 경우인데, 위의 코드처럼 배열을 반환하는 경우 새로운 메모리가 할당되기 때문에 배열의 크기가 큰 경우 힙 공간이 빠르게 소모된다. 배열은 참조 타입이기 때문에 굳이 반환을 해주지 않아도 아래 코드처럼 파라미터로 전달받은 함수의 값을 수정해주면 값이 유지된다는 점을 활용하면 가비지 생성을 줄일 수 있다는 장점이 있다.

 

 

게임 플레이에 대한 영향을 최소화하는 전략이 크게 2가지가 있는데, 작은 힙과 빠르고 빈번한 가비지 컬렉션을 이용하는 방법, 큰 힙과 느리지만 덜 빈번한 가비지 컬렉션을 이용하는 방법이다. iOS에서 이 방법을 사용할 때 할당할 일반적인 힙 크기는 200KB이며, 이 경우 가비지 콜렉션은 iPhone 3G에서 대략 5ms 정도 걸린다고 한다. 힙의 크기가 1MB로 증가하면 7ms 정도 걸린다고 한다.

if (Time.frameCount % 30 == 0)
{
   System.GC.Collect();
}

위 코드와 같이 일정 프레임마다 가비지 컬렉션을 수행해 힙의 크기를 일정 이하로 유지하여 가비지 콜렉션에 소요되는 시간을 짧게 유지하여 성능 저하를 최대한 막는 방법이라고 볼 수 있겠다. 다만 이 기법은 주의해서 사용한다고 쓰여있는데, 매 프레임마다 생성되는 힙의 크기가 커서 가비지 컬렉션에 걸리는 시간이 길다면 이 방법은 썩 좋지 않을 것으로 생각된다. 매 초마다 툭툭 끊기는 게임이 될 수도 있으니... 힙의 크기를 최대한 작게 유지할 수 있는 경우에만 사용하는 것이 좋겠다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // release reference
        tmp = null;
    }
}


//JS script example
function Start() {
    var tmp = new System.Object[1024];

    // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (var i : int = 0; i < 1024; i++)
        tmp[i] = new byte[1024];

    // release reference
        tmp = null;
}

두 번째는 큰 힙과 느리지만 덜 빈번한 가비지 컬렉션을 사용하는 방법이다. 가비지 컬렉션은 메모리가 부족할 때 수행되기 때문에, 힙의 크기를 처음부터 크게 가져갈 수 있다면 가비지 컬렉션이 덜 빈번하게 발생할 것이다. 물론 크기가 큰 만큼 가비지 컬렉션에 걸리는 시간은 증가하겠지만, 가비지를 모아놨다가 맵을 이동하는 등의 로딩 시간에 한 번에 처리하면 게임 플레이에 영향을 별로 주지 않고 가비지 컬렉션을 실행할 수 있을 것이다. 힙의 크기는 수동으로 확장해야 하는데, 위의 코드는 이를 위해서 무의미한 오브젝트를 생성하는 방식을 활용한다. 다만, 주의할 점으로는 힙이 너무 커지면 운영체제가 시스템 메로리를 확보하려고 앱을 강제종료하는 문제가 생길 수 있으니 이것은 피해야 한다.

 

또한 오브젝트 풀링 기법을 사용하는 것도 예시로 나와있다.

 

(Unity) 유니티 최적화 방법들 (tistory.com)

 

(Unity) 유니티 최적화 방법들

스크립팅 최적화 SendMessage 와 BroadcastMessage 함수 사용자제 Find 관련 함수 사용자제 자식이 많은 오브젝트 transform 변경시 많은 비용 발생 Update , LateUpdate 등 함수가 비어있으면 지워라 , 빈 Update 종

happysalmon.tistory.com

이외에도 인터넷을 찾아보면 다양한 최적화 방법들이 나와있고, 유니티에서 최적화를 하는 방법들을 모아놓은 책도 있다. 방법이 아주아주 많아서 차근차근 익혀나가는 수밖에 없을 것 같다.

======================================

 

오늘은 가비지 컬렉터, 컬렉션에 대해서 알아보고 정리해보았다. 가비지 컬렉터의 일부만 알고 있을 때는 가비지가 되지 않을 것들이나 가비지가 될 것들을 변수를 생성할 때 미리 분류할 수 있으면 더 효율적이지 않을까 싶었는데, 당연스럽게도 이런 부분까지도 자동으로 관리를 해주고 분류해주는 것이 참 대단하게 느껴졌다. 이제는 가비지 컬렉터에 대해서 "어느정도는" 안다고 이야기할 수 있을 것 같다. 가비지 컬렉터 관련된 문서들 중 아직 읽지 않은 문서들도 꽤나 있고, 특히 마이크로소프트의 문서들은 아직 잘 모르는 기술적인 용어들이 들어가 있는 경우가 많아서 아직까지 완벽하게 이해하기는 어려운 것 같다. 지금도 이전에 비해서는 많이 읽기 수월해져서 앞으로 공부를 계속 하다보면 언젠가는 잘 이해할 날이 오지 않을까 기대해본다.