힙메모리 (Heap Memory)

스택메모리는 컴파일러가 미리 효율적으로 배치할 수 있는 저장 공간이다. 런타임(실행시간)에 생기는 변수들을 고려하지 않아도 되기 때문에 컴파일러는 최적의 효율을 계산할 수 있다. 효율적이라는 뜻은 적은 용량을 사용하면서 속도가 빠르다는 것을 의미한다. (공간 효율성, 시간 효율성) 

 

비주얼 스튜디오에서 프로젝트 -> 속성 -> 구성속성 -> 링커 -> 스택예약크기에서 스택 메모리의 기본값은 1MB 이다. 1MB는 4바이트 정수형이 25만개 정도 들어갈 수 있는 크기다. 1메가바이트가 PC초창기에는 상당히 큰 메모리였다. 지금은 기가단위 메모리를 사용할 수 있기 때문에 비교하면 작은 크기지만 과거 DOS 프로그램에서는 1MB로 충분히 사용했었다. 닌텐도의 초창기 게임기의 메모리가 4KB 였다는 것을 생각하면 충분한 비교가 될 것이다.

 

그럼 기가 단위의 나머지 메모리들은 어떻게 되는가? 대부분의 남은 메모리들을 힙영역에서 사용한다. 8기가 메모리라면 1메가는 스택을 위해 사용하고 나머지는 힙영역에서 사용한다는 뜻이다. 현대의 운영체제는 여러개의 프로그램이 작동하고 있기 때문에 하나의 프로세스에 이렇게 할당한다고 보면 될 것 이다.

 

16기가의 메모리가 장착된 필자의 메모리는 아래와 같다. 운영체제에서 사용하는 메모리는 다른 목적에도 사용되지만 약 10기가의 사용가능한 메모리가 있다. C++ 프로그램이 구동하면 대부분을 힙영역이 사용하게 된다.

 

호텔방의 비유

 

힙영역은 스택보다 유연하게 사용할 수 있고 전역 변수 공간보다 효율적으로 사용할 수 있다.

 

C++ 프로그래머가 할일은 사용하고자 하는 운영체제와 런타임으로 부터 메모리를 예약하고 포인터를 받는 것이다. 메모리가 호텔의 예약된 공간이라면 포인터는 키와 같다. 메모리 공간을 주로 방에다 비유를 하는데 참신하지는 않지만 이해가 쉽기도 하다.

 

사용이 다 끝났다면 사용자가 키를 반납하고 방을 비워줘야 한다. 스택은 이 과정이 자동으로 진행되지만 힙은 직접 키를 반납하고 체크아웃을 해야한다는 점이 차이가 있다. 만약 키를 반납하지 않고 체크아웃을 하지 않는다면? 그 방은 다른 사용자가 사용할 수 없을 것이다. 프로그램이 끝날 때까지 메모리를 점유하고 있다고 생각하면 된다. 아무리 많은 방을 가진 호텔이라도 방에있는 사람들이 그냥 나가버리면? 새로운 사람을 받을 수 없게된다.

 

이 방을 관리하는 일이 C++ 프로그래머가 할 일이다. 사용이 끝나면 당연히 체크아웃을 해야하는데 대용량의 데이터를 처리하는 프로그램에서는 그게 말 처럼 쉽지가 않다. 이 일을 자체적으로 하는 것을 가상머신이라고 한다. 대표적인 가상머신에는 JVM (JAVA VIRTUAL MACHINE) 이 있다. 자바의 프로그래머는 기본적으로 메모리 관리를 신경쓰지 않아도 된다는 모토를 가진다. 자바의 JVM은 마치 특별한 호텔 관리인 처럼 사용이 끝난 방에 대하여 체크아웃을 할 수 있다.

 

그 일을 직접 하는 사람을 GC(Garbage Collector - 쓰레기 수거자)라고 하는데 자바는 메모리가 호텔이라고 생각하지는 않았는가 보다. 어쨋든 사람들이 사용하고 남은 메모리를 수거하러 다닌다. 예전에는 이런 과정이 불필요하고 성능을 저하시킨다는 인식이 있었는데 지금의 JVM은 진화해서 가상머신이 느려서 프로그램을 못돌리겠다는 이야기는 나오지 않는다. 애초에 성능관리가 중요한 애플리케이션은 C++을 선택하고 있다.

 

C++에서 사용하는 힙과 자바에서 사용하는 힙은 결국 컴퓨터에 설치된 동일한 메모리를 사용하는데, 프로그래머가 직접 관리하느냐 JVM이 관리하느냐의 차이가 있다. 이는 자바에서 객체를 만들 때 new 키워드를 열고 사용하면 되지만 C++에서는 new로  객체를 생성했다면 delete 키워드로 메모리를 해제해야 한다는 뜻이다. 그렇지 않으면 프로그램이 종료될 때 까지 메모리를 계속 차지하고 있을 것이다. 실제 하는일은 없고.

 

 힙의 사용 new

위의 맥락에서 C++의 힙메모리를 사용하는 장점은 프로그래머가 메모리를 컨트롤 할 수 있다는 것이다. 예를 들어 객체를 생성하였다면 그 객체는 프로그래머가 명시적으로 메모리에서 해제하기 전까지 남아있다. 함수가 실행하고 리턴하면 사라지는 스택 메모리와 달리 클래스의 멤버는 그 값을 힙 메모리에 유지하고 있다. 전역변수 처럼 런타임이 끝날 때 까지 상주하지도 않으니 메모리가 부족하지 않게 사용이 끝난 객체는 메모리에서 해제시키면 추가의 공간확보를 할 수 있다.

 

다음의 예제는 정수형 포인터를 선언하고 new 키워드로 4바이트의 메모리 공간을 할당하여 사용한다.

 

사용이 끝난 포인터의 메모리는 delete 키워드로 반환하는데 더이상 반환된 메모리에 접근이 불가능하다. 정확히는 메모리를 자유공간으로 해제시키고 포인터에서 그 메모리의 주소를 지워버린다. 포인터 변수에 어떤 값이 남아있을 수는 있으나 쓰레기값이다. 쓰레기 값에 접근하는 행위는 런타임 오류를 일으킬 수 있다. 따라서 반환이 끝난 메모리의 포인터는 다시 new 키워드로 다른 메모리 주소르 할당하거나 NULL 로 초기화 하든지 할 수 있다.

 

포인터 변수를 재사용하는 경우 int 형은 int만 할당할 수 있다. 코드의 가독성을 위해 새로운 변수를 사용할지 선택이 필요하다. 아무래도 같은 포인터에 메모리를 여러번 할당하면 디버그 시에 메모리 추적이 복잡해질 수 있다. 다른 용도로 바뀌는 경우 새로운 포인터 변수를 사용하는게 나을 수 있다. C++에서는 이런 부분들을 생각하기 때문에 자바에 비해 할일이 많다.

 

또한 return 0 으로 종료하기 전에 delete 가 나오면 프로그램에서는 의미가 없을 수 있는데 프로그램이 종료하면 프로그램이 사용한 모든 메모리(스택, 힙)를 가릴 것 없이 운영체제에게 돌려주기 때문이다. 그렇다 하더라도 명시적으로 delete 를 사용하는 것은 바람직한 습관이다. 프로그램이 나중에 확장되는 경우에도 안전하다.

 

#include <iostream>

using namespace std;


int main()
{
	int* myPointer;
	myPointer = new int;
	*myPointer = 10;

	cout << "\n myPointer with new keyword" << endl;
	cout << " address : 0x" << myPointer << endl;
	cout << " value   : " << *myPointer << endl;


	cout << "\n After Pointer deleted" << endl;
	delete myPointer;
	
	
	// 초기화하지 않은 메모리에 접근하는 자체가 잘못된 일이다(쓰레기값)
	cout << " address : 0x" << myPointer << endl;

	myPointer = NULL;
	// NULL 값으로 초기화를 해주면 오류 가능성은 줄일 수 있다.
	cout << " address : 0x" << myPointer << endl;

	// * 연산자로 메모리에 접근할 수 없어서 오류가 난다.
	// cout << " value   : " << *myPointer << endl;

	return 0;
}

 

* 아래 코드는 int 형 변수를 5개 할당한다. 배열로 4바이트를 5개 연속으로 할당하고 포인터 연산을 통해서 안의 값을 사용할 수 있다. 주소를 보면 16진수가 0, 4, 8 , C(12), 0(16) 로 증가하는 것을 볼 수 있다.

 

디버거에서 정확히 붙어있는 모습을 볼 수 있다. 리틀엔디안이라 바이트를 반대로 보면 된다. 0, 2, 4, 6, 8 이다.

 

#include <iostream>

using namespace std;

#define ARR_SIZE 5

int main()
{
	int* myPointer;
	myPointer = new int[ARR_SIZE];

	for (int i = 0; i < ARR_SIZE; i++)
	{
		*(myPointer + i) = i * 2;
	}

	for (int i = 0; i < ARR_SIZE; i++)
	{
		cout << "\n myPointer [" <<i << "]"  << endl;
		cout << " address : 0x" << (myPointer+i) << endl;
		cout << " value   : " << *(myPointer+i) << endl;
	}
    
    delete myPointer;
    
	return 0;
}

 

메모리 유출 Memory Leak

포인터를 재할당하기 전에 delete 키워드를 잊어버리면 메모리 유출(Memory Leak)이 발생한다. 다음의 코드는 메모리 유출이다. delete를 하지 않고 포인터에 메모리를 할당했다.

 

메모리 주소를 보면 00809D60 은 프로그램에서 다시 접근할 수가 없고 메모리는 프로그램이 종료할 때 까지 그대로 사용하고 있다. 4바이트가 아니라 4메가라면? 40메가라면? 큰 손실이 발생할 수 밖에 없다.

 

메모리를 사용하면 반드시 해제해야 한다는 것에 주의한다.

 

int main()
{
	int* myPointer;
	myPointer = new int;

	*myPointer = 77;
	
	cout << " address : 0x" << myPointer << endl;
	cout << " value   : " << *myPointer << endl;

	myPointer = new int;

	*myPointer = 999;

	cout << " address : 0x" << myPointer << endl;
	cout << " value   : " << *myPointer << endl;

	return 0;
}

 

메모리를 해제한 경우의 주소를 비교해보자. 그대로 돌아와있다. 즉 힙영역에서 int 메모리를 해제하고 다시 할당한 것을 알 수 있다. 번지수가 올라가지 않기 때문에 메모리 사용량은 그대로다.

 

 delete로 해제한 경우

 

감상과 요약

포인터를 이해하면 컴퓨팅에 자체에 대한 깊이있는 해석을 할 수 있다.

 

물론 요즘의 추세를 보면 애플리케이션은 포인터를 몰라도 개발할 수 있다. 실제로 많은 분들이 포인터를 사용하지 않는 다양한 언어로 멋진 앱을 만들어 낸다. 메모리에 대한 개념은 어느정도는 있으니까 그게 현대 언어에서 큰 문제가 되지 않을 것이다.

 

하지만 포인터를 모른체 해도 운영체제와 응용프로그램이 포인터를 사용안하는 것은 아니니까... 어떤 언어를 사용하더라도 포인터를 잘 안다면 좀 더 나은 방법을 채택할 수 있을 것이다. 이게 마치 학창시절에 수학을 몰라도 세상을 살아가는데 지장이 없지만 수학적 사고 자체는 인생에 도움이 되는 그런 말과도 비슷하게 들리는데... 개인적으로는 포인터 개념은 배워야 한다고 생각한다. C++ 을 사용하는 사람들은 말할 것도 없겠고 다른 언어의 사용자들도 장점이 있다.

 

다만 그런 컨텐츠가 부족하다. 포인터와 다른 언어들간의 연관성 이런 것들을 좀 알기 쉽게 다루는 컨텐츠가 있다면 배우는 사람 입장에서 도움이 될 것이다. 유튜브가 활성화되면서 앞으로는 그런 컨텐츠가 많이 나올거라 기대하고 있다.

 

*요약

 

-> C++ 의 모든 변수는 주소를 갖고 있다. & 주소연산자로 확인이 가능하다. 이 주소는 포인터 변수에 저장할 수 있다. 

 

-> 포인터는 타입으로 선언할 수 있다. (객체, 기본자료형을 가리킨다.) 초기화는 NULL을 직접해야한다. 포인터는 시스템에 따라 32비트 주소나 64비트 주소를 저장할 수 있고 간접연산자 * 로 해당 주소의 값에 접근할 수 있다.

 

-> 힙 메모리에 새로운 객체를 만들기 위해서 new 키워드를 사용한다. 사용이 끝나면 반드시 delete 를 사용해서 메모리를 힙 메모리의 자유공간으로 되돌려줘야한다. 만일 delete를 하지 않고 포인터를 재사용하면 기존 메모리에는 다시 접근할 수 없는 메모리 유출(Memory Leak) 현상이 발생한다. 이는 런타임에서 오류로 잡지 않는데 응용프로그램의 정상적인 이용이라고 판단하기 때문이다.

 

-> delete 가 포인터를 지우는게 아니라 포인터가 가리키는 할당된 힙 메모리를 해제하고 포인터와 그 주소를 분리시키는 것이다. 포인터는 delete 후에 초기화를 하든지 처리해야 한다.

 

-> 포인터는 자신의 할당된 영역을 넘어서 연산을 할 수 있다. 4바이트를 할당하고 *(pointer + 1) 처럼 8바이트 영역에 접근하는 것도 가능하다. 이것은 포인터 사용에 실수를 하면 프로그램에 치명적 결과를 가져올 수 있다는 말이다. 할당 된 영역에만 접근하도록 주의한다.

공유하기

facebook twitter kakaoTalk kakaostory naver band