티스토리 뷰

유니티 프로파일러의 메모리 부분에서 Mono 가 힙영역을 의미한다. 타임라인에서 계속 크기가 변하는 것을 확인할 수 있다.


메모리 힙영역

프로그램이 돌아가면서 생성되는 값들은 값들은 메모리의 힙영역에 할당된다. 이것을 동적 메모리 할당이라 부른다. 게임을 예로들면 캐릭터를 새로 생성으로 인한 값 생성, 네트워크 작업을 통해 일정한 데이타를 받아 배열을 생성하거나 등등.. 많은 부분에 동적이 메모리 할당이 일어난다. 이러한 메모리 할당 요청이 발생하면 힙 메모리를 관리하는 관리자가 필요한 만큼의 영역을 힙에 예약한다. 예약된 영역은 다른 값이 할당될 수 없는 곳이된다. 예약된 곳은 핸들러나 포인터를 반환하여 값을 할당하는 등의 접근이 가능하게 된다.


누수 현상(memory leak)

오류로 인한 영역을 가리키는 포인터 반환이 안된경우, 또는 영역의 예약이 잘못 된경우, 등으로 해당 힙 영역을 사용하지 못하게 된다. 이것을 메모리 누수라고 한다. 문제가되는 이 영역은 다시 사용할 수 있도록 예약 해제가 되어야한다. 그래야 다시 사용할 수 있는 상태가 되기 때문이다. 사용되지 않는 영역이 쌓이다보면 언제가 프로그램이 멈추게된다.


가비지 컬렉션(garbage collection)

누수현상으로인한 영역의 값들을 쓰레기(garbage)라고 부른다. 이 값들을 모으는 행위를 쓰레기 수집(가비지 컬렉션)이라한다. 왜 모을까? 이것들을 모아서 해제하면 다시 사용할 수 있는 영역이 되기때문이다. Java 나 C# 언어에서는 애초부터 쓰레기 수집을 염두해 두었기 때문에 프로그래머는 쓰레기 수집에 신경을 덜 쓸 수있다. 하지만 C언어의 경우는 프로그래머가 직접 해제해야하기 때문에 불편함이 있다. 


쓰레기 수집은 만능이 아니다

가비지 컬렉터가 쓰레기 수집 알아서 해주는 것에 100%의지하면 안된다. Java 나 C#은 알아서 쓰레기 수집을 해주지만 직접적으로 필요한 시점에 쓰레기 수집을 해야할 때도있다. 하지만 이작업은 의외로 CPU 사용이 많을 수있으므로 짧은 시간에 자주 사용하는 것은 프로그램 퍼포몬스 저하를 발생할 수있다. 언제 얼마나 해야할지 혹은 안 해도 될지 어떻게 판단해야 할지 알아내는 것도 쉽지않다. 유니티의 경우 프로파일링을 통해 어느 시점에 힙 메모리의 변화 큰지를 감지할 수 있다. 프로파일링을 통해 갑자기 힙 메모리의 증가를 발견하였다면 문제가 되는 코드를 추적할 수있다. 아래의 코드는 그 중 하나의 예를 간략화한 것이다

string ConcatExample(int[] intArray)
{
    string line = intArray[0].ToString();

    for (int i = 1; i < intArray.Length; i++)
    {
        line += ", " + intArray[i].ToString();
    }

    return line;
}

파라미터로 받은 정수형 배열을 루프를 돌며 콤마를 추가하는 스트링을 만들고있다. 이 코드는 매우 간결해 보이지만 가비지 컬렉터에게는 악몽같은 코드이다. line 변수는 매 루프마다 내용이 모두 삭제되고 다시 할당된다. 문자열이 증가할 때마다 힙의 크기도 증가한다. 이 함수호출이 일어날 때마다 수백바이트의 힙 공간을 차지한다.


해결책

퍼포먼스를 위하여 힙영역에 메모리를 불필요하게 차지하는 행위를 하지말자. 위의 예에서는 StringBuilder 를 사용하는 것이 옳다. 


또다른 예

public Vector3 targetPos;

void Update()
{
    Vector3 movePostion = targetPos + Vector3.right;
    transform.position = movePostion;
}

매 프래임마다 오브젝트를 tagetPos의 수치에 따라 움직이게하는 코드이다. 이러한 방식은 값이 변하지 않음에도 불구하고 새로운 값을 생성하는 비효율적이다. 불필요한 쓰레기 조각도 조금씩 생성된다.


해결책

public Vector3 targetPos;
public Vector3 oldPos;

void Update()
{
    if (targetPos != oldPos)
    {
        Vector3 movePostion = targetPos + Vector3.right;
        transform.position = movePostion;
        targetPos = oldPos;
    }
    
}

오브젝트의 position이동은 targetPos에 의존적이기 때문에 targetPos값에 변함이 없다면 굳이 이동하는 로직을 수행할 필요가 없다. 변함이 있을 경우에만 해당 로직을 수행하여 오브젝트의 포지션을 변경한다.


또다른 예

float[] RandomList(int numElements)
{
    var result = new float[numElements];

    for (int i = 0; i < numElements; i++)
    {
        result[i] = Random.value;
    }

    return result;
}

배열을 생성하고 루프에서 배열에 값을 채운다음 결과를 반환한다. 전혀 이상할 것 없어 보이는 코드이다. 하지만 이 함수가 호출 될 때마다 배열의 크기만큼 새로운 힙 영역을 할당하게 된다.


해결책

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

반환하지 않는 함수 방식이다. 참조형으로 받은 배열을 그대로 값만 바꾼다. 배열을 새로 new 키워드로 생성할 필요가 없으므로 추가적인 힙 메모리 할당이 없다.


오브젝트의 생성과 파괴

유니티에서, 게임 중 동적으로 오브젝트를 생성하고 파괴하는 것은 부담이 크다. 그 양이 많을 수록 부담도 커진다. 예를들면 총알을 발사하는 게임에서 총알을 발사할 때 마다 총알 오브젝트를 생성하고 부딪힌 지점에서 총알을 파괴하는 것. 타워 디펜스장르의 게임에서 수 많은 적들을 짧은 시간에 생성하고 파괴시키는 것. 등.. 그 양이 많지 않다면 큰 문제가 되지는 않는다. 하지만 짧은 시간에 많은 오브젝트의 생성과 파괴는 게임 시간이 길어질 수록 많은 자원을 사용하여 베터리 소모나 발열의 원인이 된다. 또한 힙 메모리를 짧은 시간에 자주 할당함으로써 가비지가 쌓이게 된다.


오브젝트 풀링

오브젝트의 생성과 파괴가 부담이 된다면, 생성과 파괴를 최소한으로 줄여보자. 좋은 방법으로는 생성된 오브젝트를 파괴하지 않고 재사용하는 것이다. 필요한 만큼 만들어 둔 뒤, 사용해야할 때 사용하고 사용이 끝나면 파괴하지 말고 다시 사용가능하도록 하는 것이다. 이것을 오브젝트 풀링이라고한다.

다음의 간단한 순서에의해 오브젝트 풀을 사용한다.



오브젝트 생성 -> 오브젝트 풀에 넣음 -> 꺼냄 -> 사용전 초기화 -> 사용 -> 사용 종료 -> 오브젝트 풀에 넣음. 다시 사용이 필요할 때 오브젝트를 생성하지 않고 pool에서 꺼낸다. 이렇게 하면 한 번의 생성으로 오브젝트를 재사용함으로 추가 생성과 파괴에 대한 부담이 없어진다.


가변적인 풀링

게임 중 화면에 보이게 될 총알 오브젝트가 10개라서 10개를 풀처리 하였다. 그런데 무기를 업그래이드하여 총알 발사 속도가 빨라졌다. 그래서 10개 그 이상이 필요하게 된 경우가 발생하였다. 이 경우를 대비하여 풀에 오브젝트의 수가 0일 때 pop 요청이 발생하면 오브젝트를 생성하여 반하는 로직이 필요하다. 또는, 어느 순간 오브젝트의 요청이 많아져 풀의 크기가 급격히 증가하였다. 하지만 이후에 사용률이 낮아져 많은 양의 오브젝트가 사용되지 않았다. 이경우 풀에 불필요한 오브젝트를 쌓아 둘 필요가 없어 제거하는 로직이 필요하다.

※ 제거하는 로직에 대해서는 의견이 나뉜다.

메모리의 효율적이 관리를 위해 오랫동안 사용하지 않은 오브젝트는 제거해아한다는 의견. 오브젝트가 pool에 있을 경우 오브젝트를 비활성화 한다면 해당 오브젝트에 붙은 컴포넌트를 실행할 일이 없고 보이지 않으므로 렌더링에도 포함되지 않는다. 제거할 때 부담만 생긴다. 그러니까 그대로 둬야한다는 의견. 


오브젝트 풀 예제

오브젝트 풀을 구현하는 방법에는 여러가지가 있다. 자료구조(리스트,스택,큐)를 사용하는 방법. 사용하지 않는 방법. 구조를 단순화 하여 getter와 setter를 이용한 방법(http://blog.boredmormongames.com/2014/08/object-pooling.html) 다양한 방법이지만 이것들의 근본적인 목적은 오브젝트를 재사용하는 것이다. 그중 리스트를 활용한 예를 들어보자

public class PoolManager : MonoBehaviour
{
    private List<GameObject> listObj;       //풀 오브젝트들이 들어갈 리스트
    private Transform pool;                 //풀
    private GameObject obj;                 //풀에 오브젝트가 없을 경우를 대비한 비상용 오브젝트

    //풀 초기화
    public void InitPool(GameObject obj, int poolSize)
    {
        //리스트 생성
        listObj = new List<GameObject>();
        //풀 캐싱
        pool = transform;
        //오브젝트
        this.obj = obj;

        for (int i = 0; i < poolSize; i++)
        {
            //오브젝트를 생성
            GameObject go = Instantiate(obj) as GameObject;
            //풀에 푸시함
            PushObject(go);
        }
    }

    //풀에서 오브젝트를 하나 꺼냄
    public GameObject PopObject()
    {
        if (listObj.Count > 0)
        {
            GameObject obj = listObj[0];
            //리스트에서 제거
            listObj.RemoveAt(0);
            return obj;
        }
        else
        {   //없을 경우 만들어서 반환
           return Instantiate(obj) as GameObject;
        }
    }

    //오브젝트 풀에 넣기
    public void PushObject(GameObject obj)
    {
        //오브젝트를 리스트에 넣음
        listObj.Add(obj);
        //오브젝트를 풀에 넣음
        obj.transform.parent = pool;
        //위치 초기화
        obj.transform.localPosition = Vector3.zero;
        //비활성화
        obj.SetActive(false);
    }

    //풀 비우기
    public void ClearPool()
    {
        //리스트 null로 초기화 or Clear()
        listObj = null;

        //풀에 있는 자식 오브젝트 모두 제거
        foreach (Transform child in pool)
        {
            GameObject.Destroy(child.gameObject);
        }
    }
}

설명

InitPool 부분에서 사용할 오브젝트에대한 풀링 초기화를 진행한다. 게임에 사용할 오브젝트와 필요한 양을 파라미터로 전달하여 초기화한다. 절달받은 오브젝트를 필요한 만큼 생성하여 PushObject 함수를 이용해 풀에 넣는다. PushObject 에서는 오브젝트를 풀과 리스트에 넣고 위치를 초기화하고 오브젝트를 비활성화 시킨다. PushObject 함수는 InitPool 함수 뿐아니라 사용된 오브젝트가 반환될 때에도 똑같이 사용된다. PopObject 함수에서는 풀에서 오브젝트를 하나 꺼내서 반환한다. 반환할 오브젝트가 없다면 생성하여 반환한다. 사용이 끝난 오브젝트는 PopObject 함수에 의하여 다시 풀로 돌아오게된다. 나중에 재사용되기 위하여 오브젝트는 초기화 된다. 


오브젝트 풀링 예제

ObjectPoolExample.unitypackage


참고

위키백과 : https://ko.wikipedia.org/wiki/쓰레기_수집_(컴퓨터_과학)

유니티 메뉴얼 : http://docs.unity3d.com/kr/current/Manual/UnderstandingAutomaticMemoryManagement.html 

http://docs.unity3d.com/kr/current/Manual/ProfilerMemory.html

댓글