C++ 메모리 누수 찾기: Valgrind·ASan·CRT 디버그 힙 사용법과 누수 패턴

이 글의 핵심

서버를 며칠 돌리면 메모리가 계속 늘어 OOM Killer에 프로세스가 죽는 문제는 대개 작은 누수가 쌓인 결과입니다. 진짜 누수와 캐시처럼 의도된 메모리 증가를 구분하는 방법, 도구 출력에서 할당 위치를 읽는 법, 옵션과 환경 변수 설정을 짚어 leak 0 bytes 결과를 만드는 과정을 따라갑니다.

들어가며: “프로그램을 오래 실행하면 메모리가 계속 늘어나요”

메모리 누수(Memory Leak)는 할당한 메모리를 해제하지 않아 프로그램이 사용하는 메모리가 계속 증가하는 버그입니다. 단기 실행 프로그램은 문제가 없지만, 장시간 실행되는 서버·데몬·게임에서는 결국 메모리 부족(Out of Memory)으로 크래시가 발생합니다.

// ❌ 메모리 누수 코드
void processRequest() {
    int* data = new int[1000];
    // ... 처리 ...
    // delete[] data; 를 깜빡함!
}

// 요청이 100만 번 오면 → 4GB 누수!

이 글에서 다루는 것:

  • 메모리 누수의 5가지 주요 원인
  • Valgrind로 누수 탐지 (Linux)
  • AddressSanitizer로 누수 탐지 (모든 플랫폼)
  • Visual Studio 메모리 프로파일러 (Windows)
  • 실전 누수 패턴 10가지와 해결법
  • RAII로 누수 방지하기
  • 프로덕션 환경에서 누수 모니터링

예방·모델링은 스마트 포인터·RAII·이동 의미론과 연결하며, 개념 정리는 메모리 누수 가이드, Valgrind 사용은 Valgrind 가이드를 참고하세요. 언어 비교는 Rust 소유권·Rust 구조체가 도움이 됩니다.


메모리 누수란?

개념

메모리 누수는 힙에 할당한 메모리를 해제하지 않아 프로그램 종료 시까지 메모리가 계속 증가하는 현상입니다.

void leak() {
    int* ptr = new int(42);  // 힙에 4바이트 할당
    // delete ptr; 를 안 함!
}  // ptr은 스택에서 사라지지만, 힙 메모리는 남아 있음

// leak()을 100만 번 호출하면 → 4MB 누수

메모리 누수 vs 메모리 증가

// ❌ 메모리 누수 (버그)
std::vector<int*> ptrs;
for (int i = 0; i < 1000000; ++i) {
    ptrs.push_back(new int(i));  // 해제 안 함!
}

// ✅ 정상적인 메모리 증가
std::vector<int> vec;
for (int i = 0; i < 1000000; ++i) {
    vec.push_back(i);  // vector가 자동으로 관리
}

차이: 정상적인 메모리 증가는 프로그램이 의도적으로 데이터를 저장하는 것이며, 메모리 누수는 더 이상 접근할 수 없는 메모리가 쌓이는 것입니다.

누수의 영향

flowchart TB
    Start[프로그램 시작: 100MB]
    L1[1시간 후: 500MB]
    L2[6시간 후: 2GB]
    L3[24시간 후: 8GB]
    Crash[OOM Killer: 프로세스 종료]
    
    Start --> L1 --> L2 --> L3 --> Crash

장기 실행 프로그램에서는 작은 누수도 누적되어 큰 문제가 됩니다.

누수를 추적할 때 먼저 구분해야 하는 것이 도구가 찾는 누수와 운영에서 문제가 되는 누수가 다르다는 점입니다. Valgrind와 AddressSanitizer는 프로그램 종료 시점에 “더 이상 어떤 포인터도 가리키지 않는 블록”을 누수로 보고합니다. 반면 운영 중에 메모리를 계속 늘리는 원인은 해제 정책 없이 커지는 캐시, 삭제되지 않는 세션 맵, 등록만 되고 해제되지 않는 콜백 목록처럼 여전히 참조는 되고 있지만 다시 쓰이지 않는 데이터인 경우가 많습니다. 이런 논리적 누수는 종료 시점 검사로는 보이지 않으므로, 8장의 모니터링과 힙 프로파일러(Valgrind massif, heaptrack)가 필요합니다.

또 프로세스의 RSS(상주 메모리)가 줄지 않는다고 해서 곧 누수는 아닙니다. glibc malloc은 해제된 메모리를 곧바로 운영체제에 돌려주지 않고 다음 할당을 위해 보관하는 경우가 많고, 작은 블록이 흩어져 해제되면 단편화 때문에 반환이 더 어렵습니다. 트래픽이 몰린 뒤 메모리가 높은 수준에서 평탄하게 유지된다면 할당자 동작일 가능성이 크고, 같은 부하에서 계속 우상향한다면 누수를 의심해야 합니다. 제가 누수 신고를 받으면 가장 먼저 확인하는 것도 이 그래프의 모양입니다.


메모리 누수의 5가지 주요 원인

원인 1: new/delete 짝 안 맞음

// ❌ 누수
void bad() {
    int* ptr = new int(42);
    // delete ptr; 없음
}

// ❌ 배열 누수
void bad2() {
    int* arr = new int[100];
    delete arr;  // ❌ delete[] 아님!
}

원인 2: 예외 발생 시 해제 안 됨

// ❌ 예외로 인한 누수
void bad() {
    int* ptr = new int(42);
    
    riskyOperation();  // 예외 발생 시 아래 코드 실행 안 됨
    
    delete ptr;  // 도달하지 못함
}

원인 3: 순환 참조 (shared_ptr)

// ❌ 순환 참조
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // ❌ 순환 참조
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a;  // a와 b가 서로를 가리킴 → 참조 카운트가 0이 안 됨

원인 4: 컨테이너에 포인터 저장

// ❌ 누수
std::vector<int*> ptrs;
for (int i = 0; i < 1000; ++i) {
    ptrs.push_back(new int(i));
}
// vector는 포인터만 해제하며, 가리키는 메모리는 해제 안 함

원인 1~4는 공통적으로 소유권이 코드에 드러나지 않는다는 문제입니다. 원시 포인터 int*만 보고는 이 포인터를 받은 쪽이 delete해야 하는지, 누군가 다른 곳에서 해제할 것인지 알 수 없습니다. 예외 경로(원인 2)는 특히 리뷰에서 놓치기 쉬운데, new와 delete 사이에 예외를 던질 수 있는 호출이 하나라도 끼면, 코드상으로는 짝이 맞아 보여도 그 경로에서는 delete가 실행되지 않습니다. std::vector::push_back이나 std::string 생성처럼 메모리 부족 시 std::bad_alloc을 던지는 평범한 호출도 여기에 해당합니다.

순환 참조(원인 3)는 스마트 포인터를 쓰고 있어서 방심하기 쉬운 경우입니다. 두 노드가 서로를 shared_ptr로 가리키면 바깥 변수가 사라져도 서로의 참조 카운트가 1로 남아 둘 다 해제되지 않습니다. Valgrind에서는 이 메모리가 definitely lost가 아니라 indirectly lost로 함께 보고되는 경우가 많아, 출력만 보고 “간접 누수니 무시해도 되겠다”고 넘기지 않아야 합니다.

원인 5: 싱글톤 패턴

// ❌ 누수 (프로그램 종료 시까지 해제 안 됨)
class Singleton {
    static Singleton* instance_;
public:
    static Singleton* getInstance() {
        if (!instance_) {
            instance_ = new Singleton();  // 해제 안 됨
        }
        return instance_;
    }
};

엄밀히 말해 원인 5는 운영상 문제가 되는 누수가 아닙니다. 프로그램 수명 동안 하나만 존재하는 객체는 종료 시 운영체제가 프로세스 메모리 전체를 회수하므로 커지지 않습니다. 다만 Valgrind가 still reachable로 계속 보고하면 진짜 누수가 그 출력에 묻히기 쉽고, 소멸자에서 파일을 닫거나 버퍼를 비우는 일이 필요한 객체라면 그 정리 작업이 실행되지 않는다는 실제 문제가 생깁니다.


Valgrind로 누수 탐지

설치 (Linux)

터미널에서 다음 명령어를 실행합니다.

# Ubuntu/Debian
sudo apt install valgrind

# Fedora/RHEL
sudo dnf install valgrind

# macOS (제한적 지원)
brew install valgrind

기본 사용법

# 컴파일 (디버그 심볼 포함)
g++ -g -std=c++17 -o myapp main.cpp

# Valgrind 실행
valgrind --leak-check=full --show-leak-kinds=all ./myapp

Valgrind는 프로그램을 재컴파일하지 않고, 가상 CPU 위에서 명령어를 하나씩 해석하며 모든 malloc/new/free/delete를 가로채 기록합니다. 그래서 서드파티 라이브러리나 소스가 없는 바이너리도 검사할 수 있는 대신, 실행 속도가 수십 배 느려집니다. -g는 필수입니다. 디버그 정보가 없으면 스택 트레이스가 0x400B2C: ??? (in /path/myapp)처럼 주소로만 나와 할당 위치를 알 수 없습니다. 최적화 수준은 -O0이나 -O1을 권합니다. -O2 이상에서는 인라이닝 때문에 스택 프레임이 합쳐져서 보고된 함수 이름이 실제 할당 위치와 한두 단계 어긋날 수 있습니다.

출력 예시

==12345== HEAP SUMMARY:
==12345==     in use at exit: 4,000 bytes in 1,000 blocks
==12345==   total heap usage: 1,000 allocs, 0 frees, 4,000 bytes allocated
==12345== 
==12345== 4,000 bytes in 1,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 0x400B2C: processRequest() (main.cpp:15)
==12345==    by 0x400B5D: main (main.cpp:25)
==12345== 
==12345== LEAK SUMMARY:
==12345==    definitely lost: 4,000 bytes in 1,000 blocks
==12345==    indirectly lost: 0 bytes in 0 blocks
==12345==      possibly lost: 0 bytes in 0 blocks
==12345==    still reachable: 0 bytes in 0 blocks
==12345==         suppressed: 0 bytes in 0 blocks

해석:

  • definitely lost: 확실한 누수 (포인터를 완전히 잃어버림)
  • indirectly lost: 간접 누수 (누수된 객체가 가리키는 메모리)
  • possibly lost: 의심스러운 누수 (포인터가 중간을 가리킴)
  • still reachable: 접근 가능한 메모리 (전역 변수 등, 대부분 무시 가능)

출력은 위에서 아래로 읽으면 됩니다. 4,000 bytes in 1,000 blocks are definitely lost 아래의 스택에서 첫 줄 operator new는 Valgrind가 가로챈 할당 함수이고, 그 바로 아래의 내 코드 줄(processRequest() (main.cpp:15))이 누수된 메모리를 할당한 위치입니다. 주의할 점은 이것이 할당 위치이지 “해제를 빠뜨린 위치”가 아니라는 것입니다. 해제는 보통 다른 함수에서 해야 했던 것이라, 할당 위치에서 출발해 그 포인터가 어디로 전달됐는지 따라가야 원인이 나옵니다.

possibly lost는 블록의 시작이 아니라 중간을 가리키는 포인터만 남은 경우입니다. 대부분은 진짜 누수지만, std::string의 참조 카운트 구현이나 헤더를 앞에 붙이는 커스텀 할당자처럼 의도적으로 내부 포인터를 쓰는 코드에서도 나오므로, 반복해서 보이면 그 할당 위치의 코드를 확인해 보고 판단합니다.

Valgrind 옵션

# 전체 누수 리포트
valgrind --leak-check=full --show-leak-kinds=all ./myapp

# 초기화되지 않은 값이 어디서 왔는지 추적 (더 느려짐)
valgrind --leak-check=full --track-origins=yes ./myapp

# 오류가 있으면 종료 코드 1로 끝냄 (CI에서 실패 처리용)
valgrind --leak-check=full --error-exitcode=1 ./myapp

# 로그 파일로 저장
valgrind --leak-check=full --log-file=valgrind.log ./myapp

--track-origins=yes는 누수와는 관계가 없고, “Conditional jump or move depends on uninitialised value(s)” 오류가 났을 때 그 값이 어디서 할당됐는지 알려 주는 옵션입니다. --error-exitcode=1도 실행을 중단시키지 않습니다. 검사는 끝까지 진행되고, 오류가 하나라도 있으면 프로세스의 종료 코드만 1로 바꿔 CI가 실패로 처리하게 합니다. CI에 넣을 때는 누수도 오류로 세도록 --errors-for-leak-kinds=definite를 함께 지정해야 의도대로 동작합니다.

한 가지 더 알아 둘 점은 리포트가 프로그램이 끝날 때 출력된다는 것입니다. Ctrl+C(SIGINT)로 끝내면 Valgrind가 그 시점의 요약을 출력하지만, 스택 변수와 아직 정리되지 않은 객체가 한꺼번에 남아 있어 still reachable과 possibly lost가 크게 부풀려집니다. kill -9로 강제 종료하면 리포트 자체가 나오지 않습니다. 서버라면 종료 신호를 받아 정상적으로 main을 빠져나오는 경로를 만들어 두어야 의미 있는 결과를 얻을 수 있습니다.


AddressSanitizer로 누수 탐지

컴파일 (GCC/Clang)

# ASan 활성화
g++ -g -fsanitize=address -std=c++17 -o myapp main.cpp

# 실행
./myapp

컴파일 (Visual Studio)

프로젝트 속성 → C/C++ → 일반 → Address Sanitizer 사용 → 예

출력 예시

=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 4000 byte(s) in 1000 object(s) allocated from:
    #0 0x7f8b2c3d1b96 in operator new(unsigned long)
    #1 0x400b2c in processRequest() main.cpp:15
    #2 0x400b5d in main main.cpp:25

SUMMARY: AddressSanitizer: 4000 byte(s) leaked in 1000 allocation(s).

장점:

  • 빠름: 계측 코드를 컴파일 시점에 넣기 때문에 Valgrind보다 훨씬 빠름 (보통 2배 안팎의 속도 저하)
  • 정확한 줄 번호: 소스 코드의 정확한 위치 표시
  • 메모리 오류도 함께 탐지: 누수뿐 아니라 버퍼 오버플로, use-after-free를 즉시 보고

플랫폼 주의: ASan 자체는 Linux, macOS, Windows에서 모두 동작하지만, 누수 탐지를 맡는 LeakSanitizer는 사실상 Linux에서만 쓸 수 있습니다. MSVC의 ASan은 누수 탐지를 지원하지 않고, macOS의 Apple Clang에서 detect_leaks=1을 주면 AddressSanitizer: detect_leaks is not supported on this platform. 오류가 납니다. Windows에서 누수를 찾으려면 아래의 CRT 디버그 힙을, macOS에서는 Xcode Instruments의 Leaks 도구를 쓰는 편이 현실적입니다.

LeakSanitizer도 Valgrind처럼 종료 시점에 도달 불가능한 블록을 찾는 방식이라, 참조가 남아 있는 캐시의 증가는 보고하지 않습니다. 또 누수 검사는 프로세스가 정상 종료할 때 실행되므로, 프로그램이 _exit()로 끝나거나 다른 sanitizer 오류로 먼저 중단되면 누수 리포트가 나오지 않습니다. CI에서 “ASan을 켰는데 누수가 하나도 안 나온다”면 테스트가 실제로 정상 종료 경로를 타는지부터 확인하세요.

ASan 환경 변수

Linux의 GCC·Clang에서는 detect_leaks가 기본으로 켜져 있어서 첫 줄은 명시용입니다. 서드파티 라이브러리의 알려진 누수를 무시하려면 LSAN_OPTIONS=suppressions=lsan.supp로 억제 파일을 지정합니다.

# 누수 탐지 활성화
export ASAN_OPTIONS=detect_leaks=1

# 프로그램 종료 시 리포트
export ASAN_OPTIONS=detect_leaks=1:leak_check_at_exit=1

# 로그 파일로 저장
export ASAN_OPTIONS=detect_leaks=1:log_path=asan.log

Visual Studio 메모리 프로파일러

사용법

Visual Studio의 도구는 GUI로 실행합니다. 네이티브 C++ 코드는 ”.NET 개체 할당 추적”이 아니라 메모리 사용량 도구를 사용합니다.

1. 디버그 → 성능 프로파일러 (Alt+F2)
2. "메모리 사용량" 체크
3. "시작" 클릭
4. 프로그램 실행 후 종료
5. 메모리 사용량 그래프 확인

메모리 사용량 도구는 누수를 직접 지목하지 않고 두 시점의 힙 스냅샷 차이를 보여 줍니다. 요청을 한 번 처리하기 전과 후에 스냅샷을 찍고, 그 차이에서 개수가 계속 늘어나는 타입과 할당 스택을 보는 방식으로 씁니다. 같은 작업을 여러 번 반복한 뒤 비교하면 일회성 초기화 할당과 반복될 때마다 쌓이는 할당이 구분됩니다.

CRT 디버그 힙

// Windows 전용: CRT 메모리 누수 탐지
#define _CRTDBG_MAP_ALLOC
#include <crtdbg.h>
#include <stdlib.h>

int main() {
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
    
    // 프로그램 코드
    int* leak = new int(42);  // 의도적 누수
    
    return 0;
}

// 출력:
// Detected memory leaks!
// Dumping objects ->
// {79} normal block at 0x00000123 4 bytes long.
//  Data: <*   > 2A 00 00 00

자주 나오는 누수 패턴 10가지

패턴 1: new/delete 짝 안 맞음

// ❌ 누수
void bad() {
    int* ptr = new int(42);
    // delete 없음
}

// ✅ 해결
void good() {
    int* ptr = new int(42);
    delete ptr;
}

// ✅ 더 좋은 해결: 스마트 포인터
void best() {
    auto ptr = std::make_unique<int>(42);
    // 자동 해제
}

패턴 2: 예외 발생 시 누수

// ❌ 예외로 인한 누수
void bad() {
    int* ptr = new int(42);
    
    if (someCondition()) {
        throw std::runtime_error("Error");  // delete 건너뜀
    }
    
    delete ptr;
}

// ✅ 해결: RAII
void good() {
    auto ptr = std::make_unique<int>(42);
    
    if (someCondition()) {
        throw std::runtime_error("Error");  // 자동 해제
    }
}

패턴 3: 배열 delete 실수

// ❌ 누수 (일부만 해제)
void bad() {
    int* arr = new int[100];
    delete arr;  // ❌ delete[] 아님!
}

// ✅ 해결
void good() {
    int* arr = new int[100];
    delete[] arr;
}

// ✅ 더 좋은 해결
void best() {
    std::vector<int> vec(100);
    // 자동 해제
}

패턴 4: 컨테이너에 포인터 저장

// ❌ 누수
void bad() {
    std::vector<int*> ptrs;
    for (int i = 0; i < 1000; ++i) {
        ptrs.push_back(new int(i));
    }
    // vector는 포인터만 해제, 가리키는 메모리는 남음
}

// ✅ 해결 1: 수동 해제
void good() {
    std::vector<int*> ptrs;
    for (int i = 0; i < 1000; ++i) {
        ptrs.push_back(new int(i));
    }
    
    for (int* ptr : ptrs) {
        delete ptr;
    }
    ptrs.clear();
}

// ✅ 해결 2: 스마트 포인터
void best() {
    std::vector<std::unique_ptr<int>> ptrs;
    for (int i = 0; i < 1000; ++i) {
        ptrs.push_back(std::make_unique<int>(i));
    }
    // 자동 해제
}

패턴 5: 순환 참조 (shared_ptr)

// ❌ 순환 참조로 인한 누수
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;
};

void bad() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    
    a->next = b;
    b->prev = a;  // 순환 참조 → 참조 카운트가 0이 안 됨
}

// ✅ 해결: weak_ptr 사용
struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // 약한 참조
};

void good() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    
    a->next = b;
    b->prev = a;  // weak_ptr는 참조 카운트를 증가시키지 않음
}

패턴 6: 조건부 해제 실수

// ❌ 누수
void bad(bool condition) {
    int* ptr = new int(42);
    
    if (condition) {
        delete ptr;
    }
    // condition이 false면 누수!
}

// ✅ 해결
void good(bool condition) {
    auto ptr = std::make_unique<int>(42);
    // 항상 자동 해제
}

패턴 7: 얕은 복사 (Shallow Copy)

// ❌ 이중 해제 또는 누수
class Bad {
    int* data_;
public:
    Bad(int size) : data_(new int[size]) {}
    ~Bad() { delete[] data_; }
    
    // 컴파일러 생성 복사 생성자: 포인터만 복사
};

void bad() {
    Bad obj1(100);
    Bad obj2 = obj1;  // 얕은 복사 → data_ 주소만 복사
}  // obj1, obj2 둘 다 소멸 → 이중 해제 또는 누수

// ✅ 해결: Rule of Five
class Good {
    int* data_;
    size_t size_;
public:
    Good(size_t size) : data_(new int[size]), size_(size) {}
    ~Good() { delete[] data_; }
    
    // 복사 생성자: 깊은 복사
    Good(const Good& other) : data_(new int[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }
    
    // 복사 대입 연산자
    Good& operator=(const Good& other) {
        if (this != &other) {
            delete[] data_;
            size_ = other.size_;
            data_ = new int[size_];
            std::copy(other.data_, other.data_ + size_, data_);
        }
        return *this;
    }
    
    // 이동 생성자
    Good(Good&& other) noexcept : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    
    // 이동 대입 연산자
    Good& operator=(Good&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }
};

패턴 8: 팩토리 함수에서 소유권 불명확

// ❌ 누수 위험
int* createData() {
    return new int(42);  // 호출자가 delete 해야 하는지 불명확
}

void bad() {
    int* ptr = createData();
    // delete 깜빡함
}

// ✅ 해결: unique_ptr 반환
std::unique_ptr<int> createData() {
    return std::make_unique<int>(42);
}

void good() {
    auto ptr = createData();
    // 자동 해제
}

패턴 9: 전역 변수 누수

// ❌ 전역 포인터 누수
int* globalPtr = nullptr;

void init() {
    globalPtr = new int(42);
}

void cleanup() {
    delete globalPtr;  // 호출 안 하면 누수
}

// ✅ 해결: 스마트 포인터
std::unique_ptr<int> globalPtr;

void init() {
    globalPtr = std::make_unique<int>(42);
}
// cleanup 불필요 (프로그램 종료 시 자동 해제)

패턴 10: 멀티스레드 누수

// ❌ 스레드 함수에서 누수
void threadFunc() {
    int* data = new int[1000];
    // ... 처리 ...
    // delete[] 없음
}

std::thread t(threadFunc);
t.join();

// ✅ 해결
void threadFunc() {
    auto data = std::make_unique<int[]>(1000);
    // ... 처리 ...
    // 자동 해제
}

RAII로 누수 방지

RAII 원칙

RAII(Resource Acquisition Is Initialization—리소스 획득은 초기화다)는 생성자에서 리소스를 획득하고 소멸자에서 자동 해제하는 패턴입니다.

// RAII 예제
class FileHandle {
    FILE* file_;
public:
    FileHandle(const char* filename) {
        file_ = fopen(filename, "r");
        if (!file_) throw std::runtime_error("Cannot open file");
    }
    
    ~FileHandle() {
        if (file_) fclose(file_);
    }
    
    // 복사 금지
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
    
    FILE* get() const { return file_; }
};

// 사용
void process() {
    FileHandle file("data.txt");
    // ... 파일 처리 ...
    // 예외가 나도 자동으로 fclose 호출
}

스마트 포인터 선택 가이드

상황권장
단독 소유std::unique_ptr
공유 소유std::shared_ptr
순환 참조 방지std::weak_ptr
배열std::unique_ptr<T[]> 또는 std::vector
커스텀 삭제자std::unique_ptr<T, Deleter>

컨테이너 선택

// ❌ 포인터 벡터
std::vector<MyClass*> bad;

// ✅ 값 벡터
std::vector<MyClass> good;

// ✅ unique_ptr 벡터 (다형성 필요 시)
std::vector<std::unique_ptr<Base>> best;

프로덕션 환경에서 누수 모니터링

메모리 사용량 추적

#include <fstream>
#include <string>

// Linux: /proc/self/status에서 메모리 사용량 읽기
size_t getCurrentMemoryUsage() {
    std::ifstream status("/proc/self/status");
    std::string line;
    
    while (std::getline(status, line)) {
        if (line.substr(0, 6) == "VmRSS:") {
            // VmRSS: 실제 물리 메모리 사용량 (KB)
            return std::stoul(line.substr(7));
        }
    }
    return 0;
}

// 주기적으로 로깅
void monitorMemory() {
    static size_t baseline = getCurrentMemoryUsage();
    size_t current = getCurrentMemoryUsage();
    
    if (current > baseline * 1.5) {  // 50% 증가
        LOG_WARNING("Memory usage increased: " << current << " KB");
    }
}

할당 통계 수집

#include <atomic>

// 전역 카운터
std::atomic<size_t> g_alloc_count{0};
std::atomic<size_t> g_free_count{0};
std::atomic<size_t> g_alloc_bytes{0};

// operator new 오버로드
void* operator new(size_t size) {
    ++g_alloc_count;
    g_alloc_bytes += size;
    return malloc(size);
}

void operator delete(void* ptr) noexcept {
    ++g_free_count;
    free(ptr);
}

// 통계 출력
void printMemoryStats() {
    std::cout << "Allocations: " << g_alloc_count << '\n';
    std::cout << "Frees: " << g_free_count << '\n';
    std::cout << "Leaked: " << (g_alloc_count - g_free_count) << '\n';
    std::cout << "Total allocated: " << g_alloc_bytes << " bytes\n";
}

주기적 누수 체크

#include <chrono>
#include <thread>

class MemoryMonitor {
    size_t baseline_;
    
public:
    MemoryMonitor() : baseline_(getCurrentMemoryUsage()) {}
    
    void check() {
        size_t current = getCurrentMemoryUsage();
        size_t increase = current - baseline_;
        
        if (increase > 100 * 1024) {  // 100MB 증가
            LOG_ERROR("Possible memory leak: +" << increase << " KB");
            
            // 알림 전송, 스택 트레이스 수집 등
        }
    }
};

// 백그라운드 스레드에서 모니터링
void monitoringThread() {
    MemoryMonitor monitor;
    
    while (true) {
        std::this_thread::sleep_for(std::chrono::minutes(5));
        monitor.check();
    }
}

실전 사례 분석

사례 1: HTTP 서버 메모리 누수

증상: 서버를 며칠 돌리면 메모리가 10GB까지 증가합니다.

// ❌ 버그 코드
void handleRequest(const Request& req) {
    char* buffer = new char[req.contentLength];
    req.readBody(buffer);
    
    processData(buffer);
    
    // delete[] buffer; 를 깜빡함!
}

Valgrind 출력:

==12345== 1,000,000 bytes in 10,000 blocks are definitely lost
==12345==    at 0x4C2E0EF: operator new
==12345==    by 0x400B2C: handleRequest(Request const&) (server.cpp:42)

해결:

// ✅ 수정된 코드
void handleRequest(const Request& req) {
    auto buffer = std::make_unique<char[]>(req.contentLength);
    req.readBody(buffer.get());
    
    processData(buffer.get());
    // 자동 해제
}

// ✅ 더 좋은 방법: vector 사용
void handleRequest(const Request& req) {
    std::vector<char> buffer(req.contentLength);
    req.readBody(buffer.data());
    
    processData(buffer.data());
}

사례 2: 캐시 누수

증상: LRU 캐시에서 만료된 항목이 삭제되지 않습니다.

// ❌ 버그 코드
class Cache {
    std::map<std::string, int*> data_;
    
public:
    void put(const std::string& key, int value) {
        data_[key] = new int(value);  // 기존 값 누수!
    }
    
    ~Cache() {
        // 해제 안 함!
    }
};

해결:

// ✅ 수정된 코드
class Cache {
    std::map<std::string, std::unique_ptr<int>> data_;
    
public:
    void put(const std::string& key, int value) {
        data_[key] = std::make_unique<int>(value);  // 기존 값 자동 해제
    }
    
    // 소멸자 불필요 (자동 해제)
};

사례 3: 싱글톤 누수

증상: Valgrind에서 “still reachable” 누수가 보고됩니다.

// ❌ 누수 (프로그램 종료 시까지 해제 안 됨)
class Singleton {
    static Singleton* instance_;
public:
    static Singleton* getInstance() {
        if (!instance_) {
            instance_ = new Singleton();
        }
        return instance_;
    }
};

Singleton* Singleton::instance_ = nullptr;

해결:

// ✅ Meyers 싱글톤 (자동 해제)
class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance;  // 프로그램 종료 시 자동 소멸
        return instance;
    }
    
private:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Valgrind의 definitely lost, indirectly lost, still reachable은 각각 어떻게 대응해야 하나요?

A. definitely lost는 해당 블록을 가리키는 포인터가 완전히 사라진 진짜 누수라 가장 먼저 고쳐야 합니다. indirectly lost는 누수된 블록이 가리키던 하위 블록이라, 상위의 definitely lost를 고치면 함께 사라지는 경우가 많습니다. still reachable은 종료 시점에 전역 변수나 싱글톤이 아직 가리키고 있는 메모리로 대개 치명적이지 않지만, 장기 실행 서버에서 이 수치가 계속 커진다면 본문의 캐시 누수 사례처럼 해제 정책이 없는 논리적 누수를 의심해야 합니다.