블로그

  • 가비지 컬렉션과 동반되는 문제

    넓은 의미에서, 가비지 컬렉션이 게임에 미치는 영향을 줄이는 방법에는 3가지 방법이 있습니다:

    • 가비지 컬렉터를 실행하는데 소모되는 시간을 줄일 수 있습니다.
    • 가비지 컬렉터가 실행되는 빈도를 줄일 수 있습니다.
    • 가비지 컬렉터를 의도적으로 실행시켜서, 로딩 화면 등 퍼포먼스가 중요하지 않은 시점에 가비지 컬렉터가 실행되도록 하는 것입니다.

    위의 3가지 방법을 염두해두면, 가비지 컬렉션의 영향을 줄일 수 있는 3가지 전략이 있습니다:

    • 힙 할당과 오브젝트 참조가 적게 발생하도록 게임을 구성합니다. 힙 상의 오브젝트와 검사해야 할 오브젝트 참조가 적으면 가비지 컬렉션이 발생할때, 실행하는데 소모되는 시간을 줄일 수 있습니다.
    • 퍼포먼스가 중요한 시점에 힙 할당 및 해제를 줄입니다. 할당과 해제가 적으면 가비지 컬렉션의 발생 빈도를 줄일 수 있습니다. 이 방법은 힙 파편화도 줄입니다.
    • 가비지 컬렉션과 힙 확장을 의도한 시점에 발생하도록 구성합니다. 이를 통해서, 가비지 컬렉션과 힙 확장이 예측한 시점과 이들의 영향을 받지 않는 시점에 발생하도록 합니다. 이 방법은 구현이 더 어렵고, 덜 확실한 방법이지만, 전반적인 메모리 관리 전략의 일부로 사용하면 가비지 컬렉션에 의한 영향을 줄일 수 있습니다.

    생성되는 가비지의 양 줄이기 (Reducing the amount of garbage created)

    생성되는 가비지를 코드에서 줄일 수있는 몇가지 방법에 대해서 살펴보겠습니다.

    캐싱 (Caching)

    코드에서 힙 할당을 야기하는 함수를 반복적으로 호출하고, 그 결과를 버리면 불필요한 가비지가 발생합니다.
    그 대신, 이런 오브젝트를 가리키는 참조 값을 저장하고 재사용하는 방법을 사용합니다. 이 기법을 캐싱이라고 합니다.

    다음 예제에 있는 코드는 새로운 배열을 생성하기때문에, 호출될때마다 힙 할당을 발생시킵니다.

    void OnTriggerEnter(Collider other)
    {
        Renderer[] allRenderers = FindObjectsOfType<Renderer>();
        ExampleFunction(allRenderers);
    }

    힙 할당을 한번만 발생시키고 그 참조값이 저장됩니다. 저장된 배열은 언제든지 가비지를 생성시키지 않고 재사용 가능입니다.

    private Renderer[] allRenderers;
     
    void Start()
    {
        allRenderers = FindObjectsOfType<Renderer>();
    }
     
    void OnTriggerEnter(Collider other)
    {
        ExampleFunction(allRenderers);
    }

    자주 호출하는 함수안에서 할당하지 마십시오 (Don’t allocate in functions that are called frequently)

    MonoBehaviour에서 힙 메모리를 할당할때, 가장 안좋은 방법은 자주 호출되는 함수 안에서 할당하는 것입니다.
    예를 들어, Update()와 LateUpdate()는 매 프레임마다 호출됩니다. 따라서, 이 함수에서 가비지를 생성하도록 코드를 작성하면, 이렇게 생성된 가비지가 아주 빨리 쌓이게 됩니다.
    이 경우, 가능하다면, 이 오브젝트의 참조 값을 Start()또는 Awake()에서 캐싱하는 방법을 고려해보는 것이 좋습니다.
    또는 할당을 발생시키는 코드가 꼭 필요할때만 실행되도록 구성하는 것이 좋습니다.

    상황이 바뀔 때만 코드가 실행되도록 작성한, 이동 코드의 예를 살펴보겠습니다. 다음 코드에서는, Update()에서 할당을 발생시키는 함수를 호출해서, 가비지가 자주 발생합니다:

    void Update()
    {
        ExampleGarbageGeneratingFunction(transform.position.x);
    }

    transform.position.x의 값이 변경되었을 때만 할당을 발생시키는 함수가 호출되도록 코드를 변경했습니다. 이제, 매 프레임 할당이 발생하지 않고, 필요할 때만 할당이 발생합니다.

    private float previousTransformPositionX;
     
    void Update()
    {
        float transformPositionX = transform.position.x;
     
        if (transformPositionX != previousTransformPositionX)
        {
            ExampleGarbageGeneratingFunction(transformPositionX);
            previousTransformPositionX = transformPositionX;
        }
    }

    Update()함수에서 가비지를 줄이는 다른 방법 중 하나는 타이머를 사용하는 것입니다. 이 방법은 가비지를 생성하는 코드가 매 프레임 실행될 필요는 없고, 정기적으로 실행되어야 하는 경우에 적합합니다.

    다음 예제 코드에서, 가비지를 생성시키는 함수는 매 프레임마다 호출됩니다.

    void Update()
    {
        ExampleGarbageGeneratingFunction();
    }

    다음 코드에서는, 타이머를 사용해서 가비지를 생성시키는 함수가 1초당 한번씩 호출되도록 했습니다.

    private float timeSinceLastCalled;
     
    private float delay = 1f;
     
    void Update()
    {
        timeSinceLastCalled += Time.deltaTime;
     
        if (timeSinceLastCalled < delay)
        {
            ExampleGarbageGeneratingFunction();
            timeSinceLastCalled = 0f;
        }
    }

    자주 실행되는 코드에 대해서, 이렇게 간단한 변경만으로도 생성되는 가비지의 양을 현저하게 줄일 수 있습니다.

    컬렉션 지우기 (Clearing collections)

    새 컬렉션을 생성하면 힙 할당이 발생합니다. 코드에서 새 컬렉션을 한번 이상 생성하는 경우, 이 컬렉션의 참조 값을 캐시에 저장하고, new 키워드를 반복적으로 호출하는 대신, Clear() 함수를 이용해서 내용을 비웁니다.

    void Update()
    {
        List myList = new List();
        PopulateList(myList);
    }

    다음 예제에서는, 컬렉션이 생성되거나 컬렉션의 크기를 조절해야하는 경우에만 할당이 발생합니다. 이렇게 하면 생성되는 가비지의 양을 현저하게 줄일 수 있습니다.

    private List myList = new List();
     
    void Update()
    {
        myList.Clear();
        PopulateList(myList);
    }

    오브젝트 풀링 (Object pooling)

    스크립트에서 할당을 줄이더라도, 런타임에서 다수의 오브젝트를 생성하고, 삭제하면 여전히 가비지 컬렉션의 문제에서 자유로울 수 없습니다.
    오브젝트 풀링(Object Pooling)은, 반복적으로 생성하고 삭제해야하는 오브젝트의 재사용을 통해서, 할당과 해제를 줄이는 기법입니다.
    오브젝트 풀링은 게임에서 광범위하게 사용되며, 오브젝트를 자주 스폰(Spawn)하고 삭제해야하는 상황에 가장 적합한 방법입니다. 총에서 총알을 발사하는 경우를 예로들 수 있습니다.

    오브젝트 풀링에 대한 전체 가이드는 이 글의 범위를 벗어나지만, 실제로 매우 유요한 기술이며, 배울 가치가 있는 기법입니다.
    유니티 Learn 사이트의 오브젝트 풀링에 대한 강좌는, 유니티에서 오브젝트 풀링 시스템을 구현하는데 좋은 가이드입니다.
    (저의 블로그 링크에 가시면 오브젝트 풀링을 구현하는 강좌가 준비되어 있으니 참고하시기 바랍니다.)

  • 가비지 컬렉션에 의한 피해 줄이기

    가비지 컬렉션과 동반되는 문제 (Problems with garbage collection)

    유니티의 메모리 관리 측면에서 가비지 컬렉션이 수행하는 역할을 살펴봤습니다.
    이제, 가비지 컬렉션에 의해서 발생할 수 있는 문제를 살펴보겠습니다.

    가장 명백한 문제는 가비지 컬렉터를 실행하는데 상당한 시간이 걸릴 수 있다는 점입니다.
    가비지 컬렉터에서 검사해야하는 오브젝트 또는 오브젝트 참조값이 많은 경우, 이 많은 오브젝트들을 모두 검사하는 처리과정이 느려질 수 있습니다.
    이로 인해 게임이 뚝뚝 끊기거나, 실행이 느려질 수 있습니다.

    다른 문제는 가비지 컬렉터가 불필요한 시간에 실행될 수 있다는 점입니다.
    CPU가 이미 성능이 매우 중요한 시점에서 열심히 작업을 처리하고 있는경우, 가비지 컬렉션에의해서 발생한 약간의 오버헤드(overhead)는 프레임 속도(frame rate)와 성능을 떨어뜨릴 수 있습니다.

    힙 파편화(heap fragmentation)가 발생할 수 있다는 점 역시 문제가 될 수 있습니다.
    힙 상에 메모리가 할당되면, 저장해야하는 데이터의 크기에 따라서 사용가능한 메모리 공간에서 크기가 서로 다른 메모리 블록(Memory block)단위로, 메모리를 차지합니다.

    이러한 메모리 블록이 힙으로 반환되면, 힙은 할당된 메모리 블록으로 구분되는 여러 개의 작은 빈 블록으로 분할될 수 있습니다.
    즉, 사용가능한 전체 메모리의 양이 많더라도, 가비지 컬렉터를 실행하거나 힙을 확장하지 않고서는, 기존 블록이 충분하지 않기 때문에 큰 메모리 블록을 할당할 수 없습니다.

    힙 파편화는 2가지의 결과로 이어집니다. 첫째, 게임의 메모리 사용량이 게임에서 필요로하는 것보다 더 많아집니다. 둘째, 가비지 컬렉터가 더 자주 실행됩니다.

    가비지 컬렉션에 의해서 발생하는 문제 진단 (Diagnosing problems with garbage collection)

    가비지 컬렉션에 의해서 발생하는 성능 문제는 낮은 프레임 속도, 간헐적인 멈춤, 성능 저하 등으로 나타날 수 있습니다.
    게임에 이런 성능 문제가 발생했을때 가장 먼저 해야할 일은, 유니티의 프로파일러(Profiler)를 이용해서 발생하는 문제가 실제로 가비지 컬렉션에 의한 것인지 확인하는 것입니다.

    프로파일러를 이용해서 성능 문제의 원인을 찾는 방법은 이 강좌를 참고하시기 바랍니다.

    프로파일러를 이용해서 게임을 살펴보고, 가비지 컬렉션에 의해서 문제가 발생한 경우, 계속해서 글을 읽어주세요.

    힙 할당 찾기 (Finding heap allocations)

    가비지 컬렉션에 의해서 문제가 발생된다는 점을 발견했다면, 작성한 코드의 어느 부분이 가비지(Garbage)를 생성하는지 이해해야합니다.
    가비지는 힙 변수가 스코프를 벗어날때 생성되기때문에, 첫째로 힙에 변수를 할당하는 주체를 알아야합니다.

    힙 상에 메모리을 할당하는 주체는 뭘까요?
    유니티에서는, 값 타입의 로컬 변수는 스택에 할당되고, 나머지는 모두 힙에 할당됩니다.
    값 타입과 참조 타입의 차이점에 대해서 잘 모르는 경우, 이 강좌를 참고하시기 바랍니다.

    아래 예제는 스택 할당의 예입니다. localInt 변수는 로컬 변수이고 값 타입입니다. 이 변수를 위해서 할당된 메모리는, 이 함수의 실행이 종료되는 즉시 스택에서 해제됩니다.

    void ExampleFunction()
    {
        int localInt = 5;
    }

    아래 예제는 힙 할당의 예입니다. lovalList 변수는 로컬변수지만, 참조 타입입니다. 이 변수에 할당된 메모리는 가비지 컬렉터가 실행될때 해제됩니다.

    void ExampleFunction()
    {
        List localList = new List();
    }

    프로파일러를 사용해서 힙 할당 찾기 (Using the Profiler window to find heap allocations)

    작성한 코드에서 힙 할당을 발생시키는 부분이 어딘지, 프로파일러를 이용해서 찾을 수 있습니다.

    CPU 사용량을 보여주는 프로파일러가 선택된 상태에서, 프로파일러 창 하단의 특정 프레임을 선택해서, 해당 프레임에 대한 CPU 사용량 데이터를 확인할 수 있습니다. 데이터를 보여주는 열(column)에는 GC alloc이라고 이름 붙여진 열이 있습니다. 이 열(column)은 선택된 프레임에서 생성된 힙 할당을 보여줍니다. 컬럼 헤더(column header)를 선택하면, 통계를 통한 데이터 정렬이 가능합니다.

    이를 통해서, 작성한 코드에서 어떤 함수가 힙 할당을 가장 많이 발생시키는지 쉽게 알 수 있습니다.
    어떤 함수가 힙 할당을 잘생시키는지 확인하면, 해당 함수를 검사할 수 있습니다.

    해당 함수의 어떤 코드가 가비지를 생성하는지 찾았다면, 문제를 해결할 방법을 결정하고, 생성되는 가바지의 양을 최소화할 수 있습니다.

  • 유니티 가비지 컬렉션 개요 (Introduction)

    게임이 실행되면, 데이터를 저장하기 위해서 메모리를 사용합니다. 그런데 이 데이터가 더 이상 필요하지 않게되면, 재사용(reuse)이 가능하도록 데이터를 저장하는데 사용된 메모리를 비웁니다.
    가비지(Garbage)란 데이터를 저장하기 위해서 따로 설정했다가 더이상 사용하지않는 메모리를 지칭하는 용어입니다.
    가비지 컬렉션(Garbage Collection)이란 이러한 메모리를 재사용이 가능하도록 처리하는 과정을 나타내는 용어입니다.

    유니티는 메모리를 관리하는 한 부분으로, 가비지 컬렉션을 사용합니다.
    가비지 컬렉션이 너무 자주 발생하거나, 가비지 컬렉션에서 처리해야할 일이 너무 많으면 게임의 퍼포먼스(perfomance, 성능)가 매우 낮게 나옵니다.
    즉, 가비지 컬렉션은 퍼포먼스 문제를 발생시키는 주요 원인입니다.

    이번 글에서, 가비지 컬렉션이 동작하는 방법과 언제 가비지 컬렉션이 발생하는지에 대해서 살펴봅니다.
    그리고 가비지 컬렉션이 게임에 미치는 영향을 최소한으로 줄여서 메모리를 효율적으로 사용할 수 있는 방법에 대해서 살펴봅니다.

    유니티에서 메모리를 관리하는 방법에 대한 간략한 소개 (A brief introduction to memory management in Unity)

    가비지 컬렉션이 동작하는 방법과 언제 발생하는지에 대해서 이해하려면, 유니티에서 메모리 사용을 어떻게 처리하는지에 대해서 먼저 이해해야 합니다.
    유니티에서 메모리를 관리하는 방법을 자동 메모리 관리라고 부릅니다.
    다시 말해, 메모리를 관리하는 방법을 유니티에서 명시적으로, 상세하게 알려줄 필요가 없습니다. 유니티가 우리를 위해서 이 부분을 알아서 처리합니다.

    가장 기본적인 수준에서, 유니티에서 자동 메모리 관리는 다음과 같은 방식으로 동작합니다:

    • 유니티는 두 가지 메모리풀(Memory Pool)에 대한 접근권한을 갖습니다: 바로 스택(Stack)과 힙(Heap)입니다. 스택은 작은 단위의 데이터를 단기간 저장하는데 사용됩니다. 힙은 좀더 큰 데이터를 장기간 저장하는데 사용됩니다.
    • 변수가 생성되면, 유니티는 스택(Stack) 또는 힙(Heap)으로부터 메모리 블록(메모리 공간)을 요청합니다.
    • 변수가 스코프 내에(여전히 코드에서 접근가능한 상태)있는 경우, 이 변수에 할당된 메모리는 사용가능한 상태로 남습니다. 이때 이 메모리를 할당된 상태(allocated)라고 말합니다. 스택 메모리에 저장된 변수를 스택 상의 오브젝트라고 하며, 힙 메모리에 저장된 변수를 힙 상의 오브젝트라고 합니다.
    • 변수가 스코프를 벗어나면, 이를 위해서 할당되었던 메모리는 더이상 필요가 없습니다. 그리고 이 메모리가 할당되었던 메모리 풀(Memory Pool, 스택 또는 힙)로 반환될 수 있습니다. 메모리가 메모리 풀에 반환되면, 이 메모리를 해제된 상태(deallocated)라고 말합니다. 스택에서 할당된 메모리는 이 메모리가 스코프를 벗어나는 즉시 해제됩니다. 반면에, 힙에서 할당된 메모리는 이 메모리가 스코르를 벗어난 시점에 할당된 상태로 남습니다.
    • 가비지 컬렉터(Garbage Collector)는 힙 메모리에서 현재 사용되지 않는 메모리를 식별하고, 이 메모리를 해제됩니다. 가비지 컬렉터는 일정 기간마다 실행되어 힙 메모리를 청소합니다.

    이제 메모리관리에 대한 기본적인 이벤트 흐름에 대해서 살펴봤습니다. 이어서 스택 메모리의 할당과 해제가 힙 메모리의 할당과 해제와 어떻게 다른지에 대해서 좀 더 자세히 살펴보겠습니다.

    스택의 할당과 해제 과정에서는 어떤 일이 발생하나요? (What happens during stack allocation and deallocation?)

    스택 메모리의 할당 및 해제는 빠르고 간단합니다. 이는 스택이 작은 데이터를 단기간 저장하는데 사용되기 때문입니다.
    할당과 해제가 발생하는 시점과 그 메모리의 크기는 항상 예측가능합니다.

    스택은 스택 데이터 타입과 유사하게 동작합니다: 스택은 특정 요소들의 집합입니다.
    스택 메모리에서 이 요소들은 메모리 블록(Memory Block)이며, 엄격한 순서에 따라서 메모리 블록을 추가하거나 제거할 수 있습니다.
    이런 단순함과 엄격함이 스택 메모리를 빠르게 동작할 수 있도록 만듭니다: 스택에 변수가 저장되면, 이 변수를 저장하기 위한 메모리는 스택의 “끝”에 할당됩니다.
    스택 변수가 스코프에서 벗어나면, 이 변수를 저장하는데 사용된 메모리는 그 즉시 재사용이 가능하도록 스택에 반환됩니다.

    힙 할당 과정에서는 어떤 일이 발생하나요? (What happens during a heap allocation?)

    힙 메모리의 할당 과정은 스택 할당 과정에 비해서 훨씬 복잡합니다.
    힙은 데이터의 단기간 저장과 장기간 저장에 모두 사용되며, 저장될 수 있는 데이터의 타입이 매우 많고, 그 크기도 다양하기 때문에 힙 메모리의 할당과정이 훨씬 복잡한 것입니다.
    힙 메모리의 할당과 해제는 예측가능한 시점에 발생하지 않으며, 할당과 해제가 발생하는 메모리 블록의 크기가 매우 다양합니다.

    힙 메모리에 변수가 생성되면, 다음의 과정이 발생합니다:

    • 첫째로, 유니티는 힙 메모리상에 사용가능한 메모리 공간이 있는지 확인합니다. 힙에 사용가능한 충분한 공간이 있는 경우에, 변수를 저장하기 위한 메모리가 할당됩니다.
    • 힙에 사용가능한 메모리 공간이 부족한 경우, 유니티는 가비지 컬렉터를 실행해서 현재 사용하지 않는 메모리를 비웁니다. 이 작업은 시간이 많이 소모될 수 있는 과정입니다. 메모리를 비우는 과정이 끝난 뒤에, 변수를 저장하는데 필요한 충분한 메모리가 확보되면, 해당 메모리가 할당됩니다.
    • 가비지 컬렉션 작업 이후에도 메모리가 충분하지 않으면, 유니티는 힙 메모리의 양을 증가시킵니다. 이 작업 역시 시간이 많이 소모될 수 있는 과정입니다. 이제 변수를 저장하기 위한 메모리가 할당됩니다.

    힙의 할당 과정은 시간이 많이 소모됩니다. 특히, 가비지 컬렉터가 실행되어야 하거나 힙이 확장되어야 하는 상황에서는 더욱 시간이 많이 소모됩니다.

    가비지 컬렉션 과정 중에는 어떤 일이 발생하나요? (What happens during garbage collection?)

    힙에 할당된 변수가 스코프를 벗어나면, 이 변수를 저장하는데 사용된 메모리는 이때 바로 해제되지 않습니다. 이렇게 사용되지 않는 힙 메모리는 가비지 컬렉터가 실행되어야만 해제됩니다.

    가비지 컬렉터가 실행될때마다, 아래 설명된 과정이 발생합니다:

    • 가비지 컬렉터가 힙 상의 모든 오브젝트를 검사합니다.
    • 가비지 컬렉터가 오브젝트 참조(references)값을 모두 확인해서 해당 오브젝트가 힙 상에서 여전히 스코프내에 있는지(사용 중인지) 확인합니다.
    • 스코프를 벗어난 오브젝트는 모두 삭제를 위해서 플래그(flag)로 표시해둡니다.
    • 플래그로 표시된 오브젝트가 삭제되어, 할당되었던 메모리가 힙에 반환됩니다.

    가비지 컬렉션을 처리하는데 시간이 많이 걸릴 수 있습니다.
    힙 상의 오브젝트가 더 많을수록, 오브젝트 참조값이 더 많을수록, 처리해야하는 일이 더 많아집니다.

    가비지 컬렉션은 언제 발생하나요? (When does garbage collection happen?)

    가비지 컬렉터는 아래 설명된 3가지 상황에서 실행될 수 있습니다:

    • 사용가능한 메모리가 충분하지 않은 상황에서 힙 할당이 발생하면, 가비지 컬렉터가 실행됩니다.
    • 가비지 컬렉터는 가끔씩 실행됩니다(얼마나 자주 실행되는지 여부는 플랫폼마다 다릅니다).
    • 가비지 컬렉터를 수동으로 강제해서 실행시킬 수 있습니다.

    가비지 컬렉션은 자주 실행될 수 있습니다.
    힙을 할당해야하는데 사용가능한 메모리 공간이 충분하지 않으면 가비지 컬렉터가 실행됩니다.
    즉, 힙 메모리의 할당과 해제를 자주하게되면 가비지 컬렉션이 자주 발생할 수 있습니다.