REDUCING MEMORY USAGE IN UNITY, C# AND .NET/MONO
유니티, C#, .NET/모노에서 메모리 사용량 줄이는 방법
– 원문 링크 –
REDUCING MEMORY USAGE IN UNITY, C# AND .NET/MONO
유니티는 iOS에서 모노(Mono) 힙 관리자(Heap Manager)의 초기 버전을 사용합니다. 초기 버전의 모노 힙 관리자는 패킹(Packing)을 하지 않기 때문에, 힙이 파편화되면 메모리를 새로 할당합니다. C#은 가독성을 해치지않고 강력한 코드를 작성할 수 있는 매우 매력적인 언어입니다. 하지만 C#을 주의하지 않고 그대로 사용하면 가비지 콜렉션(Garbage Collection)이 많이 발생하는 문제가 있습니다. 이 문제를 해결하는 유일한 방법은 힙 할당을 없애거나 줄이는 것입니다. 이를 위해서 사용할 수 있는 방법 몇가지를 이 글에 정리해 두었습니다. 정리한 방법을 진행하면 C# 코드가 C++ 코드와 매우 유사해지고 C#의 강력함을 일부 잃어버릴 수 있지만, 이는 문제를 해결하기 위해서 어쩔 수 없는 부분입니다. 또한 보너스로 힙 할당은 본질적으로 스택 할당보다 CPU에 부하를 더 가중시키기 때문에, 프레임 시간도 절약할 수 있습니다. 유니티 프로파일러를 이용하면 힙 할당이 많이 발생하는 지점을 찾을 수 있습니다. 프로파일러를 열고 게임을 실행한다음, CPU 프로파일러를 선택하고 GC Alloc 열(Column)을 클릭해서 힙 할당을 가장 많이 하는 대상을 기준으로 정렬시킵니다.
- foreach()의 사용을 피합니다. foreach() 구문은 List 타입에서 GetEnumerator()를 호출하며, 이는 나중에 그냥 버려질 enumerator를 힙에 할당합니다. foreach() 구문을 사용하는 대신, 좀 더 C++ 스타일에 가까운 for(;;) 구문을 사용하는 것이 좋습니다. 수정: 배열을 대상으로 foreach() 구문을 사용하지 않는한, 힙 할당이 발생하지 않습니다.
- 문자열 사용을 피합니다. .NET에서 String은 변경이 불가능하며 힙에 할당됩니다. C에서와 같이 내부에서 값을 조절할 수 없습니다. UI의 경우, StringBuilder를 사용해서 메모리 효율적인 방식으로 문자열을 생성하고, 최대한 늦게 string으로 변환시킵니다. 메모리의 같은 인스턴스를 가리키기 때문에 StringBuilder를 키(Key)로 사용할 수 있지만, 너무 자주 변경하지 않는 것이 좋습니다.
- 구조체를 사용합니다. 모노의 클래스는 힙에 할당되기 때문에, 스코프를 벗어나지 않는 유틸리티 클래스가 있는 경우 구조체로 만드는 것이 좋습니다. 구조체는 값(value)으로 전달되기 때문에, 매겨변수로 전달할때 ref 키워드를 이용하면 복사 비용을 피할 수 있습니다.
- 크기가 고정된 배열은 구조체로 대체합니다. 스코프를 벗어나지 않는 고정된 크기의 배열을 사용하는 경우 재사용이 가능한 멤버 배열로 대체하거나 배열의 각 필드의 값을 저장할 수 있는 구조체로 대체합니다. 예를 들어, Vector3[4] 배열은 4개의 필드를 가진 구조체로 대체할 수 있습니다. 또한 this[] 속성을 이용하면 인덱스 기반으로 접근이 가능합니다. 빈번하게 사용되는 배열을 멤버 배열이나 구조체로 대체하면 다수의 힙 할당을 줄일 수 있습니다.
- 함수에서 새 배열을 반환시키기 보다는 List를 ref 매개변수로 전달해서 값을 채우는 것이 좋습니다. 이렇게 한다고해서 절약하는 부분이 없는 것처럼 보이지만, 메소드에서 새로 배열을 반환하기 위해서는 힙 할당이 필요하기 때문에 이를 절약할 수 있습니다.
- 자주 사용되는 함수 내에서 사용되는 변수를 멤버 변수로 전환하는 것을 고려합니다. 함수에서 매번 크기가 큰 리스트를 필요로하는 경우에는 해당 리스트를 멤버 변수로 만들고 프레임 간에 이 저장공간을 유지합니다. C# 리스트는 .Clear() 메소드를 호출해도 버퍼 공간이 삭제되지 않기 때문에 다음 프레임에 힙을 새로 할당하거나 할당된 힙을 제거할 필요가 없습니다. 이 방법은 코드의 가독성을 해치는 깔끔한 방법이 아니기 때문에 주석을 잘 남겨놔야 하지만, 리스트의 크기가 큰 경우에는 성능 차이가 클 수 있습니다.
- 함수 포인터의 사용을 최소화 합니다. 클래스 메소드를 딜리게이트 또는 Func<>에 추가하면 박싱이 발생하고, 이는 힙을 할당합니다. 박싱을 발생시키지 않고 메소드를 연결하는 방법이 없고, 클래스 간의 의존 관계를 해결하는데 큰 도움이 되기 때문에 함수 포인터를 사용해야 하지만 최소화 시키는 것이 좋습니다.
- 원시 구조체(raw struct)를 Dictionary의 키(key)값으로 사용하지 않는 것이 좋습니다. Dictionary(K,V)를 사용하고, 키 값으로 구조체를 사용하면, TryGetValue() 또는 인덱스 접근자를 이용해서 Dictionary에서 값을 가져오려는 경우에 힙 할당이 발생합니다. 이 문제는 구조체에서 IEquatable<K>를 구현하면 해결할 수 있습니다. 또한 원시 구조체를 List로 만들고 List<>.Contains()를 호출하면 힙 할당이 발생하는데 이 역시, 구조체에서 IEquatable<K>를 구현하면 문제가 해결됩니다.
- Enum.GetValues() 또는 myEnumValue.ToString()을 과다하게 사용하지 않는 것이 좋습니다. Enum.GetValues(typeof(MyEnum))는 호출될 때마다 배열을 할당하고, Enum.GetNames() 역시 이와 비슷하게 동작합니다. 사용이 편리하기 때문에 .ToString()와 함께 enum 변수에서 이런 기능들을 사용하기가 쉬운데, 열거형 값을 미리 배열에 캐싱해서 사용하는 것을 권장합니다.
내용 끝까지 읽어주셔서 감사합니다.
배너 클릭은 저에게 많은 힘이 됩니다.
감사합니다 🙂
질문이 있는데요? 구조체는 힙이 아니라 스택에 할당 되는것 아닌가요? 클래스가 힙에 할당되는걸로 알고 있습니다만…
맞습니다 ㅎㅎ 잘못 적었네요.
수정하겠습니다.
지적 감사합니다.
자주 사용되는 맴버 변수 관련해서 질문이 있습니다. List같은 컨테이너쪽에 국한된것인가요? 아니면 일반적인 밸류들도 포함되는건지요?
그리고 밸류형의 지역변수 같은경우에는 스택에 올라가므로 지역변수로 사용하는게 더 좋지 않을까요?
이 글이 일단 전체적으로 힙(Heap) 할당에 대한 내용입니다.
말씀하신 스택에 할당되는 데이터들은 이 글에 해당되는 내용이 거의 없습니다.
상황에 따라 다르겠지만 값(Value) 타입의 경우는 지역 변수를 쓰셔도 될 것 같고, 아주 자주 사용된다면 멤버 변수로 전환 하는 것을 고려해보시는 것도 좋을 것 같습니다 🙂
질문이 있습니다.
자주 사용하는 변수의 경우에 맴버변수 관려해서 인데요.
일반적인 컨터이너들(List)에 대한 한정인가요? 아니면 밸류들에 대해서도 그런간가요?
지역 밸류형들은 스택에 올라가므로 밸류형들은 걍 지역변수가 더 좋지 않나요?