메모리 누수 탐지
동적 메모리의 해제 책임을 잃는 누수 원인을 이해하고 스마트 포인터·분석 도구·정적 분석으로 탐지하고 예방합니다.
메모리 누수(memory leak)는 동적으로 할당한 메모리를 다 쓴 뒤에도 해제하지 않아, 프로그램이 끝날 때까지 그 메모리를 다시 쓸 수 없게 되는 현상입니다. 한 번에 새는 양이 작으면 당장은 아무 문제가 없어 보이지만, 오래 실행되는 서버나 게임에서 같은 코드가 반복되면 사용하는 메모리가 계속 늘어나 느려지거나 할당에 실패해 끝날 수 있습니다. 프로그램이 바로 비정상 종료하지 않으므로 발견이 늦고, 발견한 뒤에도 어디서 새는지 찾기가 어렵습니다.
이 글은 누수가 생기는 원인, 스마트 포인터로 누수를 미리 막는 방법, 누수를 찾는 도구와 그 보고서를 읽는 법, 그리고 정적 분석과 여러 진단을 함께 쓰는 방법을 다룹니다. 아래 그림은 원시 포인터(raw pointer)를 쓰는 leaky_function과 unique_ptr를 쓰는 safe_unique_ptr_function이 각각 객체를 만들고, 함수가 끝날 때 무엇이 남는지를 비교합니다. 두 함수는 따로 실행되는 별개의 예이며, 한쪽에서 다른 쪽으로 이어지는 흐름이 아닙니다.
소유권은 획득에서 해제까지 끊기지 않아야 한다
leaky_function과 safe_unique_ptr_function의 독립 실행에서 할당·범위 종료·객체 잔존/해제를 같은 시점에 나란히 보이면 소유권 차이가 드러난다. 두 함수를 이어 실행한 것으로 표시하지 않는다.
C++ · RESOURCE RELEASE
포인터 변수의 소멸과 힙 객체의 해제를 구분한다
본문의 raw 포인터와 unique_ptr 예제를 같은 함수 종료 시점에서 비교한다. 소유권 이동은 선택 가능한 연산이며 해제에 항상 필요한 단계는 아니다.
raw 포인터 · 누수
본문 leaky_function은 MyClass(1)을 new하고 delete하지 않는다. 함수 종료 때 obj_ptr 변수만 사라지고 힙 객체는 남는다.
unique_ptr · 자동 해제
본문 safe_unique_ptr_function은 MyClass(2)의 해제 책임을 unique_ptr에 연결한다. 함수 종료 때 소유자가 소멸해 객체와 힙 자원을 정리한다.
본문 shared_ptr 예제의 MyClass(3)은 마지막 강한 소유자가 사라질 때 소멸한다. shared_ptr 순환은 weak_ptr로 설계해야 하며, raw 관찰 포인터의 유효 수명은 별도로 보장한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 서로 다른 두 함수의 비교이다. raw 경로에서는 함수 종료가 포인터 변수만 정리하고 MyClass1의 해제 책임을 잃는다. unique_ptr 경로에서는 소유자 소멸이 기본 삭제자를 실행해 MyClass2를 소멸시키고 힙 자원을 반환한다. 두 경로를 잇는 실행 화살표는 없다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 독립 할당 leaky_function과 safe_unique_ptr_function이 각각 자원을 만든다. |
|
|
| 범위 종료 두 함수의 지역 변수가 범위를 나간다. |
|
|
| 자원 결과 명시적 해제가 없는 raw와 RAII 해제를 구분한다. |
|
|
소유권은 획득에서 해제까지 끊기지 않아야 한다
leaky_function과 safe_unique_ptr_function의 독립 실행에서 할당·범위 종료·객체 잔존/해제를 같은 시점에 나란히 보이면 소유권 차이가 드러난다. 두 함수를 이어 실행한 것으로 표시하지 않는다.
C++ · RESOURCE RELEASE
포인터 변수의 소멸과 힙 객체의 해제를 구분한다
본문의 raw 포인터와 unique_ptr 예제를 같은 함수 종료 시점에서 비교한다. 소유권 이동은 선택 가능한 연산이며 해제에 항상 필요한 단계는 아니다.
raw 포인터 · 누수
본문 leaky_function은 MyClass(1)을 new하고 delete하지 않는다. 함수 종료 때 obj_ptr 변수만 사라지고 힙 객체는 남는다.
unique_ptr · 자동 해제
본문 safe_unique_ptr_function은 MyClass(2)의 해제 책임을 unique_ptr에 연결한다. 함수 종료 때 소유자가 소멸해 객체와 힙 자원을 정리한다.
본문 shared_ptr 예제의 MyClass(3)은 마지막 강한 소유자가 사라질 때 소멸한다. shared_ptr 순환은 weak_ptr로 설계해야 하며, raw 관찰 포인터의 유효 수명은 별도로 보장한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 서로 다른 두 함수의 비교이다. raw 경로에서는 함수 종료가 포인터 변수만 정리하고 MyClass1의 해제 책임을 잃는다. unique_ptr 경로에서는 소유자 소멸이 기본 삭제자를 실행해 MyClass2를 소멸시키고 힙 자원을 반환한다. 두 경로를 잇는 실행 화살표는 없다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 독립 할당 leaky_function과 safe_unique_ptr_function이 각각 자원을 만든다. |
|
|
| 범위 종료 두 함수의 지역 변수가 범위를 나간다. |
|
|
| 자원 결과 명시적 해제가 없는 raw와 RAII 해제를 구분한다. |
|
|
누수가 생기는 원인
다음 프로그램은 그림의 두 함수에 shared_ptr를 쓰는 함수 하나를 더해, 세 객체가 언제 소멸하는지 출력합니다. MyClass는 생성자와 소멸자에서 자기 번호를 출력하므로, 출력에서 created 뒤에 같은 번호의 destroyed가 나오는지를 보면 그 객체가 해제되었는지 알 수 있습니다. 세 함수는 객체를 각각 원시 포인터, unique_ptr, 한 객체를 함께 가리키는 shared_ptr 두 개로 다룹니다.
#include <iostream>
#include <memory>
class MyClass {
public:
explicit MyClass(int v) : value(v) {
std::cout << "MyClass(" << value << ") created\n";
}
~MyClass() {
std::cout << "MyClass(" << value << ") destroyed\n";
}
int value;
};
void leaky_function() {
MyClass* obj_ptr = new MyClass(1);
std::cout << "using " << obj_ptr->value << '\n';
} // obj_ptr만 사라지고 객체는 남음
void safe_unique_ptr_function() {
auto obj_uptr = std::make_unique<MyClass>(2);
std::cout << "using " << obj_uptr->value << '\n';
} // obj_uptr가 소멸하며 객체를 delete
void safe_shared_ptr_function() {
auto first = std::make_shared<MyClass>(3);
auto second = first;
std::cout << "owners " << first.use_count() << '\n';
} // 마지막 소유자가 사라질 때 객체를 delete
int main() {
leaky_function();
safe_unique_ptr_function();
safe_shared_ptr_function();
std::cout << "end of main\n";
}MyClass(1) created
using 1
MyClass(2) created
using 2
MyClass(2) destroyed
MyClass(3) created
owners 2
MyClass(3) destroyed
end of mainleaky_function이 끝나면 지역 변수 obj_ptr는 사라지지만, 그 변수가 가리키던 힙의 MyClass(1)은 그대로 남습니다. 그 객체의 주소를 아는 변수가 더 이상 없으므로, 프로그램은 이 객체를 해제할 방법을 잃었고 출력에도 MyClass(1) destroyed가 없습니다. 운영체제는 프로세스가 끝날 때 그 메모리를 거두어 가지만, 이 함수가 반복해서 호출되면 호출할 때마다 객체 하나만큼 메모리가 쌓이고, 소멸자가 실행되지 않으므로 객체가 가진 파일이나 잠금도 정리되지 않습니다.
누수가 생기는 흔한 경우는 다음과 같습니다.
| 원인 | 예 |
|---|---|
delete를 빠뜨림 | 위의 leaky_function |
| 포인터를 덮어씀 | p = new int(2);로 이전 블록의 주소를 잃음 |
이른 return이나 예외로 delete를 건너뜀 | 오류 검사 뒤 바로 반환 |
shared_ptr끼리 서로 가리킴 | 두 객체의 참조 횟수가 0이 되지 않음 |
release()로 꺼낸 주소를 버림 | 받은 쪽이 delete하지 않음 |
void overwrite() {
int* p = new int(1);
p = new int(2); // 1을 담은 블록의 주소를 잃음
delete p;
}
void early_exit(bool fail) {
int* buffer = new int[100];
if (fail) {
return; // delete[]를 건너뜀
}
delete[] buffer;
}overwrite는 두 번째 할당의 주소로 p를 덮어써서 첫 블록을 해제할 방법을 잃고, early_exit은 fail이 참일 때 delete[]에 도달하지 못합니다. 두 경우 모두 해제하는 코드가 있기는 하지만, 할당과 해제 사이의 모든 경로가 그 코드를 지나가지 않아서 생기는 누수입니다.
스마트 포인터로 막기
누수를 줄이는 가장 좋은 방법은 해제를 사람이 기억해야 하는 일로 남기지 않는 것입니다. 스마트 포인터에서 본 것처럼, 객체의 해제를 소유자 객체의 소멸에 묶으면 소유자가 범위를 벗어나는 모든 경로에서 해제가 일어납니다.
std::unique_ptr는 객체를 혼자 소유하며, 소멸할 때 비어 있지 않으면 삭제자(deleter)를 호출합니다. 기본 삭제자는 delete를 하므로, leak_example.cpp의 safe_unique_ptr_function이 끝날 때 MyClass(2)가 소멸했습니다. std::shared_ptr는 소유를 여럿이 나누고, 같은 객체를 소유하는 마지막 shared_ptr가 소멸하거나 reset()될 때 객체를 해제하므로, MyClass(3)은 first와 second가 모두 사라진 뒤에 소멸했습니다.
early_exit처럼 배열을 잠시 쓰는 경우라면 std::vector<int> buffer(100);로 바꾸기만 해도 어느 경로로 함수를 빠져나가든 메모리가 해제됩니다. 스마트 포인터를 써도 남는 누수 경로가 있다는 점도 기억해 둡니다.
auto owner = std::make_unique<MyClass>(4);
MyClass* observer = owner.get(); // 보기만 함, 해제하지 않음
MyClass* raw = owner.release(); // owner는 비고, 객체는 raw를 받은 쪽에 남음
delete raw; // 이 줄이 없으면 누수get()이 돌려준 포인터는 객체를 보기만 하므로 해제하면 안 되고, 소유자가 객체를 해제한 뒤에는 쓸 수도 없습니다. release()는 소유를 포기하고 주소만 돌려줄 뿐 객체를 해제하지 않으므로, 받은 주소를 다른 소유자에게 넘기거나 직접 delete해야 합니다. shared_ptr끼리 서로 가리키는 순환은 참조 횟수가 0이 되지 않아 둘 다 남으므로, 한쪽을 std::weak_ptr로 바꿉니다.
누수를 찾는 도구
오래된 코드나 원시 포인터를 쓰는 라이브러리처럼 스마트 포인터만으로 정리할 수 없는 곳에서는 도구로 누수를 찾습니다. 도구마다 쓸 수 있는 플랫폼이 다릅니다.
| 도구 | 쓸 수 있는 곳 | 쓰는 법 | 특징 |
|---|---|---|---|
| Valgrind Memcheck | Linux, FreeBSD 등 | valgrind --leak-check=full ./program | 다시 빌드하지 않아도 되지만 실행이 수십 배 느려짐 |
| AddressSanitizer와 LeakSanitizer | GCC, Clang (누수 검사는 Linux, macOS 등) | -fsanitize=address로 빌드 후 실행 | 보통 2배 정도 느려지며, 누수 검사는 프로세스가 끝날 때 함 |
| CRT 디버그 힙 | Windows의 MSVC 디버그 빌드 | _CrtSetDbgFlag로 끝날 때 검사 | 남은 블록의 할당 번호와 크기를 보여 줌 |
| Visual Studio 진단 도구 | Windows의 Visual Studio | 메모리 사용량 스냅샷 비교 | 두 시점 사이에 늘어난 할당을 보여 줌 |
Valgrind는 Windows를 지원하지 않고, Clang 문서에 따르면 AddressSanitizer의 누수 검사도 Linux와 macOS 밖에서는 아직 지원되지 않습니다. 그래서 이 교재의 Windows 환경에서는 같은 코드를 WSL 같은 Linux 환경에서 빌드해 검사하거나, Visual Studio의 MSVC로 빌드해 CRT 디버그 힙을 씁니다.
Valgrind와 AddressSanitizer
다음 프로그램은 int 하나와 std::vector<char> 하나를 new로 만들고 해제하지 않습니다. vector 객체는 원소 1024개를 담을 버퍼를 따로 할당하므로, 함수가 끝나면 int, vector 객체, 그 버퍼의 세 블록이 해제되지 않은 채 남습니다. 아래에서는 같은 프로그램을 Valgrind로 한 번, AddressSanitizer로 한 번 검사합니다.
#include <iostream>
#include <vector>
void create_leak() {
int* number = new int(10);
auto* bytes = new std::vector<char>(1024);
std::cout << "value " << *number << '\n';
std::cout << "vector size " << bytes->size() << '\n';
}
int main() {
create_leak();
std::cout << "Program exiting.\n";
}g++ -std=c++20 -g -O0 leaky_program.cpp -o leaky_program
valgrind --leak-check=full ./leaky_programvalue 10
vector size 1024
Program exiting.프로그램의 출력 뒤에 Valgrind가 누수 보고서를 덧붙입니다. -g로 빌드했으므로 보고서는 definitely lost와 possibly lost로 분류된 블록마다 그 블록을 할당한 함수와 소스 줄을 보여 줍니다. 이 프로그램에서는 int 블록과 vector 객체 블록이 definitely lost로, vector만 가리키던 1024바이트 원소 버퍼가 indirectly lost로 분류됩니다. x86-64 Linux의 libstdc++에서 vector 객체는 포인터 세 개로 24바이트이므로, definitely lost는 4 + 24 = 28바이트, indirectly lost는 1024바이트가 됩니다. indirectly lost인 1024바이트 버퍼는 기본으로 따로 항목이 나오지 않고, vector 객체 블록의 항목에 1,048 (24 direct, 1,024 indirect) bytes처럼 함께 표시됩니다. 환경에 따라 표준 라이브러리 내부의 할당이 함께 보일 수도 있습니다.
g++ -std=c++20 -g -fsanitize=address -fno-omit-frame-pointer leaky_program.cpp -o leaky_program_asan
./leaky_program_asanAddressSanitizer(ASan)는 컴파일할 때 메모리 접근마다 검사 코드를 넣어, 해제한 메모리 사용이나 배열 범위 밖 접근을 그 자리에서 잡습니다. 누수는 ASan에 들어 있는 LeakSanitizer(LSan)가 맡으며, Linux에서는 기본으로 켜져 프로세스가 끝날 때 한 번 남은 블록을 검사합니다. 보고서는 Direct leak과 Indirect leak으로 나누어 할당 위치를 보여 주며, 각각 Memcheck의 definitely lost와 indirectly lost에 해당합니다.
Windows의 CRT 디버그 힙
MSVC의 C 런타임 라이브러리(CRT)는 디버그 빌드에서 할당과 해제를 기록하는 디버그 힙(debug heap)을 제공합니다. 아래 코드는 main의 첫 줄에서 _CrtSetDbgFlag로 할당 기록과 종료할 때의 누수 검사를 켠 뒤, int 하나를 new로 만들고 해제하지 않습니다. 디버그 힙은 디버그용 CRT에만 있으므로, 빌드 명령의 /MDd로 디버그 CRT를 링크합니다. cl은 필요한 환경 변수가 설정된 창에서만 실행되므로, Visual Studio가 시작 메뉴에 만드는 x64 Native Tools Command Prompt for VS 같은 개발자 명령 프롬프트를 열어 이 명령을 실행합니다.
#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>
int main() {
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
int* leaked = new int(7);
*leaked += 1;
}cl /EHsc /W4 /D_DEBUG /MDd crt_leak.cpp_CrtSetDbgFlag에 _CRTDBG_LEAK_CHECK_DF를 주면 프로그램이 끝날 때 해제되지 않은 블록의 목록이 출력되며, 디버거로 실행하면 기본으로 출력 창의 디버그 영역에 나타납니다. 목록의 각 항목은 할당 번호, 블록 종류, 주소, 크기, 앞쪽 데이터를 보여 주므로 이 예에서는 4바이트 블록 하나가 나옵니다. 같은 조건으로 다시 실행하면서 _CrtSetBreakAlloc(할당 번호)를 부르면 그 블록을 할당하는 순간 멈추므로, 호출 스택으로 누가 할당했는지 확인할 수 있습니다.
Visual Studio의 진단 도구(Diagnostic Tools) 창에서는 디버깅 중에 메모리 사용량 스냅샷을 찍고 두 스냅샷을 비교해, 그 사이에 늘어난 할당을 형식별로 볼 수 있습니다. 코드에서 _CrtMemCheckpoint로 두 시점의 상태를 기록하고 _CrtMemDifference로 비교하면, 의심스러운 구간을 반씩 나누어 가며 누수가 생기는 곳을 좁힐 수도 있습니다.
더 깊이: MSVC의 AddressSanitizer
Visual Studio 2019 버전 16.9부터는 MSVC도 /fsanitize=address로 AddressSanitizer를 지원합니다. Microsoft 문서에 따르면 해제한 힙 메모리 사용, 이중 해제, new와 delete의 짝 불일치, 힙과 스택과 전역 변수의 범위 밖 접근 같은 오류를 실행 중에 잡으며, 모든 최적화 수준과 x86, x64 구성에서 쓸 수 있습니다. 다만 편집하며 계속하기(edit and continue), 증분 링크, /RTC 실행 시간 검사와는 함께 쓸 수 없으므로, 디버그 구성에서는 /ZI, /RTC1, /INCREMENTAL을 먼저 끕니다.
같은 문서는 지원 범위를 Windows 10 이후의 x86과 x64로 밝히고, /fsanitize=leak이나 /fsanitize=thread는 앞으로 고려할 기능으로 남겨 두고 있습니다. 그래서 Windows의 MSVC 빌드에서는 잘못된 메모리 접근을 ASan으로 찾고, 누수는 앞의 CRT 디버그 힙이나 진단 도구로 찾는 식으로 나누어 씁니다. 또 이 문서는 AddressSanitizer를 배포용 프로그램에 쓰지 말라고 적고 있습니다.
누수 보고서 읽기
Memcheck는 남은 블록을, 프로그램에 아직 그 블록을 가리키는 포인터가 있는지에 따라 네 가지로 나눕니다.
| 분류 | 뜻 | 대처 |
|---|---|---|
| definitely lost | 블록을 가리키는 포인터를 전혀 찾을 수 없음 | 가장 먼저 고칠 누수 |
| indirectly lost | 블록을 가리키는 블록들이 모두 lost | 그 블록을 가리키던 상위 블록을 고치면 대개 함께 해결됨 |
| possibly lost | 블록에 이르는 포인터 중 블록의 시작이 아닌 중간을 가리키는 내부 포인터가 있음 | 그 내부 포인터가 의도한 것인지 확인 |
| still reachable | 시작 주소를 가리키는 포인터가 아직 있음 | 기본으로는 문제로 보지 않지만, 계속 늘어나면 조사 |
예를 들어 연결 리스트의 첫 노드를 가리키던 포인터를 잃으면, 첫 노드는 definitely lost, 그 노드를 통해서만 닿던 나머지 노드는 indirectly lost가 됩니다. 이때는 나머지 노드를 하나씩 쫓기보다, 첫 노드의 주소를 잃은 곳을 고칩니다.
--leak-check의 기본값은 summary라서 요약만 보여 주므로, 블록마다 할당 위치를 보려면 --leak-check=full을 줍니다. 보고서에 기본으로 나오는 종류도 definite와 possible뿐이어서, still reachable까지 보려면 --show-leak-kinds=all을 더합니다. 이 분류는 Memcheck의 것이므로, 다른 도구의 보고서 용어가 이와 꼭 같다고 가정하지는 않습니다.
정적 분석
실행하는 도구는 그 실행에서 지나간 경로의 누수만 찾습니다. 정적 분석(static analysis)은 프로그램을 실행하지 않고 코드를 읽어 의심스러운 경로를 찾으므로, 테스트가 닿지 않는 오류 경로의 누수도 미리 잡을 수 있습니다.
clang-tidy leak_paths.cpp --checks=-*,clang-analyzer-cplusplus.NewDeleteLeaks,modernize-make-unique -- -std=c++20clang-analyzer-cplusplus.NewDeleteLeaks는 new로 할당한 메모리가 해제되지 않는 경로를 찾고, modernize-make-unique는 unique_ptr에 new를 직접 넘기는 코드를 std::make_unique로 바꾸도록 권합니다. -- 뒤에는 컴파일 옵션을 적습니다. CMake 프로젝트라면 빌드할 때마다 검사하도록 설정할 수 있습니다.
cmake -DCMAKE_CXX_CLANG_TIDY="clang-tidy;-checks=bugprone-*,performance-*,modernize-*" -S . -B build
cmake --build build정적 분석은 실제 실행 상황을 모두 알지 못하므로, 문제가 없는 코드를 의심하거나 실제 누수를 놓칠 수도 있습니다. 그래서 정적 분석은 실행하는 도구를 대신하기보다, 그 앞에서 걸러 내는 단계로 씁니다.
진단을 겹쳐 쓰기
메모리 문제는 누수만이 아니라 해제한 메모리 사용, 배열 범위 밖 쓰기, 데이터 경쟁(data race)처럼 여러 형태로 나타납니다. 아래 그림처럼 설계 단계의 예방, 실행 전의 정적 분석, 실행 중의 도구, 오래 실행하며 보는 추세를 차례로 겹쳐, 한 단계가 놓친 것을 다음 단계가 잡게 합니다.
C++ · MEMORY DIAGNOSTICS
예방·분석·실행·장시간 관측을 함께 사용한다
RAII 예방, 정적 분석, 동적 도구, 장시간 관측을 서로 다른 증거 층으로 사용한다.
- 값·RAII·소유권
해제 책임을 설계에 명시한다. shared_ptr 순환과 관찰 포인터의 유효 수명도 확인한다.
- 경고·정적 분석
clang-tidy 등으로 실행 전에 의심되는 경로를 분석한다.
- sanitizer·플랫폼 도구
ASan은 잘못된 접근, LSan은 지원 환경의 누수, TSan은 데이터 레이스를 검사한다. Memcheck는 플랫폼에 맞게 사용한다.
- 부하·스냅샷·지표
스냅샷과 반복 부하의 증가 추세를 비교한다. 증가 자체만으로 누수를 단정하지 않는다.
ASan과 LSan의 지원·탐지 시점을 구분한다. LSan은 보통 종료 시 누수를 검사한다. 증가 추세나 보고서가 없는 한 번의 실행만으로 누수 유무를 단정하지 않는다.
예방 단계에서는 값과 RAII로 해제할 쪽을 설계에 드러내고, shared_ptr 순환이나 get()으로 얻은 포인터의 수명도 함께 확인합니다. 실행 단계에서는 잘못된 접근에 ASan, 지원되는 환경의 누수에 LSan, 데이터 경쟁에 -fsanitize=thread의 ThreadSanitizer(TSan), 정수 오버플로 같은 정의되지 않은 동작에 -fsanitize=undefined의 UndefinedBehaviorSanitizer(UBSan)를 쓰며, GCC에서 TSan은 ASan이나 LSan과 함께 켤 수 없으므로 따로 빌드합니다.
마지막으로 같은 요청을 오래 반복하는 부하 테스트에서 메모리 스냅샷이나 사용량 지표를 비교해, 반복할수록 늘어나는 추세가 있는지 봅니다. 사용량이 늘어난다는 사실만으로 누수라고 단정할 수는 없는데, 캐시처럼 의도적으로 커지는 자료구조일 수도 있기 때문입니다. 반대로 한 번 실행해 보고서가 비어 있었다고 해서 누수가 없다고 단정할 수도 없으며, 그 실행이 지나가지 않은 경로는 검사되지 않았습니다.
정리
- 메모리 누수는 할당한 메모리의 주소를 잃거나 해제를 건너뛰어, 프로그램이 끝날 때까지 다시 쓸 수 없게 되는 현상입니다.
delete누락, 포인터 덮어쓰기, 이른 반환과 예외,shared_ptr순환,release()한 주소의 방치가 흔한 원인입니다.- 해제를
unique_ptr,shared_ptr,vector같은 소유자의 소멸에 묶으면 함수를 빠져나가는 모든 경로에서 해제됩니다. - Linux에서는 Valgrind Memcheck와 ASan의 LeakSanitizer, Windows의 MSVC에서는 CRT 디버그 힙과 Visual Studio 진단 도구로 누수를 찾습니다.
- 보고서에서는 definitely lost를 먼저 고치고, indirectly lost는 그 상위 블록과 함께, possibly lost는 내부 포인터를 확인하며 봅니다.
- 정적 분석, 실행 중 도구, 오래 반복하는 테스트를 겹쳐 쓰며, 보고서 한 번의 결과로 누수 유무를 단정하지 않습니다.
연습 문제
1. 예외가 지나가는 길
다음 함수에서 누수가 생기는 입력은 무엇이고, 어떻게 고치나요? new[]와 delete[] 사이에서 함수를 빠져나갈 수 있는 길이 몇 가지인지부터 세어 보세요.
#include <stdexcept>
void process(int count) {
int* data = new int[count];
if (count > 1000) {
throw std::length_error("too many");
}
// data 사용
delete[] data;
}해설 보기
보이는 코드에서 new[]와 delete[] 사이에 함수를 빠져나가는 길은 throw 하나이고, // data 사용 부분이 예외를 던지거나 return할 수 있다면 그만큼 늘어납니다. count가 1000보다 크면 배열을 할당한 뒤 예외를 던져 delete[]에 도달하지 못하므로, 할당한 배열이 그대로 남습니다. 검사를 할당보다 앞으로 옮기면 이 경로는 사라지지만, 사용하는 도중에 다른 예외가 나도 안전하려면 std::vector<int> data(count);로 바꿉니다. 그러면 함수를 어떻게 빠져나가든 vector의 소멸자가 메모리를 해제합니다.
2. 보고서의 우선순위
Memcheck 보고서에 definitely lost 16바이트 한 블록과, 그 블록만 가리키던 indirectly lost 32바이트 두 블록이 나왔습니다. 어느 것부터 조사해야 하나요?
해설 보기
definitely lost 블록부터 조사합니다. indirectly lost 블록은 자신을 가리키던 블록이 lost라서 함께 닿을 수 없게 된 것이므로, 상위 블록의 주소를 잃은 곳을 고쳐 그 블록을 제대로 해제하면 대개 함께 해결됩니다. 그 블록의 소멸자나 해제 코드가 자식 블록까지 해제하는지도 함께 확인합니다.
3. 늘어나는 still reachable
같은 요청을 반복하는 테스트에서, 첫 번째 프로그램은 요청 횟수와 관계없이 still reachable이 64바이트로 일정하고, 두 번째 프로그램은 64, 128, 192바이트로 계속 늘어났습니다. 어느 쪽을 먼저 조사하고, 무엇을 확인해야 하나요?
해설 보기
계속 늘어나는 두 번째 프로그램을 먼저 조사합니다. still reachable은 아직 포인터가 남아 있다는 뜻이라 기본으로는 누수로 보지 않지만, 요청마다 늘어난다면 전역 목록이나 캐시에 항목을 넣기만 하고 빼지 않는 경우일 수 있습니다. 다만 의도적으로 커지는 캐시일 수도 있으므로, 그 할당이 어디서 일어나는지, 최대 크기가 정해져 있는지, 언제 비워지는지를 함께 확인합니다.
이것으로 C++ 교재의 본문을 마칩니다. 처음부터 다시 훑어보고 싶다면 C++ 목차로 돌아가, 각 장의 개념이 서로 어떻게 이어지는지 확인해 보세요.