본문 바로가기
게임 개발/Unity3D

[Unity | 유니티] 메모리 최적화

by 불타는홍당무 2019. 9. 6.

7.1 모노 플랫폼

  • 모노 플랫폼은 마이크로소프트 닷넷 프레임워크의 라이브러리의 , 특징, API 기초로 자체적인 프레임워크와 라이브러리를 만든 오픈소스 프로젝트
  • 마이크로소프트의 기본 닷넷 클래스 라이브러리와 완전히 호환되지만 닷넷 프레임워크의 자료를 거의 혹은 전혀 사용하지 않고 오픈소스로 다시 만든 것이다.
  • 그러나 유니티 엔진은 모노 플랫폼만으로 만들어진 것은 아니다.
  • 성능에 영향을 끼치는 오디오, 렌더링 엔진, 물리 엔진과 같은 백엔드는 C++, 사용자가 직접 다루는 부분은 모노 플랫폼으로 개발되었다.

7.1.1 컴파일 과정

  • 통합 개발 환경에서 C# 코드를 바꾸면 유니티 에디터로 돌아가는 순간, 컴파일이 진행된다. 그러나 C# 코드는 컴파일러처럼 바로 네이티브 코드로 변환되지 않고 CIL이라고 불리느 중간 언어로 컴파일된다.
  • CIL 자바의 바이트 코드와 비슷한 추상적인 언어로, CPU 이해할 없어 CIL 코드로는 바로 실행될 없다.
  • CIL 코드는 같은 코드를 플랫폼에 상관없이 사용하도록 해주는 모노 가상 머신에 실행된다.
  • 모노 가상 머신은 OS 인식하고 적합한 가상 머신을 구동한다.
  • 기반 OS iOS IOS 적합한 가상 머신을, 리눅스면 리눅스 가상 머신을 구동시킨다.

7.2 메모리 사용 최적화

 

7.2.1 유니티의 메모리 영역

  • 첫번째 영역은 네이티브 영역
    • 영역은 C++ 작성된 유니티 엔진의 기저부로 목표 플랫폼에 따라 네이티브 코드로 콤파일된다.
    • 메모리 영역에는 텍스처와 메시 같은 에셋 데이터, 물리 렌더링 인풋 시스템과 같은 하부 시스템, GameObject 컴포넌트 같은 기본 클래스의 위치 데이터와 물리 객체 구성 요소 등이 있다.
  • 두번째 영역은 매니지드 영역
    • 영역이 바로 가비지 컬렉터가 관리하는 영역으로, 모노 플랫폼이 작업하는 곳이다.
    • 스크립트 객체나 사용자 클래스들은 메모리 영역에 저장된다.
    • 여기에서 네이티브 메모리 영역의 객체들을 호출하기 위한 (Wrapper) 객체들이 생성된다.
    • 호출 객체들은 모노 코드와 네이티브 코드를 연결하는데, 호출 객체 등을 통해 같은 객체를 서로 다른 영역에서 너무 자주 부르면 성능에 악영향을 끼친다.
    • 새로운 게임 객체나 구성 요소가 생성되면 매니지드 영역과 네이티브 영역 모두에 메모리가 할당된다.
    • 물리 시스템이나 렌더링 시스템은 직접 네이티브 메모리 영역에 있는 객체의 트랜스폼 정보(위치 정보) 제어하지만, 스크립트 코드에 적힌 트랜스폼 정보는 참조 자료로서 매니지드 영역에서 네이티브 영역으로 넘겨진다.
  • 세번째 영역은 메모리 영역
    • 외부 DLL 위한 네이티브 메모리 영역으로, 프로젝트에 사용한 다이렉트X, OpenGL, 기타 DLL들을 위한 곳이다.
    • 여역에 모노 C# 코드를 적용시키면 모노 코드와 네이티브 간의 메모리 공간 전환과 비슷한 일이 일어난다.
  • 매니지드 메모리
    • 대부분의 현대 OS들은 동적 메모리를 스택과 힙이라는 가지 개념으로 나눈다.
    • 스택은 메모리에 따로 할당된 영역으로, 일반적으로 작고 금방 사라질 데이터 저장에 이용된다.
    • 스택에 저장된 메모리는 별도로 메모리 재할당을 필요가 없다.
    • 함수 내부에서 잠깐 불리는 지역 변수 스택에 의해 호출된 함수들은 스택의 호출에 따라 자동으로 덮어 쓰이기 때문이다.
    • 스택 메모리는 내용물을 지우기보다 스택 메모리의 주소를 반복해 재사용하는 식으로 관리된다.
    • 힙은 스택을 제외한 나머지 메모리의 영역을 의미하며, 동적 메모리의 대부분을 차지한다.
    • 자료형(혹은 구조체의 크기) 스택의 크기보다 너무 크거나 함수 외에서 유지되어야 하는 변수(전역 혹은 스태틱 변수)들은 힙에 저장된다.
    • 모노 플랫폼의 힙은 가비지 컬렉터의 의해 관리되기 때문에 매니지드 힙이라고도 불린다.
    • 프로그램의 초기화 과정에서 모노 플랫폼은 메모리 덩어리를 OS 요청해 힙을 만든다.
    • 처음 생성된 힙은 보통 1메가바이트 정도의 작은 크기인데, 스크립트 코드에 의해 메모리가 호출될 때마다 크기가 조금씩 커진다.
  • 가비지 콜렉션
    • 모노 플랫폼은 프로그램이 메모리를 요청할 힙에 충분한 공간이 있다면 메모리를 할당한다.
    • 여유 공간이 충분하지 않을 때는 사용되지 않는 메모리들을 제거해 여유 공간을 만들고자 가비지 컬렉터를 호출하고 할당된 메모리를 점검한다.
    • 할당과 해제를 통해 객체의 수는 적절히 유지된다.
    • 그렇기 때문에 힙은 언제나 자료를 위한 적절한 공간을 가지며, 힙의 크기도 일정하게 유지된다.
    • 하지만 현실에서는 자료들이 메모리에 할당된 순서대로 해제되지도 않고 자료형의 크기도 일정하지 않아 메모리 파편화가 일어난다.
  • 메모리 파편화
    • 파편화는 객체들이 할당되는 순서와 해제되는 순서가 다를 주로 발생한다.
    • 시간이 지남에 따라 메모리는 점점 복잡해지고 작은 조각으로 나뉘게 된다.
    • 이런 과정이 반복되면 메모리 영역은 스위스 치즈처럼 사용이 불가능한 구멍들이 늘어난다.
    • 메모리 파편화가 발생하면 시간이 지남에 따라 객체에 할당할 전체 메모리 공간이 줄어들게 된다.
    • 둘째로 객체에 적당한 메모리 공간을 찾기 위한 시간이 늘어나 메모리 할당이 점점 느려진다.
  • 가비지 컬렉션 전략
    • 가비지 컬렉션의 의한 문제를 해결할 하나의 방법은 숨기는 것이다.
    • 게이머가 눈치 채지 못할 만한 상황에 가비지 컬렉션을 미리 수동으로 호출하는 것이다.
    • System.GC.Collect( ) 가비지 컬렉션을 수동으로 호출할 있다.
    • 가비지 컬렉션을 숨기기 좋은 타이밍은 새로운 레벨 로딩, 게임에 포즈가 걸리고 메뉴 화면이 나오는 사이, 컷신 전환 과정, 게이머가 순간적인 프레임 저하를 눈치채지 못하거나 게임이 멈추는 순간 등이다.
    • Profiler.GetMonoUsedSize( ), Profiler.GetMonoHeapSize( ) 함수를 사용해 현재 사용되는 메모리의 정보를 확인해 가비지 컬렉션이 필요한지 살펴보는 것도 좋은 전략이다.
    • 메모리 관리를 위해 특정 객체를 직접 해제할 수도 있다.
    • GameObject MonoBehaviour 같이 유니티 구성 요소를 포함한 객체들은 Dispose( )라는 함수를 통해 네이티브 도메인의 메모리를 해제할 있다.
    • 강제 해제가 유용한 유일한 경우는 WWW 클래스 뿐이다.
    • 유니티 프로그램은 실시간으로 그래픽 같은 자료를 내려받고 압축을 해제하는데 최종적으로 압축이 해제된 파일을 위한 네이티브 메모리가 필요하다.
    • 모든 파일들을 전부 메모리에 장기간 보관하는 것은 엄청난 낭비다.
    • 그러므로 Dispose( ) 함수를 이용해 메로리 버퍼를 빠르고 정확하게 비우는게 좋다.
    • Resources.UnloadUnusedAssets( ) 같은 에셋은 메모리를 사용하지 않는 에셋을 해제하는 함수를 가지고 있다. 

7.2.2 형식과 참조 형식

  • 가비지 컬렉터가 동작하는 것은 참조 형식뿐이다.
  • 참조 형식은 자료의 복잡도, 자료의 크기, 자료의 사용 행태 때문에 일반적으로 메모리에 오래 남아 있는다.
  • 보통 클래스의 인스턴스와 자료구조들이 참조 형식인데, 배열(나열한 자료가 형식 이던 참조 형식이던), 위임자, 모든 클래스, MonoBehaviour, GameObject 비롯한 모든 개인 클래스가 바로 참조 형식이다.
  • 가비지 컬렉션이 동작하지 않도록 하려면 가능하면 형식을 사용하는 것이 좋다.
  • 함수를 사용한 이상 사용할 일이 없으면 참조 형식 대신 형식을 사용하는 것이다.
  • 서로 다른 함수끼리 값을 주고받으면 형식이 참조 형식으로 바뀌지 않으며, 해당 함수가 모두 종료될 때까지 스택에 남았다가 함수의 종료와 함께 자동으로 해제된다.
  • 값에 의한 전달과 참조에 의한 전달
    • 형식이든, 참조 형식이든 함수에서 다른 함수의 인자로 값이 전달될 때는 복사가 일어난다.
    • 이를 '값에 의한 전달'이라고 부른다.
    • 이때 참조 형식은 포인터를 복사하기 때문에 실제 자료의 크기와 무관하게 4 8바이트의 메모리를 소모한다.
    • 그에 반해 형식은 자신을 복사한 새로운 자료를 만들어 값을 전달하는 값에 의한 전달이 일어난다.
    • 일반적으로 형식의 자료형은 포인터와 비슷한 크기의 자료이기 때문에 문제가 되지만, 구조체인 경우 형식이나 크기가 매우 연산 자원을 많이 소모한다.
    • 참조에 의한 전달은 ref 키워드를 사용한다.
    • '참조에 의한 전달' '참조 형식 전달' 전혀 다른 개념이다.
    • 참조에 의한 전달은 크게 형식을 참조(ref 사용), 값에 의한 전달, 참조 형식을 참조(ref 사용), 값에 의한 전달 가지다.
    • 참조에 의한 전달은 변수에 대한 '포인터' 값을 넘겨주기 때문에 원본 변수를 외부에서도 변경할 있다.
  • 배열은 참조 형식이다
    • 배열은 거대한 양의 자료를 모아두는 컨테이너라서 스택에서 형식으로 취급하기가 쉽지 않다.
    • 따라서 배열은 내용물이 형식과 무관하게 참조 형식으로 취급되기 때문에 값을 넘길 전체를 복사하지 않고 참조를 넘긴다.
  • 문자열은 바꿀 없는 참조 형식이다
    • 문자열은 기본적으로 char 형식의 배열이다.
    • 따라서 언제나 참조 형식이며 참조 형식의 모든 규칙을 따른다.
    • 문자열 값을 넘기면 실제 값이 아니라 포인터가 넘어가고 힙에 할당된다.
    • 문자열이 수정되면 기존 문자열은 힙에 그대로 있고, 새로운 공간에 수정된 문자열이 새로운 배열로 할당된다.
  • 문자열 연결
    • 문자열 연결이란 하나의 문자열을 다른 문자열 뒤에 붙이는 작업이다.
    • 문자열 연결에 + +=연산자를 이용하면 할당의 연쇄 작용을 일으켜 상당한 메모리 자원을 낭비한다.
    • 따라서 문자열을 생성할 때는 StringBuilder 클래스나 string 클래스의 멤버 함수를 이용하는 것이 좋다.
    • StringBuilder 문자열을 버퍼를 통해 효율적으로 연결할 있는 문자열 클래스다.
    • String 클래스의 내부 함수로는 Format( ), Join( ), Concat( ) 있다.
    • 함수들은 내부적으로 조금 다르게 문자열을 생성하지만, 하나의 문자열을 만들어 메모리에 할당한다는 점은 같다.