프로그램에서 사용하는 모든 데이터가 같은 방식으로 생성되고 사라지는 것은 아닙니다.
함수 안에 선언한 지역 변수는 일반적으로 함수 호출과 함께 생성되고 함수가 끝나면 자동으로 정리됩니다.
void Function()
{
int number = 10;
}
반면 new로 동적 할당한 객체는 함수를 벗어나도 자동으로 해제되지 않습니다.
int* number = new int(10); delete number; number = nullptr;
이러한 차이를 이해하려면 프로그램이 사용하는 스택(Stack)과 힙(Heap)의 역할을 알아야 합니다.
스택은 함수 호출과 함께 자동으로 관리되는 메모리이고, 힙은 프로그램 실행 중 필요한 크기와 수명에 맞춰 동적으로 할당하는 메모리입니다.
이번 글에서는 프로그램의 일반적인 메모리 구조부터 스택 프레임, 스택 오버플로우, 힙 메모리의 할당과 해제, 메모리 누수와 단편화, 현대 C++의 RAII까지 알아보겠습니다.
목차
프로그램이 사용하는 메모리 영역
실행 중인 프로그램의 메모리는 용도에 따라 여러 영역으로 나누어 생각할 수 있습니다.
| 영역 | 주요 용도 |
|---|---|
| 코드 영역 | 컴파일된 프로그램의 명령어 |
| 데이터 영역 | 전역 변수와 정적 변수 |
| 힙 영역 | 실행 중 동적으로 할당한 객체와 배열 |
| 스택 영역 | 함수 호출 정보와 지역 변수 등 |
코드 영역
코드 영역에는 컴파일된 프로그램의 기계어 명령어가 저장됩니다.
int Add(int first, int second)
{
return first + second;
}
위 함수의 실행 명령은 컴파일과 링크를 거쳐 실행 파일에 포함되며, 프로그램이 실행될 때 메모리에 배치됩니다.
데이터 영역
전역 변수와 정적 변수처럼 프로그램 또는 정적 저장 기간을 가진 객체는 일반적으로 데이터와 관련된 영역에 배치됩니다.
int globalCount = 10;
void Function()
{
static int callCount = 0;
++callCount;
}
이러한 객체는 일반적인 지역 변수와 달리 함수가 종료되어도 사라지지 않습니다.
스택 영역
스택은 함수 호출에 필요한 정보와 자동 저장 기간을 가진 지역 객체 등을 관리하는 데 사용됩니다.
힙 영역
힙은 프로그램 실행 중에 필요한 크기와 수명에 맞춰 메모리를 동적으로 할당하는 데 사용됩니다.
이러한 구분은 프로그램의 메모리 구조를 이해하기 위한 일반적인 실행 모델입니다. 변수의 실제 배치와 레지스터 사용 여부, 영역의 세부 구조는 운영체제, 컴파일러, ABI 및 최적화 설정에 따라 달라질 수 있습니다.
스택 영역이란?
스택은 함수 호출과 관련된 데이터를 관리하는 메모리 영역입니다.
함수가 호출되면 해당 함수를 실행하는 데 필요한 정보가 스택에 추가되고, 함수가 종료되면 관련 정보가 제거됩니다.
스택은 LIFO(Last In, First Out) 방식으로 동작합니다.
마지막에 들어온 데이터가 가장 먼저 나가는 구조입니다. 가장 나중에 호출된 함수가 먼저 종료되는 함수 호출의 구조와 잘 맞습니다.
void FunctionB()
{
}
void FunctionA()
{
FunctionB();
}
int main()
{
FunctionA();
}
함수는 다음 순서로 호출됩니다.
main → FunctionA → FunctionB
종료 순서는 호출 순서의 반대입니다.
FunctionB → FunctionA → main
스택의 주요 특징
- 함수 호출과 반환에 맞춰 자동으로 관리됩니다.
- 일반적으로 메모리 확보와 반환 비용이 작습니다.
- 함수 호출 순서에 따라 LIFO 방식으로 사용됩니다.
- 사용 가능한 크기가 제한되어 있습니다.
- 지역 객체는 범위를 벗어날 때 자동으로 소멸합니다.
스택 프레임과 함수 호출
함수가 호출될 때 해당 함수의 실행에 필요한 정보를 보관하기 위해 만들어지는 영역을 스택 프레임(Stack Frame)이라고 합니다.
스택 프레임에는 일반적으로 다음과 같은 정보가 포함될 수 있습니다.
- 함수의 매개변수 또는 매개변수 처리에 필요한 정보
- 지역 변수
- 함수 실행 후 돌아갈 위치를 나타내는 반환 주소
- 호출 전에 보존해야 하는 레지스터 값
- 정렬과 호출 규약에 필요한 기타 정보
다음 예제에서 함수 호출에 따른 스택 프레임의 변화를 살펴보겠습니다.
#include <iostream>
void FunctionB()
{
int numberB = 20;
std::cout << numberB << '\n';
}
void FunctionA()
{
int numberA = 10;
FunctionB();
std::cout << numberA << '\n';
}
int main()
{
FunctionA();
}
프로그램이 실행되면 먼저 main 함수가 실행됩니다.
main에서 FunctionA를 호출하면 FunctionA의 스택 프레임이 추가됩니다.
FunctionA에서 다시 FunctionB를 호출하면 FunctionB의 스택 프레임이 그 위에 추가됩니다.
FunctionB 스택 프레임 FunctionA 스택 프레임 main 스택 프레임
FunctionB가 종료되면 가장 위에 있던 FunctionB의 스택 프레임이 먼저 제거됩니다.
이후 FunctionA가 종료되고 마지막으로 main이 종료됩니다.
모든 매개변수와 지역 변수가 반드시 실제 스택 메모리에 저장되는 것은 아닙니다. 컴파일러는 최적화와 호출 규약에 따라 일부 값을 레지스터에 저장하거나 제거할 수 있습니다. 스택 프레임은 함수 호출 구조를 이해하기 위한 일반적인 모델입니다.
디버거에서 호출 스택 확인하기
Visual Studio와 같은 개발 환경에서는 디버거의 호출 스택(Call Stack) 창을 통해 현재 함수가 어떤 순서로 호출되었는지 확인할 수 있습니다.
앞의 예제에서 FunctionB에 중단점을 설정하면 호출 스택에서 다음과 같은 흐름을 확인할 수 있습니다.
FunctionB FunctionA main
가장 위에는 현재 실행 중인 함수가 표시되고, 아래에는 이 함수를 호출한 함수들이 순서대로 표시됩니다.
호출 스택은 다음과 같은 상황을 분석할 때 유용합니다.
- 현재 함수가 어떤 경로를 통해 호출되었는지 확인할 때
- 예상하지 못한 함수 호출의 원인을 찾을 때
- 재귀 호출이 얼마나 깊어졌는지 확인할 때
- 충돌이 발생한 함수까지의 호출 경로를 분석할 때
스택 객체의 수명
함수 안에 선언한 일반적인 지역 객체는 선언된 블록을 벗어날 때 자동으로 소멸합니다.
void Function()
{
int number = 10;
}
Function이 종료되면 number의 수명도 끝납니다. 개발자가 delete를 호출할 필요가 없습니다.
클래스 타입의 지역 객체라면 범위를 벗어날 때 소멸자가 자동으로 호출됩니다.
#include <iostream>
class Character
{
public:
Character()
{
std::cout << "생성\n";
}
~Character()
{
std::cout << "소멸\n";
}
};
void Function()
{
Character character;
}
int main()
{
Function();
}
실행 결과
생성 소멸
Function이 종료될 때 지역 객체 character의 소멸자가 자동으로 실행됩니다.
이러한 특성 때문에 함수의 지역 객체와 관련된 메모리를 자동 메모리라고 표현하기도 합니다.
스택 오버플로우
스택은 사용할 수 있는 크기가 제한되어 있습니다.
함수 호출이 지나치게 깊어지거나 매우 큰 데이터를 지역 변수로 만들면 사용할 수 있는 스택 범위를 초과할 수 있습니다. 이를 스택 오버플로우(Stack Overflow)라고 합니다.
종료되지 않는 재귀 호출
대표적인 원인은 종료 조건이 없거나 잘못 작성된 재귀 함수입니다.
void Recursive()
{
Recursive();
}
int main()
{
Recursive();
}
Recursive가 자신을 호출할 때마다 새로운 함수 호출 정보가 필요합니다.
호출이 끝나지 않고 계속 반복되면 스택 공간을 모두 사용하게 되고 결국 스택 오버플로우가 발생합니다.
매우 큰 지역 배열
매우 큰 배열을 지역 변수로 선언하는 경우에도 문제가 발생할 수 있습니다.
int main()
{
int largeArray[1000000000];
}
위 배열이 실제로 생성된다면 약 40억 바이트의 공간이 필요할 수 있습니다. 일반적인 스택 크기로는 감당하기 어렵습니다.
컴파일러와 최적화 상태에 따라 컴파일 단계에서 경고나 오류가 발생하거나, 실행 시 스택 오버플로우가 발생할 수 있습니다.
큰 데이터가 필요하다면 다음과 같은 방법을 고려할 수 있습니다.
std::vector와 같은 동적 컨테이너 사용- 동적 메모리 할당
- 데이터를 작은 단위로 나누어 처리
- 재귀 알고리즘을 반복문으로 변경
힙 영역이란?
힙은 프로그램 실행 중 필요한 메모리를 동적으로 할당하는 데 사용하는 영역입니다.
필요한 크기가 실행 중에 결정되거나, 객체가 생성된 함수보다 오래 유지되어야 할 때 힙 메모리를 사용할 수 있습니다.
int* number = new int(10);
new int(10)은 int 객체를 위한 메모리를 동적으로 할당하고, 그 객체를 10으로 초기화한 뒤 주소를 반환합니다.
힙에 할당된 객체는 객체를 생성한 함수가 종료되었다는 이유만으로 자동 해제되지 않습니다.
int* CreateNumber()
{
int* number = new int(10);
return number;
}
지역 포인터 변수 number의 수명은 함수 종료와 함께 끝나지만, 포인터가 가리키는 동적 객체는 해제되지 않습니다.
호출한 쪽에서 더 이상 필요하지 않을 때 해제해야 합니다.
int* number = CreateNumber(); std::cout << *number << '\n'; delete number; number = nullptr;
new와 delete
C++에서는 new와 new[]를 사용해 메모리를 동적으로 할당할 수 있습니다.
객체 하나 할당하기
int* number = new int(10);
new로 할당한 단일 객체는 delete로 해제합니다.
delete number; number = nullptr;
배열 할당하기
int* numbers = new int[10] {};
new[]로 할당한 배열은 delete[]로 해제합니다.
delete[] numbers; numbers = nullptr;
| 할당 | 해제 |
|---|---|
new Type |
delete |
new Type[count] |
delete[] |
할당과 해제 방식을 일치시키지 않으면 정의되지 않은 동작이 발생합니다.
#include <iostream>
int main()
{
int* number = new int(10);
int* array = new int[10] {};
std::cout << *number << '\n';
delete number;
delete[] array;
number = nullptr;
array = nullptr;
}
일반적인
new표현식이 메모리 할당에 실패하면 기본적으로std::bad_alloc예외가 발생합니다. 자동으로nullptr를 반환하는 것은 아닙니다.std::nothrow를 사용하는 별도의 형태에서는 실패 시nullptr를 반환할 수 있습니다.
포인터 변수와 힙 객체의 차이
다음 코드에는 포인터 변수와 동적으로 할당한 객체가 함께 존재합니다.
int* number = new int(10);
지역 포인터 변수 number와 포인터가 가리키는 int 객체는 서로 다른 대상입니다.
| 대상 | 일반적인 위치 | 수명 |
|---|---|---|
포인터 변수 number |
함수의 스택 프레임 | 선언된 범위를 벗어날 때까지 |
new int(10)으로 생성한 객체 |
힙 | delete로 해제할 때까지 |
포인터 변수의 수명이 끝나도 동적으로 할당한 객체가 자동으로 해제되지는 않습니다.
void CreateNumber()
{
int* number = new int(10);
}
함수가 종료되면 지역 포인터 number는 사라집니다. 그러나 new int(10)으로 할당한 메모리는 해제되지 않습니다.
이제 해당 주소를 보관한 포인터도 없으므로 메모리에 다시 접근하거나 정상적으로 해제할 방법을 잃게 됩니다.
포인터의 수명과 포인터가 가리키는 객체의 수명은 서로 다를 수 있습니다. 동적 메모리를 사용할 때는 주소를 저장하는 포인터뿐 아니라 객체를 누가 언제 해제할지도 함께 관리해야 합니다.
힙 메모리는 어떻게 할당될까?
힙에 메모리를 요청하면 메모리 할당기는 요청한 크기를 저장할 수 있는 사용 가능한 공간을 찾아 할당합니다.
int* numberA = new int; int* numberB = new int; int* array = new int[10];
array가 가리키는 하나의 동적 배열 안에서는 10개의 int 원소가 연속해서 배치됩니다.
하지만 별도로 할당한 numberA, numberB, array의 세 메모리 블록이 서로 나란히 배치된다는 보장은 없습니다.
- 하나의 동적 배열을 구성하는 원소들은 연속해서 배치됩니다.
- 각각 독립적으로 할당한 블록의 위치는 서로 떨어져 있을 수 있습니다.
- 메모리 할당기는 크기와 정렬 조건에 맞는 공간을 선택합니다.
- 요청한 크기 이외에 메모리 관리를 위한 부가 정보가 사용될 수도 있습니다.
메모리 단편화
크기가 다른 메모리 블록을 반복해서 할당하고 해제하면 사용 가능한 공간이 여러 조각으로 나뉠 수 있습니다. 이를 메모리 단편화(Memory Fragmentation)라고 합니다.
전체 여유 공간은 충분하더라도 필요한 크기의 연속된 공간을 확보하기 어렵거나, 메모리 활용 효율이 낮아질 수 있습니다.
단편화의 실제 영향과 관리 방식은 사용하는 메모리 할당기와 할당 패턴에 따라 달라집니다.
힙 사용 비용이 큰 이유
스택의 메모리 확보와 반환은 일반적으로 스택 포인터의 위치를 조정하는 비교적 단순한 작업으로 처리할 수 있습니다.
반면 동적 할당에서는 요청한 크기와 정렬 조건에 맞는 공간을 찾고, 할당 상태를 기록하고, 이후 해제를 처리해야 합니다.
따라서 일반적으로 동적 할당과 해제는 스택을 이용한 지역 객체 생성보다 비용이 큽니다.
힙을 사용하는 코드의 성능에는 다음 요소가 영향을 줄 수 있습니다.
- 동적 할당과 해제에 필요한 메모리 관리 비용
- 여러 위치에 흩어진 객체에 접근하면서 발생하는 낮은 캐시 지역성
- 메모리 단편화
- 포인터를 통한 간접 접근
- 멀티스레드 환경에서 할당기를 공유하며 발생할 수 있는 동기화 비용
int* number = new int(10); std::cout << *number << '\n'; *number = 20; delete number; number = nullptr;
*number를 사용하면 포인터에 저장된 주소를 따라가 실제 객체에 접근합니다.
하지만 힙이 일반적으로 더 큰 비용을 가진다는 사실을 포인터 역참조 한 번으로만 설명해서는 안 됩니다. 할당과 해제, 메모리 배치 및 접근 패턴이 함께 성능에 영향을 줍니다.
힙을 사용하면 항상 프로그램이 느려진다는 의미는 아닙니다. 실제 성능은 할당 횟수, 객체 크기, 접근 패턴, 메모리 할당기와 하드웨어 캐시 등에 따라 달라집니다.
메모리 누수
동적으로 할당한 메모리를 더 이상 사용하지 않으면서도 해제하지 못한 상태를 메모리 누수(Memory Leak)라고 합니다.
대표적인 원인은 할당된 메모리의 주소를 잃어버리는 것입니다.
int* number = new int(10); number = new int(20); delete number; number = nullptr;
처음 실행한 new int(10)은 첫 번째 동적 객체의 주소를 반환합니다.
그러나 같은 포인터에 new int(20)의 결과를 바로 대입하면서 첫 번째 주소를 잃어버렸습니다.
마지막 delete number는 두 번째로 할당한 객체만 해제합니다. 첫 번째 객체는 해제되지 않은 채 남습니다.
올바르게 해제한 뒤 다시 할당하기
int* number = new int(10); delete number; number = new int(20); delete number; number = nullptr;
프로그램이 종료되면 운영체제가 해당 프로세스에 할당한 메모리를 회수하는 것이 일반적입니다.
그렇다고 실행 중의 메모리 누수를 무시해도 되는 것은 아닙니다. 프로그램이 장시간 실행되거나 누수가 반복되면 다음 문제가 발생할 수 있습니다.
- 메모리 사용량이 계속 증가합니다.
- 시스템의 가용 메모리가 줄어듭니다.
- 성능이 저하될 수 있습니다.
- 새로운 메모리 할당에 실패할 수 있습니다.
- 프로그램 또는 시스템이 불안정해질 수 있습니다.
댕글링 포인터와 이중 해제
메모리를 해제한 뒤에도 포인터에는 이전 주소가 남아 있을 수 있습니다.
int* number = new int(10); delete number;
이제 number가 가리키던 객체의 수명은 끝났지만 포인터 변수에는 해제된 주소가 남아 있습니다.
이처럼 유효하지 않은 객체를 가리키는 포인터를 댕글링 포인터(Dangling Pointer)라고 합니다.
// std::cout << *number; // 정의되지 않은 동작
해제 후 포인터에 nullptr를 저장하면 같은 포인터를 실수로 다시 사용하는 일을 줄일 수 있습니다.
delete number; number = nullptr;
이미 해제한 메모리를 다시 해제하는 것도 잘못된 동작입니다.
int* number = new int(10); delete number; // delete number; // 이중 해제
여러 포인터가 같은 객체를 가리키면 하나의 포인터에 nullptr를 저장하는 것만으로 다른 포인터까지 안전해지지는 않습니다.
int* first = new int(10); int* second = first; delete first; first = nullptr; // second는 해제된 객체의 주소를 계속 가지고 있다.
동적 메모리를 안전하게 관리하려면 해당 객체를 누가 소유하고 해제할지 명확하게 정해야 합니다.
스택과 힙 비교하기
| 구분 | 스택 | 힙 |
|---|---|---|
| 주요 용도 | 함수 호출 정보와 지역 객체 | 실행 중 동적으로 생성하는 객체 |
| 메모리 관리 | 범위를 벗어나면 자동으로 정리 | 소유자가 적절한 시점에 해제 |
| 할당 비용 | 일반적으로 작음 | 일반적으로 더 큼 |
| 사용 가능한 크기 | 비교적 제한적 | 일반적으로 더 큰 공간 사용 가능 |
| 객체의 수명 | 선언된 범위와 함수 호출에 영향받음 | 해제하거나 소유 객체가 정리할 때까지 유지 |
| 대표적인 문제 | 스택 오버플로우 | 메모리 누수, 단편화, 댕글링 포인터 |
| 주소의 연속성 | 호출 구조에 따라 스택 프레임 사용 | 서로 다른 할당 블록의 연속성을 보장하지 않음 |
클래스 객체라고 해서 반드시 힙에 생성되는 것은 아닙니다.
Character firstCharacter; Character* secondCharacter = new Character;
firstCharacter는 지역 객체로 선언되었으므로 범위를 벗어날 때 자동으로 소멸합니다.
secondCharacter가 가리키는 객체는 new로 동적 생성했으므로 명시적으로 해제하거나 스마트 포인터로 관리해야 합니다.
delete secondCharacter; secondCharacter = nullptr;
배열 역시 반드시 힙에 생성되는 것은 아닙니다.
int firstArray[10] = {};
int* secondArray = new int[10] {};
메모리 영역은 데이터가 클래스인지 배열인지가 아니라 선언과 생성 방식, 저장 기간 등에 따라 달라집니다.
언제 힙을 사용할까?
힙은 스택보다 무조건 좋거나 나쁜 메모리 영역이 아닙니다. 데이터에 필요한 크기와 수명, 소유 관계에 따라 선택합니다.
실행 중에 필요한 크기가 결정되는 경우
int count = 0; std::cin >> count; std::vector<int> numbers(count);
사용자 입력이나 파일 데이터에 따라 필요한 원소 개수가 달라진다면 동적으로 크기를 결정할 수 있는 컨테이너가 적합합니다.
함수가 끝난 뒤에도 객체가 유지되어야 하는 경우
객체를 만든 함수가 종료된 뒤에도 해당 객체를 사용해야 한다면 동적 수명 관리가 필요할 수 있습니다.
매우 큰 데이터를 다루는 경우
스택 크기를 초과할 수 있는 큰 데이터라면 동적 메모리 또는 동적 컨테이너를 고려할 수 있습니다.
소유권을 이전하거나 공유해야 하는 경우
하나의 객체를 다른 시스템으로 전달하거나 여러 객체가 함께 사용해야 한다면 스마트 포인터 등을 통해 소유 관계를 표현할 수 있습니다.
다음과 같은 이유만으로 힙을 선택할 필요는 없습니다.
- 클래스 객체이기 때문
- 배열이기 때문
- 포인터 문법을 사용하고 싶기 때문
- 힙이 항상 더 많은 메모리를 제공한다고 생각하기 때문
먼저 지역 객체나 표준 컨테이너로 해결할 수 있는지 확인하고, 동적 수명과 소유권이 필요한 경우 힙 사용을 고려하는 것이 좋습니다.
현대 C++의 동적 메모리 관리
new와 delete의 동작을 이해하는 것은 C++의 메모리 구조와 객체 수명을 학습하는 데 중요합니다.
하지만 실제 코드에서는 메모리를 직접 할당하고 해제하기보다 표준 컨테이너와 스마트 포인터로 관리하는 것이 일반적입니다.
동적 배열을 관리하는 std::vector
#include <vector>
int main()
{
std::vector<int> numbers(10);
}
std::vector는 필요한 메모리를 내부에서 관리합니다. 함수가 종료되어 numbers의 수명이 끝나면 내부 메모리도 자동으로 정리됩니다.
단독 소유를 표현하는 std::unique_ptr
#include <memory>
int main()
{
std::unique_ptr<int> number = std::make_unique<int>(10);
}
std::unique_ptr는 동적 객체를 하나의 소유자만 소유한다는 의미를 표현합니다.
스마트 포인터의 수명이 끝나면 소유한 객체를 자동으로 해제합니다.
공유 소유를 표현하는 std::shared_ptr
#include <memory>
int main()
{
std::shared_ptr<int> first = std::make_shared<int>(10);
std::shared_ptr<int> second = first;
}
std::shared_ptr는 여러 스마트 포인터가 하나의 객체를 공동으로 소유해야 할 때 사용할 수 있습니다.
마지막 소유자가 사라지면 객체가 자동으로 해제됩니다.
| 필요한 기능 | 사용할 수 있는 도구 |
|---|---|
| 크기가 변하는 연속된 배열 | std::vector |
| 동적 객체의 단독 소유 | std::unique_ptr |
| 동적 객체의 공유 소유 | std::shared_ptr |
RAII
이러한 타입은 객체의 수명과 자원의 수명을 연결하는 RAII(Resource Acquisition Is Initialization) 방식을 사용합니다.
자원을 관리하는 객체가 생성될 때 자원을 확보하고, 객체의 수명이 끝날 때 소멸자에서 자원을 정리합니다.
void Function()
{
std::vector<int> numbers(10);
std::unique_ptr<int> number = std::make_unique<int>(10);
}
Function이 종료되면 지역 객체인 numbers와 number의 소멸자가 실행됩니다. 이 과정에서 두 객체가 관리하던 동적 메모리도 자동으로 정리됩니다.
RAII를 사용하면 함수가 중간에 종료되거나 예외가 발생하더라도 객체 수명에 따라 자원을 정리할 수 있어 누수 위험을 줄일 수 있습니다.
스마트 포인터를 모든 포인터의 대체품으로 사용하는 것은 아닙니다. 스마트 포인터는 주로 동적 객체의 소유권을 표현하고 관리할 때 사용합니다. 객체를 소유하지 않고 잠시 가리키는 포인터나 레퍼런스는 별도의 목적을 가집니다.
정리
- 실행 중인 프로그램의 메모리는 용도에 따라 코드, 데이터, 스택, 힙 등의 영역으로 나누어 이해할 수 있습니다.
- 실제 메모리 배치와 세부 동작은 운영체제, 컴파일러와 최적화 설정 등에 따라 달라질 수 있습니다.
- 스택은 함수 호출과 지역 객체를 관리하는 데 사용됩니다.
- 함수 호출 하나에 대응해 만들어지는 실행 영역을 스택 프레임이라고 합니다.
- 스택은 마지막에 호출된 함수가 먼저 종료되는 LIFO 방식과 잘 맞습니다.
- 지역 객체는 선언된 범위를 벗어날 때 자동으로 소멸합니다.
- 스택 메모리의 확보와 반환은 일반적으로 비용이 작습니다.
- 스택의 크기는 제한되어 있으므로 지나치게 깊은 함수 호출이나 매우 큰 지역 데이터에 주의해야 합니다.
- 스택의 사용 가능한 범위를 초과하는 문제를 스택 오버플로우라고 합니다.
- 종료 조건이 없는 재귀 호출은 스택 오버플로우의 대표적인 원인입니다.
- 힙은 실행 중 필요한 크기와 수명에 맞춰 메모리를 동적으로 할당하는 데 사용합니다.
new는 동적으로 할당한 객체의 주소를 반환합니다.new로 할당한 단일 객체는delete로 해제해야 합니다.new[]로 할당한 배열은delete[]로 해제해야 합니다.- 일반적인
new가 할당에 실패하면 기본적으로std::bad_alloc예외가 발생합니다. - 지역 포인터 변수와 포인터가 가리키는 힙 객체의 수명은 서로 다릅니다.
- 포인터 변수의 수명이 끝나도 동적으로 할당한 객체가 자동으로 해제되는 것은 아닙니다.
- 각각 별도로 할당한 힙 메모리 블록이 서로 연속해서 배치된다는 보장은 없습니다.
- 하나의 동적 배열을 구성하는 원소들은 연속해서 배치됩니다.
- 동적 할당과 해제에는 공간 검색과 관리 비용이 필요하므로 일반적으로 스택 메모리 사용보다 비용이 큽니다.
- 힙 메모리의 성능에는 할당 비용, 캐시 지역성, 단편화와 접근 패턴 등이 영향을 줍니다.
- 동적으로 할당한 메모리의 주소를 잃어버리거나 해제하지 않으면 메모리 누수가 발생할 수 있습니다.
- 해제된 객체를 계속 가리키는 포인터를 댕글링 포인터라고 합니다.
- 같은 메모리를 두 번 해제하면 정의되지 않은 동작이 발생합니다.
- 클래스 객체나 배열이라고 해서 반드시 힙에 할당해야 하는 것은 아닙니다.
- 메모리의 크기와 필요한 수명, 소유 관계를 기준으로 스택과 힙 사용을 결정해야 합니다.
- 실제 C++ 코드에서는 동적 배열에
std::vector를 사용할 수 있습니다. - 단독 소유 객체에는
std::unique_ptr, 공유 소유 객체에는std::shared_ptr를 사용할 수 있습니다. - RAII는 자원의 수명을 자원을 관리하는 객체의 수명과 연결합니다.
- 표준 컨테이너와 스마트 포인터를 사용하면 직접적인 메모리 관리에서 발생하는 누수 위험을 줄일 수 있습니다.
이전 글: C++ 문자열이란? 문자 배열과 포인터로 이해하는 C 스타일 문자열
다음 글: