Version: 2018.2
이벤트 함수 실행 순서(Execution Order of Event Functions)
플랫폼별 컴파일

자동 메모리 관리 이해

오브젝트나 문자열, 배열을 생성한 이후 저장하려면 메모리 공간이 필요합니다. 필요한 공간은 heap이라고 하는 중심 풀에서 할당됩니다. 메모리 공간을 할당받은 항목이 더 이상 사용되지 않게 되면 차지하던 메모리를 회수하여 다른 항목을 저장하는 데 사용할 수 있습니다. 이전에는 프로그래머가 적절한 함수 호출을 통해 명시적으로 힙 메모리 블록을 할당하고 회수해야 했습니다. 하지만 최근에는 Unity Mono 엔진과 같은 런타임 시스템이 자동으로 메모리 관리를 수행합니다. 자동으로 메모리를 관리하면 명시적으로 할당하고 회수하는 것보다 코딩이 덜 필요하며 메모리 누수 현상이 발생할 가능성을 낮춥니다(메모리 누수 현상이란, 메모리가 할당된 이후 회수되지 않는 경우를 의미합니다).

값 타입과 레퍼런스 타입

함수가 호출되면 하위 파라미터의 값이 해당 함수 호출을 위해 지정된 메모리 구역에 복사됩니다. 몇 바이트만 차지하는 데이터 타입은 빠르고 쉽게 복사할 수 있지만, 보통 오브젝트, 문자열, 배열은 이보다 더 큰 경우가 많습니다. 따라서 이를 주기적으로 복사하는 것은 대단히 비효율적이지만, 다행히도 꼭 그렇게 할 필요는 없습니다. 큰 항목의 실제 저장 공간은 힙에서 할당되며, 저장되는 위치를 기억하기 위해 작은 “포인터” 값이 사용됩니다. 그 이후부터는 파라미터를 패스할 때 포인터만 복사하면 됩니다. 런타임 시스템이 포인터가 식별하는 항목을 찾을 수 있는 경우 데이터를 한 번만 복사하더라도 여러 번 사용할 수 있습니다.

값 타입은 파라미터가 전달되는 동안 사본이 직접 저장되는 타입입니다. 이 타입으로는 정수, 부동소수점, 부울, Unity의 구조체 타입(예를 들어, ColorVector3)이 포함됩니다. 힙에 할당하고 그 후 포인터를 통해 액세스하는 타입을 레퍼런스 타입이라고 하며 변수에 저장되는 값은 어디까지나 실제 데이터를 “참조”합니다. 참조 타입의 예로는 오브젝트, 문자열, 배열 등이 있습니다.

메모리 할당 및 가비지 컬렉션

메모리 관리자는 힙에서 사용되지 않는 영역을 트래킹합니다. 오브젝트가 인스턴스화되는 것과 같이 새로운 메모리 블록이 요청되는 경우, 관리자는 블록을 할당하기 위해 미사용 영역을 선택한 후 할당된 메모리를 제거합니다. 이 과정은 필요한 블록 크기를 할당할 수 없는 빈 공간이 없을 때까지 반복됩니다. 이 시점에서는 힙에서 할당된 모든 메모리가 사용 중일 가능성이 매우 낮습니다. 힙에 있는 참조 항목을 접근하려면 해당 항목을 찾을 수 있도록하는 참조 변수가 필요합니다. 참조 변수가 재할당되거나 로컬 변수로 변하는 경우와 같이 메모리 블록에 대한 모든 참조가 사라진 경우, 해당 메모리 블록을 안전하게 재할당할 수 있게 됩니다.

어떤 힙 블록이 더 이상 사용되지 않고 있는지를 확인하기 위해, 메모리 관리자는 현재 모든 액티브 참조 변수를 검색하고 이 변수가 참조하는 블록을 “살아있음(live)”이라고 표시합니다. 검색이 끝나면 메모리 관리자는 살아 있는 블록 사이의 모든 공간을 비어 있다고 간주하며 다음 할당 요청 시 사용할 수 있다고 간주합니다. 이러한 이유로, 미사용 메모리를 파악하고 해제하는 프로세스를 가비지 컬렉션(garbage collection, GC)이라 합니다.

최적화

가비지 컬렉션(GC)은 자동으로 일어나며 프로그래머에게 비가시적으로 일어납니다. 그러나 컬렉션 과정은 실제로는 내부적으로 상당한 CPU 시간을 요구합니다. 제대로 사용하면 자동 메모리 관리는 일반적으로 전반적인 성능에 있어 수동 할당과 비슷하거나 훨씬 더 나은 결과를 나타냅니다. 그러나 프로그래머 입장에서는 가비지 컬렉터가 필요 이상으로 자주 실행되어 게임 실행 중에 멈추는 현상을 유발하는 실수를 막는 것이 중요합니다.

처음에는 아무 문제 없어 보일지라도 이후 GC의 악몽을 불러올 수 있는 악명 높은 알고리즘이 일부 존재합니다. 가장 잘 알려진 사례로 문자열 접합 반복이 있습니다.

//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;
}

여기서 주목해야 하는 사실은 새로운 조각이 문자열이 한 개씩 추가되지는 않는다는 점입니다. 실제로는 루프가 실행될 때마다 라인 변수의 이전 내용이 삭제되며, 기존 조각 끝에 새로운 부분이 더해진 형태의 새로운 문자열이 할당됩니다. i의 값이 커질수록 문자열은 길어지므로, 사용되는 힙 공간 역시 증가합니다. 따라서 이 함수는 매번 호출될 때마다 순식간에 빈 힙 공간을 수백 바이트 사용합니다. 문자열을 동시에 여러 개 연결해야 하는 경우, Mono 라이브러리의 System.Text.StringBuilder 클래스를 사용하는 것이 좋습니다.

하지만 문자열 연결이 반복되어도 지나치게 자주 호출되지 않은 이상 큰 문제는 일어나지 않습니다. 예를 들어, 아래와 같이 Unity에서 프레임 업데이트마다 문자열 연결을 반복하지만 않으면 됩니다.

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

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}


//JS script example
var scoreBoard: GUIText;
var score: int;

function Update() {
    var scoreText: String = "Score: " + score.ToString();
    scoreBoard.text = scoreText;
}

…위 코드는 Update가 호출될 때마다 새 문자열을 할당하며 지속적으로 새 가비지 메모리를 조금씩 생성합니다. 대부분의 메모리 누수는 score가 변경될 때에만 텍스트를 업데이트하여 줄일 수 있습니다.

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

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;
    
    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}


//JS script example
var scoreBoard: GUIText;
var scoreText: String;
var score: int;
var oldScore: int;

function Update() {
    if (score != oldScore) {
        scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
        oldScore = score;
    }
}

발생할 수 있는 또 다른 문제로는 어떤 함수가 배열 값을 반환하는 경우가 있습니다.

//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;
    }
}

위 함수는 배열의 기존 내용을 새로운 값으로 간단하게 교체합니다. 코드를 호출하는 과정에서 배열을 초기에 할당해야 하지만(따라서 덜 깔끔하지만), 이 함수는 호출되더라도 새로운 가비지를 생성하지 않습니다.

가비지 컬렉션 요청

위에서 언급한 것처럼 할당을 최대한 피하는 것이 최선입니다. 하지만 할당 과정을 완전히 제거할 수는 없습니다. 따라서 아래에 있는 게임플레이에 대한 영향을 최소화하는 두 가지 전략을 사용하는 것이 좋습니다.

작은 힙과 빠르고 빈번한 가비지 컬렉션

이 방법은 오래 플레이되는 게임에서 부드러운 프레임률을 유지하는 데 가장 적합합니다. 이러한 게임은 작은 블록을 자주 할당하게 되지만, 이들 블록은 짧은 기간 동안만 사용됩니다. 이 방법을 iOS에서 사용할 때 할당할 일반적인 힙 크기는 200KB이며, 가비지 콜렉션은 이 경우 iPhone 3G에서 대략 5ms 정도 걸리게 됩니다. 힙 크기가 1MB로 증가하면 가비지 컬렉션은 7ms 정도 걸리게 됩니다. 따라서 가비지 콜렉션을 일정 프레임 간격마다 주기적으로 요청하는 것이 좋습니다. 이렇게 하면 가비지 컬렉션이 실제로 필요한 만큼 이상으로 발생하게 되지만 더 빠르게 수행되어 게임플레이에 최소한의 지장을 주게 됩니다. 아래를 참조하십시오.

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

그러나 이 기법은 주의해서 사용해야 하며 게임에서 실제로 가비지 컬렉션 시간을 감소시키는지 프로파일러 통계를 확인해야 합니다.

큰 힙과 느리지만 덜 빈번한 가비지 컬렉션

이 전략은 메모리 할당과 가비지 콜렉션이 비교적 자주 발생하지 않아 게임플레이 중간에 처리할 수 있는 게임에 적합합니다. 힙 용량은 최대한 큰 것이 좋습니다. 다만 너무 커서 운영체제가 시스템 메모리를 확보하려고 앱을 강제종료하는 경우는 피해야 합니다. Mono 런타임은 자동으로 힙을 확장하는 것을 최대한 피합니다. 따라서 수동으로 힙을 확장해야 하는데, 시작할 때 일부 플레이스홀더를 미리 할당하면 됩니다. 예를 들어, 메모리 관리자에서 메모리를 할당받기 위해 “무의미한” 오브젝트를 인스턴스 처리하면 됩니다.

//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;
}

충분히 큰 힙을 확보하면 게임플레이 중 일시정지가 발생할 때까지 완전히 힙이 차버려서 가비지 컬렉션이 일어나는 일은 발생하지 않습니다. 일시정지가 발생하면 명시적으로 가비지 컬렉션을 요청할 수 있습니다.-

System.GC.Collect();

재차 강조하지만, 이 전략을 사용할 때에는 원하는 효과가 알아서 구현될 것이라 단정하지 말고 프로파일러 통계를 계속해서 참조하십시오.

재사용 가능 오브젝트 풀

새로 생성되고 제거되는 오브젝트의 수를 줄이는 것으로 간단하게 가비지 생성을 줄일 수 있습니다. 게임에서는 투사체와 같이 여러 번 반복되지만 한 번에는 몇 개만 사용되는 오브젝트 유형이 있습니다. 이런 경우 오브젝트를 새로 생성하여 기존 오브젝트를 대체하는 것보다 오브젝트를 재사용하는 편이 좋습니다.

추가 정보

메모리 관리는 섬세하고 복잡한 과정으로, 상당한 노력과 학습이 수반됩니다. 자세한 내용을 알아보려면 memorymanagement.org를 참조하십시오. 다수의 문건과 온라인 문서를 제공합니다. 오브젝트 풀링에 대한 자세한 내용은 Wikipedia 페이지Sourcemaking.com를 참조하십시오.

이벤트 함수 실행 순서(Execution Order of Event Functions)
플랫폼별 컴파일