왜 런타임에 메모리를 요청하는가
지금까지 만든 모든 변수는 스택에 존재했습니다. 크기가 컴파일 타임에 정해지고, 스코프가 끝나면 자동으로 소멸됩니다. 이는 빠르고 안전하지만, 프로그램이 실행되기 전까지 얼마나 많은 메모리가 필요한지 알 수 없는 경우는 다룰 수 없습니다. 예를 들어 사용자 입력으로 크기가 정해지는 버퍼, 자신을 만든 함수보다 오래 살아남아야 하는 구조체, 또는 모양을 미리 알 수 없는 그래프 같은 경우입니다.
이런 경우를 위해 C++은 new로 힙(프리 스토어라고도 함)에서 할당하고 delete로 되돌려 줄 수 있게 해 줍니다. new 표현식은 블록을 예약하고, 생성자를 실행하며, 그것을 가리키는 포인터를 반환합니다. 이는 이전 페이지에서 본 포인터 위에 곧바로 쌓아 올린 것입니다.
변수 p 자체는 스택에 존재합니다. 그저 포인터일 뿐입니다. 그것이 가리키는 int는 힙에 존재하며, 스코프가 아무리 오가더라도 여러분이 delete할 때까지 살아 있습니다.
스택과 힙
이 구분은 new가 존재하는 이유 그 자체이므로 구체적으로 짚어볼 가치가 있습니다.
void demo() {
int a = 10; // 스택에 있음 - demo()가 반환되면 사라짐
int* b = new int(10); // 'b'는 스택에, 그것이 가리키는 int는 힙에
} // 'a'는 소멸됨; 힙의 int는 누수됨 - 절대 delete되지 않음
핵심 차이점:
- 스택 - 자동 수명, 매우 빠름, 크기 제한(보통 몇 MB), 스코프를 벗어나면 자동으로 해제됨.
- 힙 - 수동 수명, 약간 느림, 큼, 그리고
delete를 호출할 때만 해제됨.
이는 책임을 대가로 유연성을 얻는 거래입니다. 힙 메모리는 여러분이 원하는 만큼 정확히 살아 있지만, 그것을 해제하는 것을 기억해야 하는 사람이 바로 여러분이 됩니다.
new[]로 배열 할당하기
길이가 런타임에 정해지는 블록이 필요할 때는 배열 형식 new T[n]을 사용합니다. 이는 첫 번째 요소를 가리키는 포인터를 반환하며, 짝이 되는 delete[]로 해제합니다.
규칙은 엄격하고 틀리기 쉽습니다. new로 얻은 메모리는 delete로, new[]로 얻은 메모리는 delete[]로 해제합니다. 이를 섞는 것(new[]로 할당한 것에 delete arr를 쓰는 것)은 여러분의 컴퓨터에서 동작하는 것처럼 보이더라도 정의되지 않은 동작입니다.
세 가지 고전적인 버그
수동 메모리 관리에는 대부분의 힙 버그를 차지하는 소수의 실수가 있습니다. 세 가지를 모두 알아볼 수 있도록 익혀 두세요.
1. 메모리 누수 - delete를 한 번도 호출하지 않음. 블록이 영원히 예약된 채로 남습니다. 한 번이면 무해하지만, 반복문 안에서는 치명적입니다.
void leaky() {
int* p = new int(5);
// ... delete 없음 ...
} // p는 사라짐; 힙의 int는 이제 도달할 수도 없고 해제되지도 않음
2. 댕글링 포인터 - 메모리를 해제한 뒤에 사용함. 포인터는 여전히 옛 주소를 들고 있지만, 그 메모리는 더 이상 여러분의 것이 아닙니다.
3. 이중 해제 - 같은 블록을 두 번 delete함. 이는 힙의 내부 관리 정보를 손상시키고 대개 충돌을 일으킵니다.
int* p = new int(1);
delete p;
delete p; // 이중 해제 - 정의되지 않은 동작, 흔히 충돌
포인터를 삭제한 뒤 nullptr로 설정하면 댕글링 사용과 이중 해제를 모두 무력화합니다. nullptr을 역참조하면 즉시 충돌하고(디버깅이 쉬움), delete nullptr은 명시적으로 안전한, 아무것도 하지 않는 연산이기 때문입니다.
현실적인 할당-사용-해제 주기
이 모든 것을 종합하면, 올바른 수동 관리의 형태는 다음과 같습니다. 할당하고, 사용하고, 정확히 한 번 해제하고, 그 후에는 포인터를 건드리지 않는 것입니다.
클래스 타입의 경우 delete u가 두 가지 일을 한다는 점에 주목하세요. 먼저 객체의 소멸자를 실행하고, 그다음 원시 메모리를 해제합니다. 이 순서는 객체가 자신만의 리소스를 소유하게 되는 순간 중요해집니다.
미묘한 함정: new와 delete 사이에서 예외가 던져지면 delete는 결코 실행되지 않고 누수가 발생합니다. 이를 처리하려고 모든 할당을 try/catch로 감싸는 것은 번거롭고 실수하기 쉬운데, 바로 이것이 다음 페이지가 해결하는 문제입니다.
다음: 스마트 포인터
이제 메모리를 직접 다루는 데 드는 모든 비용을 보았습니다. 모든 new는 나중에 delete하겠다는 약속이며, 단 한 번의 빠뜨림, 중복, 또는 너무 이른 해제도 정의되지 않은 동작입니다. 현대 C++은 이 약속을 손으로 하는 일이 거의 없습니다. 다음 페이지에서는 스마트 포인터를 소개합니다. std::unique_ptr과 std::shared_ptr는 힙 할당을 소유하고 스코프를 벗어날 때 여러분 대신 자동으로 delete를 호출하는 객체로, 세 가지 고전적인 버그를 컴파일러와 RAII가 여러분을 대신해 처리해 주는 것으로 바꿔 줍니다.
자주 묻는 질문
C++에서 new와 delete의 차이는 무엇인가요?
new는 런타임에 힙에 메모리를 할당하고 그 메모리를 가리키는 포인터를 반환합니다. delete는 new로 할당한 메모리를 해제합니다. 모든 new는 정확히 하나의 delete와 짝을 이뤄야 하며, 그렇지 않으면 메모리가 누수됩니다. 배열의 경우 new[]와 delete[]를 함께 사용합니다.
C++에서 delete 호출을 잊으면 어떻게 되나요?
메모리 누수가 발생합니다. 더 이상 아무것도 그 블록을 가리키지 않더라도, 힙 블록은 프로그램이 실행되는 동안 내내 예약된 상태로 남습니다. 한 번의 누수는 대개 무해하지만, 반복문 안이나 오래 실행되는 서비스에서의 누수는 쌓이다가 결국 메모리가 부족해져 프로그램이 충돌합니다.
현대 C++에서 new와 delete를 직접 써야 하나요?
거의 쓰지 않습니다. 메모리를 자동으로 해제해 주는 std::vector 같은 컨테이너나 스마트 포인터(std::unique_ptr, std::shared_ptr)를 선호하세요. 스마트 포인터가 내부에서 이를 감싸기 때문에 날것의 new/delete를 이해해 둘 가치는 있지만, 일상적인 코드에서는 누수와 댕글링 포인터의 원인이 됩니다.