C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴

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 누락 시 누수.

목차

  1. new와 delete의 동작 원리
  2. new/delete의 위험한 패턴 5가지
  3. 메모리 누수: 실제 사례 분석
  4. 완전한 메모리 누수 예제와 탐지
  5. 메모리 누수 탐지 도구
  6. 실전 디버깅: 메모리 누수 찾기
  7. 메모리 누수 흔한 패턴 정리
  8. 자주 발생하는 에러와 해결법
  9. 모범 사례와 프로덕션 패턴
  10. 예방법: 스마트 포인터 미리보기

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가 호출되지 않아 누수가 됩니다.

내부 동작:

  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 사용하지 마세요. 생성자/소멸자가 호출되지 않아 리소스 누수가 발생합니다.


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는 무시됨)
}

위 코드 설명: deleteptr = 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;  // ❌ 이미 해제된 메모리 접근 → 크래시!

위 코드 설명: ptr1ptr2가 같은 힙 블록을 가리키는데, 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한 포인터 재삭제, 또는 mallocdelete로 해제. 해결: deleteptr = nullptr, new/deletemalloc/free 짝 맞추기, 스마트 포인터 사용.

에러 3: “AddressSanitizer: heap-use-after-free”

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

에러 4: “Mismatched free() / delete”

원인: new[]delete로, 또는 newdelete[]로 해제. 해결: newdelete, new[]delete[], mallocfree 짝 맞추기.

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

원인: pthread, glibc 등이 종료 시 해제하지 않는 메모리. still reachable로 표시됨.

해결법: --show-leak-kinds=definitedefinitely lost만 확인. still reachable은 라이브러리 이슈일 수 있음.


9. 모범 사례와 프로덕션 패턴

모범 사례 요약

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

프로덕션 패턴 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 lost 0 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[]

예외 안전성: 예외 발생 시에도 메모리 해제 보장

해결책: 스마트 포인터 사용 (다음 글에서 상세히)

실무 교훈

서버를 다운시킨 경험에서 배운 것:

  1. 메모리 누수는 조용히 발생한다 (즉시 크래시 안 함)
  2. 프로덕션 전에 Valgrind 필수
  3. 스마트 포인터를 기본으로 사용
  4. 모든 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 |