본문 바로가기
c++/기초

c++ 기초 - 동적 할당

by 농사짓는 도동 2024. 2. 5.

메모리 구조

1) 코드 영역: 실행할 코드가 저장되는 영역
2) 데이터 영역: 전역, 정적 변수가 저장되는 영역, 프로그램 실행 도중 계속 사용되는 메모리
3) 스택 영역: 지역 변수, 매개 변수가 저장되는 영역 (함수와 관련), 함수가 끝나면 메모리에서 해제
4) 힙 영역: 동적 할당

힙 영역의 필요성

기존에 데이터 영역과 스택 영역을 활용해 프로그램을 작성하는데 큰 문제가 없었다. 그렇다면 왜 힙 영역이라는 새로운 영역이 필요할까?

 

예를 들어 MMORPG에서 플레이어나 몬스터 생성에 대해 생각해보자. 플레이어는 1명부터 많게는 몇만명에 이른다. 몬스터 또한 다양한 숫자로 존재할 수 있다. 

 

기존에 사용하던 스택 메모리로 처리하게 되면 어떻게 될까? 스택은 애초부터 많은 공간을 사용하는 것을 의도하고 만들어진 것이 아니며 유한한 공간을 가지고 있다. 스택 메모리에서 500만 개의 객체를 생성해보면 stack overflow 에러가 일어나게 된다.

  • Stack Overflow
    Stack 영역의 메모리가 지정된 범위를 넘어갈 때 발생한다.
    한 함수에서 너무 큰 지역 변수를 잡거나 함수를 재귀적으로 호출하면서 주로 발생하게 된다.
class Temp
{
public:
    int _a;
    int _b;
    int _c;
}

int main()
{
    Temp temp[500 * 10000]; // stack overflow 에러 발생!
}

 

전역 메모리의 경우는 어떨까? 스택 메모리처럼 따로 에러가 나지는 않지만 메모리 상 항상 큰 영역에 맞춰서 공간을 할당하게 된다. 그래서 메모리 낭비가 심하다는 문제가 있다. ( 현재 레벨 10인 유저 한 명이 접속한 상태에서 레벨 200짜리 몬스터들을 몇 천마리씩 만들어두는건 너무 낭비다.)

Temp temp[500*10000]; // 메모리 낭비!

int main()
{

}

 

이에 유동성 있게 필요한 만큼 쓰고, 다 쓰면 메모리를 다시 반환해주는 메모리 시스템이 필요하다는 것을 알 수 있다. 또한, 스택과 다르게 생성과 소멸 시점을 관리할 수 있는 메모리가 필요하다.

 

그렇게 힙 메모리가 등장하게 되었다!

동적 할당을 위한 메모리 할당 받기

일반적으로 메모리는 커널 영역과 유저 영역으로 분리된다. 유저 영역은 우리가 흔히 아는 실행되는 프로그램들 (ex. 메모장, 계산기 등)이며 커널 영역의 경우 운영체제의 핵심 코드들로 구성되어 있다.

 

유저 영역쪽에서 메모리 막 할당하다 보면 다른 프로그램을 침범할 수도 있다. 현대 운영체제에서는 유저 영역에서 실행하는 프로그램을 완전히 독립적으로 실행한다.

 

프로그램이 동적 할당을 해서 메모리 추가적으로 사용해야 할 때 메모리는 각 프로그램이 관리하는 것이 아니라 커널 영역에 요청해서 메모리를 받아온다.

 

이때 메모리는 딱 필요한 만큼만 받아오면 매번 메모리가 필요할 때 마다 커널에 요청하게 될 것이다. 커널은 여러 프로그램을 관리해줘야 하는데 모든 프로그램이 이런 식으로 메모리를 가져가게 되면 요청이 너무 빈번히 일어날 것이다. 따라서 일반적으로 프로그램이 메모리를 요청할 때는 큰 메모리 영역을 받아오고 각 프로그램에서 해당 메모리를 적절히 잘 분할해서 사용하게 된다. 

 

그래서 힙 메모리 영역은 스택 영역처럼 이미 정해진 메모리 공간인 것이 아니라 원하면 할당 받고 적당히 잘 분할해서 사용하는 개념이다. c++에서는 기본적으로 CRT(c 런타임 라이브러리)의 힙 관리자를 통해 힙 영역을 사용한다. 

 

동적 할당 연산자

이제 힙 메모리를 프로그램 내에서 어떻게 할당 받고 해제하는지 알아보자. 일단 c++에서는 new와 delete 조합을 주로 사용하게 되는데 그 전에 c언어에서 사용하던 malloc과 free를 먼저 살펴보자.

malloc & free

malloc은 c에서 등장하는 메모리 관리 개념이다! 필요할때마다 메모리를 할당에 사용되는 연산자이며, 할당할 메모리 크기를 인자로 넘겨주어 사용한다.

 

malloc은 메모리를 할당한 후 시작 주소를 가리키는 포인터를 반환해준다. 만약 메모리가 부족하다면 null pointer가 반환된다.

  • void 형 포인터: 포인터를 타고 갔을 때 저장된 값의 자료형을 지정하지 않고, 프로그래머가 직접 변환해서 사용하라는 의미로 void를 사용한다. 
void* pointer = malloc(1000);

Animal* animal1 = (Animal*)pointer;
animal1->intimacy = 0;

 

주로 클래스 자체의 크기만큼만 메모리를 할당 받으면 되기 때문에 class의 크기를 넘겨준다.

void* pointer = malloc(sizeof(Animal));

 

free는 메모리가 필요 없을 때 해제해주는 연산자이다. 

malloc(혹은 기타 calloc, realloc 등)을 통해 할당된 영역을 해제하며, 힙 관리자가 할당과 미할당 여부를 구분해서 관리한다.

// 더이상 이 메모리 영역 안써!
free(pointer);

 

Heap Overflow

유효한 힙 범위를 초과해서 실행할 때 일어나는 문제이다. 아래의 코드를 실행해보면 Heap Corruption 에러가 난다.

void* pointer malloc(2); // 2 byte만 메모리 받아옴!

Animal* animal1 = (Animal*)pointer; // 4 byte 영역 필요
animal1->intimacy = 0;

 

동적 할당 관련 문제

1. 메모리 누수 현상

메모리 할당 후 해제를 해줘야 해당 영역을 재사용 할 수 있게 된다. 하지만 할당 후 해제하지 않는다면 메모리 누수가 발생한다. 결과적으로 사용할 메모리 공간이 없게 되면 프로그램이 뻗어버린다.

 

2. double free 문제

메모리 해제를 여러번 하는 경우를 의미한다. 메모리 해제를 하는 순간 몇 byte를 사용한지 남기는 정보(header)도 모두 날린다. 이 상태에서 다시 메모리 해제를 시도하면 헤더쪽에 쓰레기 값이 들어 있어 실패하게 된다.

 

3. Use After Free 문제

메모리 해제 후 더 이상 접근해서는 안 되는데 pointer 주소는 계속 변수로 남아 있어 접근하게 되는 경우 발생하는 문제이다. 메모리를 해제하고 해당 공간에 다른 데이터를 넣었다면 의도와 다른 메모리를 건들이게 될 수 있으니 주의해야 한다.

void* pointer malloc(4);

Animal* animal1 = (Animal*)pointer;
animal1->intimacy = 0;

free(pointer); // 메모리 반환

// use after free 문제 발생!
animal1->intimacy = 10;

 

따라서 동적 할당을 사용할때는 주의 깊게 처리할 필요가 있다!
예를들어 use after free 문제의 경우 메모리를 해제한 후 포인터와 관련된것들은 null로 바꿔주는 식으로 사용한다.

free(pointer)
pointer = nullptr;
animal1 = nullptr;

 

new & delete

C++에서 추가된 개념이다. 앞의 malloc과 delete가 함수였다면, new와 delete는 연산자이다. malloc과 free와 비교해 가장 큰 차이점을 꼽자면 바로 "생성자"와 "소멸자" 호출 여부라고 보면 된다. 

 

new 연산자는 해당 메모리를 사용해 객체를 만든 다음 할당된 메모리의 주소가 포함된 포인터를 반환해준다. 나중에 할당된 메모리에 접근하기 위해 포인터 변수에 할당하여 사용한다.

 

내부적으로는 operator new 함수를 사용해 메모리를 할당하고 할당된 메모리에 대해 한 개 이상의 생성자를 호출하게 된다.

 

delete 연산자를 통해 메모리를 해제하게 된다.

 

내부적으로는 기존에 할당된 메모리에 대해 한 개 이상의 소멸자를 호출하고 operator delete라는 함수를 사용해 메모리가 해제된다.

자료형* 변수이름 = new 자료형;
Animal* animal1 = new Animal; // 타입 크기만큼 뱉어줌!
animal1->intimacy = 0;

delete animal1;

 

new[] & delete[]

 

배열 문법과 유사하게 메모리 공간에 이어서 할당해주며, 여러 객체를 동시에 만들 수 있다. 이때 new[]와 delete[]도 각각 생성자와 소멸자 호출 횟수랑 메모리 크기랑 관련 있다. 그래서 new는 delete와, new[]는 delete[]와 짝을 꼭 맞춰줘야한다.

Animal* animals = new Animal[5];
animals->intimacy = 0;

delete[] animals;

 

배열로 만들게 되면 메모리 공간 또한 정의한 개수만큼 잡히게 된다. (Animal = 4byte, 4byte * 5 => 20 byte 공간)

각 객체에는 이전에 배열에서 접근하는 방법과 동일하게 접근할 수 있다. 

Animal* animal1 = (animals + 1);
animal1->intimacy = 0;
728x90