C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지
이 글의 핵심
Valgrind Memcheck로 메모리 누수·버퍼 오버런·초기화 안 된 값 찾기. definitely lost·invalid read/write·uninitialized value 완전 예제, suppression 파일 작성, CI 통합, 프로덕션 패턴까지.
들어가며: “메모리가 계속 늘어나는데 원인을 못 찾겠어요”
Valgrind로 3일 걸리던 버그를 10분에 찾은 이야기
게임 서버가 24시간 돌아가다 보니, 메모리 사용량이 시간이 지날수록 계속 증가했습니다. 재시작하면 정상인데, 12시간 후면 OOM으로 죽는 패턴이 반복되었습니다. 코드를 눈으로 훑어봐도 new/delete 짝이 맞아 보였고, 어디서 누수가 나는지 감이 오지 않았습니다.
Valgrind Memcheck를 돌려 보니, 10분 만에 “definitely lost: 2,048 bytes in 1 blocks”와 함께 정확한 파일:줄 번호가 나왔습니다. handleRequest 함수에서 early return 경로에 delete가 빠져 있었던 것입니다.
flowchart LR
subgraph problem["문제"]
P1[메모리 증가]
P2[원인 불명]
P1 --> P2
end
subgraph solution["Valgrind로 해결"]
V[valgrind --leak-check=full]
L[definitely lost 위치]
F[코드 수정]
V --> L --> F
end
problem --> solution
이 글을 읽으면:
- Valgrind Memcheck로 메모리 누수·버퍼 오버런·초기화 안 된 값을 찾을 수 있습니다.
definitely lost,invalid read,uninitialized value출력을 해석할 수 있습니다.- Suppression 파일로 외부 라이브러리 경고를 억제할 수 있습니다.
- CI에 Valgrind를 통합하고 프로덕션 전 검증 패턴을 적용할 수 있습니다.
이전 글: C++ 메모리 누수에서 new/delete 위험 패턴을 다뤘습니다.
목차
- 문제 시나리오: Valgrind가 필요한 상황
- Valgrind 개요 및 설치
- Memcheck 완전 예제
- 메모리 누수 탐지 상세
- Invalid Read/Write 탐지
- 초기화 안 된 값(Uninitialized Value)
- Suppression 파일
- 자주 발생하는 에러와 해결법
- 베스트 프랙티스
- Valgrind 출력 해석 가이드
- 프로덕션 패턴 및 CI 통합
- Valgrind vs AddressSanitizer 비교
- Suppression 파일 예제 확장
- Valgrind Memcheck 동작 흐름
- 실제 디버깅 워크플로우
- FAQ
- 체크리스트
1. 문제 시나리오: Valgrind가 필요한 상황
시나리오 1: “서버가 2-3시간마다 죽어요”
"메모리 사용량이 500MB에서 7GB까지 계속 증가하다가 OOM으로 죽어요."
"코드를 보면 new/delete가 맞아 보이는데, 어디서 누수인지 모르겠어요."
원인: early return, 예외, 여러 분기 경로에서 delete가 누락된 경우. Valgrind Memcheck의 --leak-check=full로 정확한 할당 위치와 누수 경로를 찾을 수 있습니다.
시나리오 2: “특정 입력에서만 Segmentation fault”
"길이 1000인 배열은 괜찮은데, 1001이면 터져요."
"버퍼 오버런인 것 같은데, 어디서 넘어썼는지 모르겠어요."
원인: 배열 인덱스 범위 초과, strcpy/memcpy 길이 오류. Valgrind는 Invalid write of size N으로 정확한 주소와 스택을 보고합니다.
시나리오 3: “결과가 가끔 이상해요”
"같은 입력인데 실행마다 결과가 달라요."
"초기화 안 한 변수를 쓴 것 같은데, 찾기 어려워요."
원인: 스택/힙 변수를 초기화하지 않고 사용. Valgrind의 Conditional jump or move depends on uninitialised value로 정확한 사용 위치를 찾을 수 있습니다.
시나리오 4: “delete 후 포인터를 또 썼어요”
"해제한 메모리를 참조했다가 크래시해요."
"use-after-free인데, 어디서 발생하는지 추적이 안 돼요."
원인: delete 후 포인터를 null로 만들지 않았거나, 다른 경로에서 해제된 메모리 접근. Valgrind는 Invalid read of size N으로 해제 위치와 접근 위치를 모두 보고합니다.
시나리오 5: “외부 라이브러리에서 경고가 쏟아져요”
"우리 코드는 괜찮은데, OpenSSL/glibc에서 'still reachable'이 수천 개 나와요."
"진짜 우리 버그와 구분이 안 돼요."
원인: 라이브러리가 종료 시 명시적으로 해제하지 않는 메모리를 남김. Suppression 파일로 해당 경고를 억제할 수 있습니다.
시나리오 6: “네트워크 서버가 클라이언트 수에 비례해 메모리가 늘어나요”
"동시 접속 100명일 때 200MB, 1000명일 때 2GB로 비례해서 증가해요."
"연결이 끊겨도 메모리가 해제되지 않는 것 같아요."
원인: accept 후 생성한 세션 객체, 버퍼, 이벤트 핸들러가 close/disconnect 시 해제되지 않음. Valgrind의 reachable blocks로 연결당 할당된 블록을 추적할 수 있습니다.
시나리오 7: “std::vector resize 후 범위 밖 접근”
"벡터에 push_back하다가 가끔 크래시해요."
"reserve와 size를 혼동한 것 같은데, 정확한 위치를 모르겠어요."
원인: reserve는 용량만 확보하고 size는 그대로. operator[]로 size() 밖 인덱스 접근 시 Valgrind가 Invalid write로 보고합니다.
시나리오 8: “멀티스레드에서 가끔만 터져요”
"단일 스레드는 괜찮은데, 4스레드로 돌리면 10번 중 1번 정도 크래시해요."
"데이터 레이스인지, 메모리 오류인지 구분이 안 돼요."
원인: 레이스 컨디션으로 한 스레드가 delete한 메모리를 다른 스레드가 접근. Valgrind Memcheck는 Invalid read로 use-after-free를 보고하고, Helgrind는 데이터 경합을 별도 탐지합니다.
시나리오 9: “예외 발생 시 메모리가 해제되지 않아요”
"파싱 중 예외가 나면 그 시점에 할당한 버퍼가 해제되지 않는 것 같아요."
"try-catch는 있는데, 예외 경로에서 delete가 호출되지 않아요."
원인: try 블록 내 new 후 예외 발생 시 catch까지 delete가 실행되지 않음. Valgrind는 definitely lost로 예외 발생 직전 할당 위치를 보고합니다. 해결: RAII·스마트 포인터 사용.
시나리오 10: “delete[]와 delete를 혼동했어요”
"배열을 delete로 해제했는데, 가끔 힙 손상으로 크래시해요."
"new[]로 할당했는데 delete만 썼어요."
원인: new[]로 할당한 메모리를 delete로 해제하면 undefined behavior. Valgrind는 Invalid free() 또는 Mismatched free()로 보고합니다. 해결: new[] ↔ delete[], new ↔ delete 짝 맞추기.
2. Valgrind 개요 및 설치
Valgrind란?
Valgrind는 동적 바이너리 계측 도구입니다. 프로그램을 가상 CPU에서 실행해 모든 메모리 접근·할당·해제를 추적합니다. 재컴파일 없이 기존 바이너리에 적용할 수 있지만, 10~50배 느려지므로 짧은 실행·단위 테스트에 사용합니다.
Valgrind 도구 비교
| 도구 | 용도 | 명령 예시 |
|---|---|---|
| Memcheck | 메모리 누수, 잘못된 접근, 초기화 안 된 값 | valgrind --tool=memcheck ./app |
| Callgrind | CPU 프로파일링 | valgrind --tool=callgrind ./app |
| Cachegrind | 캐시 미스 시뮬레이션 | valgrind --tool=cachegrind ./app |
| Helgrind | 데이터 경합 | valgrind --tool=helgrind ./app |
이 글에서는 Memcheck를 중심으로 다룹니다. --tool을 생략하면 기본값이 Memcheck입니다.
설치
# Ubuntu/Debian
sudo apt install valgrind
# Fedora/RHEL
sudo dnf install valgrind
# macOS (Homebrew)
brew install valgrind
# 버전 확인
valgrind --version
컴파일 시 필수: 디버그 심볼
Valgrind가 파일:줄 번호를 정확히 보고하려면 -g 옵션이 필수입니다.
# ✅ 올바른 컴파일
g++ -g -O0 -std=c++17 -o myapp myapp.cpp
# ❌ -g 없으면 함수 이름만 나오고 줄 번호 없음
g++ -O2 -std=c++17 -o myapp myapp.cpp
-O0 권장: 최적화가 심하면 인라인·재정렬로 줄 번호가 어긋날 수 있습니다.
3. Memcheck 완전 예제
기본 실행
# 가장 단순한 실행 (기본 도구 = Memcheck)
valgrind ./myapp
# 누수 상세 분석 (권장)
valgrind --leak-check=full --show-leak-kinds=all ./myapp
# 로그 파일로 저장
valgrind --leak-check=full --log-file=valgrind.log ./myapp
# 에러 발생 시 exit code 1로 종료 (CI용)
valgrind --leak-check=full --error-exitcode=1 ./myapp
# 자식 프로세스까지 추적 (fork/exec 사용 시)
valgrind --trace-children=yes ./myapp
# 미초기화 값의 출처 추적 (uninitialised value 디버깅에 필수)
valgrind --track-origins=yes ./myapp
# 조용한 모드 (에러만 출력, CI에서 로그 정리)
valgrind --leak-check=full --error-exitcode=1 --quiet ./myapp
Memcheck 핵심 옵션 요약
| 옵션 | 용도 |
|---|---|
--leak-check=full | 누수 발생 위치의 전체 스택 트레이스 |
--show-leak-kinds=all | definitely/indirectly/possibly/still reachable 모두 표시 |
--track-origins=yes | 미초기화 값이 어디서 생성되었는지 추적 |
--trace-children=yes | fork로 생성된 자식 프로세스도 검사 |
--error-exitcode=1 | 에러 시 비정상 종료 (CI 연동용) |
--gen-suppressions=all | 출력을 suppression 형식으로 생성 |
예제 1: 메모리 누수 (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
Valgrind 출력:
==12345== 40 bytes in 1 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: process(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) (leak_example.cpp:12)
==12345== by 0x400B12: main (leak_example.cpp:22)
==12345==
==12345== LEAK SUMMARY:
==12345== definitely lost: 40 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: 확실한 누수. 반드시 수정해야 함.by 0x400A3C: process(...) (leak_example.cpp:12): 12번째 줄new Data에서 할당.by 0x400B12: main (leak_example.cpp:22): 22번째 줄process("")호출 시 누수 발생.
수정:
void process(const std::string& input) {
auto data = std::make_unique<Data>(input);
if (input.empty()) {
return; // ✅ unique_ptr 소멸 시 자동 해제
}
std::cout << data->content << "\n";
}
4. 메모리 누수 탐지 상세
누수 종류 (Leak Kinds)
| 종류 | 의미 | 대응 |
|---|---|---|
| definitely lost | 포인터가 완전히 손실됨. 반드시 수정 | delete 추가 또는 스마트 포인터 |
| indirectly lost | definitely lost로 인해 해제되지 않은 자식 블록 | 부모 수정 시 함께 해결 |
| possibly lost | 내부 포인터만 남아 있을 수 있음 (예: 중간 포인터) | 검토 필요 |
| still reachable | 종료 시점에 포인터가 남아 있음 (전역, atexit 등) | 의도적이면 무시 가능 |
예제 2: 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);
delete head; // ❌ next, next->next 해제 안 됨
return 0;
}
Valgrind 출력:
==12345== 24 bytes in 1 blocks are indirectly lost in loss record 2 of 3
==12345== 24 bytes in 1 blocks are indirectly lost in loss record 3 of 3
==12345== 24 bytes in 1 blocks are definitely lost in loss record 1 of 3
==12345== by 0x400A12: main (leak_indirect.cpp:11)
해석: head만 delete하고 next 체인은 해제하지 않음. definitely lost 1개 + indirectly lost 2개.
수정:
int main() {
Node* head = new Node(1);
head->next = new Node(2);
head->next->next = new Node(3);
while (head) {
Node* tmp = head;
head = head->next;
delete tmp;
}
return 0;
}
예제 3: still reachable (라이브러리)
==12345== 72,704 bytes in 1 blocks are still reachable in loss record 1 of 2
==12345== by 0x4E3F2A1: __gthread_once (gthr-default.h:699)
==12345== by 0x4E3F3B2: std::locale::locale() (locale.cc:78)
해석: C++ 표준 라이브러리 std::locale이 종료 시 명시적으로 해제하지 않는 메모리. 의도된 동작이므로 무시해도 됩니다. Suppression 파일로 억제 가능.
possibly lost 참고: 블록 시작 주소를 잃고 내부 포인터만 남았을 때 발생합니다. 라이브러리 내부 realloc 패턴에서도 나올 수 있어, 우리 코드면 수정, 외부 라이브러리면 suppression을 고려합니다.
누수 탐지 옵션 정리
# 모든 누수 종류 표시
valgrind --leak-check=full --show-leak-kinds=all ./myapp
# definitely lost만 에러로 (still reachable 무시)
valgrind --leak-check=full --show-leak-kinds=definite ./myapp
# 누수 요약만 (상세 스택 생략)
valgrind --leak-check=summary ./myapp
5. Invalid Read/Write 탐지
예제 4: 버퍼 오버런 (Invalid write)
코드 (overflow_example.cpp):
#include <iostream>
#include <cstring>
int main() {
char buf[8];
strcpy(buf, "123456789"); // ❌ 9바이트 + null → 오버플로우
std::cout << buf << "\n";
return 0;
}
Valgrind 실행:
g++ -g -O0 -std=c++17 -o overflow_example overflow_example.cpp
valgrind ./overflow_example
Valgrind 출력:
==12345== Invalid write of size 2
==12345== at 0x4C32E34: strcpy (vg_replace_strmem.c:513)
==12345== by 0x4007A2: main (overflow_example.cpp:7)
==12345== Address 0x1ffefff6e8 is 0 bytes after a block of size 8 alloc'd
==12345== at 0x4C2E0EF: operator new(unsigned long)
==12345== by 0x40078A: main (overflow_example.cpp:6)
해석:
Invalid write of size 2: 2바이트를 허용 범위 밖에 씀 (null 포함).Address ... is 0 bytes after a block of size 8: 8바이트 블록 바로 다음 주소에 접근.
예제 5: 배열 인덱스 초과·vector reserve/size 혼동
코드 (array_oob.cpp):
int* arr = new int[10];
for (int i = 0; i <= 10; ++i) arr[i] = i; // ❌ i=10 범위 초과
delete[] arr;
vector: v.reserve(100); v[0] = 42 — reserve는 capacity만 늘리고 size()는 0. operator[]로 size() 밖 접근 시 Invalid write.
Valgrind 출력:
==12345== Invalid write of size 4
==12345== at 0x4007B2: main (array_oob.cpp:7)
==12345== Address 0x5b7c048 is 0 bytes after a block of size 40 alloc'd
==12345== at 0x4C2E0EF: operator new(unsigned long)
==12345== by 0x40078A: main (array_oob.cpp:6)
예제 6: 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;
}
Valgrind 출력:
==12345== Invalid read of size 4
==12345== at 0x4007A2: main (use_after_free.cpp:7)
==12345== Address 0x5b7c040 is 0 bytes inside a block of size 4 free'd
==12345== at 0x4C2F24B: operator delete(void*) (vg_replace_malloc.c:576)
==12345== by 0x40079A: main (use_after_free.cpp:6)
==12345== Block was alloc'd at
==12345== at 0x4C2E0EF: operator new(unsigned long)
==12345== by 0x40078A: main (use_after_free.cpp:5)
해석: delete p 후 *p 읽기. 해제 위치(6번 줄)와 접근 위치(7번 줄) 모두 표시.
예제 6-2: 이중 해제 (Double free)
코드 (double_free.cpp):
#include <iostream>
int main() {
int* p = new int(42);
delete p;
delete p; // ❌ 같은 포인터 두 번 해제
return 0;
}
Valgrind 출력:
==12345== Invalid free() / delete / delete[] / realloc()
==12345== at 0x4C2F24B: operator delete(void*) (vg_replace_malloc.c:576)
==12345== by 0x4007B2: main (double_free.cpp:7)
==12345== Address 0x5b7c040 is 0 bytes inside a block of size 4 free'd
==12345== at 0x4C2F24B: operator delete(void*)
==12345== by 0x4007A2: main (double_free.cpp:6)
해석: 두 번째 delete p에서 “이미 해제된 블록을 다시 해제”했다고 보고. 해결: delete 후 p = nullptr로 두면 실수로 이중 해제 시에도 안전하지만, 근본적으로는 스마트 포인터 사용 권장.
예제 6-3: Mismatched free (delete vs delete[])
코드 (mismatched_free.cpp):
#include <iostream>
int main() {
int* arr = new int[10];
delete arr; // ❌ new[]인데 delete 사용 → Mismatched free
return 0;
}
Valgrind 출력:
==12345== Mismatched free() / delete / delete[]
==12345== at 0x4C2F24B: operator delete(void*)
==12345== by 0x4007A2: main (mismatched_free.cpp:6)
==12345== Address 0x5b7c040 is 0 bytes inside a block of size 40 alloc'd
==12345== at 0x4C2F0EF: operator new
==12345== by 0x40078A: main (mismatched_free.cpp:5)
해석: new[]로 할당했으면 반드시 delete[]로 해제해야 합니다.
6. 초기화 안 된 값(Uninitialized Value)
예제 7: 스택 변수 미초기화
코드 (uninit_example.cpp):
#include <iostream>
int main() {
int x; // ❌ 초기화 안 함
if (x > 0) {
std::cout << "positive\n";
} else {
std::cout << "non-positive\n";
}
return 0;
}
Valgrind 출력 (기본):
==12345== Conditional jump or move depends on uninitialised value(s)
==12345== at 0x4007A2: main (uninit_example.cpp:6)
==12345== Uninitialised value was created by a stack allocation
==12345== at 0x40078A: main (uninit_example.cpp:5)
--track-origins=yes로 실행하면 “Uninitialised value was created by a stack allocation at (uninit_example.cpp:5)“처럼 값이 어디서 생성되었는지 명확히 출력됩니다.
예제 8: 힙 버퍼 일부 미초기화
코드 (uninit_buffer.cpp):
#include <iostream>
#include <cstring>
int main() {
char* buf = new char[100];
std::strcpy(buf, "hello"); // 6바이트만 초기화, 나머지 94바이트 미초기화
size_t len = std::strlen(buf);
for (size_t i = 0; i < len + 10; ++i) { // ❌ len+10까지 접근
if (buf[i] == 'x') { // 미초기화 영역 읽기
std::cout << "found x\n";
}
}
delete[] buf;
return 0;
}
Valgrind 출력:
==12345== Conditional jump or move depends on uninitialised value(s)
==12345== at 0x4C32E80: strlen (vg_replace_strmem.c:...)
==12345== by 0x4007C2: main (uninit_buffer.cpp:9)
==12345== Uninitialised value was created by a heap allocation
==12345== at 0x4C2E0EF: operator new(unsigned long)
==12345== by 0x4007A2: main (uninit_buffer.cpp:7)
예제 9: 구조체 멤버 미초기화
코드 (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;
}
Valgrind 출력 (--track-origins=yes 권장):
==12345== Conditional jump or move depends on uninitialised value(s)
==12345== at 0x4007B2: main (uninit_struct.cpp:10)
==12345== Uninitialised value was created by a stack allocation
==12345== at 0x40078A: main (uninit_struct.cpp:9)
수정: Config cfg{} 또는 멤버 기본값 설정 (int timeout = 0;).
수정: 항상 초기화
// ✅ 스택
int x = 0;
// ✅ 힙 버퍼
char* buf = new char[100](); // zero-initialize
// 또는
std::memset(buf, 0, 100);
// ✅ 구조체
struct Config {
int timeout = 0;
bool use_ssl = false;
char* hostname = nullptr;
};
// 또는
Config cfg{}; // 모든 멤버 0으로 초기화
7. Suppression 파일
왜 Suppression이 필요한가?
외부 라이브러리(OpenSSL, glibc, C++ 표준 라이브러리)가 의도적으로 종료 시 해제하지 않는 메모리가 있어서, Valgrind가 still reachable로 보고합니다. 우리 코드의 진짜 버그와 구분하기 위해 해당 경고를 억제합니다.
Suppression 파일 작성
파일 (valgrind.supp):
{
libc_locale
Memcheck:Leak
match-leak-kinds: reachable
fun:__gthread_once
fun:_ZNSt6localeC1Ev
}
{
openssl_init
Memcheck:Leak
match-leak-kinds: reachable
obj:*/libssl.so*
obj:*/libcrypto.so*
}
사용:
valgrind --leak-check=full --suppressions=valgrind.supp ./myapp
Suppression 생성 (자동)
Valgrind가 출력한 에러를 그대로 suppression으로 저장할 수 있습니다.
# 1. 에러 출력을 파일로
valgrind --leak-check=full --gen-suppressions=all ./myapp 2>&1 | tee valgrind_raw.log
# 2. 출력에서 { ... } 블록을 복사해 .supp 파일에 붙여넣기
# 3. 이름을 의미 있게 수정 (예: libc_locale → our_libc_suppression)
출력 예시:
{
<insert_a_suppression_name_here>
Memcheck:Leak
match-leak-kinds: reachable
fun:__gthread_once
fun:_ZNSt6localeC1Ev
...
}
Suppression 문법 요약
| 필드 | 의미 |
|---|---|
Memcheck:Leak | 누수 억제 |
Memcheck:Addr1 | 잘못된 주소 접근 억제 |
Memcheck:Value4 | 초기화 안 된 값 억제 |
match-leak-kinds: reachable | still reachable만 |
fun:함수이름 | 특정 함수에서 발생한 것만 |
obj:*/libssl.so* | 특정 라이브러리 |
Suppression 작성 시 주의사항
와일드카드(fun:*SSL*, obj:*/libssl*) 사용 가능. 여러 조건은 AND. 우리 코드의 definitely lost는 suppression 금지, 반드시 수정. 검증: valgrind --suppressions=valgrind.supp ./myapp 2>&1 | grep suppressed
8. 자주 발생하는 에러와 해결법
에러 1: “Valgrind: failed to start tool ‘memcheck’”
원인: Valgrind 설치 불완전 또는 아키텍처 불일치.
해결:
# 재설치
sudo apt install --reinstall valgrind
# 32비트 바이너리에 64비트 Valgrind 사용 시
# 해당 아키텍처용 Valgrind 설치 확인
에러 2: “valgrind: Cannot find memory map”
원인: macOS에서 일부 바이너리와 호환 문제.
해결:
# macOS: 최신 Valgrind 사용
brew upgrade valgrind
# 또는 Docker/Linux VM에서 실행
docker run -v $(pwd):/work -w /work ubuntu:22.04 valgrind ./myapp
에러 3: “definitely lost”가 나오는데 코드상 delete가 있어요
원인: 예외 발생 시 delete가 실행되지 않는 경로.
해결: try/catch 내 모든 경로에서 delete 호출 확인. 또는 unique_ptr/shared_ptr로 교체.
에러 4: “Conditional jump on uninitialised value” - false positive
원인: 인라인 어셈블리, JIT 생성 코드, 특정 라이브러리 내부.
해결: Suppression 파일에 추가. 또는 해당 코드 경로를 Valgrind 없이 별도 테스트.
에러 5: Valgrind가 너무 느려서 CI에서 타임아웃
원인: Valgrind는 10~50배 느림.
해결:
- 입력 크기 축소 (테스트용 작은 데이터)
--error-exitcode=1로 첫 에러에서 즉시 종료- ASan(
-fsanitize=address,leak)을 CI 주력으로, Valgrind는 주기적(예: 매일) 실행
에러 6: “still reachable”이 수천 개
원인: C++ 표준 라이브러리, OpenSSL 등이 종료 시 해제하지 않는 메모리.
해결: Suppression 파일로 억제. 우리 코드 경로(src/, main.cpp 등)만 필터링해 확인.
에러 7: “Valgrind: invalid combination of options”
--leak-check=summary일 때는 --show-leak-kinds가 무시됩니다. --leak-check=full과 함께 사용하세요.
에러 8: “Fatal error: call to __builtin___strcpy_chk”
_FORTIFY_SOURCE와 Valgrind가 충돌할 수 있습니다. g++ -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0으로 컴파일하세요.
에러 9: “Valgrind: debug info not found”
-g 없이 컴파일했거나 스트립된 바이너리입니다. g++ -g -O0으로 빌드하고 strip하지 마세요.
에러 10: “Mismatched free() / delete / delete[]”
원인: new[]↔delete, new↔delete[] 혼동. 해결: new↔delete, new[]↔delete[] 짝 맞추기.
에러 11: “still reachable만 있는데 exit code 1”
해결: --show-leak-kinds=definite,indirect로 definitely/indirectly만 에러로 처리하거나, still reachable을 suppression으로 억제.
9. 베스트 프랙티스
- -g로 컴파일:
g++ -g -O0 -std=c++17 -o myapp myapp.cpp - CI error-exitcode:
valgrind --leak-check=full --error-exitcode=1 --quiet ./myapp - 로그 저장:
--log-file=valgrind_$(date +%Y%m%d_%H%M).log - definitely lost 우선: still reachable은 suppression, 나머지 수정
- 테스트 축소:
--gtest_filter="*Memory*"등 - ASan 병행: 개발은 ASan, 릴리스 전 Valgrind
- track-origins=yes: 미초기화 값 출처 추적 필수
- 최소 권한 실행: 테스트용 계정
- 예외 경로 검증: 예외를 던지는 테스트로 Valgrind 실행. 경로 불일치 시
--fullpath-after
Valgrind 출력 해석 가이드
| 출력 요소 | 의미 |
|---|---|
==PID== | Valgrind가 추적 중인 프로세스 ID |
| HEAP SUMMARY | 종료 시점 힙 상태 (할당/해제/손실 바이트) |
| LEAK SUMMARY | definitely/indirectly/possibly/still reachable/suppressed |
| ERROR SUMMARY | 발견된 에러 개수 (N errors from M contexts) |
| 스택 트레이스 | 맨 위 = 에러 발생 위치, 맨 아래 = 호출 시작. (파일:줄)이 수정할 위치 |
스택 읽는 법: by 0x... : 함수명 (파일:줄) → 해당 줄에서 할당/접근/해제 발생.
10. 프로덕션 패턴 및 CI 통합
패턴 1: CMake Valgrind 타겟
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로 실행.
패턴 2: GitHub Actions
# .github/workflows/valgrind.yml
on: [push, pull_request]
jobs:
valgrind:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y valgrind
- run: mkdir build && cd build && cmake -DCMAKE_BUILD_TYPE=Debug .. && make
- run: cd build && valgrind --leak-check=full --error-exitcode=1 --suppressions=../valgrind.supp ./myapp --test
패턴 3: 스크립트 래퍼
#!/bin/bash
# scripts/run_valgrind.sh
VALGRIND_OPTS="--leak-check=full --show-leak-kinds=all --error-exitcode=1 --suppressions=$(dirname $0)/../valgrind.supp --log-file=valgrind_$$.log"
valgrind $VALGRIND_OPTS "$@"
패턴 4: 프로덕션에서는 Valgrind 사용 금지
Valgrind는 10~50배 느림이므로 프로덕션 바이너리에 붙이지 않습니다. 테스트·CI·로컬 디버깅에만 사용합니다.
패턴 5: Docker (macOS ARM 대안)
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
패턴 6: Makefile Valgrind 타겟
VALGRIND_OPTS = --leak-check=full --show-leak-kinds=all --error-exitcode=1 --suppressions=valgrind.supp
.PHONY: valgrind
valgrind: myapp
valgrind $(VALGRIND_OPTS) ./myapp
패턴 7: 스케줄 기반 CI (느린 테스트)
Valgrind가 느리면 CI에서 schedule(예: 매일 02:00)으로 실행하고, timeout-minutes: 60으로 타임아웃을 설정합니다.
GitHub Actions 스케줄 예시:
# .github/workflows/valgrind-nightly.yml
on:
schedule:
- cron: '0 2 * * *' # 매일 02:00 UTC
jobs:
valgrind:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- run: mkdir build && cd build && cmake -DCMAKE_BUILD_TYPE=Debug .. && make
- run: cd build && valgrind --leak-check=full --error-exitcode=1 --suppressions=../valgrind.supp ./myapp --run-all-tests
패턴 8: 로컬용 옵션
빠른 확인: valgrind --leak-check=summary --error-exitcode=1 ./myapp
심층 분석: valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./myapp
Valgrind vs 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,leak 필요 |
| 오버헤드 | 10~50배 느림 | 2~5배 느림 |
| CPU 사용 | 가상 CPU 시뮬레이션 | 계측 코드 삽입 |
| 누수 탐지 | definitely/indirectly/possibly/still reachable 구분 | LeakSanitizer(LSan) |
| 초기화 안 된 값 | ✅ | ❌ (MemorySanitizer 별도) |
| 버퍼 오버런 | ✅ | ✅ |
| Use-after-free | ✅ | ✅ |
| CI 적합성 | 느림, 타임아웃 주의 | 적합 |
| 플랫폼 | Linux, macOS(제한적) | Linux, macOS, Windows |
권장: 로컬 개발은 ASan → 의심 시 Valgrind. CI는 ASan 주력, Valgrind는 스케줄 실행. 레거시 바이너리는 Valgrind만 사용.
Suppression 파일 예제 확장
# glibc/표준 라이브러리
{ libstdcxx_locale
Memcheck:Leak
match-leak-kinds: reachable
fun:__gthread_once
fun:_ZNSt6localeC1Ev }
# OpenSSL
{ openssl_global_init
Memcheck:Leak
match-leak-kinds: reachable
obj:*/libssl.so*
obj:*/libcrypto.so* }
의도된 “still reachable”(싱글톤 캐시 등)은 fun:OurCache::getInstance 등으로 억제. 문법 오류 시 “suppression file … is empty or malformed” 발생.
Addr1·Value4 억제
# Memcheck:Addr1 (잘못된 주소)
{ libxyz_invalid_read
Memcheck:Addr1
fun:libxyz_internal_copy
obj:*/libxyz.so* }
# Memcheck:Value4 (미초기화, JIT false positive)
{ jit_uninit_fp
Memcheck:Value4
fun:jit_generated_code }
주의: 우리 코드의 진짜 버그는 억제하지 말 것.
Valgrind 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 출력]
실제 디버깅 워크플로우
- 최소 입력으로 재현 → 2. definitely lost 우선 해결 → 3. 스택 트레이스 따라가기 (
(파일:줄)에서new/malloc확인, 모든 경로에서delete/free검사) → 4. 재검증 (--error-exitcode=1로 0 확인) → 5. CI 통합
FAQ
Q. Valgrind 없이 메모리 누수를 찾을 수 있나요?
A. AddressSanitizer LeakSanitizer(-fsanitize=address,leak)로 대부분 찾을 수 있습니다. Valgrind는 재컴파일 없이, definitely/possibly/still reachable 등 세분화된 분석이 필요할 때 유리합니다.
Q. “possibly lost”는 반드시 수정해야 하나요?
A. 라이브러리 내부 realloc 등에서 나올 수 있어, 라이브러리면 suppression, 우리 코드면 원인 파악 후 수정 권장.
Q. macOS에서 Valgrind가 안 돼요.
A. macOS ARM(M1/M2)은 Valgrind 미지원. x86은 brew install valgrind 가능하나 호환성 이슈 있을 수 있음. Docker Linux 컨테이너 사용 권장.
Q. Valgrind로 테스트가 30분 타임아웃돼요.
A. 테스트 데이터 축소, --error-exitcode=1로 첫 에러에서 종료, CI에서는 ASan 주력 + Valgrind는 스케줄(매일 밤) 실행.
Q. suppression 파일이 너무 길어져요.
A. valgrind.supp, valgrind-openssl.supp 등으로 분리 후 --suppressions를 여러 번 지정: valgrind --suppressions=valgrind.supp --suppressions=valgrind-openssl.supp ./myapp
Q. Valgrind와 GDB를 함께 쓸 수 있나요?
A. valgrind --vgdb=yes --vgdb-error=0 ./myapp 실행 후 gdb ./myapp → target remote | vgdb로 연동 가능.
Q. Windows에서 Valgrind를 쓸 수 있나요?
A. Valgrind는 Linux 전용. Windows에서는 Dr. Memory 또는 Application Verifier 사용. WSL2에서 Linux 바이너리 빌드 후 Valgrind 실행도 가능.
체크리스트
Valgrind 사용 체크리스트
-
-g옵션으로 컴파일 -
--leak-check=full --show-leak-kinds=all사용 -
definitely lost0 bytes 확인 -
invalid read/write없음 확인 -
uninitialised value없음 확인 - 외부 라이브러리 경고는 suppression 파일로 억제
- CI에
--error-exitcode=1로 통합 - 프로덕션에서는 Valgrind 미사용
메모리 버그 예방 체크리스트
-
new/delete대신unique_ptr/shared_ptr사용 - 배열은
new[]/delete[]짝 맞추기 - early return·예외 경로에서 해제 확인
- 버퍼 크기 검증 (
strncpy,std::string,std::vector)
정리
Valgrind Memcheck로 메모리 누수·버퍼 오버런·초기화 안 된 값을 찾고, suppression 파일로 외부 라이브러리 경고를 억제하며, CI에 통합해 프로덕션 전 검증할 수 있습니다.
| 상황 | 권장 |
|---|---|
| 로컬 심층 디버깅 | Valgrind --leak-check=full |
| CI 빠른 피드백 | ASan -fsanitize=address,leak |
| 외부 라이브러리 경고 | Suppression 파일 |
| 프로덕션 | Valgrind 사용 금지 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Valgrind Memcheck로 메모리 누수·버퍼 오버런·초기화 안 된 값 찾기. definitely lost·invalid read/write·uninitialized value 완전 예제, suppression… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
다음 글: C++ RAII에서 리소스 관리 패턴을 다룹니다.
참고 자료
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
- C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리
- C++ 런타임 검증: AddressSanitizer와 ThreadSanitizer 완벽 가이드 [#41-2]
이 글에서 다루는 키워드 (관련 검색어)
C++, Valgrind, Memcheck, 메모리디버깅, 메모리누수, 메모리오류, suppression, AddressSanitizer 등으로 검색하시면 이 글이 도움이 됩니다.
관련 글
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
- C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
- C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ RAII |