C++ 메모리 누수 찾기: Valgrind·ASan·CRT 디버그 힙 사용법과 누수 패턴
이 글의 핵심
서버를 며칠 돌리면 메모리가 계속 늘어 OOM Killer에 프로세스가 죽는 문제는 대개 작은 누수가 쌓인 결과입니다. 진짜 누수와 캐시처럼 의도된 메모리 증가를 구분하는 방법, 도구 출력에서 할당 위치를 읽는 법, 옵션과 환경 변수 설정을 짚어 leak 0 bytes 결과를 만드는 과정을 따라갑니다.
들어가며
메모리 누수(Memory Leak)는 할당한 메모리를 해제하지 않아 프로그램이 사용하는 메모리가 계속 증가하는 버그입니다. 단기 실행 프로그램은 문제가 없지만, 장시간 실행되는 서버·데몬·게임에서는 결국 메모리 부족(Out of Memory)으로 크래시가 발생합니다.
그림: 실제 실행 결과 — LeakSanitizer가 프로그램 종료 시점에 해제되지 않은 블록과 할당 위치(copy_name)를 알려 준다. (환경: Ubuntu 24.04 · g++ 13.3)
// ❌ 메모리 누수 코드
void processRequest() {
int* data = new int[1000];
// ... 처리 ...
// delete[] data; 를 깜빡함!
}
// int가 4바이트라면 요청 100만 번에 약 4GB 누수
예방·모델링은 스마트 포인터·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가 자동으로 관리
}
정상적인 메모리 증가는 프로그램이 의도적으로 데이터를 저장하는 것이며, 메모리 누수는 더 이상 접근할 수 없는 메모리가 쌓이는 것입니다.
도구가 찾는 누수와 운영에서 문제가 되는 누수
누수를 추적할 때 먼저 구분해야 하는 것이 도구가 찾는 누수와 운영에서 문제가 되는 누수가 다르다는 점입니다. Valgrind와 AddressSanitizer는 프로그램 종료 시점에 “더 이상 어떤 포인터도 가리키지 않는 블록”을 누수로 보고합니다. 반면 운영 중에 메모리를 계속 늘리는 원인은 해제 정책 없이 커지는 캐시, 삭제되지 않는 세션 맵, 등록만 되고 해제되지 않는 콜백 목록처럼 여전히 참조는 되고 있지만 다시 쓰이지 않는 데이터인 경우가 많습니다. 이런 논리적 누수는 종료 시점 검사로는 보이지 않으므로, ‘프로덕션 환경에서 누수 모니터링’ 절의 모니터링과 힙 프로파일러(Valgrind massif, heaptrack)가 필요합니다.
또 프로세스의 RSS(상주 메모리)가 줄지 않는다고 해서 곧 누수는 아닙니다. glibc malloc은 해제된 메모리를 곧바로 운영체제에 돌려주지 않고 다음 할당을 위해 보관하는 경우가 많고, 작은 블록이 흩어져 해제되면 단편화 때문에 반환이 더 어렵습니다. 트래픽이 몰린 뒤 메모리가 높은 수준에서 평탄하게 유지된다면 할당자 동작일 가능성이 크고, 같은 부하에서 계속 우상향한다면 누수를 의심해야 합니다. 그래서 메모리 증가 신고를 받으면 가장 먼저 시간에 따른 메모리 그래프의 모양을 확인하는 것이 좋습니다.
메모리 누수의 5가지 주요 원인
원인 1: new/delete 짝 안 맞음
// ❌ 누수
void bad() {
int* ptr = new int(42);
// delete ptr; 없음
}
// ❌ new[]와 delete 불일치: 누수가 아니라 정의되지 않은 동작
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: 최신 macOS(특히 Apple Silicon)는 공식 지원되지 않으므로
# ASan이나 Xcode Instruments의 Leaks를 씁니다
기본 사용법
# 컴파일 (디버그 심볼 포함)
g++ -g -std=c++17 -o myapp main.cpp
# Valgrind 실행
valgrind --leak-check=full --show-leak-kinds=all ./myapp
Valgrind는 프로그램을 재컴파일하지 않고, 실행 중인 기계어를 자체 중간 표현으로 변환해 계측 코드를 끼워 넣은 뒤 실행하는 방식(동적 바이너리 계측)으로 모든 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 blocks
==12345== total heap usage: 1 allocs, 0 frees, 4,000 bytes allocated
==12345==
==12345== 4,000 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2E0EF: operator new[](unsigned long) (vg_replace_malloc.c:...)
==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 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: 누수된 블록이 가리키던 하위 블록입니다. 상위의 definitely lost를 고치면 함께 사라지는 경우가 많습니다.
- possibly lost: 블록의 시작이 아니라 중간을 가리키는 포인터만 남은 경우입니다.
- still reachable: 종료 시점에도 전역 변수나 싱글톤이 가리키고 있는 메모리입니다. 대개 치명적이지 않지만, 장기 실행 서버에서 이 수치가 계속 커진다면 아래 캐시 사례처럼 해제 정책이 없는 논리적 누수를 의심해야 합니다.
출력은 위에서 아래로 읽으면 됩니다. 4,000 bytes in 1 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가 실패로 처리하게 합니다. --leak-check=full일 때 기본적으로 definite와 possible 누수가 오류로 집계되므로, possibly lost 때문에 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 1 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 1 allocation(s).
ASan은 계측 코드를 컴파일 시점에 넣기 때문에 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;
}
// 출력 (Visual Studio 출력 창):
// Detected memory leaks!
// Dumping objects ->
// {79} normal block at 0x00000123 4 bytes long.
// Data: <* > 2A 00 00 00
_CRTDBG_MAP_ALLOC은 malloc 계열 호출에만 파일명과 줄 번호를 붙여 줍니다. new로 할당한 블록은 위처럼 할당 번호({79})만 나오므로, _CrtSetBreakAlloc(79)로 그 번호의 할당에서 디버거를 멈춰 호출 스택을 확인하는 방법을 씁니다. 할당 순서가 실행마다 같아야 번호가 의미가 있다는 점에 주의합니다.
자주 나오는 누수 패턴과 해결법
패턴 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 실수
// ❌ 정의되지 않은 동작 (int처럼 소멸자가 없는 타입은 우연히 동작하기도 하지만,
// 소멸자가 있는 타입은 첫 원소만 소멸되거나 힙이 깨질 수 있음)
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 둘 다 소멸 → 같은 버퍼를 두 번 delete[] (정의되지 않은 동작)
// ✅ 해결: 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_);
}
// 복사 대입 연산자 (간단한 버전: new[]가 예외를 던지면 *this가 해제된 포인터를 들고 남으므로,
// 실무에서는 복사본을 먼저 만든 뒤 swap하는 copy-and-swap이 안전함)
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 불필요 (프로그램 종료 시 자동 해제)
RAII로 누수 방지
RAII 원칙
RAII(Resource Acquisition Is Initialization)는 생성자에서 리소스를 획득하고 소멸자에서 해제하는 패턴입니다. 스택 객체의 소멸자는 예외로 빠져나갈 때도 실행되므로, 예외 경로의 누수(원인 2)가 구조적으로 사라집니다.
// 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>
#include <cstdlib>
#include <iostream>
#include <new>
// 전역 카운터
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(std::size_t size) {
++g_alloc_count;
g_alloc_bytes += size;
if (void* p = std::malloc(size == 0 ? 1 : size)) return p;
throw std::bad_alloc{}; // operator new는 실패 시 nullptr이 아니라 예외를 던져야 함
}
void operator delete(void* ptr) noexcept {
if (!ptr) return;
++g_free_count;
std::free(ptr);
}
// 통계 출력
void printMemoryStats() {
std::cout << "Allocations: " << g_alloc_count << '\n';
std::cout << "Frees: " << g_free_count << '\n';
std::cout << "Outstanding: " << (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();
if (current <= baseline_) return; // 부호 없는 뺄셈이 음수로 넘어가지 않도록
size_t increase = current - baseline_;
if (increase > 100 * 1024) { // 100MB(102,400 KB) 이상 증가
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: 요청 처리 버퍼 누수
요청마다 본문 버퍼를 new[]로 만들고 해제를 빠뜨리면, 서버 메모리가 요청 수에 비례해 계속 증가합니다.
// ❌ 버그 코드
void handleRequest(const Request& req) {
char* buffer = new char[req.contentLength];
req.readBody(buffer);
processData(buffer);
// delete[] buffer; 를 깜빡함!
}
Valgrind는 이 할당 위치를 definitely lost로 보고합니다.
==12345== 1,000,000 bytes in 10,000 blocks are definitely lost
==12345== at 0x4C2E0EF: operator new[](unsigned long)
==12345== by 0x400B2C: handleRequest(Request const&) (server.cpp:42)
unique_ptr<char[]>나 std::vector<char>로 바꾸면 함수가 어떤 경로로 끝나든 해제됩니다.
// ✅ 수정된 코드
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: 캐시 값 덮어쓰기 누수
같은 키로 put할 때마다 이전 값을 가리키던 포인터를 덮어써 버리고, 소멸자도 남은 값을 해제하지 않습니다.
// ❌ 버그 코드
class Cache {
std::map<std::string, int*> data_;
public:
void put(const std::string& key, int value) {
data_[key] = new int(value); // 기존 값 누수!
}
~Cache() {
// 해제 안 함!
}
};
값을 unique_ptr로 들고 있으면 덮어쓸 때와 캐시가 소멸할 때 모두 자동으로 해제됩니다. 다만 이것은 진짜 누수만 고칩니다. 항목이 영원히 쌓이는 문제는 크기 제한이나 만료 정책으로 따로 해결해야 합니다.
// ✅ 수정된 코드
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: 싱글톤 누수
원인 5의 싱글톤은 Valgrind에서 still reachable로 보고됩니다.
// ❌ 누수 (프로그램 종료 시까지 해제 안 됨)
class Singleton {
static Singleton* instance_;
public:
static Singleton* getInstance() {
if (!instance_) {
instance_ = new Singleton();
}
return instance_;
}
};
Singleton* Singleton::instance_ = nullptr;
함수 안의 static 지역 변수로 바꾸면 프로그램 종료 시 소멸자가 실행됩니다(C++11부터 초기화도 스레드 안전).
// ✅ 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;
};