C++ 메모리 누수: 흔한 5가지 패턴과 Valgrind·ASan으로 찾는 방법

💡 핵심 개념: new를 썼으면 모든 return·예외 경로에서 delete(또는 스마트 포인터)가 실행되는지 의심하라. “조금씩만 새는” 누수는 Valgrind·AddressSanitizer 없이는 잡기 어렵습니다.

금요일 오후 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로 누수를 찾는 방법, 그리고 흔한 누수 패턴과 프로덕션에서의 예방책을 차례로 다룹니다.

게임 서버·이미지 배치·LRU 캐시·콜백에서 새는 경우

시나리오 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가 하는 일

new 한 번 호출이 내부적으로는 “메모리 할당 → 생성자 호출 → 주소 반환” 순서로 진행됩니다. operator new가 힙에서 크기에 맞는 블록을 찾아 할당하며, 그 주소에 MyClass 생성자를 호출한 뒤, 그 주소를 포인터로 돌려줍니다. 따라서 new를 쓴 쪽은 반드시 delete로 같은 주소를 한 번만 해제해야 하며, 중간에 return이나 throw가 나오면 delete가 실행되지 않을 수 있어 누수나 이중 해제의 원인이 됩니다.

MyClass* obj = new MyClass(arg1, arg2);

new MyClass(...)가 호출되면 먼저 operator new로 힙에서 메모리를 잡으며, 그 주소에서 생성자를 호출한 뒤 포인터를 반환합니다. 이 포인터는 나중에 반드시 delete obj로 한 번만 해제해야 하며, 중간에 return이나 예외가 나면 delete가 호출되지 않아 누수가 됩니다. 내부 동작:

  1. 메모리 할당: operator new로 메모리 요청
  2. 생성자 호출: 할당된 메모리에 객체 생성
  3. 포인터 반환: 객체의 주소 반환

delete가 하는 일

delete obj;

delete obj는 먼저 해당 주소의 소멸자를 호출해 객체를 정리한 뒤, operator delete로 힙 메모리를 반환합니다. obj는 반드시 new로 얻은 유효한 주소여야 하며, 이미 삭제한 포인터에 다시 delete를 쓰면 이중 삭제로 정의되지 않은 동작이 됩니다. 내부 동작:

  1. 소멸자 호출: 객체 정리
  2. 메모리 해제: 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 사용하지 마세요. 생성자/소멸자가 호출되지 않아 리소스 누수가 발생합니다.


이중 삭제·댕글링·delete[] 혼동 등 위험한 다섯 가지

이중 삭제 (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로 바꿔서는 댕글링이 남으므로, 스마트 포인터 사용이 더 안전합니다.

댕글링 포인터 (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가 호출되므로 댕글링과 이중 삭제를 피할 수 있습니다.

메모리 누수 (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 처리하는 것이 안전합니다.

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[]를 써야 모든 원소의 소멸자가 호출되고 메모리가 올바르게 반환됩니다.

예외 안전성 무시

겪은 메모리 누수:

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일 동안 찾지 못한 버그:

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를 할 필요가 없습니다.

조건부 리턴

서버를 다운시킨 코드:

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());
    // ✅ 자동 해제!
}

예외 처리 실수

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());  // ✅ 예외 발생해도 자동 해제
}

Valgrind와 ASan으로 누수 잡아내기

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("") 호출 시 누수.

배열 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로 수정.

컨테이너 포인터 누수 (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 필요.


Valgrind·ASan·Visual Studio 도구 비교

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 (GCC·Clang·MSVC, 누수 탐지는 Linux)

사용법

# 컴파일 (Linux에서는 누수 탐지 LeakSanitizer 포함)
g++ -fsanitize=address -fno-omit-frame-pointer -g program.cpp -o program
# 실행
./program

탐지 가능한 오류

  • Use after free: 해제된 메모리 접근
  • Heap buffer overflow: 배열 범위 초과
  • Stack buffer overflow: 스택 배열 범위 초과
  • Memory leaks: 메모리 누수 (Linux. MSVC·macOS Apple Clang은 미지원)
  • Use after return: 스택 변수 주소 반환 후 사용 (GCC는 ASAN_OPTIONS=detect_stack_use_after_return=1을 줘야 검사)

실제 출력 예제

=================================================================
==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(특히 Apple Silicon)는 공식 지원이 없어 사실상 쓸 수 없습니다.
  • AddressSanitizer: 빠르고 CI/CD에 적합. Linux의 GCC/Clang에서는 -fsanitize=address만으로 LeakSanitizer(누수 탐지)가 함께 켜집니다. 주의할 점은 누수 탐지가 플랫폼마다 다르다는 것입니다. MSVC의 ASan은 메모리 오류(범위 초과, use-after-free)는 잡지만 누수 탐지를 지원하지 않고, macOS의 Apple Clang에서도 LeakSanitizer는 지원되지 않습니다. “ASan을 켰는데 누수가 하나도 안 나온다”면 코드가 깨끗한 게 아니라 플랫폼이 누수 검사를 안 하는 것일 수 있습니다.
  • CRT 디버그 힙 / VS Memory Profiler (Windows): 위의 _CrtSetDbgFlag 방식으로 종료 시 누수 목록을 받고, 할당 번호({145})를 _CrtSetBreakAlloc(145)에 넣어 다시 실행하면 그 할당 지점에서 디버거가 멈춥니다. 할당 순서가 매번 같은 프로그램에서 특히 유용합니다.

서버 누수를 찾아낸 디버깅 과정

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 누락 없는가?
  • 컨테이너에 원시 포인터 저장하지 않았는가?

팩토리·맵·순환 참조·C API 경계에서 반복되는 누수

팩토리 함수에서 소유권 전달 실수

// ❌ 호출자가 delete 해야 하는데 누락하기 쉬움
Widget* createWidget() {
    return new Widget();
}
// ✅ unique_ptr로 소유권 명시
std::unique_ptr<Widget> createWidget() {
    return std::make_unique<Widget>();
}

주의점: raw 포인터 반환 시 “누가 delete하는가?”가 불명확해져 누수·이중 삭제 위험이 큼.

예외 발생 가능 구간 사이의 할당

// ❌ new와 delete 사이에 예외 가능 코드
void bad() {
    A* a = new A();
    doSomething();  // 예외 발생 시 a 누수
    delete a;
}
// ✅ RAII
void good() {
    auto a = std::make_unique<A>();
    doSomething();
}

맵/셋에 포인터 저장 후 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();  // 자동 해제

순환 참조 (shared_ptr)

// ❌ 순환 참조 → 참조 카운트가 0이 안 됨
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;   // 뒤쪽 노드도 앞쪽을 소유
};
void build() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    a->next = b;
    b->prev = a;
}   // a, b 지역 변수는 사라지지만 서로가 서로를 가리켜 카운트가 1로 남음 → 둘 다 누수

// ✅ 역방향 링크는 weak_ptr로
struct NodeFixed {
    std::shared_ptr<NodeFixed> next;
    std::weak_ptr<NodeFixed> prev;   // 가리키기만 하고 소유하지 않음
};

build()가 끝나면 두 노드에 접근할 방법은 완전히 사라지지만, 참조 카운트는 “아직 누가 가리키고 있다”는 사실만 볼 뿐 그 가리키는 쪽도 이미 쓰레기라는 것은 알 수 없습니다. weak_ptr는 대상을 가리키되 카운트에 기여하지 않으므로, prev를 weak_ptr로 바꾸면 진짜 소유 경로(a → next → b)만 남아 정상적으로 소멸합니다. 부모-자식, 앞-뒤, 관찰자-주체처럼 양방향 관계가 생기면 어느 방향이 소유이고 어느 방향이 참조인지를 먼저 정해야 합니다.

실무에서 더 찾기 어려운 순환은 자료구조가 아니라 콜백에서 생깁니다.

class Connection : public std::enable_shared_from_this<Connection> {
    std::function<void()> onTimeout_;
public:
    void arm() {
        // ❌ 자기 자신을 소유하는 콜백을 자기 멤버에 저장 → 스스로를 영원히 살려 둠
        onTimeout_ = [self = shared_from_this()] { self->close(); };
        // ✅ weak_ptr로 캡처
        onTimeout_ = [weak = weak_from_this()] {
            if (auto self = weak.lock()) self->close();
        };
    }
    void close();
};

연결이 끊긴 뒤에도 Connection 객체가 하나도 소멸하지 않아 서버 메모리가 연결 수만큼 계속 늘어나는데, delete도 new도 코드에 없으니 코드 리뷰로는 잘 보이지 않는 전형적인 패턴입니다. 이런 순환이 종료 시점에 어디서도 도달할 수 없는 상태라면 LeakSanitizer가 잡아 주지만, 연결 목록을 쥔 전역 매니저처럼 도달 가능한 곳에 매달려 있으면 “still reachable”로 분류돼 누수로 보고되지 않습니다. 그래서 오래 도는 프로세스는 도구 결과가 깨끗해도 소멸자 호출 횟수를 세거나 메모리 사용량 추이를 따로 확인해야 합니다(아래 “메모리 사용량 모니터링” 패턴).

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); }
};

definitely lost·double free 등 도구 메시지 해석

”definitely lost”가 Valgrind에 계속 나옴

원인: new에 대응하는 delete가 실행되지 않는 경로 존재. 해결: Valgrind가 가리킨 줄 확인 → return/throw 경로 검사 → unique_ptr/shared_ptr로 교체.

”invalid free” / “double free”

원인: 이미 delete한 포인터 재삭제, 또는 malloc을 delete로 해제. 해결: delete 후 ptr = nullptr, new/delete와 malloc/free 짝 맞추기, 스마트 포인터 사용.

”AddressSanitizer: heap-use-after-free”

원인: delete한 메모리 접근 (댕글링 포인터). 해결: delete 후 포인터 미사용, shared_ptr/weak_ptr로 수명 관리.

”Mismatched free() / delete”

원인: new[]를 delete로, 또는 new를 delete[]로 해제. 해결: new↔delete, new[]↔delete[], malloc↔free 짝 맞추기.

Release에서는 괜찮은데 Valgrind에서만 누수

원인: pthread, glibc 등이 종료 시 해제하지 않는 메모리. still reachable로 표시됩니다. 해결법: --show-leak-kinds=definite로 definitely lost만 확인. still reachable은 라이브러리 이슈일 수 있음.

Valgrind가 보고하는 네 종류를 구분하면 우선순위를 정하기 쉽습니다.

분류의미대응
definitely lost블록을 가리키는 포인터가 더는 없음가장 먼저 고칠 진짜 누수
indirectly lost누수된 블록 안에서만 가리켜지는 블록 (누수된 트리의 자식 노드 등)보통 definitely lost의 원인을 고치면 같이 사라짐
possibly lost블록의 시작이 아니라 중간을 가리키는 포인터만 있음커스텀 할당기·포인터 연산이면 오탐일 수 있으니 확인
still reachable종료 시점에도 포인터가 남아 있음 (전역 캐시, 싱글톤 등)대개 의도된 것이지만, 끝없이 커지는 캐시라면 누수와 다름없음

indirectly lost가 수천 개 찍혀 겁이 나도 실제 원인은 definitely lost 한두 줄인 경우가 많으니, 목록을 위에서부터 차례로 보기보다 definitely lost의 스택부터 확인하는 것이 빠릅니다.


누수를 막는 원칙과 운영 환경 패턴

원칙 요약표

원칙나쁜 예좋은 예
할당new Tstd::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>

CI에서 ASan 빌드

cmake -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_CXX_FLAGS="-fsanitize=address,leak -fno-omit-frame-pointer" ..
make && ctest

효과: PR마다 메모리 버그 자동 탐지.

메모리 사용량 모니터링

// 주기적으로 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));

효과: 서버 메모리가 서서히 증가하는지 추적 가능.

객체 풀과 스마트 포인터

// ❌ pool.release(obj) 누락 시 누수
Object* obj = pool.acquire();
// ✅ unique_ptr + 커스텀 deleter → 스코프 벗어나면 자동 release
auto obj = std::unique_ptr<Object, PoolDeleter>(pool.acquire());

코드 리뷰 체크리스트

  • new가 보이면 unique_ptr/shared_ptr로 대체 가능한지 검토
  • 모든 return/throw 경로에서 리소스 해제 여부 확인
  • 컨테이너에 raw 포인터를 넣지 않았는지 확인
  • delete/delete[] 짝이 맞는지 확인

배포 전 확인할 것

  • Valgrind/ASan으로 definitely lost 0 bytes 확인
  • 장시간 부하 테스트 후 메모리 수렴 확인
  • OOM 킬 로그 모니터링

스마트 포인터가 해결하는 문제 미리보기

원시 포인터 코드와 스마트 포인터 코드 비교

// ❌ 원시 포인터의 문제들
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: 순환 참조 방지

메모리 누수 정리

  • new/delete는 위험함: 메모리 누수, 이중 삭제, 댕글링 포인터
  • 메모리 누수 탐지: Valgrind, AddressSanitizer 필수
  • delete 후 nullptr: 이중 삭제 방지
  • delete vs delete[]: 배열은 반드시 delete[]
  • 예외 안전성: 예외 발생 시에도 메모리 해제 보장
  • 해결책: 스마트 포인터 사용 (다음 글에서 상세히)

서버를 다운시킨 경험에서 얻은 교훈

  1. 메모리 누수는 조용히 발생한다 (즉시 크래시 안 함)
  2. 프로덕션 전에 Valgrind 필수
  3. 스마트 포인터를 기본으로 사용
  4. 모든 early return 경로 검토

입문자가 먼저 확인할 네 가지

  • new/new[]와 delete/delete[] 짝이 맞는가? (배열은 delete[])
  • if 분기·return·throw마다 해제가 빠지지 않았는가?
  • 이미 delete한 포인터를 다시 쓰거나, 두 번 지우지 않았는가?
  • 로컬 개발에서는 AddressSanitizer(-fsanitize=address), Linux에서는 Valgrind로 한 번씩 돌려 보았는가?

💡 다음 단계: 수동 delete를 줄이려면 스마트 포인터(#6-3)에서 unique_ptr·shared_ptr을 기본으로 두는 습관을 들이면 됩니다.


자주 묻는 질문 (FAQ)

Q. 도구가 누수를 하나도 보고하지 않는데 메모리가 계속 늘어요.

A. 두 가지를 먼저 의심합니다. 하나는 플랫폼입니다. MSVC와 macOS의 ASan은 누수 탐지를 하지 않으므로 Linux에서 다시 확인합니다. 다른 하나는 “도달 가능한 누수”입니다. 끝없이 커지는 캐시나 전역 매니저에 매달린 shared_ptr 순환(패턴 D)은 종료 시점에도 포인터가 남아 있어 still reachable로 분류되므로 누수로 보고되지 않습니다. 이 경우 캐시 크기 상한, 소멸자 호출 횟수 로깅, 시간에 따른 RSS 추이로 원인을 좁힙니다.


같이 보면 좋은 글

참고 자료

메모리 디버깅 도구

메모리 관리 가이드