C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
이 글의 핵심
C++ 메모리 누수에 대한 실전 가이드입니다. 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴 등을 예제와 함께 상세히 설명합니다.
들어가며: 금요일 오후 5시, 서버가 멈췄다
메모리 누수로 서버를 다운시킨 이야기
프로젝트 런칭 2주 차, 금요일 오후 5시에 서버가 응답을 멈췄습니다. 재시작하면 정상 작동하지만, 2-3시간마다 다시 멈추는 패턴이 반복되었습니다.
확인한 것들:
- ✅ CPU 사용률: 10% (정상)
- ✅ 디스크 공간: 50GB 남음 (충분)
- ⚠️ 메모리 사용률: 시작 시 500MB → 3시간 후 7.8GB → 크래시
원인: 메모리 누수(Memory Leak—한 번 할당한 메모리를 해제하지 않아 프로그램이 계속 그 메모리를 차지하는 상태. 비유하면 물이 새는 수도처럼 조금씩 쌓이다 한도에 닿으면 문제가 됨)
메모리 누수의 원인 → 탐지 → 해결 흐름을 요약하면 아래와 같습니다.
flowchart LR
subgraph cause["원인"]
N[new만 하고]
R[return/예외 시]
N --> R
R --> L[delete 누락]
end
subgraph detect["탐지"]
V[Valgrind]
A[AddressSanitizer]
end
subgraph fix["해결"]
U[unique_ptr]
RAII[RAII]
end
cause --> detect --> fix
제 코드 어딘가에서 메모리를 할당(new)했지만 해제(delete)하지 않았고, 시간이 지나면서 메모리가 고갈되어 서버가 다운된 것입니다.
정의를 풀어 쓰면 “메모리 누수”는 “한 번 할당한 메모리를 해제하지 않아, 프로그램이 계속 그 메모리를 차지하고 있는 상태”입니다. 물이 새는 수도처럼 조금씩 쌓이다가 한도에 닿으면 문제가 됩니다. 메모리 누수는 한 번에 터지지 않고, 프로세스가 살아 있는 동안 조금씩 쌓이다가 리소스 한도에 닿으면 그때서야 문제로 드러납니다. 그래서 “가끔 죽는” 현상이 나오면 누수를 의심하고, Valgrind나 AddressSanitizer로 할당/해제 균형을 확인하는 것이 좋습니다.
문제의 코드 (3일 후 발견):
processRequest 안에서 new User(data)로 힙에 객체를 만들었지만, isValid()가 false일 때 return으로 함수를 빠져나가면서 delete를 호출하지 않습니다. 즉 “에러 경로”에서는 할당만 하고 해제는 하지 않아, 요청이 들어올 때마다 User 크기만큼 메모리가 계속 쌓입니다. user->process() 후에만 delete user가 실행되므로, invalid 요청 비율이 높을수록 누수가 빠르게 증가합니다. 당시에는 “한 번에 크래시”가 아니라 서서히 메모리가 늘어나서 원인을 찾기까지 시간이 걸렸습니다.
void processRequest(const std::string& data) {
User* user = new User(data); // 메모리 할당
if (!user->isValid()) {
return; // ❌ delete 없이 리턴!
}
user->process();
delete user; // 정상 경로에서만 delete
}
문제:
- 요청 100개 중 20개가 invalid
- 20%의 요청에서 메모리 누수 발생
- 하루 10,000 요청 = 2,000번 누수 = 수 GB 메모리 누수
해결 후 (아래는 복사해 붙여넣은 뒤 g++ -std=c++17 -o leak_fix leak_fix.cpp && ./leak_fix 로 실행 가능):
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o leak_fix leak_fix.cpp && ./leak_fix
#include <memory>
#include <iostream>
#include <string>
struct User {
std::string data;
explicit User(const std::string& d) : data(d) {}
bool isValid() const { return !data.empty() && data != "invalid"; }
void process() { std::cout << "processed " << data << "\n"; }
};
void processRequest(const std::string& data) {
auto user = std::make_unique<User>(data);
if (!user->isValid()) {
return; // ✅ 자동으로 메모리 해제!
}
user->process();
}
int main() {
processRequest("hello");
processRequest("invalid");
return 0;
}
실행 결과: processed hello 한 줄이 출력됩니다. (invalid 는 early return 으로 처리되어 별도 출력 없음.)
이 경험 이후, 저는 스마트 포인터를 사용하지 않는 코드는 절대 작성하지 않습니다.
실무 정리: new만 하고 early return·예외·여러 경로 때문에 delete를 빠뜨리기 쉽기 때문에, 가능한 한 std::unique_ptr·std::make_unique로 바꾸면 “한 곳에서만 소유”가 보장되어 누수를 막을 수 있습니다. 다음 글에서 스마트 포인터 사용법을 다룹니다.
이 글을 읽으면:
- new/delete의 위험한 패턴을 이해할 수 있습니다.
- 메모리 누수를 탐지하고 수정하는 방법을 배웁니다.
- 실제 프로덕션 환경의 메모리 버그 사례를 배웁니다.
- Valgrind, AddressSanitizer 같은 도구를 활용할 수 있습니다.
추가 문제 시나리오
시나리오 1: 게임 서버 — 플레이어 퇴장 시 removePlayer()가 호출되지 않는 경로가 있으면 Player* 누수 → 수 시간 후 OOM.
시나리오 2: 이미지 배치 — new unsigned char[...] 버퍼 할당 후 포맷 에러로 throw 시 해제 안 됨. 1000장 중 100장 에러 = 100개 버퍼 누수.
시나리오 3: LRU 캐시 — map<Key, Value*>에서 eviction 시 erase만 하면 Value* 객체 미해제.
시나리오 4: 콜백 — new Callback() 등록 후 unregister 누락 시 누수.
목차
- new와 delete의 동작 원리
- new/delete의 위험한 패턴 5가지
- 메모리 누수: 실제 사례 분석
- 완전한 메모리 누수 예제와 탐지
- 메모리 누수 탐지 도구
- 실전 디버깅: 메모리 누수 찾기
- 메모리 누수 흔한 패턴 정리
- 자주 발생하는 에러와 해결법
- 모범 사례와 프로덕션 패턴
- 예방법: 스마트 포인터 미리보기
1. new와 delete의 동작 원리
new가 하는 일
new 한 번 호출이 내부적으로는 “메모리 할당 → 생성자 호출 → 주소 반환” 순서로 진행됩니다. operator new가 힙에서 크기에 맞는 블록을 찾아 할당하고, 그 주소에 MyClass 생성자를 호출한 뒤, 그 주소를 포인터로 돌려줍니다. 따라서 new를 쓴 쪽은 반드시 delete로 같은 주소를 한 번만 해제해야 하고, 중간에 return이나 throw가 나오면 delete가 실행되지 않을 수 있어 누수나 이중 해제의 원인이 됩니다.
MyClass* obj = new MyClass(arg1, arg2);
위 코드 설명: new MyClass(...)가 호출되면 먼저 operator new로 힙에서 메모리를 잡고, 그 주소에서 생성자를 호출한 뒤 포인터를 반환합니다. 이 포인터는 나중에 반드시 delete obj로 한 번만 해제해야 하며, 중간에 return이나 예외가 나면 delete가 호출되지 않아 누수가 됩니다.
내부 동작:
- 메모리 할당:
operator new로 메모리 요청 - 생성자 호출: 할당된 메모리에 객체 생성
- 포인터 반환: 객체의 주소 반환
delete가 하는 일
delete obj;
위 코드 설명: delete obj는 먼저 해당 주소의 소멸자를 호출해 객체를 정리한 뒤, operator delete로 힙 메모리를 반환합니다. obj는 반드시 new로 얻은 유효한 주소여야 하고, 이미 삭제한 포인터에 다시 delete를 쓰면 이중 삭제로 정의되지 않은 동작이 됩니다.
내부 동작:
- 소멸자 호출: 객체 정리
- 메모리 해제:
operator delete로 메모리 반환
new/delete vs malloc/free
// C 스타일 (사용 지양)
MyClass* obj1 = (MyClass*)malloc(sizeof(MyClass));
// ❌ 생성자 호출 안 됨!
free(obj1);
// ❌ 소멸자 호출 안 됨!
// C++ 스타일
MyClass* obj2 = new MyClass();
// ✅ 생성자 호출됨
delete obj2;
// ✅ 소멸자 호출됨
위 코드 설명: malloc은 메모리만 잡고 생성자를 호출하지 않아 객체가 초기화되지 않고, free는 소멸자를 부르지 않아 내부 리소스(파일 핸들, 메모리 등)가 해제되지 않습니다. C++ 객체는 new/delete 쌍으로 사용해야 생성·소멸이 보장됩니다.
중요: C++에서는 절대 malloc/free 사용하지 마세요. 생성자/소멸자가 호출되지 않아 리소스 누수가 발생합니다.
2. new/delete의 위험한 패턴 5가지
패턴 1: 이중 삭제 (Double Delete)
겪은 크래시:
void processData() {
int* ptr = new int(42);
// ... 복잡한 로직 ...
delete ptr;
// ... 더 복잡한 로직 ...
delete ptr; // ❌ 이미 해제된 메모리 재삭제 → 크래시!
}
위 코드 설명: 첫 번째 delete ptr로 힙이 반환된 뒤, 같은 주소를 다시 delete하면 할당자가 이미 해제된 블록을 건드리게 되어 힙 메타데이터가 깨지거나 즉시 크래시할 수 있습니다. delete 후에는 해당 포인터를 더 이상 사용하지 않거나, ptr = nullptr로 두고 nullptr인 경우에만 delete를 호출하도록 해야 합니다.
왜 크래시하는가?:
- 첫 번째
delete: 메모리를 운영체제에 반환 - 두 번째
delete: 이미 반환된 메모리를 또 반환 시도 → 힙 구조 손상
해결법:
int* ptr = new int(42);
delete ptr;
ptr = nullptr; // ✅ nullptr로 설정
if (ptr != nullptr) {
delete ptr; // 안전 (nullptr delete는 무시됨)
}
위 코드 설명: delete 후 ptr = nullptr로 두면, 나중에 실수로 delete ptr를 다시 호출해도 C++ 표준에서 nullptr에 대한 delete는 아무 동작도 하지 않도록 정의되어 있어 이중 삭제를 피할 수 있습니다. 다만 여러 포인터가 같은 객체를 가리킬 때는 하나만 nullptr로 바꿔서는 댕글링이 남으므로, 스마트 포인터 사용이 더 안전합니다.
패턴 2: 댕글링 포인터 (Dangling Pointer)
int* ptr1 = new int(42);
int* ptr2 = ptr1; // 같은 메모리를 가리킴
delete ptr1;
ptr1 = nullptr;
std::cout << *ptr2; // ❌ 이미 해제된 메모리 접근 → 크래시!
위 코드 설명: ptr1과 ptr2가 같은 힙 블록을 가리키는데, delete ptr1으로 그 블록을 해제하면 ptr2는 이미 무효가 된 주소를 갖게 됩니다. *ptr2는 use-after-free로 정의되지 않은 동작이며, 크래시나 잘못된 값이 나올 수 있습니다.
문제: ptr1을 해제했지만 ptr2는 여전히 그 주소를 가리킴
해결법:
auto ptr1 = std::make_shared<int>(42);
auto ptr2 = ptr1; // 참조 카운트 증가
ptr1.reset(); // 참조 카운트 감소
std::cout << *ptr2; // ✅ 안전 (아직 메모리 유효)
위 코드 설명: std::make_shared로 만들면 참조 카운트가 관리되고, ptr2가 그대로 있어도 ptr1.reset()만으로는 메모리가 해제되지 않습니다. 마지막으로 shared_ptr이 사라질 때 한 번만 delete가 호출되므로 댕글링과 이중 삭제를 피할 수 있습니다.
패턴 3: 메모리 누수 (Memory Leak)
void function() {
int* ptr = new int(42);
if (someCondition) {
return; // ❌ delete 없이 리턴 → 메모리 누수!
}
delete ptr;
}
위 코드 설명: someCondition이 true이면 delete ptr에 도달하기 전에 return하므로, 한 번 할당된 메모리가 해제되지 않고 누수됩니다. 이런 경로가 반복되면 힙 사용량이 계속 늘어나므로, early return 전에 해제하거나 std::unique_ptr로 RAII 처리하는 것이 안전합니다.
패턴 4: delete vs delete[] 혼동
겪은 미묘한 버그:
int* arr = new int[100];
delete arr; // ❌ delete[] 대신 delete 사용
// 결과:
// - 첫 번째 원소만 소멸자 호출
// - 나머지 99개는 메모리 누수
// - 디버그 모드: 즉시 크래시
// - Release 모드: 조용히 누수 (더 위험!)
위 코드 설명: new int[100]으로 배열을 할당했으면 반드시 delete[]로 해제해야 합니다. delete만 쓰면 할당자는 단일 객체 하나만 해제한다고 가정해, 나머지 원소는 해제되지 않고 메모리 누수 또는 힙 손상이 발생할 수 있습니다.
올바른 사용:
int* single = new int(42);
delete single; // ✅ 단일 객체
int* array = new int[100];
delete[] array; // ✅ 배열
MyClass* obj = new MyClass();
delete obj; // ✅ 단일 객체
MyClass* objs = new MyClass[10];
delete[] objs; // ✅ 배열
위 코드 설명: 단일 객체는 new/delete, 배열은 new[]/delete[]로 짝을 맞춰야 합니다. MyClass처럼 생성자/소멸자가 있는 타입도 배열이면 delete[]를 써야 모든 원소의 소멸자가 호출되고 메모리가 올바르게 반환됩니다.
패턴 5: 예외 안전성 무시
겪은 메모리 누수:
void processFile(const std::string& filename) {
char* buffer = new char[1024];
std::ifstream file(filename);
if (!file) {
throw std::runtime_error("File not found");
// ❌ 예외 발생 시 delete 실행 안 됨!
}
file.read(buffer, 1024);
delete[] buffer;
}
위 코드 설명: readFile이나 if (!file)에서 예외가 나면 delete[] buffer까지 실행되지 않습니다. 예외가 발생하면 스택이 풀리면서 그 위의 코드는 건너뛰기 때문에, raw 포인터는 예외 경로에서 누수되기 쉽습니다. RAII(스마트 포인터나 래퍼 클래스)를 쓰면 스택 언와인딩 시 자동으로 해제됩니다.
해결법:
void processFile(const std::string& filename) {
auto buffer = std::make_unique<char[]>(1024);
std::ifstream file(filename);
if (!file) {
throw std::runtime_error("File not found");
// ✅ 예외 발생해도 자동 해제!
}
file.read(buffer.get(), 1024);
}
위 코드 설명: std::make_unique<char[]>(1024)로 버퍼를 감싸면, 예외가 나거나 early return이 나와도 buffer의 소멸자에서 자동으로 delete[]가 호출됩니다. buffer.get()으로 raw 포인터만 넘기면 기존 API와 호환됩니다.
3. 메모리 누수: 실제 사례 분석
사례 1: 컨테이너에 포인터 저장
3일 동안 찾지 못한 버그:
class UserManager {
std::vector<User*> users;
public:
void addUser(const std::string& name) {
User* user = new User(name);
users.push_back(user);
}
~UserManager() {
users.clear(); // ❌ 벡터만 비워짐, User 객체는 누수!
}
};
int main() {
UserManager manager;
for (int i = 0; i < 10000; i++) {
manager.addUser("User" + std::to_string(i));
}
// 프로그램 종료 시 10,000개 User 객체 메모리 누수!
return 0;
}
위 코드 설명: users에는 User*만 들어가고, 실제 User 객체는 힙에 있습니다. clear()는 벡터 안의 포인터 값만 지울 뿐, 그 포인터들이 가리키던 객체에 대한 delete는 호출하지 않습니다. 따라서 소멸자에서 각 포인터에 대해 delete를 하거나, 처음부터 vector<unique_ptr<User>>를 쓰는 것이 맞습니다.
문제: vector는 포인터만 관리, 실제 객체는 관리 안 함
해결법 1: 수동 해제 (권장하지 않음):
~UserManager() {
for (User* user : users) {
delete user;
}
users.clear();
}
위 코드 설명: 소멸자에서 벡터에 들어 있는 각 User*에 대해 delete를 호출해 메모리를 반환한 뒤 clear()로 포인터 목록을 비웁니다. 이렇게 하면 누수는 막을 수 있지만, 예외 안전성이나 복사/이동 처리가 번거로우므로 가능하면 스마트 포인터를 쓰는 편이 좋습니다.
해결법 2: 스마트 포인터 (권장):
class UserManager {
std::vector<std::unique_ptr<User>> users;
public:
void addUser(const std::string& name) {
users.push_back(std::make_unique<User>(name));
}
// ✅ 소멸자 불필요! 자동으로 모든 User 해제
};
위 코드 설명: vector<unique_ptr<User>>를 쓰면 각 요소가 User 객체를 소유합니다. UserManager가 소멸될 때 벡터가 정리되면서 각 unique_ptr의 소멸자가 호출되고, 그때 User가 자동으로 delete 되므로 별도 소멸자에서 수동 delete를 할 필요가 없습니다.
사례 2: 조건부 리턴
서버를 다운시킨 코드:
void handleRequest(const Request& req) {
Response* res = new Response();
if (!req.isValid()) {
logError("Invalid request");
return; // ❌ 메모리 누수!
}
if (req.requiresAuth() && !authenticate(req)) {
logError("Authentication failed");
return; // ❌ 메모리 누수!
}
res->setData(processRequest(req));
sendResponse(res);
delete res; // 정상 경로에서만 실행됨
}
위 코드 설명: new Response()로 할당한 뒤 isValid() 실패나 authenticate() 실패 시 return하면 delete res에 도달하지 않아 매번 누수됩니다. 에러 경로가 많을수록 delete를 하나씩 넣기 어렵고 누락되기 쉽므로, std::make_unique<Response>()로 바꾸면 모든 경로에서 자동 해제됩니다.
문제:
- 10,000 요청 중 2,000개가 early return
- 2,000번 메모리 누수
- 하루 후 서버 메모리 고갈
해결법:
void handleRequest(const Request& req) {
auto res = std::make_unique<Response>();
if (!req.isValid()) {
logError("Invalid request");
return; // ✅ 자동 해제!
}
if (req.requiresAuth() && !authenticate(req)) {
logError("Authentication failed");
return; // ✅ 자동 해제!
}
res->setData(processRequest(req));
sendResponse(res.get());
// ✅ 자동 해제!
}
사례 3: 예외 처리 실수
void processData() {
char* buffer = new char[1024];
// 파일 읽기 (예외 발생 가능)
readFile(buffer); // ❌ 예외 발생 시 delete 실행 안 됨!
delete[] buffer;
}
위 코드 설명: readFile 안에서 예외가 나면 그 다음 줄인 delete[] buffer가 실행되지 않습니다. 예외가 던져지면 스택 언와인딩만 일어나기 때문에, raw 포인터는 해제되지 않고 누수되므로 버퍼도 스마트 포인터나 RAII로 감싸는 것이 안전합니다.
해결법:
void processData() {
auto buffer = std::make_unique<char[]>(1024);
readFile(buffer.get()); // ✅ 예외 발생해도 자동 해제
}
4. 완전한 메모리 누수 예제와 탐지
예제 1: Early Return 누수 (Valgrind로 탐지)
누수 코드 (leak_early_return.cpp):
#include <iostream>
#include <string>
struct Data {
std::string content;
explicit Data(const std::string& s) : content(s) {}
};
void process(const std::string& input) {
Data* data = new Data(input);
if (input.empty()) {
return; // 누수!
}
std::cout << data->content << "\n";
delete data;
}
int main() {
process("hello");
process(""); // 여기서 누수
return 0;
}
컴파일 및 Valgrind 실행:
g++ -g -std=c++17 -o leak_early_return leak_early_return.cpp
valgrind --leak-check=full --show-leak-kinds=all ./leak_early_return
Valgrind 출력:
==12345== 40 bytes in 1 blocks are definitely lost
==12345== by 0x400A3C: process(std::string const&) (leak_early_return.cpp:12)
==12345== by 0x400B12: main (leak_early_return.cpp:22)
==12345== LEAK SUMMARY:
==12345== definitely lost: 40 bytes in 1 blocks
해석: process 12번째 줄에서 new Data로 할당했으나 input.empty()일 때 return하여 해제되지 않음. main 22번째 줄 process("") 호출 시 누수.
예제 2: 배열 delete[] 누수 (ASan으로 탐지)
누수 코드 (leak_array.cpp):
#include <iostream>
int main() {
int* arr = new int[1000];
for (int i = 0; i < 1000; ++i) arr[i] = i;
delete arr; // ❌ delete[] 여야 함
return 0;
}
컴파일 및 ASan 실행:
g++ -fsanitize=address,leak -g -std=c++17 -o leak_array leak_array.cpp
./leak_array
ASan 누수 출력:
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4000 byte(s) in 1 object(s) allocated from:
#1 0x4a1100 in main leak_array.cpp:6
SUMMARY: AddressSanitizer: 4000 byte(s) leaked in 1 allocation(s).
해석: new int[1000]으로 4000바이트 할당했으나 delete만 사용. delete[] arr로 수정.
예제 3: 컨테이너 포인터 누수 (Valgrind 상세)
누수 코드 (leak_container.cpp):
#include <vector>
#include <string>
struct Item {
std::string name;
explicit Item(const std::string& n) : name(n) {}
};
int main() {
std::vector<Item*> items;
for (int i = 0; i < 100; ++i) {
items.push_back(new Item("item" + std::to_string(i)));
}
items.clear(); // 포인터만 제거, Item 객체는 누수
return 0;
}
Valgrind 실행 및 출력:
g++ -g -std=c++17 -o leak_container leak_container.cpp
valgrind --leak-check=full --show-leak-kinds=all ./leak_container
==12345== 4,800 bytes in 100 blocks are definitely lost
==12345== by 0x400B23: main (leak_container.cpp:14)
해석: 100개 Item 객체가 new로 할당되었으나 clear()만 호출되어 포인터만 제거되고 객체는 해제되지 않음. vector<unique_ptr<Item>> 또는 소멸 시 for (auto* p : items) delete p 필요.
5. 메모리 누수 탐지 도구
Valgrind (Linux/macOS) - 가장 많이 쓰는 도구
설치
# Ubuntu/Debian
sudo apt install valgrind
# macOS
brew install valgrind
사용법
# 1. 디버그 심볼 포함하여 컴파일
g++ -g program.cpp -o program
# 2. Valgrind 실행
valgrind --leak-check=full --show-leak-kinds=all ./program
실제 출력 예제 (서버 버그 찾을 때)
==12345== HEAP SUMMARY:
==12345== in use at exit: 2,048,000 bytes in 2,000 blocks
==12345== total heap usage: 12,000 allocs, 10,000 frees, 5,120,000 bytes allocated
==12345==
==12345== 2,048,000 bytes in 2,000 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2E0EF: operator new(unsigned long) (vg_replace_malloc.c:334)
==12345== by 0x400A3C: handleRequest(Request const&) (server.cpp:45)
==12345== by 0x400B12: main (server.cpp:120)
==12345==
==12345== LEAK SUMMARY:
==12345== definitely lost: 2,048,000 bytes in 2,000 blocks
해석:
definitely lost: 확실한 메모리 누수server.cpp:45:handleRequest함수에서 발생- 2,000개 블록, 총 2MB 누수
AddressSanitizer (모든 플랫폼) - 가장 빠른 도구
사용법
# 컴파일 (누수 탐지 포함)
g++ -fsanitize=address,leak -g program.cpp -o program
# 실행
./program
탐지 가능한 오류
- ✅ Use after free: 해제된 메모리 접근
- ✅ Heap buffer overflow: 배열 범위 초과
- ✅ Stack buffer overflow: 스택 배열 범위 초과
- ✅ Memory leaks: 메모리 누수
- ✅ Use after return: 스택 변수 주소 반환 후 사용
실제 출력 예제
=================================================================
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x60300000eff0
READ of size 4 at 0x60300000eff0 thread T0
#0 0x4a1234 in main program.cpp:15
#1 0x7f1234567890 in __libc_start_main
0x60300000eff0 is located 0 bytes inside of 4-byte region [0x60300000eff0,0x60300000eff4)
freed by thread T0 here:
#0 0x4b5678 in operator delete(void*)
#1 0x4a1200 in main program.cpp:12
previously allocated by thread T0 here:
#0 0x4b1234 in operator new(unsigned long)
#1 0x4a1100 in main program.cpp:10
Visual Studio Memory Profiler (Windows)
#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>
int main() {
// 메모리 누수 탐지 활성화
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
// 프로그램 코드
int* leak = new int(42); // 의도적 누수
// 프로그램 종료 시 자동으로 누수 리포트
return 0;
}
출력:
Detected memory leaks!
Dumping objects ->
{145} normal block at 0x00000123456789AB, 4 bytes long.
Data: <* > 2A 00 00 00
Object dump complete.
도구 선택 가이드
- Valgrind (Linux/macOS): 가장 정확한 누수 분석, 로컬 디버깅. 실행 10~20배 느려질 수 있음.
- AddressSanitizer (모든 플랫폼): 빠르고 CI/CD 적합.
-fsanitize=address,leak로 누수 탐지. - VS Memory Profiler (Windows): Visual Studio 통합.
6. 실전 디버깅: 메모리 누수 찾기
실제로 디버깅한 과정
1단계: 증상 확인
# 메모리 사용량 모니터링
top -p $(pgrep myprogram)
# 또는
watch -n 1 'ps aux | grep myprogram'
관찰 결과:
시작: 500MB
1시간 후: 1.2GB
2시간 후: 2.5GB
3시간 후: 7.8GB → 크래시
2단계: Valgrind로 누수 위치 찾기
valgrind --leak-check=full --log-file=valgrind.log ./myprogram
발견: handleRequest 함수에서 누수
3단계: 코드 분석
void handleRequest(const Request& req) {
Response* res = new Response(); // ← 여기서 할당
if (!req.isValid()) {
return; // ← 여기서 누수!
}
// ...
delete res;
}
4단계: 수정 및 검증
void handleRequest(const Request& req) {
auto res = std::make_unique<Response>();
if (!req.isValid()) {
return; // ✅ 자동 해제
}
// ...
}
# 재검증
valgrind --leak-check=full ./myprogram
결과:
==12345== HEAP SUMMARY:
==12345== in use at exit: 0 bytes in 0 blocks
==12345== total heap usage: 10,000 allocs, 10,000 frees
==12345==
==12345== All heap blocks were freed -- no leaks are possible
메모리 누수 디버깅 체크리스트
- 메모리 사용량이 계속 증가하는가?
- Valgrind로 누수 위치 확인
- 모든
new에 대응하는delete있는가? - 예외 발생 시에도
delete실행되는가? - early return 경로에서
delete누락 없는가? - 컨테이너에 원시 포인터 저장하지 않았는가?
7. 메모리 누수 흔한 패턴 정리
패턴 A: 팩토리 함수에서 소유권 전달 실수
// ❌ 호출자가 delete 해야 하는데 누락하기 쉬움
Widget* createWidget() {
return new Widget();
}
// ✅ unique_ptr로 소유권 명시
std::unique_ptr<Widget> createWidget() {
return std::make_unique<Widget>();
}
주의점: raw 포인터 반환 시 “누가 delete하는가?”가 불명확해져 누수·이중 삭제 위험이 큼.
패턴 B: 예외 발생 가능 구간 사이의 할당
// ❌ new와 delete 사이에 예외 가능 코드
void bad() {
A* a = new A();
doSomething(); // 예외 발생 시 a 누수
delete a;
}
// ✅ RAII
void good() {
auto a = std::make_unique<A>();
doSomething();
}
패턴 C: 맵/셋에 포인터 저장 후 clear만 호출
// ❌
std::map<int, Node*> cache;
cache[1] = new Node();
cache.clear(); // Node 객체는 누수
// ✅
std::map<int, std::unique_ptr<Node>> cache;
cache[1] = std::make_unique<Node>();
cache.clear(); // 자동 해제
패턴 D: 순환 참조 (shared_ptr)
// ❌ 순환 참조 → 참조 카운트가 0이 안 됨
struct A { std::shared_ptr<A> other; };
auto a = std::make_shared<A>();
a->other = a; // 순환 → 누수
// ✅ weak_ptr로 끊기
struct A { std::weak_ptr<A> other; };
패턴 E: C API와의 경계
// ❌ some_c_api_free(ptr) 누락
void* ptr = some_c_api_alloc();
// ✅ RAII 래퍼
struct CAllocGuard {
void* p;
CAllocGuard(void* ptr) : p(ptr) {}
~CAllocGuard() { if (p) some_c_api_free(p); }
};
8. 자주 발생하는 에러와 해결법
에러 1: “definitely lost”가 Valgrind에 계속 나옴
원인: new에 대응하는 delete가 실행되지 않는 경로 존재. 해결: Valgrind가 가리킨 줄 확인 → return/throw 경로 검사 → unique_ptr/shared_ptr로 교체.
에러 2: “invalid free” / “double free”
원인: 이미 delete한 포인터 재삭제, 또는 malloc을 delete로 해제. 해결: delete 후 ptr = nullptr, new/delete와 malloc/free 짝 맞추기, 스마트 포인터 사용.
에러 3: “AddressSanitizer: heap-use-after-free”
원인: delete한 메모리 접근 (댕글링 포인터). 해결: delete 후 포인터 미사용, shared_ptr/weak_ptr로 수명 관리.
에러 4: “Mismatched free() / delete”
원인: new[]를 delete로, 또는 new를 delete[]로 해제. 해결: new↔delete, new[]↔delete[], malloc↔free 짝 맞추기.
에러 5: Release에서는 괜찮은데 Valgrind에서만 누수
원인: pthread, glibc 등이 종료 시 해제하지 않는 메모리. still reachable로 표시됨.
해결법: --show-leak-kinds=definite로 definitely lost만 확인. still reachable은 라이브러리 이슈일 수 있음.
9. 모범 사례와 프로덕션 패턴
모범 사례 요약
| 원칙 | 나쁜 예 | 좋은 예 |
|---|---|---|
| 할당 | new T | std::make_unique<T>() |
| 배열 | new T[n] | std::vector<T> 또는 std::make_unique<T[]>(n) |
| 소유권 전달 | return new T() | return std::make_unique<T>() |
| 컨테이너 요소 | vector<T*> | vector<unique_ptr<T>> |
| 공유 소유 | T* 여러 곳에서 사용 | std::shared_ptr<T> |
프로덕션 패턴 1: CI에서 ASan 빌드
cmake -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=address,leak -fno-omit-frame-pointer" ..
make && ctest
효과: PR마다 메모리 버그 자동 탐지.
프로덕션 패턴 2: 메모리 사용량 모니터링
// 주기적으로 RSS 확인 (Linux)
#include <sys/resource.h>
size_t getCurrentRSS() {
struct rusage usage;
getrusage(RUSAGE_SELF, &usage);
return usage.ru_maxrss * 1024; // KB -> bytes
}
// 로그: LOG_INFO("RSS: {} MB", getCurrentRSS() / (1024 * 1024));
효과: 서버 메모리가 서서히 증가하는지 추적 가능.
프로덕션 패턴 3: 객체 풀과 스마트 포인터
// ❌ pool.release(obj) 누락 시 누수
Object* obj = pool.acquire();
// ✅ unique_ptr + 커스텀 deleter → 스코프 벗어나면 자동 release
auto obj = std::unique_ptr<Object, PoolDeleter>(pool.acquire());
프로덕션 패턴 4: 코드 리뷰 체크리스트
-
new가 보이면unique_ptr/shared_ptr로 대체 가능한지 검토 - 모든
return/throw경로에서 리소스 해제 여부 확인 - 컨테이너에 raw 포인터를 넣지 않았는지 확인
-
delete/delete[]짝이 맞는지 확인
프로덕션 배포 전 체크리스트
- Valgrind/ASan으로
definitely lost0 bytes 확인 - 장시간 부하 테스트 후 메모리 수렴 확인
- OOM 킬 로그 모니터링
10. 예방법: 스마트 포인터 미리보기
스마트 포인터가 해결하는 모든 문제
// ❌ 원시 포인터의 문제들
void oldWay() {
int* ptr = new int(42);
// 문제 1: 메모리 누수
if (error1) return;
// 문제 2: 예외 안전성
if (error2) throw std::exception();
// 문제 3: 이중 삭제
delete ptr;
delete ptr;
}
// ✅ 스마트 포인터로 모든 문제 해결
void modernWay() {
auto ptr = std::make_unique<int>(42);
// ✅ 메모리 누수 없음
if (error1) return;
// ✅ 예외 안전
if (error2) throw std::exception();
// ✅ 이중 삭제 불가능
}
위 코드 설명: oldWay에서는 return·throw 시 delete가 호출되지 않고, delete를 두 번 쓰면 이중 삭제가 됩니다. modernWay에서는 std::make_unique가 소유권을 갖고, 스코프를 벗어날 때(return·throw 포함) 소멸자에서 한 번만 delete가 호출되므로 누수와 이중 삭제가 사라집니다.
다음 글 예고
스마트 포인터의 종류와 사용법은 다음 글에서 자세히 다룹니다:
unique_ptr: 독점 소유권shared_ptr: 공유 소유권weak_ptr: 순환 참조 방지
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
- C++ 기술 면접 질문 30선 | “포인터와 참조의 차이는?” 실전 답변 정리
이 글에서 다루는 키워드 (관련 검색어)
C++ 메모리 누수, new delete, Valgrind, AddressSanitizer, 댕글링 포인터, 이중 삭제, 메모리 누수 탐지, 메모리 디버깅, delete[] 등으로 검색하시면 이 글이 도움이 됩니다.
마무리
핵심 요약
✅ new/delete는 위험함: 메모리 누수, 이중 삭제, 댕글링 포인터
✅ 메모리 누수 탐지: Valgrind, AddressSanitizer 필수
✅ delete 후 nullptr: 이중 삭제 방지
✅ delete vs delete[]: 배열은 반드시 delete[]
✅ 예외 안전성: 예외 발생 시에도 메모리 해제 보장
✅ 해결책: 스마트 포인터 사용 (다음 글에서 상세히)
실무 교훈
서버를 다운시킨 경험에서 배운 것:
- 메모리 누수는 조용히 발생한다 (즉시 크래시 안 함)
- 프로덕션 전에 Valgrind 필수
- 스마트 포인터를 기본으로 사용
- 모든 early return 경로 검토
다음 글
메모리 누수의 근본적인 해결책은 스마트 포인터입니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ 메모리 누수 탐지·해결 완벽 가이드. 서버를 다운시킨 실제 메모리 누수 사례, new/delete 위험 패턴 5가지, 이중 삭제·댕글링 포인터·배열 delete[] 실수, Valgrind·AddressSani… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: new/delete 대신 스마트 포인터·Valgrind·ASan으로 누수를 막고 찾을 수 있습니다. 다음으로 스마트 포인터(#6-3)를 읽어보면 좋습니다.
다음 글: C++ 실전 가이드 #6-3: 스마트 포인터 완벽 가이드 - unique_ptr, shared_ptr, weak_ptr의 모든 것을 설명합니다.
참고 자료
메모리 디버깅 도구
메모리 관리 가이드
관련 글
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
- C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지
- C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ RAII |