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

핵심: new를 썼다면 모든 return·예외 경로에서 delete(또는 스마트 포인터의 소멸자)가 실행되는지 의심해야 합니다. 조금씩만 새는 누수는 Valgrind나 AddressSanitizer 없이는 잡기 어렵습니다.

조금씩 새는 누수가 서버를 멈추는 방식

메모리 누수(memory leak)는 한 번 할당한 메모리를 해제하지 않아, 프로그램이 더는 쓰지도 못하는 메모리를 계속 차지하는 상태입니다. 누수는 한 번에 터지지 않습니다. 프로세스가 살아 있는 동안 조금씩 쌓이다가 컨테이너 메모리 제한이나 시스템 메모리에 닿으면 그때 OOM으로 프로세스가 죽습니다. 그래서 “재시작하면 멀쩡하다가 몇 시간마다 죽는다”, “CPU와 디스크는 정상인데 RSS만 꾸준히 오른다”는 증상이 보이면 누수를 먼저 의심하고, Valgrind나 AddressSanitizer로 할당과 해제의 균형을 확인하는 것이 순서입니다.

원인부터 해결까지의 흐름은 아래와 같습니다.

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

가장 흔한 형태는 요청 처리 함수의 early return입니다.

void processRequest(const std::string& data) {
    User* user = new User(data);  // 메모리 할당
    if (!user->isValid()) {
        return;  // ❌ delete 없이 리턴!
    }
    user->process();
    delete user;  // 정상 경로에서만 delete
}

processRequest 안에서 new User(data)로 힙에 객체를 만들었지만, isValid()가 false일 때 return으로 빠져나가면서 delete를 호출하지 않습니다. 에러 경로에서는 할당만 하고 해제는 하지 않으므로, 잘못된 요청이 들어올 때마다 User 하나만큼 메모리가 쌓입니다. 잘못된 요청의 비율이 높을수록 누수가 빨리 커지고, 한 번에 크래시하지 않으니 원인을 찾기까지 시간이 걸립니다.

아래처럼 std::unique_ptr로 바꾸면 모든 경로에서 소멸자가 해제를 맡습니다. 복사해 붙여넣은 뒤 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으로 끝나지만 user의 소멸자가 객체를 해제합니다. new만 하고 early return·예외·여러 분기 때문에 delete를 빠뜨리기 쉬우므로, 소유권을 가진 포인터는 기본적으로 std::unique_ptr·std::make_unique로 작성하는 편이 안전합니다. 스마트 포인터 사용법은 다음 글에서 다룹니다.

같은 구조는 다른 곳에서도 반복됩니다. 플레이어 퇴장 처리가 빠지는 경로가 있는 게임 서버, 버퍼를 new[]로 잡은 뒤 포맷 오류로 throw하는 이미지 처리 코드, map<Key, Value*>에서 eviction할 때 erase만 하는 캐시, 등록만 하고 해제하지 않는 콜백 객체가 모두 같은 원인입니다.

new와 delete가 실제로 하는 일

new가 하는 일

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

new MyClass(...) 한 번은 내부적으로 세 단계로 진행됩니다. 먼저 operator new가 힙에서 sizeof(MyClass)만큼의 메모리를 할당하고, 그 주소에서 MyClass 생성자를 호출한 뒤, 객체의 주소를 포인터로 돌려줍니다. 이 포인터는 나중에 정확히 한 번 delete obj로 해제해야 하며, 중간에 return이나 예외가 나면 delete가 호출되지 않아 누수가 됩니다.

delete가 하는 일

delete obj;

delete obj는 먼저 그 주소에 있는 객체의 소멸자를 호출해 정리한 뒤, operator delete로 메모리를 할당자에 돌려줍니다. obj는 반드시 new로 얻은 주소이거나 nullptr이어야 하며, 이미 삭제한 포인터를 다시 delete하면 정의되지 않은 동작이 됩니다.

new/delete와 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으로 할당해 돌려준 메모리처럼 malloc/free가 필요한 경우도 있지만, 그때는 반드시 free로 해제해야 하고 delete와 섞으면 안 됩니다.


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

이중 삭제 (Double Delete)

void processData() {
    int* ptr = new int(42);
    // ... 복잡한 로직 ...
    delete ptr;
    // ... 더 복잡한 로직 ...
    delete ptr;  // ❌ 이미 해제된 메모리 재삭제 → 정의되지 않은 동작
}

첫 번째 delete는 블록을 할당자(glibc malloc 등)의 free list에 돌려줍니다. 운영체제에 바로 반환되는 것이 아니라 할당자가 재사용할 수 있도록 관리하는 상태가 됩니다. 같은 주소를 다시 delete하면 할당자가 이미 해제된 블록을 또 free list에 넣으려 하면서 힙 메타데이터가 깨집니다. glibc는 일부 경우를 감지해 free(): double free detected로 프로세스를 중단하지만, 감지하지 못하면 나중에 전혀 상관없는 할당에서 크래시가 나기도 합니다.

int* ptr = new int(42);
delete ptr;
ptr = nullptr;  // ✅ nullptr로 설정
delete ptr;     // 아무 일도 일어나지 않음

C++ 표준은 nullptr에 대한 delete를 아무 동작도 하지 않도록 정의하므로, delete 직후 ptr = nullptr로 두면 같은 변수를 통한 이중 삭제는 막을 수 있습니다. if (ptr != nullptr) 검사도 필요 없습니다. 다만 여러 포인터가 같은 객체를 가리킬 때는 하나만 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로 정의되지 않은 동작이며, 크래시할 수도 있고 이전 값이나 다른 할당이 덮어쓴 값이 조용히 나올 수도 있습니다. 후자가 더 위험합니다.

auto ptr1 = std::make_shared<int>(42);
auto ptr2 = ptr1;  // 참조 카운트 증가
ptr1.reset();      // 참조 카운트 감소
std::cout << *ptr2;  // ✅ 안전 (아직 메모리 유효)

std::make_shared로 만들면 참조 카운트가 관리되어, ptr2가 남아 있는 동안 ptr1.reset()만으로는 메모리가 해제되지 않습니다. 마지막 shared_ptr이 사라질 때 한 번만 delete가 호출되므로 댕글링과 이중 삭제를 함께 피할 수 있습니다. 공유가 꼭 필요하지 않다면 소유자는 unique_ptr 하나로 두고 나머지는 수명이 더 짧은 참조로 쓰는 편이 더 단순합니다.

메모리 누수 (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와 delete[] 혼동

int* arr = new int[100];
delete arr;  // ❌ delete[] 대신 delete 사용 → 정의되지 않은 동작

new[]로 할당한 배열을 delete로 해제하는 것은 정의되지 않은 동작입니다. 결과는 구현과 타입에 따라 다릅니다. int처럼 소멸자가 없는 타입은 GCC/glibc 조합에서 겉보기에 문제없이 동작하는 경우가 많아 오래 숨어 있기 쉽습니다. 반면 소멸자가 있는 클래스 타입의 배열은 구현이 블록 앞에 원소 개수를 따로 기록해 두는 경우가 많아, 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[]로 짝을 맞춰야 모든 원소의 소멸자가 호출되고 메모리가 올바르게 반환됩니다. 애초에 배열이 필요하면 std::vector를 쓰면 이 실수를 할 일이 없습니다.

예외 안전성 무시

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

if (!file)에서 예외를 던지면 스택이 풀리면서 그 아래 코드는 건너뛰므로 delete[] buffer가 실행되지 않습니다. 스택 언와인딩은 지역 객체의 소멸자만 호출하고, raw 포인터가 가리키는 힙 메모리는 건드리지 않기 때문입니다.

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)로 버퍼를 감싸면 buffer가 지역 객체이므로, 예외나 early return이 나도 소멸자에서 delete[]가 호출됩니다. 기존 API에는 buffer.get()으로 raw 포인터만 넘기면 됩니다. 이 경우라면 std::vector<char> buffer(1024)로도 충분합니다.


컨테이너와 조건부 리턴에서 생기는 누수

컨테이너에 포인터 저장

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));
    }
    return 0;  // 10,000개 User 객체가 해제되지 않음
}

users에는 User*만 들어가고 실제 User 객체는 힙에 따로 있습니다. clear()는 벡터 안의 포인터 값만 지울 뿐, 그 포인터가 가리키던 객체에 대한 delete는 호출하지 않습니다. 벡터는 원소의 소멸자를 호출하는데, raw 포인터의 “소멸자”는 아무 일도 하지 않기 때문입니다.

소멸자에서 직접 해제할 수도 있습니다.

~UserManager() {
    for (User* user : users) {
        delete user;
    }
}

이렇게 하면 누수는 막지만, UserManager가 복사되면 두 인스턴스가 같은 포인터들을 각각 delete하는 이중 삭제가 생깁니다. 복사 생성자와 대입 연산자까지 직접 막거나 구현해야 하므로 번거롭습니다. 원소 타입을 unique_ptr로 바꾸는 편이 낫습니다.

class UserManager {
    std::vector<std::unique_ptr<User>> users;
public:
    void addUser(const std::string& name) {
        users.push_back(std::make_unique<User>(name));
    }
    // ✅ 소멸자 불필요, 복사도 컴파일러가 막아 줌
};

vector<unique_ptr<User>>에서는 각 원소가 User를 소유합니다. UserManager가 소멸될 때 벡터가 각 unique_ptr의 소멸자를 호출하고 그때 User가 delete되므로, 별도 소멸자가 필요 없습니다. unique_ptr가 복사 불가이므로 UserManager의 암시적 복사도 삭제되어 이중 삭제 위험까지 사라집니다.

조건부 리턴

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;  // 정상 경로에서만 실행됨
}

isValid()나 authenticate()가 실패하면 delete res에 도달하지 않아 요청마다 Response 하나가 샙니다. 에러 경로가 늘어날수록 delete를 빠짐없이 넣기가 어려워지고, processRequest나 sendResponse가 예외를 던지는 경로까지 생각하면 수동 관리로는 사실상 막을 수 없습니다.

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());
}   // ✅ 정상 경로와 예외 경로 모두 자동 해제

Valgrind와 ASan으로 누수 잡아내기

아래 출력은 x86-64 Linux의 GCC/libstdc++ 기준으로, 주소와 PID는 실행마다 달라지고 스택 프레임 표기도 버전에 따라 조금씩 다릅니다.

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;
}
g++ -g -std=c++17 -o leak_early_return leak_early_return.cpp
valgrind --leak-check=full --show-leak-kinds=all ./leak_early_return
==12345== 32 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4846FA3: operator new(unsigned long) (vg_replace_malloc.c:...)
==12345==    by 0x109300: process(std::__cxx11::basic_string<char, ...> const&) (leak_early_return.cpp:8)
==12345==    by 0x1093E5: main (leak_early_return.cpp:17)
==12345==
==12345== LEAK SUMMARY:
==12345==    definitely lost: 32 bytes in 1 blocks

스택은 “어디서 해제가 빠졌는지”가 아니라 “어디서 할당했는지”를 보여 줍니다. 8번째 줄의 new Data로 할당한 블록이 해제되지 않았고, 그 호출은 17번째 줄 process("")에서 왔습니다. 크기가 32바이트인 이유는 Data가 std::string 하나만 갖고 있고, libstdc++에서 sizeof(std::string)이 64비트 기준 32바이트이기 때문입니다. 빈 문자열은 SSO(짧은 문자열 최적화) 버퍼 안에 들어가므로 별도의 힙 할당이 없습니다. 이제 할당 지점에서 출발해 그 포인터가 지나가는 모든 return·throw 경로를 확인하면 됩니다.

new[]를 delete로 해제 (ASan)

#include <iostream>
int main() {
    int* arr = new int[1000];
    for (int i = 0; i < 1000; ++i) arr[i] = i;
    delete arr;  // ❌ delete[] 여야 함
    return 0;
}
g++ -fsanitize=address -fno-omit-frame-pointer -g -std=c++17 -o leak_array leak_array.cpp
./leak_array
==12345==ERROR: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x...
    #0 ... in operator delete(void*, unsigned long)
    #1 ... in main leak_array.cpp:5
...
0x... is located 0 bytes inside of 4000-byte region [0x...,0x...)
allocated by thread T0 here:
    #0 ... in operator new[](unsigned long)
    #1 ... in main leak_array.cpp:3

이 실수는 누수로 보고되지 않습니다. Linux의 ASan은 기본적으로 alloc_dealloc_mismatch 검사가 켜져 있어, new[]로 받은 블록을 delete로 해제하는 순간 할당 지점과 해제 지점을 함께 보여 주고 프로세스를 중단합니다. delete[] arr로 고치면 사라집니다. Valgrind로 돌려도 Mismatched free() / delete / delete []로 같은 문제를 알려 줍니다.

컨테이너 포인터 누수 (Valgrind)

#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;
}
g++ -g -std=c++17 -o leak_container leak_container.cpp
valgrind --leak-check=full --show-leak-kinds=all ./leak_container
==12345== 3,200 bytes in 100 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4846FA3: operator new(unsigned long) (vg_replace_malloc.c:...)
==12345==    by 0x10939A: main (leak_container.cpp:10)

100개의 Item(각 32바이트)이 하나의 레코드로 묶여 보고됩니다. Valgrind는 같은 스택에서 할당된 같은 종류의 누수를 합쳐서 보여 주므로, 블록 수가 반복 횟수와 같다면 루프 안의 할당을 의심하면 됩니다. 이름이 15자를 넘었다면 문자열 버퍼도 따로 할당되어 indirectly lost로 함께 나타났을 것입니다.


Valgrind·ASan·Visual Studio 도구 비교

Valgrind (Linux)

# Ubuntu/Debian
sudo apt install valgrind

# 디버그 심볼을 넣어 컴파일한 뒤 실행
g++ -g program.cpp -o program
valgrind --leak-check=full --show-leak-kinds=all ./program

Valgrind는 재컴파일 없이 바이너리를 가상 CPU 위에서 실행하며 모든 메모리 접근을 추적합니다. 그 대가로 Memcheck는 일반 실행보다 수십 배 느리므로, 서버 전체를 오래 돌리기보다는 의심 경로를 재현하는 작은 시나리오에 쓰는 것이 현실적입니다. -O0이나 -Og로 빌드하면 스택의 줄 번호가 더 정확합니다.

AddressSanitizer (GCC·Clang·MSVC, 누수 탐지는 Linux)

# Linux에서는 -fsanitize=address만으로 LeakSanitizer도 함께 켜짐
g++ -fsanitize=address -fno-omit-frame-pointer -g program.cpp -o program
./program

ASan이 잡는 오류는 다음과 같습니다.

  • use-after-free: 해제된 메모리 접근
  • heap-buffer-overflow: 힙 배열 범위 초과
  • stack-buffer-overflow: 스택 배열 범위 초과
  • 메모리 누수: Linux에서만 기본 동작 (MSVC·macOS Apple Clang은 미지원)
  • stack-use-after-return: 함수가 끝난 뒤 그 지역 변수 주소를 사용. 컴파일러와 버전에 따라 ASAN_OPTIONS=detect_stack_use_after_return=1을 줘야 검사합니다.

use-after-free를 만나면 아래처럼 접근 지점, 해제 지점, 할당 지점의 스택 세 개를 한꺼번에 보여 줍니다. 디버거로 재현하기 어려운 버그도 이 세 스택만으로 원인이 드러나는 경우가 많습니다.

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x... in main program.cpp:15
0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
    #0 0x... in operator delete(void*, unsigned long)
    #1 0x... in main program.cpp:12
previously allocated by thread T0 here:
    #0 0x... in operator new(unsigned long)
    #1 0x... in main program.cpp:10

여기서 주의할 점은 누수 탐지가 플랫폼마다 다르다는 것입니다. MSVC의 ASan은 범위 초과나 use-after-free는 잡지만 누수 탐지를 지원하지 않고, macOS의 Apple Clang에서도 LeakSanitizer가 지원되지 않습니다. “ASan을 켰는데 누수가 하나도 안 나온다”면 코드가 깨끗한 것이 아니라 그 플랫폼이 누수 검사를 하지 않는 것일 수 있습니다.

CRT 디버그 힙 (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.

중괄호 안의 할당 번호({145})를 _CrtSetBreakAlloc(145)에 넣고 다시 실행하면 그 할당이 일어나는 순간 디버거가 멈춥니다. 할당 순서가 매번 같은 프로그램에서 특히 유용합니다. _CRTDBG_MAP_ALLOC은 malloc 계열에 파일·줄 정보를 붙여 주지만 new에는 적용되지 않으므로, new 누수는 이 할당 번호로 추적하는 편이 빠릅니다.

무엇을 쓸까

Linux라면 평소 개발과 CI에는 ASan을 켜 두고, 재컴파일이 어렵거나 ASan이 놓친 부분을 확인할 때 Valgrind를 씁니다. Valgrind는 최신 macOS(특히 Apple Silicon)를 공식 지원하지 않으므로, macOS 개발자는 누수 확인만큼은 Linux 컨테이너나 CI에서 돌리는 것이 확실합니다. Windows에서는 CRT 디버그 힙과 Visual Studio의 메모리 사용량 도구(힙 스냅샷 비교)를 함께 씁니다.


서버 누수를 좁혀 가는 순서

먼저 정말 누수인지 확인합니다. RSS가 부하와 상관없이 단조 증가하는지 봅니다.

# 1초마다 해당 프로세스의 RSS(KB) 출력
watch -n 1 'ps -o pid,rss,cmd -p $(pgrep -d, myprogram)'

부하가 일정한데도 RSS가 계속 오른다면 누수 가능성이 큽니다. 반대로 처음 몇 분 동안 오르다가 일정 수준에서 멈춘다면 캐시가 채워지거나 할당자가 해제된 메모리를 OS에 돌려주지 않고 쥐고 있는 것일 수 있어, 누수와 구분해야 합니다.

다음으로 의심 경로를 재현하는 시나리오를 Valgrind나 ASan 빌드로 돌려 할당 지점을 얻습니다.

valgrind --leak-check=full --log-file=valgrind.log ./myprogram

리포트에 handleRequest가 가리키는 할당이 있다면, 그 포인터가 지나가는 모든 return과 throw 경로를 따라갑니다. 앞의 handleRequest 예처럼 에러 경로에서 delete가 빠진 경우가 대부분이며, std::make_unique로 바꾼 뒤 같은 시나리오를 다시 돌려 다음과 같은 결과를 확인합니다.

==12345== HEAP SUMMARY:
==12345==     in use at exit: 0 bytes in 0 blocks
==12345==   total heap usage: ... allocs, ... frees, ... bytes allocated
==12345==
==12345== All heap blocks were freed -- no leaks are possible

allocs와 frees 수가 같고 “All heap blocks were freed”가 나오면 그 시나리오에서는 누수가 없습니다. 마지막으로 수정한 빌드를 실제와 비슷한 부하로 오래 돌려 RSS가 수렴하는지 확인해야 도구가 볼 수 없는 “도달 가능한 누수”까지 배제할 수 있습니다.


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

팩토리 함수의 소유권 전달

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

raw 포인터를 반환하면 “누가 delete하는가”가 시그니처에 드러나지 않아 누수와 이중 삭제가 모두 생기기 쉽습니다. unique_ptr를 반환하면 소유권이 호출자에게 넘어간다는 사실이 타입에 적히고, 호출자가 반환값을 무시해도 임시 객체가 바로 해제됩니다. 호출자가 공유 소유가 필요하면 std::shared_ptr<Widget> w = createWidget();처럼 그대로 변환할 수 있습니다.

new와 delete 사이의 예외 가능 코드

// ❌ doSomething()이 예외를 던지면 a 누수
void bad() {
    A* a = new A();
    doSomething();
    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();  // 자동 해제

erase로 하나씩 제거하는 eviction 코드도 마찬가지입니다. 값 타입이 unique_ptr이면 erase가 곧 해제가 됩니다.

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로 캡처 (weak_from_this는 C++17)
        onTimeout_ = [weak = weak_from_this()] {
            if (auto self = weak.lock()) self->close();
        };
    }
    void close();
};

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

C API와의 경계

// ❌ some_c_api_free(ptr) 누락
void* ptr = some_c_api_alloc();

// ✅ unique_ptr + 커스텀 deleter
struct CApiDeleter {
    void operator()(void* p) const { some_c_api_free(p); }
};
std::unique_ptr<void, CApiDeleter> guard(some_c_api_alloc());

C API가 돌려준 포인터를 감싸는 래퍼를 직접 만들 수도 있지만, 복사 생성자를 막지 않으면 복사본 두 개가 같은 포인터를 각각 해제하는 이중 해제가 생깁니다. unique_ptr에 deleter를 붙이면 복사 금지와 이동 지원이 따라오고, nullptr일 때는 deleter가 호출되지 않습니다. 파일 핸들처럼 포인터가 아닌 핸들(int fd)이라면 작은 RAII 클래스를 만들고 복사 연산을 = delete로 지정합니다.

객체 풀

// ❌ pool.release(obj) 누락 시 누수
Object* obj = pool.acquire();

// ✅ 스코프를 벗어나면 풀에 반환
struct PoolDeleter {
    Pool* pool;
    void operator()(Object* o) const { pool->release(o); }
};
std::unique_ptr<Object, PoolDeleter> obj(pool.acquire(), PoolDeleter{&pool});

여기서 “누수”는 힙 누수가 아니라 풀 슬롯 누수라서 Valgrind나 ASan은 잡지 못합니다. 풀이 끝까지 메모리를 쥐고 있기 때문입니다. 풀 크기와 사용 중인 슬롯 수를 지표로 내보내 두는 편이 좋습니다.


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

definitely lost가 계속 나온다면 new에 대응하는 delete가 실행되지 않는 경로가 있다는 뜻입니다. Valgrind가 가리킨 할당 줄에서 출발해 return/throw 경로를 확인하고 unique_ptr/shared_ptr로 바꿉니다.

invalid free나 double free는 이미 해제한 포인터를 다시 해제했거나, malloc으로 받은 메모리를 delete로 해제하는 식으로 할당과 해제 함수가 엇갈렸을 때 나옵니다.

heap-use-after-free(ASan)는 해제된 메모리에 접근했다는 뜻이며, 출력의 “freed by” 스택이 누가 먼저 해제했는지 알려 줍니다. 수명이 엇갈리는 두 포인터 중 어느 쪽이 소유자인지 정한 뒤, 관찰만 하는 쪽은 weak_ptr이나 수명이 더 짧은 참조로 바꿉니다.

Mismatched free() / delete / delete [](Valgrind)나 alloc-dealloc-mismatch(ASan)는 new[]를 delete로, new를 delete[]로, 또는 malloc을 delete로 해제했을 때 나옵니다.

Valgrind에서만 누수가 보이고 실제 서비스에는 영향이 없는 경우는 대개 glibc나 pthread처럼 프로세스 종료 시 일부러 해제하지 않는 라이브러리 메모리입니다. 이런 블록은 보통 still reachable로 표시되므로, --show-leak-kinds=definite로 definitely lost만 보거나 suppression 파일로 걸러 냅니다.

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>> 또는 vector<T>
공유 소유T* 여러 곳에서 사용std::shared_ptr<T>

CI에서 ASan 빌드

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

테스트를 ASan 빌드로 돌리면 테스트가 지나가는 경로의 누수와 메모리 오류가 PR 단계에서 드러납니다. 다만 테스트가 실행하지 않는 경로는 검사되지 않으므로, 에러 경로를 테스트로 덮는 것이 누수 탐지 범위를 넓히는 가장 확실한 방법입니다.

메모리 사용량 모니터링

// 현재 RSS를 바이트 단위로 반환 (Linux)
#include <fstream>
#include <unistd.h>
size_t getCurrentRSS() {
    long pages = 0, resident = 0;
    std::ifstream statm("/proc/self/statm");
    statm >> pages >> resident;  // 두 번째 값이 상주 페이지 수
    return static_cast<size_t>(resident) * sysconf(_SC_PAGESIZE);
}
// 로그: LOG_INFO("RSS: {} MB", getCurrentRSS() / (1024 * 1024));

getrusage()의 ru_maxrss는 현재 값이 아니라 프로세스가 지금까지 기록한 최대 RSS(Linux에서는 KB 단위)라서, 메모리가 줄어도 내려가지 않습니다. 추이를 보려면 위처럼 /proc/self/statm을 읽거나 외부에서 수집하는 지표를 써야 합니다. 이 값을 주기적으로 남겨 두면 도구가 보고하지 않는 도달 가능한 누수도 그래프로 드러납니다.


스마트 포인터가 해결하는 문제

// ❌ 원시 포인터
void oldWay() {
    int* ptr = new int(42);
    if (error1) return;                  // 누수
    if (error2) throw std::exception();  // 예외 경로 누수
    delete ptr;
}
// ✅ 스마트 포인터
void modernWay() {
    auto ptr = std::make_unique<int>(42);
    if (error1) return;                  // 자동 해제
    if (error2) throw std::exception();  // 자동 해제
}   // 정상 경로도 자동 해제, 이중 삭제할 코드 자체가 없음

oldWay에서는 return·throw 경로마다 delete를 직접 챙겨야 합니다. modernWay에서는 unique_ptr가 소유권을 갖고 스코프를 벗어날 때(return·throw 포함) 소멸자에서 한 번만 delete를 호출하므로, 누수와 이중 삭제가 구조적으로 사라집니다. unique_ptr(독점 소유), shared_ptr(공유 소유), weak_ptr(소유하지 않는 관찰)의 종류와 사용법은 스마트 포인터 글에서 이어서 다룹니다.


같이 보면 좋은 글

참고 자료