동적 메모리 할당
new와 delete의 객체 수명·저장 공간·소유권 관계를 구분하고, 예외와 별칭까지 고려해 RAII로 안전하게 관리합니다.
C++의 new 표현식은 실행 중에 저장 공간을 얻고 그곳에 객체를 생성할 수 있게 합니다. 이 방식으로 만든 객체는 동적 저장 기간(dynamic storage duration)을 가지며, 흔히 “힙에 만든다”고 표현합니다.
핵심은 주소 그 자체가 아니라 객체의 수명과 저장 공간을 누가 소유하고 언제 정리하는가입니다. 포인터 변수는 주소를 보관할 뿐이며, 포인터에 nullptr를 대입하거나 포인터 변수가 범위를 벗어난다고 해서 가리키던 객체가 자동으로 파괴되지는 않습니다.
현대 C++ 애플리케이션 코드에서는 직접 new와 delete를 짝짓기보다 std::vector, std::string, std::unique_ptr 같은 RAII 타입을 기본으로 사용합니다. 다만 수동 관리의 규칙을 알아야 기존 코드와 저수준 API를 정확히 읽을 수 있습니다.
동적 저장 공간이 필요한 경우
실행 중에 원소 수가 결정되거나, 객체의 소유권을 다른 범위로 옮겨야 하거나, 저수준 인터페이스가 직접 할당을 요구할 때 동적 저장 공간이 필요할 수 있습니다.
- 실행 중 결정되는 연속 원소 묶음은 보통
std::vector<T>로 소유합니다. - 문자열은 보통
std::string으로 소유합니다. - 단일 객체의 독점 소유권은 보통
std::unique_ptr<T>로 표현합니다. - raw 포인터
T*는 소유권을 나타내지 않는 관찰자나 C API 경계에서 제한적으로 사용하는 편이 안전합니다.
동적 저장 기간은 소스 코드의 중괄호 범위와 직접 연결되지 않습니다. 그렇다고 “반드시 delete할 때까지 또는 프로그램 종료까지 같은 상태로 남는다”는 뜻은 아닙니다. 객체 수명과 저장 공간의 상태는 수행한 생성·파괴·해제 연산과 프로그램의 실행 경로에 따라 판단해야 합니다.
new 표현식은 저장 공간을 얻고 객체를 만든다
new 표현식은 필요한 저장 공간을 할당한 뒤 그 공간에서 객체를 초기화하고, 생성된 객체를 가리키는 포인터 값을 반환합니다.
T* one = new T(arguments); // 단일 T 객체 생성
T* many = new T[count]{}; // T 객체 count개로 이루어진 배열 생성단일 객체와 배열은 해제 표현식의 짝이 다릅니다.
#include <cstddef>
#include <iostream>
int main() {
int* value = new int{42};
std::cout << *value << '\n';
delete value; // new int{42}와 짝
value = nullptr;
constexpr std::size_t count = 3;
int* values = new int[count]{10, 20, 30};
std::cout << values[1] << '\n';
delete[] values; // new int[count]와 짝
values = nullptr;
}new T에는 delete, new T[n]에는 delete[]를 사용합니다. 어떤 해제 표현식을 쓸지는 포인터 변수의 표면적인 타입만 보고 정하는 것이 아니라, 그 저장 공간을 만든 할당 형태에 맞춰 정합니다.
NEW-EXPRESSION · OBJECT LIFETIME · MATCHED DELETE
동적 할당은 포인터가 아니라 객체 수명을 관리하는 일이다
new 표현식은 저장 공간을 얻고 그곳에 객체를 초기화한 뒤 포인터 값을 돌려줍니다. 단일 객체와 배열은 서로 다른 형태로 만들었으므로 수명을 끝낼 때도 각각 delete와 delete[]를 정확히 짝지어야 합니다.
T* p = new T(args);
동적으로 할당한 저장 공간에서 T 객체 하나를 초기화합니다. 더 이상 필요하지 않을 때 delete p;가 그 객체를 파괴하고 대응하는 저장 공간을 해제합니다.
T* p = new T[n]{};
T 객체 n개로 이루어진 동적 배열을 만듭니다. 배열 형태의 할당에는 대괄호를 보존한 delete[] p;가 필요합니다.
성공과 실패를 포함한 한 객체의 생명주기
-
저장 공간을 요청한다
일반적인
new가 필요한 저장 공간을 얻지 못하면 기본 동작으로std::bad_alloc계열 예외를 던집니다. 널 포인터 반환은std::nothrow를 명시한 별도 형태입니다. -
객체를 초기화하고 수명을 시작한다
저장 공간을 얻은 뒤 생성자 또는 초기화가 수행됩니다. 초기화가 예외로 완료되지 못하면 해당
new표현식이 얻었던 저장 공간은 대응하는 해제 함수로 정리됩니다. -
포인터로 객체에 접근한다
반환된 포인터 값으로 살아 있는 객체를 사용합니다. 다른 포인터에 같은 값을 복사할 수 있지만, 포인터 값의 복사가 자동으로 소유권 규칙을 만들어 주지는 않습니다.
-
할당 형태와 맞는 표현식으로 정리한다
delete p;또는delete[] p;가 대상 객체의 수명을 끝내고 대응하는 저장 공간을 해제합니다. 잘못된 짝과 이중 해제는 정의되지 않은 동작입니다. -
남은 모든 별칭을 더 이상 사용하지 않는다
해제 뒤 원래 포인터에
nullptr를 넣어도 다른 포인터 변수는 자동으로 바뀌지 않습니다. 남은 별칭을 간접 참조하거나 다시 해제하면 정의되지 않은 동작입니다.
| 시작 표현식 | 올바른 종료 | 핵심 결과 |
|---|---|---|
new T(args) | delete p; | T 객체 하나를 파괴하고 대응하는 저장 공간을 해제합니다. |
new T[n]{} | delete[] p; | 배열 원소들의 수명을 끝내고 배열 저장 공간을 해제합니다. |
| 널 포인터 값 |
또는 | 대상이 없으므로 아무 일도 하지 않습니다. |
| 이미 해제된 주소 또는 잘못된 짝 | 다시 해제하지 않음 | 이중 해제와 new/delete[] 짝 불일치는 정의되지 않은 동작입니다. |
new T(args) → delete p;
단일 객체 하나의 수명을 끝내고 저장 공간을 해제합니다.
new T[n]{} → delete[] p;
배열 원소들의 수명을 끝내고 배열 저장 공간을 해제합니다.
널 포인터 해제
대상이 없으므로 아무 일도 하지 않습니다.
해제된 주소·잘못된 짝
다시 해제하지 않습니다. 이중 해제와 짝 불일치는 정의되지 않은 동작입니다.
new 성공만 가정하지 않는다
기본 할당 실패는 nullptr 검사로 잡히지 않습니다. 복구 정책이 있는 계층에서 std::bad_alloc을 처리하거나 상위로 전파합니다.
owner = nullptr는 별칭을 지우지 않는다
delete owner; 뒤 owner만 널로 바꾸어도 같은 주소를 저장하던 alias는 자동 갱신되지 않습니다. 소유권은 RAII 타입 하나에 모으는 편이 안전합니다.
동적 저장 기간은 중괄호 범위에 자동으로 묶이지 않습니다. 마지막 포인터 값을 잃으면 수동으로 만든 객체를 정상적으로 파괴하고 저장 공간을 해제할 경로도 잃을 수 있으므로, 실제 코드는 컨테이너와 스마트 포인터에 소유권을 맡깁니다.
할당 실패는 기본적으로 예외로 알린다
일반적인 new 표현식은 저장 공간을 얻지 못하면 널 포인터를 반환하는 대신 std::bad_alloc 예외를 던집니다. 배열 길이가 구현 한계를 넘는 경우에는 std::bad_array_new_length가 던져질 수도 있으며, 이 예외는 std::bad_alloc에서 파생됩니다.
따라서 기본 new의 결과를 단순히 nullptr와 비교하는 코드는 할당 실패 처리가 되지 않습니다. 널 반환 동작이 꼭 필요한 특수한 코드만 <new>의 std::nothrow 형태를 명시적으로 선택합니다.
#include <cstddef>
#include <iostream>
#include <memory>
#include <new>
int main() {
const std::size_t count = 1'000;
try {
auto values = std::make_unique<int[]>(count);
values[0] = 42;
std::cout << values[0] << '\n';
} catch (const std::bad_alloc& error) {
std::cerr << "메모리 할당 실패: " << error.what() << '\n';
return 1;
}
}예외를 잡은 뒤 실제로 복구할 방법이 없다면, 무조건 현장에서 잡기보다 상위 계층에 전파하여 일관된 오류 정책으로 처리하는 편이 낫습니다. 위 예제의 std::unique_ptr는 할당 이후 다른 연산에서 예외가 발생하더라도 정상적인 스택 되감기 과정에서 소유한 배열을 정리합니다.
delete는 객체 수명과 저장 공간을 함께 정리한다
단일 객체에 대한 올바른 delete 표현식은 객체의 소멸자를 실행해 수명을 끝낸 뒤 대응하는 저장 공간을 해제합니다. delete[]는 동적으로 만든 배열의 원소들을 파괴한 뒤 배열 저장 공간을 해제합니다.
delete nullptr;와 delete[] nullptr;는 아무 일도 하지 않습니다. 반대로 다음 연산은 정의되지 않은 동작(undefined behavior)입니다.
- 이미 해제된 대상을 다시
delete하는 이중 해제 new와delete[], 또는new[]와delete를 섞는 짝 불일치- 해제된 객체를 가리키는 포인터를 간접 참조하거나 다시 해제하는 사용
- 현재 소유하지 않거나 올바른
new표현식에서 얻지 않은 주소를delete하는 사용
정의되지 않은 동작은 반드시 즉시 충돌한다는 뜻이 아닙니다. 우연히 실행되는 것처럼 보여도 결과를 신뢰할 수 없습니다.
nullptr는 한 포인터만 지운다
여러 포인터가 같은 객체를 가리킬 수 있습니다. 한 포인터로 객체를 해제하면 그 객체의 수명은 끝나지만, 다른 포인터 변수의 비트가 자동으로 nullptr로 바뀌지는 않습니다.
int* owner = new int{7};
int* alias = owner; // 같은 객체를 가리키는 별칭
delete owner; // 객체 수명 종료와 저장 공간 해제
owner = nullptr; // owner 하나만 널 포인터로 바뀜
// alias는 자동으로 바뀌지 않으며 더 이상 유효한 객체를 가리키지 않는다.
// *alias = 9; // 정의되지 않은 동작
// delete alias; // 이중 해제: 정의되지 않은 동작
alias = nullptr; // 사용하지 않도록 이 변수도 따로 정리해제 직후 소유 포인터를 nullptr로 바꾸는 습관은 같은 변수의 우발적인 재사용을 줄일 수 있지만, 모든 별칭을 고쳐 주거나 소유권 설계를 대신하지는 않습니다. 가장 중요한 예방책은 소유자를 하나의 RAII 객체로 표현하고, 다른 raw 포인터가 그 소유자보다 오래 살아남지 않게 하는 것입니다.
수동 관리가 실패하는 경로
RAW OWNERSHIP · FAILURE PATHS · RAII
수동 해제의 빈틈은 소유권을 타입에 맡겨 없앤다
직접 new한 raw 포인터는 모든 정상 반환과 예외 경로에서 정확히 한 번, 정확한 형태로 해제해야 합니다. 분기마다 delete를 기억하는 대신 컨테이너와 스마트 포인터가 수명을 소유하게 하면 실패 경로도 같은 규칙으로 정리됩니다.
주소와 해제 책임이 코드 여러 곳으로 흩어진다
raw 포인터 복사만으로는 어느 변수가 소유자인지 알 수 없습니다. 새 반환·예외·소유권 전달이 추가될 때 누수나 중복 해제 경로가 생기기 쉽습니다.
소유 객체의 소멸자가 정리를 한곳에서 수행한다
std::vector, std::string, std::unique_ptr는 자원과 소유권을 하나의 타입에 묶습니다. 정상 범위 종료와 예외 스택 되감기에서 같은 소멸 규칙이 적용됩니다.
| ! | 무엇이 잘못되는가 | 구조적인 예방책 |
|---|---|---|
| 누수 | 마지막 소유 경로를 잃거나 조기 반환이 해제를 건너뜁니다. 접근할 수 없는 객체와 저장 공간을 정상적으로 정리할 방법도 잃습니다. | 연속 원소는 std::vector, 문자열은 std::string, 단일 객체는 std::unique_ptr가 소유합니다. |
| 별칭 | 한 포인터로 해제한 뒤 같은 객체를 가리키던 다른 포인터가 남습니다. 그 별칭의 간접 참조나 재해제는 정의되지 않은 동작입니다. | 소유자는 하나로 제한하고, raw 관찰자는 소유 객체의 수명보다 오래 보관하지 않습니다. |
| 이중 | 이미 수명이 끝나고 해제된 저장 공간을 다시 delete합니다. 결과는 정의되지 않은 동작입니다. | std::unique_ptr로 독점 소유권을 표현하고 이동으로만 전달합니다. |
| 짝 | new T에 delete[]를 쓰거나 new T[n]에 delete를 쓰면 정의되지 않은 동작입니다. | 직접 배열 할당 대신 std::vector를 사용하고, 저수준 경계의 할당 형태는 한 추상화 안에 감춥니다. |
| 이탈 | 할당 뒤 수동 해제 전 흐름이 빠져나가면 정리 구문에 도달하지 못합니다. | 자원을 얻는 즉시 RAII 객체가 소유하게 하여 모든 정상 반환과 예외 스택 되감기에 소멸자를 연결합니다. |
마지막 소유 경로를 잃음
std::vector, std::string, std::unique_ptr가 소유하게 합니다.
해제된 객체를 가리키는 별칭
raw 관찰자는 소유 객체보다 오래 보관하지 않습니다. 간접 참조와 재해제는 정의되지 않은 동작입니다.
같은 저장 공간을 두 번 해제
std::unique_ptr로 소유자를 하나로 만들고 이동으로 전달합니다.
new와 delete[]의 짝 불일치
직접 배열 할당보다 std::vector를 사용합니다. 잘못된 짝은 정의되지 않은 동작입니다.
예외나 반환이 정리 코드를 건너뜀
자원을 얻는 즉시 RAII 객체에 맡겨 소멸자가 정리를 담당하게 합니다.
필요한 데이터 형태에서 소유 타입을 먼저 고른다
-
가변 길이 원소 묶음은
std::vector<T>크기, 원소 수명, 저장 공간 해제를 한 객체가 함께 관리합니다. 반복과 범위 기반 접근도 직접 배열보다 명확합니다.
-
소유 문자열은
std::string문자 버퍼의 크기와 종료 조건을 직접 추적하지 않고 값 타입처럼 복사·이동할 수 있습니다.
-
단일 객체의 독점 소유권은
std::make_unique<T>std::unique_ptr는 복사를 막고 이동으로 소유권 전달을 드러내며, 소멸할 때 정확히 한 번 대상 객체를 정리합니다. -
raw
T*는 수명이 제한된 관찰자로 사용owner.get()으로 얻은 포인터는 소유권을 갖지 않습니다. 소유 RAII 객체가 살아 있는 동안에만 접근하고 저장 기간을 별도로 늘리지 않습니다.
수동 해제 구문 대신 소유 타입을 읽는다
std::vector<int> values(count);
std::string label = "dynamic values";
auto answer =
std::make_unique<int>(42);
int* view = answer.get();
// 비소유 관찰자는 answer보다 짧게 사용한다.
조기 반환과 예외 스택 되감기에서는 values, label, answer의 소멸자가 각 자원을 정리합니다.
한 포인터의 널 대입은 소유권 해결책이 아니다
owner = nullptr는 그 변수의 포인터 값만 바꿉니다. 같은 주소를 저장하던 다른 포인터의 상태와 대상 객체의 수명을 자동으로 관리하지 않습니다.
직접 new가 남는 곳은 책임을 한 경계에 가둔다
레거시 코드나 특수 API에서 직접 할당이 필요하면 소유자, 할당 형태, 소유권 전달, 모든 종료 경로를 하나의 작은 추상화 안에서 완결합니다.
RAII는 “해제를 잊지 말자”는 주의 문구가 아니라, 정상 범위 종료와 예외 스택 되감기를 소멸자에 연결하는 구조입니다. 비정상적인 프로세스 종료까지 모든 정리가 실행된다고 가정하지는 않습니다.
수동 할당 코드는 성공 경로보다 예외와 조기 반환 경로에서 더 쉽게 깨집니다.
void process(bool stopEarly) {
int* value = new int{42};
if (stopEarly) {
return; // delete value를 건너뛰어 누수
}
delete value;
}해제 코드를 모든 분기마다 복제하면 새 분기나 예외 경로를 추가할 때 빠뜨리기 쉽습니다. 이 문제를 구조적으로 해결하는 방식이 RAII(Resource Acquisition Is Initialization)입니다.
현대 C++의 기본 선택은 RAII다
RAII 타입은 자원의 소유권을 객체에 묶습니다. 자동 저장 기간을 가진 소유 객체가 정상적으로 범위를 벗어나거나 예외로 스택이 되감기면 소멸자가 실행되어 자원을 정리합니다.
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
void process(std::size_t count, bool stopEarly) {
std::vector<int> values(count); // 실행 중 정한 크기의 배열
std::string label = "dynamic values"; // 소유 문자열
auto answer = std::make_unique<int>(42);
if (stopEarly || values.empty()) {
return; // 세 객체의 소멸자가 각 자원을 정리
}
values.front() = *answer;
}선택 기준은 다음과 같습니다.
- 크기가 실행 중 결정되는 연속 원소는 먼저
std::vector<T>를 고려합니다. - 소유 문자열은
std::string을 사용합니다. - 단일 객체의 독점 소유권은
std::make_unique<T>로 만듭니다. - 정말 배열 형태의 독점 소유권이 필요하면
std::unique_ptr<T[]>를 사용할 수 있지만, 크기와 반복 처리가 필요한 일반적인 경우에는std::vector<T>가 더 적합합니다. - 직접
new와delete가 필요한 저수준 경계에서는 소유자, 정확한 할당 형태, 모든 종료 경로를 코드와 문서에 함께 표시합니다.
동적 메모리 관리의 목표는 delete를 잘 기억하는 것이 아니라, 누가 소유하는지 타입으로 드러내어 수동 해제를 잊을 기회를 없애는 것입니다.