C++

메모리 동적할당과 차이점

C++의 메모리 동적 할당

에 대해 설명하기 전에 일단 컴퓨터의 메모리 구조에 대해 간단히 알고 가자.

memory

컴퓨터의 메모리 영역은 위와 같이 4개로 나뉘어 있다.

  • Code : 실행한 프로그램의 코드를 저장함
  • Data : 전역변수, static 변수가 저장되며 프로그램 종료 시까지 해제되지 않음
  • Heap : 동적으로 할당된 메모리 영역으로 개발자가 직접 할당 및 해제함
  • Stack : 지역변수, 매개변수가 할당되며 해당 함수가 종료되면 자동 해제됨

우리는 지금까지 메모리를 정적 할당 해왔다. 메모리의 정적 할당은 코드에서 변수를 선언하면 자동으로 이루어지는데 프로그램을 실행하기 전 컴파일할 때에 정해진 크기만큼의 메모리를 stack 영역에 자동으로 할당해준다.

int a=10;

위와 같은 코드를 실행했다고 가정했을 때, 프로그램 실행 전 컴파일 과정에서 int a=10;이란 코드를 읽고 stack 영역에 int의 크기인 4byte만큼을 할당해주는 식이다.
이러한 정적 할당은 컴파일 시에 할당할 메모리의 크기가 결정되기 때문에 컴파일이 끝나면 크기가 변경될 수 없다. 즉 변수의 크기가 확정적으로 명시되어야 한다.

void func(int n) {
    int arr[n];
}

코딩테스트 등을 풀 때 한번쯤은 시도해봤을 위의 코드가 실패하는 이유이다. 매개변수 n에 무엇이 들어가느냐에 따라 arr배열의 크기가 달라지지만 arr은 메모리를 정적 할당하여 stack에 정해진 크기를 할당해야 하므로 오류가 나는 것이다.

반면 메모리를 동적 할당하면 stack 영역이 아닌 heap 영역에 저장된다. 또한 컴파일 시에 메모리의 크기가 정해지는 것이 아닌 런타임 동안 할당되고 해제되므로 메모리를 훨씬 가변적으로 사용할 수 있다.

void func(int n) {
    int* arr=new int[n];
}

위와 같이 메모리 동적 할당을 이용하면 아까 실패했던 코드를 제대로 동작시킬 수 있다. int 포인터 arr는 정적 할당되었기에 stack영역에 저장되지만 배열의 내용물은 동적 할당되었기에 heap 영역에 저장된다.

이렇게 보면 메모리의 동적 할당은 완벽한 정적 할당의 상위 호환처럼 보인다. 하지만 위의 코드에는 매우 큰 문제가 있다. 바로 함수가 종료되도 동적 할당해준 4*n만큼의 메모리가 계속해서 남아있는 것이다. C++에는 다른 언어의 가비지 컬렉터(garbage collector)와 같이 자동으로 할당을 해제해주는 기능이 없고 정적 할당과 같이 함수 종료 시에 자동 해제해주지도 않기 때문이다. 그렇기에 우리는 할당한 만큼 꼭 사용이 종료된 후 코드를 입력하여 해제해주어야 한다.

void func(int n) {
    int* arr=new int[n];
    //do something...
    delete[] arr;
}

위와 같이 코드를 수정하면 아무 문제가 없다. 하지만 한 줄의 코드에서는 당연히 잊지 않고 해제를 해줄 수 있겠지만 코드가 굉장히 커졌을 때도 우리가 실수 없이 모든 동적 할당을 해제해 줄 수 있을까? 메모리 릭(memory leak)은 이러한 할당 해제가 적절히 이루어지지 않아 할당해준 메모리가 계속해서 남아있어 일어나는 문제이다. 이를 방지하기 위해 스마트 포인터(smart pointer)가 있는데 추후 포스팅 하도록 하겠다.

그런데 한 가지 의문이 든다. 그냥 STL을 사용하면 안되는걸까? vector를 사용하는 것은 좋지만 역시 동적 할당은 알아둬야 한다. vector가 이 동적 할당을 이용하여 가변 배열을 만들기 때문이다.

동적 할당과 vector

vector는 C++에서 매우 편리한 STL이다. 가변 배열로써 선언할 때에 데이터 크기를 꼭 지정해주지 않아도 되며 push_back 등의 함수를 이용하여 크기를 원하는대로 늘릴 수 있는데, 이러한 기능들이 위에서 배운 동적 할당을 통해 이루어진다. vector는 심지어 동적 할당과 달리 사용이 끝나면 자동으로 해제까지 해주는 장점이 있다.

분명 장점이 많은 매우 편리한 컨테이너지만 사용에 유의해야할 점이 있는데, 그것은 메모리 재할당 문제이다. 만약 아래와 같이 int형 vector를 선언했다고 가정해보자.

vector<int> v={1,2,3};
v.push_back(1);
v.push_back(1);
v.push_back(1);

위와 같이 선언했을 때 컴퓨터는 메모리의 외부 단편화(External Fragmentation)을 피하기 위해 가능한 v의 크기에 딱 맞는 메모리를 할당해 줄 것이다. 따라서 v의 size와 capacity는 모두 3일 것이다. 그런데 여기서 push_back 함수를 사용하여 배열에 데이터를 추가한다면 어떤 일이 벌어질까? 기존에 할당되었던 메모리로는 크기가 부족하고 vector는 배열과 같이 메모리의 연속성을 보장하기 때문에 항상 연속된 메모리를 차지해야 한다. 그러므로 우리는 더 큰 크기의 빈 메모리(정확히는 현재 capacity의 1.5배 크기의)를 찾아서 데이터를 새 메모리로 모두 이동시켜야 한다. 이를 ‘메모리 재할당’이라고 하는데 추가적인 시간과 자원을 요구한다.

이러한 메모리 재할당 문제를 예방하기 위해서는 처음부터 적절한 크기의 vector를 선언하거나 reserve 함수를 사용하여 적절한 크기의 capacity를 할당해주는 등의 해결방법이 있으니 유의하며 사용하면 되겠다.

동적할당, new? malloc?

메모리 동적 할당을 위해서 C++에서는 2가지 방법을 사용할 수 있는데, C에서 계승된 malloc과 C++에서 새로 추가된 new이다. 두 방법에는 세세한 차이점들이 있는데 실제 코드를 보면서 알아보도록 하겠다.

int* a=static_cast<int*>(malloc(sizeof(int)));
*a=int(10);

int* b=new int(10);

free(a);
delete b;

일단 가장 큰 차이를 살펴보자면 malloc은 함수이고 new는 연산자라는 것이다.
malloc은 할당할 메모리의 크기를 매개변수로 받고 void형 포인터를 반환하는 함수이다. 따라서 heap 영역에 메모리를 할당할 뿐이고 생성자를 호출하거나 하지 않기 때문에 객체의 초기화가 불가능하다. 또한 void형 포인터를 반환하기 때문에 원하는 형으로 형 변환이 필요함을 위의 코드에서 확인할 수 있다.

new는 메모리를 할당하는 C++의 연산자이다. 생성자를 자동으로 호출하기 때문에 객체를 초기화할 수 있다는 것이 차이점 중 하나이다. new로 메모리를 동적 할당하면 malloc과 달리 자동으로 자료형과 개수를 읽고 원하는 자료형의 포인터를 반환한다.

malloc은 free, new는 delete로 할당을 해제한다. free는 함수의 일종으로 매개변수로 받은 포인터의 메모리 할당을 해제한다. delete는 연산자의 일종으로 메모리의 할당을 해제하면서 소멸자를 자동 호출하여 객체를 제거한다.

이외에 메모리가 부족하여 할당에 실패할 시에 malloc은 nullptr를 return 하고 new는 exception이 일어나 프로그램이 종료된다는 차이점도 있다.

이렇게 보면 new가 훨씬 좋아보이지만 malloc은 realloc 함수를 이용하여 재할당이 용이하다는 장점이 있다. new로 재할당하기 위해서는 할당을 해제한 뒤 재할당하는 과정을 거쳐야한다. 하지만 대부분 그냥 new를 쓰는 것이 맞는 것 같다.