Valgrind Memcheck로 C++ 메모리 누수 찾아내기
Valgrind Memcheck가 필요한 순간
서버 메모리가 시간이 지날수록 늘어나는데 코드를 아무리 봐도 new/delete 짝이 맞아 보이는 경우가 있습니다. 대개 early return이나 예외처럼 평소에 잘 타지 않는 경로에서 해제가 빠진 것이고, 이런 누수는 눈으로 찾기보다 도구로 할당 위치를 짚는 편이 빠릅니다. Valgrind Memcheck는 프로그램을 다시 컴파일하지 않고 실행만 감싸서, 누수된 블록이 어느 파일 몇 번째 줄에서 할당됐는지 알려 줍니다.
이전 글 C++ 메모리 누수에서 new/delete의 위험 패턴을 다뤘다면, 이 글에서는 Memcheck로 누수·잘못된 메모리 접근·초기화되지 않은 값을 실제로 찾고, 외부 라이브러리 경고를 suppression 파일로 걸러 내고, CI에 붙이는 방법을 다룹니다.
Memcheck로 찾을 수 있는 버그
Memcheck가 잘 잡는 증상은 대략 다음과 같습니다.
- 메모리 사용량이 계속 늘다가 OOM으로 죽는 경우: early return, 예외, 분기 경로에서
delete가 빠진 누수를--leak-check=full로 할당 위치와 함께 보고합니다. - 특정 입력에서만 크래시하는 경우: 힙 배열 범위를 넘는 쓰기,
strcpy/memcpy길이 오류를 Invalid write로 보고합니다. - 같은 입력인데 결과가 실행마다 달라지는 경우: 초기화하지 않은 변수를 분기 조건에 쓴 곳을 “Conditional jump or move depends on uninitialised value(s)“로 보고합니다.
- 해제한 메모리를 다시 쓰는 use-after-free: Invalid read/write와 함께 해제 위치와 접근 위치를 모두 보여 줍니다.
new[]로 할당하고delete로 해제하는 실수: Mismatched free로 보고합니다.
반대로 잡지 못하는 것도 알아 둬야 합니다. Memcheck는 힙 블록 단위로 경계를 추적하므로 스택 배열이나 전역 배열의 범위 초과는 거의 잡지 못하고, reserve로 확보한 용량 안쪽처럼 할당된 블록 내부를 잘못 쓰는 것도 잡지 못합니다. 이런 경우는 ASan이나 표준 라이브러리 디버그 모드가 더 적합합니다. 또 데이터 경합 자체는 Memcheck가 아니라 Helgrind나 DRD의 영역입니다.
Valgrind 도구 구성과 설치
Valgrind는 어떤 도구인가
Valgrind는 동적 바이너리 계측 프레임워크입니다. 프로그램의 기계어를 실행 시점에 중간 표현으로 바꾸고 검사 코드를 끼워 넣은 뒤 자체 가상 CPU에서 실행하므로, 재컴파일 없이 기존 바이너리에 쓸 수 있습니다. 대신 Memcheck는 매뉴얼 기준 20~30배 안팎으로 느려지므로 짧은 실행이나 단위 테스트에 주로 씁니다.
| 도구 | 용도 | 명령 예시 |
|---|---|---|
| Memcheck | 메모리 누수, 잘못된 접근, 초기화 안 된 값 | valgrind --tool=memcheck ./app |
| Callgrind | 함수 단위 CPU 프로파일링 | valgrind --tool=callgrind ./app |
| Cachegrind | 캐시·분기 시뮬레이션 | valgrind --tool=cachegrind ./app |
| Helgrind | 데이터 경합, 락 순서 오류 | valgrind --tool=helgrind ./app |
| Massif | 힙 사용량 추이(최대치와 할당 위치) | valgrind --tool=massif ./app |
이 글은 Memcheck를 중심으로 다룹니다. --tool을 생략하면 기본값이 Memcheck입니다.
메모리가 계속 늘어나는데 Memcheck는 누수를 거의 보고하지 않는 경우에는 Massif가 맞는 도구입니다. 캐시나 로그 버퍼처럼 프로그램이 여전히 포인터를 쥐고 있는 메모리는 종료 시점에 still reachable로 분류될 뿐 누수로 잡히지 않지만, 실행 중에는 계속 불어나 서버를 죽일 수 있습니다. Massif는 실행 동안 힙 사용량을 주기적으로 기록하고, 가장 많이 쓴 시점에 어떤 호출 경로가 메모리를 들고 있었는지 보여 줍니다.
valgrind --tool=massif ./server --run-for 60s
ms_print massif.out.<pid> | less # 시간별 그래프 + 최대 시점의 할당 트리
설치
# Ubuntu/Debian
sudo apt install valgrind
# Fedora/RHEL
sudo dnf install valgrind
# 버전 확인
valgrind --version
Valgrind는 Linux(x86, x86-64, ARM64 등)에서 가장 잘 동작합니다. 최근 macOS, 특히 Apple Silicon은 공식 지원되지 않으므로 Docker나 Linux VM에서 실행하는 편이 현실적입니다. Windows에서는 WSL2 안의 Linux 바이너리에 쓰거나, 네이티브 바이너리에는 Dr. Memory 같은 대안을 씁니다.
컴파일 시 필수: 디버그 심볼
Valgrind가 파일과 줄 번호를 보고하려면 -g로 컴파일해야 합니다.
# ✅ 디버그 정보 포함
g++ -g -O0 -std=c++17 -o myapp myapp.cpp
# ❌ -g가 없으면 함수 이름만 나오고 줄 번호가 없음
g++ -O2 -std=c++17 -o myapp myapp.cpp
최적화가 강하면 인라인과 재배치 때문에 줄 번호가 어긋날 수 있으므로 -O0이나 -O1을 권장합니다.
Memcheck 동작 흐름
flowchart TB
A[malloc/new] --> B[할당 테이블 기록]
B --> C[메모리 접근 시 검증]
C --> D{유효?}
D -->|Yes| E[정상 진행]
D -->|No| F[에러 보고]
E --> G[free/delete 시 테이블 업데이트]
G --> C
H[프로그램 종료] --> I[누수 검사]
I --> J[LEAK SUMMARY 출력]
Memcheck는 모든 바이트에 대해 “주소로 접근 가능한가(A 비트)“와 “값이 정의되었는가(V 비트)“를 따로 추적합니다. 앞의 것으로 범위 밖 접근과 use-after-free를, 뒤의 것으로 초기화되지 않은 값 사용을 잡습니다. 누수는 종료 시점에 루트(전역 변수, 스택, 레지스터)에서 포인터로 도달할 수 없는 블록을 찾는 방식으로 판정합니다.
Memcheck 기본 실행과 핵심 옵션
# 가장 단순한 실행 (기본 도구 = Memcheck)
valgrind ./myapp
# 누수 상세 분석 (권장)
valgrind --leak-check=full --show-leak-kinds=all ./myapp
# 로그 파일로 저장
valgrind --leak-check=full --log-file=valgrind.log ./myapp
# 에러가 있으면 종료 코드 1 (CI용)
valgrind --leak-check=full --error-exitcode=1 ./myapp
# 자식 프로세스까지 추적 (exec 사용 시)
valgrind --trace-children=yes ./myapp
# 미초기화 값의 출처 추적
valgrind --track-origins=yes ./myapp
| 옵션 | 용도 |
|---|---|
--leak-check=full | 누수 블록마다 할당 스택 트레이스 출력 |
--show-leak-kinds=all | definite/indirect/possible/reachable을 모두 표시 (표시만 바꿈) |
--errors-for-leak-kinds=... | 어떤 누수 종류를 에러로 셀지 지정 (기본 definite,possible) |
--track-origins=yes | 미초기화 값이 어디서 생성되었는지 추적 (더 느려짐) |
--trace-children=yes | exec로 실행한 자식 프로세스도 검사 |
--error-exitcode=1 | 에러가 하나라도 있으면 지정한 종료 코드로 끝냄 |
--gen-suppressions=all | 에러마다 suppression 블록을 함께 출력 |
메모리 누수 (definitely lost)
// leak_example.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; // ❌ delete 없이 리턴 → 누수
}
std::cout << data->content << "\n";
delete data;
}
int main() {
process("hello");
process(""); // 여기서 누수
return 0;
}
g++ -g -O0 -std=c++17 -o leak_example leak_example.cpp
valgrind --leak-check=full --show-leak-kinds=all ./leak_example
출력 예시(주소와 정확한 문자열은 환경마다 다릅니다):
==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 0x1092A5: process(std::__cxx11::basic_string<char, ...> const&) (leak_example.cpp:9)
==12345== by 0x10939B: main (leak_example.cpp:18)
==12345==
==12345== LEAK SUMMARY:
==12345== definitely lost: 32 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
==12345== ERROR SUMMARY: 1 errors from 1 contexts
definitely lost는 가리키는 포인터가 하나도 남지 않은 확실한 누수입니다. 스택의 첫 줄 아래 process(...) (leak_example.cpp:9)가 new Data를 한 위치이고, main (leak_example.cpp:18)이 누수를 만든 process("") 호출입니다. 32바이트는 libstdc++에서 std::string 하나의 크기(sizeof(Data))이고, 빈 문자열이라 별도 힙 버퍼는 없습니다. 수정은 소유권을 unique_ptr에 맡기는 것입니다.
void process(const std::string& input) {
auto data = std::make_unique<Data>(input);
if (input.empty()) {
return; // ✅ unique_ptr 소멸 시 자동 해제
}
std::cout << data->content << "\n";
}
출력 읽는 법
==PID== 접두어는 추적 중인 프로세스 ID입니다. HEAP SUMMARY는 종료 시점의 힙 상태, LEAK SUMMARY는 누수 종류별 합계, ERROR SUMMARY는 발견된 에러 수(N errors from M contexts)입니다. 스택 트레이스는 맨 위가 에러가 발생한 위치, 아래로 갈수록 호출자이며, 괄호 안의 (파일:줄) 중 내 코드가 처음 나오는 줄이 보통 고칠 곳입니다.
definitely·indirectly·possibly·still reachable 구분
| 종류 | 의미 | 대응 |
|---|---|---|
| definitely lost | 블록을 가리키는 포인터가 전혀 남지 않음 | delete 추가 또는 스마트 포인터 |
| indirectly lost | 이 블록을 가리키는 포인터가 definitely lost 블록 안에만 있음 | 부모 블록을 고치면 함께 해결 |
| possibly lost | 블록 시작이 아니라 내부를 가리키는 포인터만 남음 | 원인 검토 |
| still reachable | 종료 시점에도 포인터가 남아 있음 (전역, 싱글턴 등) | 의도된 것이면 무시 가능 |
indirectly lost
// leak_indirect.cpp
#include <iostream>
struct Node {
int value;
Node* next;
explicit Node(int v) : value(v), next(nullptr) {}
};
int main() {
Node* head = new Node(1);
head->next = new Node(2);
head->next->next = new Node(3);
head = nullptr; // ❌ 첫 노드를 가리키던 포인터를 잃음
return 0;
}
==12345== 48 (16 direct, 32 indirect) bytes in 1 blocks are definitely lost in loss record 3 of 3
==12345== at 0x4846FA3: operator new(unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x109196: main (leak_indirect.cpp:9)
==12345==
==12345== definitely lost: 16 bytes in 1 blocks
==12345== indirectly lost: 32 bytes in 2 blocks
첫 노드를 가리키는 포인터를 잃었으므로 첫 노드(64비트 환경에서 int + 패딩 + 포인터 = 16바이트)는 definitely lost이고, 그 노드 안의 next로만 도달할 수 있는 나머지 두 노드는 indirectly lost입니다. 리스트를 끝까지 순회하며 해제하거나, next를 std::unique_ptr<Node>로 바꾸면 해결됩니다.
while (head) {
Node* tmp = head;
head = head->next;
delete tmp;
}
still reachable과 possibly lost
==12345== 72,704 bytes in 1 blocks are still reachable in loss record 1 of 1
==12345== at 0x4846828: malloc (vg_replace_malloc.c:...)
==12345== by 0x4935A59: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6...)
libstdc++가 시작할 때 예외 처리용 비상 메모리 풀을 할당하고 종료 시 해제하지 않아, 예전 버전에서는 이런 still reachable이 흔히 보였습니다. 프로그램 수명 내내 쓰는 의도된 할당이므로 무시하거나 suppression으로 걸러도 됩니다.
possibly lost는 블록 시작 주소를 잃고 내부를 가리키는 포인터만 남았을 때 나옵니다. 다중 상속에서 기반 클래스 포인터로만 객체를 들고 있거나, std::string처럼 헤더 뒤를 가리키는 포인터를 쓰는 구현에서도 나올 수 있어서, 우리 코드라면 원인을 확인하고 외부 라이브러리라면 suppression을 고려합니다.
누수 관련 옵션
# 모든 누수 종류를 표시
valgrind --leak-check=full --show-leak-kinds=all ./myapp
# definitely lost만 표시
valgrind --leak-check=full --show-leak-kinds=definite ./myapp
# definitely/indirectly lost만 에러로 세서 종료 코드에 반영
valgrind --leak-check=full --errors-for-leak-kinds=definite,indirect --error-exitcode=1 ./myapp
# 누수 요약만 (스택 생략)
valgrind --leak-check=summary ./myapp
--show-leak-kinds는 무엇을 보여 줄지만 정하고, 종료 코드에 영향을 주는 것은 --errors-for-leak-kinds입니다. 기본값은 definite,possible이므로 still reachable만 있다면 --error-exitcode가 있어도 실패로 처리되지 않습니다.
Invalid read/write: 버퍼 오버런과 use-after-free
힙 버퍼 오버런 (Invalid write)
// overflow_example.cpp
#include <cstring>
#include <iostream>
int main() {
char* buf = new char[8];
std::strcpy(buf, "123456789"); // ❌ 9글자 + '\0' = 10바이트를 8바이트 버퍼에
std::cout << buf << "\n";
delete[] buf;
return 0;
}
==12345== Invalid write of size 1
==12345== at 0x484F0B5: strcpy (vg_replace_strmem.c:...)
==12345== by 0x1091D2: main (overflow_example.cpp:6)
==12345== Address 0x4e2e088 is 0 bytes after a block of size 8 alloc'd
==12345== at 0x4849013: operator new[](unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x1091BE: main (overflow_example.cpp:5)
0 bytes after a block of size 8은 8바이트 블록 바로 다음 주소에 썼다는 뜻이고, 아래 스택이 그 블록을 할당한 위치입니다. 같은 코드를 char buf[8];처럼 스택 배열로 쓰면 Memcheck는 보통 아무것도 보고하지 않습니다. 스택 프레임은 하나의 큰 유효 영역으로 보이기 때문이며, 스택 오버플로를 잡으려면 ASan(-fsanitize=address)을 써야 합니다.
배열 인덱스 초과와 vector의 reserve/size 혼동
int* arr = new int[10];
for (int i = 0; i <= 10; ++i) arr[i] = i; // ❌ i=10은 범위 밖
delete[] arr;
==12345== Invalid write of size 4
==12345== at 0x1091C8: main (array_oob.cpp:4)
==12345== Address 0x4e2e0a8 is 0 bytes after a block of size 40 alloc'd
==12345== at 0x4849013: operator new[](unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x10919E: main (array_oob.cpp:3)
반면 std::vector<int> v; v.reserve(100); v[0] = 42;는 size()가 0인데 operator[]로 쓰는 명백한 버그지만, reserve가 이미 400바이트 블록을 할당해 두었기 때문에 Memcheck 입장에서는 유효한 힙 메모리에 쓰는 것이라 보고되지 않습니다. 이런 실수는 v.at(0)(범위 검사 후 예외), libstdc++의 -D_GLIBCXX_ASSERTIONS, 또는 MSVC 디버그 빌드의 반복자 검사로 잡습니다.
Use-after-free (Invalid read)
// use_after_free.cpp
#include <iostream>
int main() {
int* p = new int(42);
delete p;
std::cout << *p << "\n"; // ❌ 해제된 메모리 읽기
return 0;
}
==12345== Invalid read of size 4
==12345== at 0x1091C9: main (use_after_free.cpp:6)
==12345== Address 0x4e2e080 is 0 bytes inside a block of size 4 free'd
==12345== at 0x484A61D: operator delete(void*, unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x1091C4: main (use_after_free.cpp:5)
==12345== Block was alloc'd at
==12345== at 0x4846FA3: operator new(unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x1091AE: main (use_after_free.cpp:4)
접근 위치(6번 줄), 해제 위치(5번 줄), 할당 위치(4번 줄)가 모두 나옵니다. Memcheck는 해제된 블록을 일정량(--freelist-vol, 기본 20MB)까지 바로 재사용하지 않고 보관해 두기 때문에 이렇게 잡을 수 있지만, 해제 후 오랜 시간이 지나 그 메모리가 재사용된 뒤의 접근은 놓칠 수 있습니다.
이중 해제 (Double free)
// double_free.cpp
int main() {
int* p = new int(42);
delete p;
delete p; // ❌ 같은 포인터 두 번 해제
return 0;
}
==12345== Invalid free() / delete / delete[] / realloc()
==12345== at 0x484A61D: operator delete(void*, unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x1091BE: main (double_free.cpp:5)
==12345== Address 0x4e2e080 is 0 bytes inside a block of size 4 free'd
==12345== at 0x484A61D: operator delete(void*, unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x1091B2: main (double_free.cpp:4)
delete 뒤에 p = nullptr로 두면 두 번째 delete가 아무 일도 하지 않지만, 같은 객체를 가리키는 다른 포인터까지 막아 주지는 못합니다. 근본적인 해결은 소유자를 하나로 정하는 스마트 포인터입니다.
Mismatched free (delete vs delete[])
// mismatched_free.cpp
int main() {
int* arr = new int[10];
delete arr; // ❌ new[]인데 delete 사용
return 0;
}
==12345== Mismatched free() / delete / delete []
==12345== at 0x484A61D: operator delete(void*, unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x1091B2: main (mismatched_free.cpp:4)
==12345== Address 0x4e2e080 is 0 bytes inside a block of size 40 alloc'd
==12345== at 0x4849013: operator new[](unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x10919E: main (mismatched_free.cpp:3)
int처럼 소멸자가 없는 타입은 이 실수로 당장 크래시하지 않는 경우가 많아 더 오래 숨어 있습니다. 소멸자가 있는 타입이면 첫 원소의 소멸자만 호출되고 할당자에 잘못된 주소가 넘어가 힙이 손상될 수 있습니다.
초기화 안 된 값 추적
스택 변수
// uninit_example.cpp
#include <iostream>
int main() {
int x; // ❌ 초기화 안 함
if (x > 0) {
std::cout << "positive\n";
} else {
std::cout << "non-positive\n";
}
return 0;
}
--track-origins=yes로 실행한 출력:
==12345== Conditional jump or move depends on uninitialised value(s)
==12345== at 0x109185: main (uninit_example.cpp:5)
==12345== Uninitialised value was created by a stack allocation
==12345== at 0x109169: main (uninit_example.cpp:3)
--track-origins 없이 실행하면 첫 두 줄만 나오고, “was created by” 부분은 나오지 않습니다. 원인이 사용 지점과 멀리 떨어진 실제 코드에서는 이 옵션이 시간을 크게 줄여 줍니다.
힙 버퍼의 일부만 초기화
// uninit_buffer.cpp
#include <cstring>
#include <iostream>
int main() {
char* buf = new char[100];
std::strcpy(buf, "hello"); // 앞 6바이트만 초기화
size_t len = std::strlen(buf);
for (size_t i = 0; i < len + 10; ++i) { // ❌ len+10까지 읽음
if (buf[i] == 'x') { // i >= 6이면 미초기화 영역
std::cout << "found x\n";
}
}
delete[] buf;
return 0;
}
==12345== Conditional jump or move depends on uninitialised value(s)
==12345== at 0x1091F8: main (uninit_buffer.cpp:9)
==12345== Uninitialised value was created by a heap allocation
==12345== at 0x4849013: operator new[](unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x1091AE: main (uninit_buffer.cpp:5)
strlen은 초기화된 “hello\0”까지만 읽으므로 문제가 없고, 보고는 buf[i] == 'x'를 비교하는 9번 줄에서 나옵니다. 범위 밖이 아니라 할당된 100바이트 안쪽이라 Invalid read는 아니고, 값이 정의되지 않은 것만 문제입니다.
구조체 멤버
// uninit_struct.cpp
#include <iostream>
struct Config {
int timeout;
bool use_ssl;
};
int main() {
Config cfg; // ❌ 멤버 미초기화
if (cfg.timeout > 0) {
std::cout << "timeout=" << cfg.timeout << "\n";
}
return 0;
}
9번 줄의 분기에서 보고되며, --track-origins=yes를 주면 main 함수의 스택 할당을 출처로 알려 줍니다. Config cfg{};로 값 초기화하거나 멤버에 기본값(int timeout = 0;)을 주면 해결됩니다.
int x = 0; // 스택 변수
char* buf = new char[100](); // 힙 버퍼 0으로 초기화
struct Config {
int timeout = 0;
bool use_ssl = false;
};
Config cfg{}; // 기본값 또는 0으로 초기화
Suppression 파일로 외부 라이브러리 경고 억제
외부 라이브러리(OpenSSL, glibc, 그래픽 드라이버 등)는 종료 시 해제하지 않는 전역 메모리를 남기거나, 최적화된 어셈블리 때문에 미초기화 값 경고를 내기도 합니다. 고칠 수 없는 이런 경고가 우리 코드의 진짜 버그를 가리지 않도록 suppression으로 걸러 냅니다.
{
openssl_global_init
Memcheck:Leak
match-leak-kinds: reachable
...
obj:*/libcrypto.so*
}
{
libxyz_cond_false_positive
Memcheck:Cond
fun:libxyz_internal_copy
obj:*/libxyz.so*
}
각 블록은 {로 시작해 이름, 도구:종류, (누수라면 match-leak-kinds), 그리고 스택 프레임 패턴을 위에서부터 한 줄씩 적은 뒤 }로 닫습니다. 프레임 줄은 fun:함수이름(맹글된 이름) 또는 obj:라이브러리경로이며, */? 와일드카드와 “임의 개수의 프레임”을 뜻하는 ...를 쓸 수 있습니다. 모든 프레임 패턴이 순서대로 일치해야 억제됩니다.
| 종류 | 의미 |
|---|---|
Memcheck:Leak | 누수 |
Memcheck:Addr1, Addr4, Addr8 등 | 해당 크기의 잘못된 주소 접근 |
Memcheck:Value4, Value8 등 | 해당 크기의 미초기화 값 사용 |
Memcheck:Cond | 미초기화 값에 의존한 조건 분기 |
Memcheck:Param | 시스템 콜 인자에 미초기화 값 |
Memcheck:Free | 잘못된 free/delete |
valgrind --leak-check=full --suppressions=valgrind.supp ./myapp
# 파일을 나눠 여러 번 지정할 수도 있음
valgrind --suppressions=valgrind.supp --suppressions=valgrind-openssl.supp ./myapp
직접 쓰기보다 Valgrind가 만들어 준 블록을 다듬는 편이 정확합니다.
valgrind --leak-check=full --gen-suppressions=all ./myapp 2>&1 | tee valgrind_raw.log
# 출력의 { ... } 블록을 .supp 파일에 옮기고,
# <insert_a_suppression_name_here>를 의미 있는 이름으로 바꾼 뒤
# 우리 코드 쪽 프레임은 지우고 라이브러리 프레임만 남김
우리 코드에서 나온 definitely lost나 Invalid read/write는 억제하지 말고 고쳐야 합니다. 억제가 실제로 적용됐는지는 출력 끝의 suppressed: 줄과 -v 옵션의 “used_suppression” 목록으로 확인합니다. 문법이 틀리면 Valgrind가 시작 단계에서 파일 이름과 줄 번호를 대며 오류를 냅니다.
실행 문제와 헷갈리는 보고
valgrind: failed to start tool 'memcheck' for platform ...은 설치가 불완전하거나, 설치된 Valgrind가 지원하지 않는 아키텍처의 바이너리를 실행했을 때 나옵니다. 배포판 패키지를 다시 설치하고, 32비트 바이너리라면 해당 아키텍처용 Valgrind 지원이 설치되어 있는지 확인합니다.
코드상 delete가 분명히 있는데 definitely lost가 나온다면 예외로 delete를 건너뛰는 경로를 의심합니다. new와 delete 사이에서 함수가 예외를 던지면 해제 코드가 실행되지 않으므로, 이런 코드는 unique_ptr로 바꾸는 것이 정답입니다.
인라인 어셈블리, JIT 생성 코드, 고도로 최적화된 라이브러리 루틴은 미초기화 값 오탐을 낼 수 있습니다. 원인이 확실히 외부 코드라면 Memcheck:Cond/Value suppression으로 걸러 냅니다.
Valgrind가 너무 느려 CI에서 타임아웃이 나면 테스트 입력을 줄이고, --exit-on-first-error=yes(Valgrind 3.14+, --error-exitcode와 함께 사용)로 첫 에러에서 멈추게 하고, 매 커밋 검사는 ASan에 맡긴 뒤 Valgrind는 야간 작업으로 돌리는 방법이 있습니다. --error-exitcode만으로는 첫 에러에서 멈추지 않습니다.
--leak-check=summary에서는 누수별 스택이 나오지 않으므로 --show-leak-kinds를 줘도 상세 목록을 볼 수 없습니다. 상세 목록이 필요하면 --leak-check=full과 함께 씁니다.
GDB와 함께 쓰려면 valgrind --vgdb=yes --vgdb-error=0 ./myapp으로 시작한 뒤 다른 터미널에서 gdb ./myapp을 열고 target remote | vgdb로 연결합니다. 첫 에러가 보고되는 순간 멈춘 상태에서 변수와 스택을 살펴볼 수 있습니다.
Valgrind와 AddressSanitizer 비교
flowchart TD
A[메모리 버그 의심] --> B{재컴파일 가능?}
B -->|Yes| C{CI/빠른 피드백 필요?}
B -->|No| D[Valgrind]
C -->|Yes| E[AddressSanitizer]
C -->|No| F{미초기화 값 추적 필요?}
F -->|Yes| D
F -->|No| E
| 항목 | Valgrind Memcheck | AddressSanitizer |
|---|---|---|
| 재컴파일 | 불필요 | -fsanitize=address 필요 |
| 속도 | 매뉴얼 기준 20~30배 안팎 느림 | 문서 기준 평균 2배 안팎 느림 |
| 방식 | 실행 시 바이너리 변환·가상 CPU | 컴파일 시 검사 코드 삽입 |
| 누수 분류 | definite/indirect/possible/reachable 구분 | LeakSanitizer(Linux 등에서 기본 활성) |
| 미초기화 값 | 탐지 | 탐지 못함 (Clang MemorySanitizer 별도) |
| 힙 오버런·use-after-free | 탐지 | 탐지 |
| 스택·전역 배열 오버런 | 대부분 못 잡음 | 탐지 |
| 플랫폼 | Linux 중심 | Linux, macOS, Windows(MSVC) |
개발 중에는 ASan을 기본으로 쓰고, ASan이 잡지 못하는 미초기화 값이 의심되거나 재컴파일할 수 없는 바이너리를 검사할 때 Valgrind를 씁니다. CI는 ASan을 매 커밋에, Valgrind를 스케줄 작업으로 두는 조합이 흔합니다.
CMake·GitHub Actions·Docker로 CI에 통합하기
add_custom_target(valgrind
COMMAND valgrind --leak-check=full --error-exitcode=1
--suppressions=${CMAKE_SOURCE_DIR}/valgrind.supp
$<TARGET_FILE:myapp> --test
WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
cmake --build . --target valgrind로 실행합니다.
# .github/workflows/valgrind-nightly.yml
on:
schedule:
- cron: '0 2 * * *' # 매일 02:00 UTC
workflow_dispatch:
jobs:
valgrind:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y valgrind
- run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build
- run: valgrind --leak-check=full --error-exitcode=1 --suppressions=valgrind.supp ./build/myapp --run-all-tests
Apple Silicon Mac처럼 Valgrind가 돌지 않는 환경에서는 Linux 컨테이너가 가장 간단한 대안입니다.
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y valgrind g++ make
WORKDIR /app
COPY . .
RUN make CXXFLAGS="-g -O0"
CMD ["valgrind", "--leak-check=full", "--error-exitcode=1", "./myapp"]
docker build -f Dockerfile.valgrind -t myapp-valgrind . && docker run --rm myapp-valgrind
Valgrind는 실행 속도를 크게 떨어뜨리므로 프로덕션 프로세스에 붙이지 않습니다. 테스트, CI, 로컬 디버깅 용도로만 씁니다.