C++에서 new로 만든 객체는 누가 delete해야 할까?
포인터와 객체의 수명을 나누어 이해하고, 메모리 누수·해제 후 접근·소유권을 Chromium 지침과 연결합니다.
이전 글에서는 지역 변수의 수명이 끝나면 그 변수를 가리키던 참조도 더 이상 유효하게 사용할 수 없다는 점을 살펴봤다.
이번에는 『열혈 C++ 프로그래밍』 2장의 new와 delete를 바탕으로, 객체의 수명을 직접 관리하는 경우를 생각해 보자. 핵심 질문은 “어떻게 메모리를 할당하는가?”에서 한 단계 더 나아간 **“만든 객체는 누가 정리하는가?”**다.
C의 포인터와 기본 함수 문법을 알고 C++을 배우기 시작한 독자를 대상으로 한다. 본문은 정수 하나를 사용하고, 클래스·소멸자·스마트 포인터는 뒤에서 짧게 소개한다. 실행 명령은 macOS의 Clang과 C++17을 기준으로 한다.
먼저, 포인터와 가리키는 객체를 나누어 보자
일반적인 지역 정수 변수는 아래처럼 만들 수 있다.
int number = 10;
이번 글에서 이런 지역 변수는 static이 없는 일반적인 경우를 뜻한다. 선언이 실행되어 초기화되고, 블록을 벗어나면 객체의 수명이 끝난다.
같은 정수를 new로 만들면 코드가 달라진다.
int* number = new int(10);
이 한 줄에는 서로 다른 두 가지가 있다.
number라는 포인터 변수new int(10)으로 생성한 정수 객체
new int(10)은 정수 객체를 만들고 10으로 초기화한 뒤, 그 객체를 가리키는 포인터를 돌려준다. 그 포인터 값으로 number를 초기화한다. 여기서는 일반적인 new가 정상적으로 할당에 성공한 상황을 다룬다. C++ 표준 초안: new
포인터 변수 number ──→ 동적으로 만든 정수 객체: 10
포인터를 선언하는 int*와, 가리키는 정수에 접근하는 *number도 구분하자. 아래 예제의 출력문에서 *number는 포인터 주소가 아니라 그 대상의 정수 값을 읽는다.
new로 만든 객체는 delete로 정리한다
다음 코드를 new_delete.cc에 저장한다.
#include <iostream>
int main() {
int* number = new int(10);
std::cout << *number << "\n";
delete number;
number = nullptr;
}
clang++ -std=c++17 -Wall -Wextra new_delete.cc -o new_delete
./new_delete
출력은 다음과 같다.
10
delete number;는 여기서 number가 가리키는 동적 객체를 파괴하고 해당 저장 공간을 해제하는 역할을 한다. 포인터 변수 number 자체를 없애는 문장은 아니다. C++ 표준 초안: delete
따라서 다음 줄의 number = nullptr;는 여전히 존재하는 포인터 변수에 “현재 가리키는 대상이 없다”는 값을 대입한다. nullptr는 널 포인터 값을 표현하는 C++ 키워드다.
이 대입을 생략했다고 앞의 정상적인 해제가 무효가 되는 것은 아니다. 다만 해제된 대상을 가리키던 포인터를 이후에 잘못 사용하는 일을 피하도록, 이 포인터를 더 이상 사용하지 않는 상태로 바꾼 것이다.
포인터 변수가 사라져도 동적 객체가 자동으로 사라지지는 않는다
다음 함수는 문제가 있다.
void ForgetNumber() {
int* number = new int(10);
*number = 20;
}
함수가 끝나면 지역 포인터 변수 number의 수명은 끝난다. 하지만 new로 만든 정수 객체는 이 포인터 변수와 별도로 존재하며, 포인터가 사라졌다고 자동으로 해제되지 않는다.
함수 실행 중:
지역 포인터 number ──→ 동적 정수 객체: 10
함수 종료 후:
number는 사라짐 동적 정수 객체는 정리되지 않음
이 예제에서는 다른 곳에 포인터를 전달하거나 보관하지도 않았으므로, 객체를 해제할 경로를 잃었다. 이런 상황은 **메모리 누수(memory leak)**다.
정수 값을 변경하는 일을 마쳤더라도 객체를 해제한 것은 아니다. 프로그램이 끝날 때 운영체제가 프로세스 자원을 회수하는 것과, 실행 중 더 이상 필요하지 않은 할당을 적절히 정리하는 것도 구분해야 한다.
본문에서는 저장 위치를 단순히 “지역 변수는 스택, new는 힙”으로 외우기보다 무엇의 수명이 어떤 조건에서 끝나는지에 집중한다.
포인터를 복사해도 객체는 복제되지 않는다
copy_pointer.cc에 저장한다.
#include <iostream>
int main() {
int* first = new int(10);
int* second = first;
*second = 20;
std::cout << *first << "\n";
delete first;
first = nullptr;
second = nullptr;
}
clang++ -std=c++17 -Wall -Wextra copy_pointer.cc -o copy_pointer
./copy_pointer
출력은 다음과 같다.
20
second = first로 복사한 것은 포인터 값이다. 정수 객체는 하나이며 두 포인터가 같은 객체를 가리킨다.
first ─┐
├──→ 정수 객체 하나: 10 → 20
second ─┘
그래서 second를 통해 값을 바꾸면 first로 읽어도 바뀐 값이 보인다. 해제는 한 번만 수행해야 한다. 이 예제에서는 first로 해제한 뒤 두 포인터에 모두 nullptr를 대입했다.
first만 nullptr로 바꾼다고 second까지 자동으로 바뀌지는 않는다. 포인터의 별칭이 여러 개 있으면 각 포인터를 따로 바꾸는 방식만으로 수명 관리를 해결하기 어려워진다.
누수, 해제 후 접근, 이중 해제는 서로 다른 문제다
세 상황을 구분해 보자.
| 문제 | 어떤 상황인가? | 정수 예제에서의 결과 |
|---|---|---|
| 메모리 누수 | 필요 없어진 동적 객체를 정리하지 않는다 | 이 예제에서는 할당된 저장 공간이 회수되지 않은 채 남는다 |
| 해제 후 접근 | 해제한 객체의 값을 다시 읽거나 쓴다 | 정의되지 않은 동작이다 |
| 이중 해제 | 이미 해제한 객체를 다시 해제하려 한다 | 정의되지 않은 동작이다 |
예를 들어 아래 코드는 잘못된 사용을 보여주는 설명용 코드다.
int* number = new int(10);
delete number;
// std::cout << *number; // 해제 후 접근: 수행하면 안 된다.
// delete number; // 같은 할당을 다시 해제: 수행하면 안 된다.
정의되지 않은 동작은 C++이 결과를 보장하지 않는다는 뜻이다. 매번 같은 오류 메시지가 나오거나, 반드시 바로 종료되는 것으로 설명할 수 없다.
메모리 누수는 여기서 해제 후 접근과 같은 동작은 아니다. 객체를 이미 없앴는데 쓰는 문제와, 필요 없어진 객체를 없애지 않는 문제를 나누어 보아야 한다.
delete가 한 줄 있어도 모든 실행 경로에서 정리되는 것은 아니다
다음 코드를 보자.
void ProcessNumber(bool should_stop) {
int* number = new int(10);
if (should_stop) {
return; // 이 경로에서는 delete를 건너뛴다.
}
delete number;
}
should_stop이 false면 마지막의 delete가 실행된다. 하지만 true면 중간에 반환하므로, 객체를 정리하지 않은 채 함수가 끝난다.
이 예제의 누수는 눈에 잘 보이지만, 실제 함수에는 조건문과 여러 반환 경로가 있을 수 있다. 따라서 함수 끝에 delete가 보인다는 사실만으로 정리가 보장되는 것은 아니다.
이 작은 예제에서는 먼저 조기 반환할 조건을 검사하고 나서 객체를 만들어도 된다. 다만 일반적으로는 객체를 생성한 뒤 어떤 경로로 빠져나가더라도 정리 책임이 유지되는 구조가 필요하다.
소유권은 객체를 정리할 책임으로 이해하자
이 문맥에서 **소유권(ownership)**은 객체를 언제까지 유지하고 누가 정리할지 책임지는 관계로 이해할 수 있다. 포인터가 있다는 사실만으로 이 책임이 C++에서 자동으로 정해지는 것은 아니다.
함수가 포인터를 받는 예제를 보자. borrow_number.cc에 저장한다.
#include <iostream>
void PrintNumber(const int* number) {
std::cout << *number << "\n";
}
int main() {
int* number = new int(10);
PrintNumber(number);
delete number;
}
clang++ -std=c++17 -Wall -Wextra borrow_number.cc -o borrow_number
./borrow_number
출력은 10이다. 이 예제의 약속은 다음과 같다.
PrintNumber는 호출 중 살아 있는 정수 객체의 포인터를 받는다. 널 포인터는 받지 않는 예제다.- 함수는 값을 읽기만 하고 정리 책임은 넘겨받지 않는다.
- 호출자인
main이 마지막에 해제한다.
이렇게 객체를 사용하지만 소유하지 않는 관계를 빌려 쓴다고 표현할 수 있다. const int*는 이 포인터를 통한 정수 변경을 제한하는 타입이며, 그 자체가 정리 책임을 표현하는 문법은 아니다.
함수 안에 delete를 넣지 않는다는 약속은 학습 예제의 계약이다. 언어 자체는 임의의 포인터 매개변수가 소유자인지 판단해 주지 않으므로, 실제 프로젝트에서는 타입과 작성 지침으로 의도를 더 분명히 나타낸다.
Chromium은 함수의 타입에서 소유권을 구분한다
Chromium C++ 스타일 가이드의 소유권 항목은 포인터 매개변수의 소유권 관계를 구분한다.
- 소유권을 바꾸지 않고 객체를 사용하는 포인터 인자는
T*로 받는다. 호출자는 필요한 동안 대상이 살아 있도록 해야 한다. - 참조 카운팅을 하지 않는 객체의 소유권을 받는 인자는
std::unique_ptr<T>로 받는다.
여기서 T는 어떤 객체 타입을 뜻한다. std::unique_ptr<T>는 객체 하나의 정리 책임을 관리하는 스마트 포인터다. 자세한 구현과 이동 문법은 나중에 배우더라도, 함수 선언에 소유권을 넘기는 의도가 드러난다는 점은 읽을 수 있다.
실제 ThreadPoolInstance::Set 선언에서도 이런 형태를 볼 수 있다.
static void Set(std::unique_ptr<ThreadPoolInstance> thread_pool);
스레드 풀의 동작이나 static의 상세 의미까지 알아야 하는 것은 아니다. 이번에는 매개변수가 ThreadPoolInstance*가 아니라 std::unique_ptr<ThreadPoolInstance>라는 점에 집중하자. 앞의 가이드에 비추어, 단순히 잠깐 사용하는 포인터가 아니라 객체의 소유권을 받는 형태로 읽을 수 있다.
단, 기존 코드가 모두 최신 지침과 일치한다고 단정할 수는 없다. 가이드도 예전 규칙으로 작성된 코드가 남아 있음을 설명한다. 실제 코드를 수정할 때는 선언뿐 아니라 구현과 호출부도 확인해야 한다.
malloc·free와 무엇이 다를까?
책에서는 malloc과 free를 대신하는 문법으로 new와 delete를 소개한다. 정수 예제에서는 메모리를 얻고 돌려주는 모습이 비슷해 보이지만, 객체의 생성과 파괴까지 고려해야 한다.
| 방식 | 이번 단계에서 구분할 내용 |
|---|---|
malloc / free |
저장 공간을 할당·해제하며 클래스의 생성자·소멸자를 호출하지 않는다 |
new / delete |
일반적인 사용에서는 저장 공간 관리와 함께 객체의 초기화·파괴를 수행한다 |
생성자는 객체를 준비할 때, 소멸자는 객체의 사용을 마칠 때 수행하는 클래스의 특별한 함수다. 아직 클래스 문법을 배우지 않았다면 용어만 먼저 구분해도 된다. int 예제에는 직접 작성한 생성자·소멸자가 없으므로 이 차이가 출력으로 보이지는 않는다.
할당과 해제는 짝을 맞춰야 한다.
int* one = new int(10);
delete one;
int* many = new int[3]{1, 2, 3};
delete[] many;
new로 만든 단일 객체에는 delete, new[]로 만든 배열에는 delete[]를 사용한다. new와 free, malloc과 delete를 섞어서 쓰면 안 된다. 특별한 할당 방식과 저수준 객체 수명 규칙은 이번 글의 범위에서 제외한다. new 규정, delete 규정
다음 학습 방향: 정리 책임을 타입에 맡긴다
아래는 스마트 포인터를 미리 보는 예제다. unique_number.cc에 저장한다.
#include <iostream>
#include <memory>
int main() {
std::unique_ptr<int> number = std::make_unique<int>(10);
std::cout << *number << "\n";
}
clang++ -std=c++17 -Wall -Wextra unique_number.cc -o unique_number
./unique_number
출력은 10이다. 이 예제에서는 main을 벗어나 number가 정리될 때, 관리하던 정수 객체도 자동으로 해제된다. 별도로 delete를 적지 않는다.
std::make_unique<int>(10)은 10으로 초기화한 정수를 관리하는 unique_ptr를 만든다. <memory>는 이런 표준 라이브러리 기능을 사용하기 위한 헤더다. 꺾쇠괄호 문법이나 스마트 포인터의 이동 규칙은 이후에 배우면 된다.
이처럼 객체의 수명에 자원의 정리를 연결하는 방식을 RAII라고 부른다. 클래스와 소멸자를 배운 뒤 더 자세히 살펴볼 내용이다. 여기서는 “모든 반환 경로에서 delete를 직접 챙기는 부담을 줄일 수 있다”는 방향만 이해하자. Google C++ 스타일 가이드의 소유권·스마트 포인터 설명
학습 예제에서 new와 delete를 직접 사용한 것은 수명과 정리 책임을 드러내기 위해서다. 실제 코드에서도 모든 정수를 동적 할당하라는 뜻은 아니다. 이런 정수 하나만 필요하다면 처음의 지역 변수 int number = 10;으로 충분할 수 있다.
핵심 요약
- 포인터 변수와 그 포인터가 가리키는 동적 객체는 별개다.
- 원시 포인터 변수가 사라져도
new로 만든 객체가 자동으로 해제되지는 않는다. - 포인터를 복사해도 객체가 복제되거나 정리 책임이 자동으로 분배되지는 않는다.
- 객체를 사용할 수 있다는 사실과, 그 객체를 정리할 책임이 있다는 사실을 구분해야 한다.
주의할 점
- 같은 할당은 한 번만 해제한다. 여러 포인터가 같은 객체를 가리킨다고 각각 해제하면 안 된다.
- 해제한 객체를 다시 읽거나 쓰지 않는다. 포인터가 남아 있다는 사실은 대상이 살아 있다는 뜻이 아니다.
- 한 포인터에 nullptr를 대입해도 다른 별칭이 안전해지지는 않는다. 그 대입은 다른 포인터를 바꾸지 않는다.
- 모든 종료 경로를 확인한다. 조기 반환 등으로 해제를 건너뛰는 경로가 있을 수 있다.
- 할당과 해제의 짝을 맞춘다. 단일 객체·배열·C의 메모리 함수 사용을 섞지 않는다.
- Chromium의 T 지침은 함수 인자에 관한 내용이다.* 멤버 필드의
raw_ptr등 다른 규칙과는 적용 범위를 구분한다.