동적 메모리 할당
new와 delete의 객체 수명·저장 공간·소유권 관계를 구분하고, 예외와 별칭까지 고려해 RAII로 안전하게 관리합니다.
지역 변수는 선언한 블록이 끝나면 사라지고, 배열의 크기는 컴파일할 때 정해야 했습니다. 하지만 입력을 받아야 원소 수를 알 수 있거나, 만든 객체를 함수 밖에서도 계속 써야 할 때가 있습니다. 이럴 때 new 표현식으로 실행 중에 객체를 만들면, 그 객체는 블록과 상관없이 직접 없앨 때까지 남습니다. 이런 객체는 동적 저장 기간(dynamic storage duration)을 가지며, 흔히 힙(heap)에 만든다고 말합니다. 힙 메모리를 할당기와 운영체제가 어떻게 나누어 주는지는 스와핑과 동적 메모리에서 다룹니다.
동적 메모리에서 중요한 것은 주소 자체가 아니라, 누가 그 객체를 소유하고 언제 정리하는지입니다. 포인터 변수가 사라지거나 nullptr을 대입해도 가리키던 객체는 사라지지 않습니다. 이 글은 new와 delete의 규칙, 수동 관리가 실패하는 경로, 그리고 현대 C++이 기본으로 쓰는 std::vector와 std::unique_ptr를 다룹니다.
new와 delete
new 표현식은 저장 공간을 얻고, 그곳에 객체를 초기화한 뒤, 그 객체를 가리키는 포인터를 돌려줍니다. 다 쓴 객체는 delete로 없애며, 배열로 만든 것은 delete[]로 없앱니다.
#include <cstddef>
#include <iostream>
int main() {
int* value = new int{42};
std::cout << *value << '\n';
delete value;
value = nullptr;
std::size_t count = 3;
int* values = new int[count]{10, 20, 30};
std::cout << values[1] << '\n';
delete[] values;
values = nullptr;
}42
20new int{42}는 값이 42인 int 하나를 만들고, new int[count]는 실행 중에 정한 개수의 int 배열을 만듭니다. 내장 배열과 달리 count가 상수가 아니어도 됩니다. 아래 그림처럼 단일 객체는 delete, 배열은 delete[]와 짝을 이루며, 어느 쪽을 쓸지는 포인터의 타입이 아니라 그 객체를 만든 new의 형태로 정합니다.
C++ 동적 할당의 객체 수명과 해제 짝
저장 공간 획득→객체 초기화/수명 시작→접근→맞는 delete로 소멸/해제→별칭 사용 중지의 자원 상태 전이이다. 할당/초기화 실패는 별도 경로로 남긴다.
NEW-EXPRESSION · OBJECT LIFETIME · MATCHED DELETE
동적 할당은 포인터가 아니라 객체 수명을 관리하는 일이다
new 표현식은 저장 공간을 얻고 그곳에 객체를 초기화한 뒤 포인터 값을 돌려줍니다. 단일 객체와 배열은 서로 다른 형태로 만들었으므로 수명을 끝낼 때도 각각 delete와 delete[]를 정확히 짝지어야 합니다.
단일 new T에는 delete, 배열 new T[n]에는 delete[]를 짝지어 객체 수명과 저장 공간을 정리한다. 기본 new의 할당 실패는 bad_alloc이다. owner만 nullptr로 바꿔도 alias의 주소는 남으므로 해제된 객체에 접근하면 안 된다.
관계도 해설과 예제 원자료
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 타입 하나에 모으는 편이 안전합니다.
동적 저장 기간은 중괄호 범위에 자동으로 묶이지 않습니다. 마지막 포인터 값을 잃으면 수동으로 만든 객체를 정상적으로 파괴하고 저장 공간을 해제할 경로도 잃을 수 있으므로, 실제 코드는 컨테이너와 스마트 포인터에 소유권을 맡깁니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 성공과 실패를 포함한 한 객체의 생명 주기 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장·수명 시작 성공 사례에서 저장 공간을 얻고 객체를 초기화한다. |
|
|
| 유효한 접근 객체가 살아 있는 동안 자원을 사용한다. |
|
|
| 소멸·해제 맞는 delete로 수명을 끝내고 저장 공간을 반납한다. |
|
|
C++ 동적 할당의 객체 수명과 해제 짝
저장 공간 획득→객체 초기화/수명 시작→접근→맞는 delete로 소멸/해제→별칭 사용 중지의 자원 상태 전이이다. 할당/초기화 실패는 별도 경로로 남긴다.
NEW-EXPRESSION · OBJECT LIFETIME · MATCHED DELETE
동적 할당은 포인터가 아니라 객체 수명을 관리하는 일이다
new 표현식은 저장 공간을 얻고 그곳에 객체를 초기화한 뒤 포인터 값을 돌려줍니다. 단일 객체와 배열은 서로 다른 형태로 만들었으므로 수명을 끝낼 때도 각각 delete와 delete[]를 정확히 짝지어야 합니다.
단일 new T에는 delete, 배열 new T[n]에는 delete[]를 짝지어 객체 수명과 저장 공간을 정리한다. 기본 new의 할당 실패는 bad_alloc이다. owner만 nullptr로 바꿔도 alias의 주소는 남으므로 해제된 객체에 접근하면 안 된다.
관계도 해설과 예제 원자료
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 타입 하나에 모으는 편이 안전합니다.
동적 저장 기간은 중괄호 범위에 자동으로 묶이지 않습니다. 마지막 포인터 값을 잃으면 수동으로 만든 객체를 정상적으로 파괴하고 저장 공간을 해제할 경로도 잃을 수 있으므로, 실제 코드는 컨테이너와 스마트 포인터에 소유권을 맡깁니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 성공과 실패를 포함한 한 객체의 생명 주기 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장·수명 시작 성공 사례에서 저장 공간을 얻고 객체를 초기화한다. |
|
|
| 유효한 접근 객체가 살아 있는 동안 자원을 사용한다. |
|
|
| 소멸·해제 맞는 delete로 수명을 끝내고 저장 공간을 반납한다. |
|
|
delete는 객체의 수명을 끝내고 그 저장 공간을 돌려줍니다. 초깃값을 주지 않은 new int는 값이 정해지지 않은 int를 만들고, new int{}나 new int()는 0으로 초기화합니다. nullptr에 delete를 적용하면 아무 일도 일어나지 않으므로, 해제 전에 널 검사를 따로 할 필요는 없습니다.
더 깊이: new 표현식이 실제로 하는 일
new 표현식은 두 단계로 이루어집니다. 먼저 할당 함수(allocation function)인 operator new를, 배열이면 operator new[]를 불러 저장 공간을 얻고, 그 공간에 객체를 초기화합니다. delete는 반대로 소멸자를 실행한 뒤 operator delete로 공간을 돌려줍니다. 전역 operator new와 operator delete는 프로그램이 직접 정의해 바꿀 수 있는 대체 가능한(replaceable) 함수여서, 할당을 세거나 기록하는 도구가 이 지점을 씁니다.
배열을 만들 때는 원소들이 차지하는 크기보다 공간을 더 얻을 수 있습니다. 많은 구현이 이 여분에 원소 개수를 저장해 두었다가, delete[]가 소멸자를 몇 번 실행할지 아는 데 씁니다. GCC와 Clang이 따르는 Itanium C++ ABI는 원소 타입에 사소하지 않은(non-trivial) 소멸자가 있으면 배열 앞에 원소 수를 담은 쿠키(cookie)를 두도록 정합니다. 그래서 new[]로 만든 배열을 delete로 해제하면 실제로 할당받은 시작 주소가 아닌 곳을 돌려주게 될 수 있으며, 이것이 짝 불일치가 정의되지 않은 동작인 이유 가운데 하나입니다.
C++14부터는 컴파일러가 new 표현식의 할당을 없애거나 합칠 수 있습니다. 예를 들어 delete[] new int[10];처럼 결과를 쓰지 않는 할당은 통째로 지울 수 있지만, 할당 함수를 직접 부른 operator delete(operator new(10));은 지울 수 없습니다. 그래서 대체한 operator new가 불린 횟수가 소스에 적힌 new의 개수와 다를 수 있습니다.
할당 실패는 예외로 알립니다
일반 new는 저장 공간을 얻지 못하면 nullptr을 돌려주지 않고 std::bad_alloc 예외를 던집니다. 그래서 new의 결과를 nullptr과 비교하는 코드는 할당 실패를 처리하지 못합니다. 실패하면 nullptr을 돌려받아야 하는 특수한 코드에서만 <new>의 new (std::nothrow) int처럼 따로 지정합니다.
#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 << "allocation failed: " << error.what() << '\n';
return 1;
}
}std::make_unique<int[]>(count)는 안에서 new int[count]()로 0으로 초기화한 배열을 만들므로, 할당에 실패하면 이 호출에서 std::bad_alloc이 던져집니다. 정상적으로 할당되면 42를 출력합니다. try와 catch는 예외를 잡는 문법으로, try-catch 구문에서 자세히 다룹니다. 할당에 실패해도 그 자리에서 복구할 방법이 없다면, 예외를 잡지 않고 위쪽으로 전달해 프로그램 전체의 오류 처리 방식에 맡기는 편이 낫습니다.
잘못된 해제는 정의되지 않은 동작입니다
delete를 잘못 쓰면 정의되지 않은 동작이 되며, 바로 충돌하지 않고 한참 뒤에 엉뚱한 곳에서 문제가 드러나기도 합니다. 다음은 대표적인 실수입니다.
| 실수 | 예 |
|---|---|
| 이중 해제 | 이미 delete한 포인터를 다시 delete |
| 짝 불일치 | new int[3]을 delete로, new int를 delete[]로 해제 |
| 해제 뒤 사용 | delete 한 객체를 포인터로 읽거나 씀 |
new로 얻지 않은 주소 해제 | 지역 변수의 주소를 delete |
| 누수 | 해제하지 않은 채 마지막 포인터를 잃어버림 |
마지막의 메모리 누수(memory leak)는 정의되지 않은 동작은 아니지만, 오래 실행되는 프로그램에서는 쓸 수 있는 메모리가 점점 줄어듭니다. 프로그램이 끝나면 운영체제가 메모리를 돌려받지만, 그 전까지는 아무도 그 객체를 없앨 수 없습니다.
nullptr은 포인터 하나만 바꿉니다
여러 포인터가 같은 객체를 가리킬 수 있습니다. 한 포인터로 객체를 해제하고 그 포인터에 nullptr을 대입해도, 다른 포인터는 해제된 주소를 그대로 들고 있습니다.
int* owner = new int{7};
int* alias = owner; // 같은 객체를 가리킴
delete owner; // 객체의 수명이 끝남
owner = nullptr; // owner만 nullptr이 됨
// *alias = 9; // 해제 뒤 사용: 정의되지 않은 동작
// delete alias; // 이중 해제: 정의되지 않은 동작
alias = nullptr; // alias도 따로 정리해야 함해제한 뒤 포인터에 nullptr을 대입하는 습관은 같은 변수를 실수로 다시 쓰는 일을 줄여 주지만, 다른 별칭까지 고쳐 주지는 않습니다. 근본적인 해결은 객체를 소유하는 것을 하나로 정하고, 소유하지 않는 포인터가 소유자보다 오래 남지 않게 구조를 짜는 것입니다.
수동 관리가 실패하는 경로
수동으로 관리하는 코드는 정상 경로보다 조기 반환이나 예외 경로에서 더 쉽게 깨집니다. 아래 함수는 new로 만든 객체를 함수 끝에서 delete하도록 짜여 있지만, 그 사이에 함수를 빠져나가는 return이 하나 있습니다. 원인을 읽기 위한 조각이어서 main 없이 함수만 보입니다.
void process(bool stopEarly) {
int* value = new int{42};
if (stopEarly) {
return; // delete를 건너뛰어 누수
}
delete value;
}stopEarly가 참이면 delete에 도달하지 않고 함수가 끝나, value가 가리키던 객체를 해제할 방법이 사라집니다. 분기마다 delete를 복사해 넣으면 이 예는 고칠 수 있지만, 나중에 분기를 추가하거나 중간에 예외가 던져지면 다시 빠뜨리기 쉽습니다. 앞의 goto 문에서 본 것처럼, 해제 호출을 점프나 반환이 건너뛰는 문제입니다.
RAII: 소유자에게 정리를 맡기기
현대 C++은 이 문제를 자원을 객체에 묶는 RAII(Resource Acquisition Is Initialization)로 해결합니다. 자원을 소유하는 객체는 블록을 벗어나면 정상 반환이든 예외든 자동으로 정리되므로, 그 객체가 자원도 함께 해제하게 만드는 것입니다. 표준 라이브러리의 std::vector, std::string, std::unique_ptr가 이런 소유자입니다.
#include <cstddef>
#include <iostream>
#include <memory>
#include <vector>
void process(std::size_t count, bool stopEarly) {
std::vector<int> values(count);
auto answer = std::make_unique<int>(42);
if (stopEarly || values.empty()) {
std::cout << "skip\n";
return;
}
values.front() = *answer;
std::cout << values.size() << ' ' << values.front() << '\n';
}
int main() {
process(3, false);
process(0, false);
process(3, true);
}3 42
skip
skipstd::vector<int> values(count)는 실행 중에 정한 개수의 int를 0으로 만들어 소유하고, std::make_unique<int>(42)는 값이 42인 int를 만들어 그것을 소유하는 std::unique_ptr를 돌려줍니다. auto는 초깃값의 타입으로 변수의 타입을 정하는 문법입니다. 아래 그림처럼 첫 호출은 값을 복사해 3 42를 출력하고, 원소가 없는 둘째 호출과 조기 반환하는 셋째 호출은 skip을 출력하며, 세 경우 모두 함수가 끝날 때 두 소유자가 각자의 메모리를 해제합니다.
C++ 수동 메모리 관리의 실패 경로와 RAII 선택
할당 성공 뒤 정상 작업은 3 42, 조기 반환은 skip을 출력하지만 둘 다 values/answer 소유자의 범위 종료 정리로 합류한다.
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 T는 delete, new T[n]는 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는 “해제를 잊지 말자”는 주의 문구가 아니라, 정상 범위 종료와 예외 스택 되감기를 소멸자에 연결하는 구조입니다. 비정상적인 프로세스 종료까지 모든 정리가 실행된다고 가정하지는 않습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 5개 노드와 5개 간선이다. values와 answer 소유 준비→stopEarly 또는 빈 vector 조건으로 이어진다. 아니요 분기는 값 복사와 3 42 출력 뒤 정리로 우회 합류하며 skip 출력을 하지 않는다. 예 분기는 skip 출력 뒤 정리로 간다. 두 경로 모두 정상 범위 종료의 소멸 계약을 따르며 실제 해제 출력을 측정한 것은 아니다. 할당 실패 예외는 본문의 별도 범위이고 이 5개 노드 그림은 성공한 할당 뒤의 두 정상 반환만 그린다. 모바일도 같다. |
| 노드·연결선 라벨 | values와 answer |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소유 준비 할당 성공 뒤 values와 answer가 자원을 보유한다. |
|
|
| 선택된 작업 본문 실행 결과를 갈래별로 비교한다. |
|
|
| 공통 정리 두 갈래 모두 소유자의 범위 종료로 정리한다. |
|
|
C++ 수동 메모리 관리의 실패 경로와 RAII 선택
할당 성공 뒤 정상 작업은 3 42, 조기 반환은 skip을 출력하지만 둘 다 values/answer 소유자의 범위 종료 정리로 합류한다.
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 T는 delete, new T[n]는 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는 “해제를 잊지 말자”는 주의 문구가 아니라, 정상 범위 종료와 예외 스택 되감기를 소멸자에 연결하는 구조입니다. 비정상적인 프로세스 종료까지 모든 정리가 실행된다고 가정하지는 않습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 5개 노드와 5개 간선이다. values와 answer 소유 준비→stopEarly 또는 빈 vector 조건으로 이어진다. 아니요 분기는 값 복사와 3 42 출력 뒤 정리로 우회 합류하며 skip 출력을 하지 않는다. 예 분기는 skip 출력 뒤 정리로 간다. 두 경로 모두 정상 범위 종료의 소멸 계약을 따르며 실제 해제 출력을 측정한 것은 아니다. 할당 실패 예외는 본문의 별도 범위이고 이 5개 노드 그림은 성공한 할당 뒤의 두 정상 반환만 그린다. 모바일도 같다. |
| 노드·연결선 라벨 | values와 answer |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소유 준비 할당 성공 뒤 values와 answer가 자원을 보유한다. |
|
|
| 선택된 작업 본문 실행 결과를 갈래별로 비교한다. |
|
|
| 공통 정리 두 갈래 모두 소유자의 범위 종료로 정리한다. |
|
|
count가 0이면 빈 vector가 만들어지며, 빈 vector의 front()는 호출하면 안 되므로 먼저 반환합니다. 출력에는 해제가 보이지 않지만, 해제는 소유자의 타입이 보장하는 동작입니다. 이처럼 어느 경로로 끝나도 같은 규칙으로 정리되므로, 분기를 추가해도 delete를 빠뜨릴 일이 생기지 않습니다.
무엇을 고를까
| 필요한 것 | 기본 선택 |
|---|---|
| 실행 중에 정하는 개수의 연속 원소 | std::vector<T> |
| 길이가 바뀌는 문자열 | std::string |
| 객체 하나를 혼자 소유 | std::make_unique<T>(...) |
| 소유하지 않고 잠시 가리키기 | T*나 참조 |
std::unique_ptr<T[]>로 배열을 소유할 수도 있지만, 크기를 기억하고 반복하는 일이 많은 보통의 경우에는 std::vector가 더 알맞습니다. 직접 new와 delete를 써야 하는 저수준 코드라면, 누가 소유하는지, 어떤 형태로 할당했는지, 모든 종료 경로에서 정확히 한 번 해제되는지를 함께 확인합니다. 목표는 delete를 잘 기억하는 것이 아니라, 소유를 타입으로 드러내 delete를 쓸 일 자체를 없애는 것입니다.
정리
new는 실행 중에 객체를 만들어 포인터를 돌려주며, 그 객체는 직접 없앨 때까지 남습니다.- 단일 객체는
delete, 배열은delete[]로 해제하며, 짝이 맞지 않거나 두 번 해제하면 정의되지 않은 동작입니다. - 일반
new는 할당에 실패하면nullptr이 아니라std::bad_alloc예외를 던집니다. - 포인터 하나에
nullptr을 대입해도 같은 객체를 가리키는 다른 포인터는 해제된 주소를 그대로 들고 있습니다. - 수동 해제는 조기 반환과 예외 경로에서 빠지기 쉬우므로, 소유를
std::vector,std::string,std::unique_ptr같은 RAII 객체에 맡깁니다.
연습 문제
1. 해제 뒤의 별칭
alias.cpp에서 delete owner; 직후에 값이 7인 int는 아직 살아 있나요? owner = nullptr;까지 실행한 뒤 delete alias;를 하면 정리가 더 확실해지나요?
해설 보기
첫 delete로 int의 수명이 끝나고 저장 공간도 돌려주었으므로 더 이상 살아 있지 않습니다. owner = nullptr;은 owner 변수만 바꿀 뿐 alias는 해제된 주소를 그대로 들고 있으므로, delete alias;는 같은 객체를 두 번 해제하는 정의되지 않은 동작입니다. alias에는 nullptr을 대입해 그 변수를 다시 쓰지 않게 하는 것만 할 수 있습니다.
2. 짝이 맞지 않는 해제
int* p = new int[3]{1, 2, 3}; 다음에 delete p;로 해제하면 어떻게 되나요? 어떻게 고치는 것이 가장 좋나요?
해설 보기
배열로 만든 객체를 delete로 해제했으므로 짝이 맞지 않아 정의되지 않은 동작입니다. 우선 delete[] p;로 고쳐야 하지만, 더 좋은 방법은 std::vector<int> p{1, 2, 3};처럼 vector에 소유를 맡겨 해제 코드를 없애는 것입니다.
3. 두 번째 할당이 실패하면
다음 함수에서 두 번째 new가 std::bad_alloc을 던지면 a가 가리키는 객체는 어떻게 되나요? std::make_unique로 바꾸면 어떻게 되나요?
void make_pair_of_values() {
int* a = new int{1};
int* b = new int{2};
// a와 b 사용
delete b;
delete a;
}해설 보기
예외가 던져지면 함수가 그 자리에서 빠져나가므로 delete a;에 도달하지 못하고, a가 가리키는 int는 누수됩니다. auto a = std::make_unique<int>(1); auto b = std::make_unique<int>(2);로 바꾸면, 두 번째 할당에서 예외가 던져져도 이미 만들어진 a가 블록을 벗어나며 자기 객체를 해제하므로 누수가 생기지 않고, delete도 쓸 필요가 없습니다.
다음 글 참조자에서는 이미 있는 객체에 다른 이름을 붙이는 참조를 다루고, 포인터와 어떻게 다른지 비교합니다.