자동 메모리 관리의 이해

오브젝트나 문자열 혹은 배열(array)이 만들어졌을 때, 이를 저장하는 데 필요한 메모리는 _heap_이라고 불리는 중앙 풀(pool)로부터 할당합니다. 그 아이템을 더 이상 사용하기 않을 경우, 그것이 한 때 차지했던 메모리는 다시 회수하여 다른 것을 위해 사용됩니다. 과거에는, 메모리 힙(heap)의 이러한 블록은 할당하고 회수하는 것은 일반적으로 프로그래머에 의해 해당하는 함수 호출을 통하여 명시적으로 이루어 졌습니다. 요즘에는, Unity의 Mono 엔진과 같은 런타임 시스템이 사용자 대신 자동으로 메모리를 관리하여 줍니다. 자동 메모리 관리는 명시적으로 할당/회수에 들어가는 프로그램 코딩 작업에 노력이 덜 필요하고, 메모리 누수의 가능성을 훨씬 줄여줍니다 (메모리가 할당은 되었지만 뒤에 회수가 되지 않은 상황).

값과 참조의(Value and Reference) 타입

함수가 호출되면, 그 매개변수()의 값이 그 특정 호출에 대해 유보하고 있는 메모리 영역이 복사됩니다. 불과 몇 바이트의 메모리만 차지하는 데이터 타입은 신속하고 쉽게 복사됩니다. 하지만, 오브젝트는 일반적으로 텍스트나 배열(array)로 훨씬 용량이 크며, 이러한 타입의 데이터가 주기적으로 복사되면 매우 비효율적일 것 입니다. 다행히도 이는 피할 수 있습니다; 큰 아이템에 대한 실제 저장공간은 heap으로부터 할당하며, 이 위치를 기억하기 위해 작은 용량의 "pointer" 값을 기억합니다. 거기서부터, 매개변수의 전달 시에는 이 포인터만 복사됩니다. 런타임 시스템이 그 포인터가 알려주는 지점에서 그 아이템을 찾을 수 있는 한, 그 데이터의 일회 복사는 얼마든지 자주 수행할 수 있습니다.

직접적으로 저장되고 매개변수 전달 시에 복사되는 타입을 값의 타입(value type)이라 부릅니다. 이는 integers, floats, Booleans, Unity의 struct 타입 들(예: ColorVector3) 등을 포함합니다. Heap에 할당되고 그리고 포인터로 접근하는 타입을 레퍼런스 타입(reference types)이라 부르며, 이는 그 변수에 저장된 변수는 단순히 실제 데이터를 "참조"만 하기 때문입니다. reference types의 예로는 오브젝트, 문자열 그리고 배열을 들 수 있습니다.

할당과 쓰레기 수거(Garbage Collection)

메모리 관리자는 heap 내에서 미사용으로 알려진 영역을 추적하여 관리합니다. 새로운 메모리 블록의 요청을 받으면(예: 오브젝트가 인스턴스 될 경우), 이 관리자는 그 블록을 할당하기 위해 미사용 영역을 선택하고, 그 메모리를 알려진 미사용 공간에서 제외시킵니다. 뒤따르는 요청도 이와 같은 방식으로 요청하는 블록 사이즈 크기를 수용할 만큼 큰 빈 영역이 고갈 될 때까지 처리됩니다. 이 시점에 heap에 할당된 메모리가 모두 사용될 가능성은 매우 낮습니다. Heap 상의 레퍼런스 아이템은 그것을 찾을 수 있는 레퍼런스 변수가 존재하는 경우에만 접근할 수 있습니다. 메모리 블록에 대한 모든 레퍼런스가 사라지면(예: 레퍼런스 변수가 재할당 되거나 범위 밖인 로컬 변수), 그것이 차지하고 있던 메모리는 안전하게 재 할당 될 수 있습니다.

어느 heap 블록이 더 이상 사용되지 않는지를 결정하려고, 메모리 매니저는 현재 유효한 모든 레퍼런스 변수를 찾아 그 들이 참조하는 블록을 "live"로 표시합니다. 검색의 마지막에, live 블록 사이의 모든 공간으로 빈 공간으로 간주하고 메모리 관리자는 그것을 후속하는 할당에 사용할 수 있습니다. 이러한 이유로, 사용되지 않는 메모리를 찾아 풀어주는 이 작업은 쓰레기 수거(garbage collection)라고 알려져 있습니다 (축약하여 GC).

최적화

쓰레기 수거는 자동으로 수행되면 프로그래머에게는 보여지지 않지만, 이 과정은 실제로 배경에서 상당한 CPU 시간을 소요합니다. 올바르게 사용된다면, 자동 메모리 관리는 수동 할당에 비해 전체 성능이 비슷하거나 더 나을 것 입니다. 하지만, 프로그래머는 이 수거작업을 필요이상으로 수행하여 실행의 일시 멈춤을 가져오는 실수를 피하여야 합니다.

얼핏 보기에는 문제가 없어 보이지만 GC 악몽을 유발할 수 있는 유명한 알고리즘이 몇 개 있습니다. 반복된 문자열 연결이 그 클래식한 예 입니다:-

function ConcatExample(intArray: int[]) {
var line = intArray[0].ToString();

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

return line;
}

여기의 핵심 세부내용은 새로운 것이 제자리에 한 개씩 추가되는 것이 아니라는 것 입니다. 실제로는 각 룹(loop)이 반복될 때 마다 라인의 이전 내용에 있는 변수는 없어진다는 것입니다 – 전체가 원래 문자열에 새로운 부분을 끝에 추가하여 새로운 문자열 전체로써 할당된다는 것 입니다. 변수 i 가 증가함에 따라 그 문자열은 더 길어짐으로, 사용되는 heap의 용량도 커지므로, 각 함수 호출마다 수백 바이트의 빈 heap 공간을 고갈 시킵니다. 만일 많은 문자열을 연결해야 할 필요가 있을 경우, Mono 라이브러리의 System.Text.StringBuilder 클래스가 훨씬 나은 선택이 됩니다.

하지만, 반복되는 연결 동작도 자주 호출되지 않는 한 큰 문제가 되지는 않습니다. 그리고 Unity에서는 이는 보통 다음과 같은 frame 업데이트를 암시합니다:-

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

…위는 Update 가 호출 될 때 마다 새 문자열을 할당하고 끊임없이 새로운 쓰레기를 생성할 것 입니다. 이 대부분은 스코어가 바뀔 때만 텍스트를 업데이트 하여 저장할 수 있습니다.:-

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

다른 문제의 가능성은 함수가 배열(array)을 반환할 경우에 발생합니다:-

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

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

return result;
}

이러한 타입의 함수는 값으로 채우진 새로운 배열을 만들 때 매우 우아하고 편리합니다. 하지만, 이것이 반복적으로 호출되면 매번 새로운 메모리가 할당 될 것 입니다. 배열은 매우 용량이 크므로, 빈 heap 공간은 빠르게 고갈되고, 결과적으로 쓰레기 수거가 자주 발생합니다. 이 문제를 피하는 한 방법은, 배열이 레퍼런스 타입이라는 사실을 이용하는 것 입니다. 매개변수로 함수에 전달된 배열은 그 함수에서 변경하고 그 결과는 함수가 반환된 이후에도 남아있게 됩니다. 이런 함수는 다음과 같은 함수로 종종 대체할 수 있습니다:-

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

이것은 배열의 기존 내용을 단순히 새 값으로 변경합니다. 비록 호출하는 코드에서 초기에는 배열을 할당해야 하지만, 이 함수의 호출이 새로운 쓰레기를 만들어내지는 않습니다.

Requesting a Collection 수거의 요청

위에서 언급했듯이, 쓰레기 수거는 종종 실행의 일시 정지를 가져오며, 특히 live 오브젝트의 검색이 복잡할 경우 더욱 그렇습니다. 만일 게임플레이 동안 이것이 발생하면 결과는 눈에 보이겠지만, 다른 경우에는 게임에서 일시 멈춤이 별 영향이 없을 수도 있습니다(예: 스크린이 사라졌거나 메뉴가 보여질 때). 부적절한 순간에 멈춤을 피하기 위하여 Heap이 꽉 차지 않았을 경우에도 쓰레기 수거의 수행을 시스템에 요청할 수 있습니다. System.GC.Collect 함수를 사용하여 이 작업을 수행 할 수 있습니다:-

function NotVeryMuchHappeningInGame() {
System.GC.Collect();
}

이 함수가 호출되어도 메모리 매니저가 반드시 수거를 수행하는 것은 아닙니다. 이는 필요하다면 지금이 GC을 수행하기에 좋은 시간이라는 것을 제안할 뿐입니다.

재활용 가능 오브젝트 풀(Reusable Object Pools)

생성되고 삭제되는 오브젝트의 개수를 줄여서 쓰레기의 배출을 줄일 수 있는 경우도 많이 존재합니다. 게임에는 적군 캐릭터와 같이 한꺼번에는 적은 수만이 같이 나타나지만 반복해서 계속 만나게 되는 오브젝트의 타입이 있습니다. 이러한 경우에, 이전 것을 삭제하고 새것을 대체하기 보다는 오브젝트를 재활용 하는 것이 가능합니다. 예를 들어, 게임에서 적이 죽게 되면, 그 게임오브젝트(Game Object)를 삭제하기 보다는 단순히 숨겨둘 수 있습니다. 그리고, 새 적의 인스턴스가 필요할 때, "dead" 적군은 필요 시 마다 되찾아 올 수 있습니다.

구현

오브젝트 풀(pool)을 구현하는 가장 간단한 방법은 해당 오브젝트 타입의 배열(array)을 시작하여 풀에 포함하는 것 입니다; 예를 들어, 게임에서 적을 나타내는 게임오브젝트를 풀에 구현한다고 합시다. 그 배열은 동시에 필요할 수 있는 적군의 최대 수를 포함하는 충분한 항목을 가지고 있어야 합니다.

var enemyPool: GameObject[];
var enemyPrefab: GameObject;
var maxNumEnemies: int;


function InitializeEnemyPool() {
	enemyPool = new GameObject[maxNumEnemies];
	
	for (i = 0; i < enemyPool.Length; i++) {
		enemyPool[i] = Instantiate(enemyPrefab);
		enemyPool[i].renderer.enabled = false;
	}
}

한 적군은 배열 항목 중 하나를 단순히 복사하여 구할 수 있습니다. 그 적군이 다시 할당되지 않게 하려면 (필요하지 않을 때 까지), 그 배열 항목은 null로 설정되어야 합니다. 뒤따르는 할당에서 할당을 위해 배열에서 첫 번째 null이 아닌 항목을 검색하는 것보다는, 할당되지 않은 적군을 담고 있는 첫 인텍스를 가리키는 정수의 변수를 하나 두는 것이 최상의 방법입니다.

var nextAvailableEnemy: int = 0;

function GetEnemy() {
	var allocatedEnemy = enemyPool[nextAvailableEnemy];
	allocatedEnemy.renderer.enabled = true;
	enemyPool[nextAvailableEnemy] = null;
	nextAvailableEnemy++;
	return allocatedEnemy;
}

적군이 죽거나 더 이상 필요치 않을 경우, 풀(pool)로 반환합니다. nextAvailableEnemy 바로 전의 배열 인덱스를 찾아 거기에 있는 적을 대체하면 됩니다.

function ReleaseEnemy(doomedEnemy: GameObject) {
	doomedEnemy.renderer.enabled = false;
	nextAvailableEnemy--;
	enemyPool[nextAvailableEnemy] = doomedEnemy;
}

효율성(Efficiency)

오브젝트 풀(pool)은 빠른 속도로 생성되고 짧은 수명 주기를 가지며, 동시간에는 작은 수만 플레이 하는 타입의 오브젝트에 가장 최적입니다. 예를 들어, 입구 지점에서 적의 "무리가" 수도 없이 파도처럼 닥치지만 각자는 재빨리 제압되는 경우입니다. 이와 유사하게, 스펠 불꽃(spell sparkles), 탄환과 폭발 같이 고갈되지 않고 제공되는 물건이지만 게임에서 재빨리 사라지는 것 등입니다. 그러한 오브젝트의 새 인스턴스가 자주 생성되면 사용 가능한 heap 메모리도 빠르게 고갈되고, 쓰레기 수거는 주기적으로 발생할 것 입니다. 하지만, 오브젝트가 재 사용되면 풀이 만들어지고 나면 추가적인 할당이 필요하지 않을 것 입니다.

하지만, 오브젝트 풀은 성능 면에서 조심하여 사용되어야 합니다. 한가지 문제는, 풀을 만들면 다른 목적으로 사용될 가용 heap 메모리가 줄어든 다는 것 입니다; 이 줄어든 heap에서 할당이 자주 발생하면 더 잦은 쓰레기 수거가 발생하게 됩니다. 다른 문제는, 수거에 소요되는 시간은 live 오브젝트의 수에 따라 증가한다는 것입니다. 이러한 문제를 염두에 두면, 너무 큰 풀을 할당하거나 거기에 포함된 오브젝트가 일정시간 사용되지 않을 경우에 성능저하가 발생한다는 것은 명백해 집니다. 더구나, 많은 오브젝트 타입은 오브젝트 풀과 맞지 않을 수 있습니다. 예를 들어, 게임에서 스펠 효과가 상당 시간 동안 지속되거나, 혹은 한꺼번에 많은 수의 적군이 나타나나 게임이 진행됨에 따라 천천히 죽어가는 경우입니다. 이러한 경우에는, 오브젝트의 성능 오버헤드가 혜택보다 월등하므로, 오브젝트 풀은 사용되어서는 안됩니다.

추가 정보

메모리 관리는 많은 학문적인 노력을 요하는 추상적이고 복잡한 주제입니다. 이에 대하여 더 공부하고 싶을 경우, memorymanagement.org는 훌륭한 자료로 많은 출판물과 온라인 기사를 담고 있습니다. 오브젝트 풀링에 대한 추가 정보는 Wikipedia pageSourcemaking.com에서 찾을 수 있습니다.

역링크