Valgrind Leak Report 분석 — definitely·indirectly·possibly·still reachable
#네 가지 누수 분류
--leak-check=full --show-leak-kinds=all로 실행하면 보고서 끝에 LEAK SUMMARY가 나옵니다.
LEAK SUMMARY:
- definitely lost: 40 bytes in 1 blocks
- indirectly lost: 80 bytes in 5 blocks
- possibly lost: 16 bytes in 1 blocks
- still reachable: 256 bytes in 3 blocks
- suppressed: 0 bytes in 0 blocks
네 종류의 의미를 정확히 잡지 못하면 어디서부터 고쳐야 할지 결정이 어렵습니다. 이 장은 그 네 가지를 정확히 다룹니다.
#definitely lost — 진짜 누수
40 bytes in 1 blocks are definitely lost in loss record 1 of 1 at 0x483977F: malloc by 0x10918A: main (leak.c:5)가장 명확한 누수. 종료 시점에 어떤 포인터로도 가리켜지지 않는 메모리. 단순한 forget to free가 원인.
char* p = malloc(40);// p가 어디에도 저장 안 됨, 함수 종료p가 함수 안의 지역 변수이고 반환 전에 해제도, 어디 저장도 안 됐다면 definitely lost.
#우선순위: 가장 높음
definitely lost는 반드시 고쳐야 합니다. 누군가가 이전에 가리키고 있었지만 잃어버린 명백한 사고입니다.
해결의 출발점은 스택 트레이스입니다. malloc()이 어디서 호출됐는지 보고, 그 위 호출자에서 해제할 책임자를 찾습니다.
#indirectly lost — Definite의 자식
struct Node { int value; struct Node* next;};
Node* head = malloc(sizeof(Node));head->next = malloc(sizeof(Node)); // ← 자식head->next->next = NULL;// head를 잃어버림 → head->next도 잃어버림이 경우:
head→ definitely lost (40 bytes)head->next→ indirectly lost (40 bytes)
head가 잃어버려진 순간 head->next도 도달 불가능해집니다. 두 누수는 같은 사고의 부산물입니다.
#우선순위: definite와 함께
indirectly lost는 직접 고칠 필요 없습니다. 대응하는 definitely lost를 고치면 자동으로 사라집니다.
보고서를 볼 때:
- definite 항목들을 모두 검토.
- 각 definite의 자식 그래프가 indirect로 묶임.
- definite를 고치면 indirect도 같이 해소.
40 bytes in 1 blocks are definitely lost in loss record X at 0x483977F: malloc by 0x10918A: main (list.c:10)
80 bytes in 5 blocks are indirectly lost in loss record Y at 0x483977F: malloc by 0x10918A: append (list.c:25)두 보고가 같은 자료 구조임을 알면 디버깅이 빨라집니다.
#possibly lost — 애매한 누수
char* p = malloc(100);p += 10; // 포인터 내부를 가리킴// 종료malloc()이 블록의 시작을 반환했지만, 변수 p는 블록 중간을 가리킵니다. Memcheck는 이렇게 판단합니다:
이 포인터가 진짜 그 블록의 시작을 의도한 것인지, 아니면 우연히 그 안쪽을 가리키는 것인지 모르겠다.
이게 possibly lost입니다. interior pointer(블록 내부 포인터)가 원인.
#흔한 발생 자리
- C++ 다중 상속
class Base1 {};class Base2 {};class Derived : public Base1, public Base2 {};
Base2* b = new Derived(); // b는 Derived의 *Base2 부분*을 가리킴 (블록 중간)// b를 잃어버리면 → possibly lost다중 상속 객체의 비-첫 베이스 포인터는 객체 시작이 아니라 내부를 가리킵니다.
- STL 컨테이너 내부 구현
std::string s("hello"); // 내부적으로 SSO 또는 heap 할당std::string 같은 STL 구현이 내부 포인터를 들고 있을 수 있어 possibly로 잡힘.
- 메모리 풀
char* pool = malloc(1024);char* slot1 = pool + 100;char* slot2 = pool + 200;// pool이 사라지면 slot1, slot2는 possibly lost#우선순위: 검토 후 판단
possibly lost는 케이스별 판단이 필요합니다.
- 다중 상속·STL은 false positive. 무시.
- 메모리 풀에서 정리 잊은 거면 진짜 누수.
--show-leak-kinds=definite,possible로 함께 검사하고, possibly가 우리 코드 자리면 definite처럼 다룸.
#still reachable — 살아 있는 캐시
char* g_config = NULL;
void init_config() { g_config = malloc(1024);}
int main() { init_config(); // free(g_config) 없이 종료}g_config는 전역 변수라 프로그램 종료까지 살아 있습니다. 메모리도 그 포인터로 여전히 도달 가능. 종료 시점에 free 안 했지만 기술적으로 누수가 아닙니다.
#흔한 자리
- 전역 캐시 (config, lookup table)
- 정적 변수 (Singleton)
atexit()핸들러가 들고 있는 메모리- 라이브러리 내부 상태 (예: zlib의 internal buffer)
- C++의 정적 로컬 (
static T instance;)
#우선순위: 보통 무시
대부분의 still reachable은 의도된 동작입니다. CI에서 빌드 실패로 만들 이유 없음.
# CI에서는 reachable 제외--errors-for-leak-kinds=definite,indirect다만 왜 reachable인지 알면 좋습니다. 어떤 정적 캐시가 종료 시 정리되지 않는지 문서화해 두면, 나중에 누수처럼 보이는 신규 코드를 빨리 식별할 수 있습니다.
#still reachable을 진짜 정리하고 싶을 때
릴리스 모드에서 완전 클린을 원하면 atexit() 핸들러를 등록합니다.
char* g_config = NULL;
void cleanup_config(void) { free(g_config); g_config = NULL;}
void init_config(void) { g_config = malloc(1024); atexit(cleanup_config);}C++에서는 Singleton 소멸자가 자동 호출됩니다.
class Config {public: static Config& instance() { static Config inst; // 종료 시 자동 소멸 return inst; }};이렇게 하면 still reachable이 0 bytes로 떨어집니다. 다만 비용 대비 효과가 낮아 대부분의 프로젝트는 그대로 둡니다.
#우선순위 결정 알고리즘
LEAK SUMMARY가 다음과 같다고 합시다.
definitely lost: 240 bytes in 6 blocks indirectly lost: 1024 bytes in 12 blocks possibly lost: 80 bytes in 2 blocks still reachable: 4096 bytes in 8 blocks이걸 고치는 순서:
- definite 6개 항목 검토 — 각각 진짜 누수. 스택 트레이스로 위치 식별.
- indirect는 함께 해소 — definite를 고치면 indirect도 사라짐.
- possible 2개 항목 검토 — 다중 상속·STL이면 무시, 우리 코드면 fix.
- reachable은 검토만 — 의도된 정적 데이터인지 확인. 보통 무시.
이 순서를 따르면 6개의 definite 자리만 고쳐도 보고서가 거의 깨끗해집니다.
#loss record — 같은 자리의 누수를 묶음
40 bytes in 5 blocks are definitely lost in loss record 3 of 8 at 0x483977F: malloc by 0x10918A: alloc_node (list.c:5) by 0x1091CD: list_push (list.c:18) by 0x109234: main (main.c:42)in 5 blocks가 핵심입니다. 같은 호출 트레이스에서 5번 할당이 일어났는데, 5개 모두 누수.
이게 통계적 가치가 큽니다. 반복 호출되는 핫 패스에서 누수가 일어나면, 시간이 지날수록 메모리가 누적됩니다.
blocks 카운트 | 의미 |
|---|---|
in 1 blocks | 일회성 누수. 짧게 도는 프로그램은 무시 가능 |
in N blocks | N번 호출된 자리에서 누수. 서버라면 시간당 누수율 측정 가능 |
서버에서 process_request가 분당 1000번 호출되고 매번 누수면, 1시간 후 60,000개 블록이 쌓입니다. 반드시 고침.
#보고서 그룹화
같은 스택 트레이스는 한 loss record로 묶입니다. 다른 트레이스는 다른 record.
== loss record 1 ==malloc → alloc_node → list_push → main (5 blocks)
== loss record 2 ==malloc → alloc_node → list_push → workflow_b (3 blocks)같은 alloc_node가 호출되지만 상위 호출자가 다르므로 별개 record. 각각의 호출 컨텍스트가 진짜 사고를 보여 줍니다.
#--leak-resolution — 트레이스 매칭 정밀도
--leak-resolution=med|high|low로 얼마나 정밀하게 트레이스를 비교할지 정합니다.
valgrind --leak-check=full --leak-resolution=high ./myapplow— 상위 2 프레임만 매칭. 같은 함수에서 호출이면 같은 record.med(기본) — 상위 4 프레임 매칭. 보통 적절.high— 모든 프레임 매칭. 가장 세밀.
세밀할수록 record 개수가 많아지고 보고서가 길어집니다. 처음에는 med로 보고, 깊은 분석 필요할 때 high로 올림.
#재귀 함수의 누수 — 트레이스 인식
void recursive(int depth) { if (depth == 0) return; char* buf = malloc(40); // 매 호출마다 누수 recursive(depth - 1);}Valgrind는 재귀의 각 깊이를 다른 호출자 컨텍스트로 봅니다. 깊이 10이면 10개의 서로 다른 loss record가 생길 수 있습니다.
40 bytes in 1 blocks definitely lost at malloc by recursive (depth 1) by main
40 bytes in 1 blocks definitely lost at malloc by recursive (depth 2) by recursive (depth 1) by main
... (10번 반복)--leak-resolution=low로 상위 몇 프레임만 매칭하면 한 loss record로 묶을 수 있습니다. 재귀 코드의 누수는 low 해상도가 디버깅에 더 편합니다.
#Reachable from
--show-reachable=yes(또는 --show-leak-kinds=reachable)로 reachable 보고를 켜면, 어디서 가리키고 있는지도 함께 보입니다.
256 bytes in 1 blocks are still reachable in loss record 1 at 0x483977F: malloc by 0x10918A: init_config (config.c:10) by 0x109234: main (main.c:5)이 자리는 config.c
에서 할당된 것이고 종료 시점에도 살아 있는 포인터로 가리켜진다는 뜻. 보통 전역 변수 또는 정적 캐시.위치를 확인하면 의도된 거인지 사고인지 즉시 판단됩니다.
#실전 시나리오 — 서버 누수 디버깅
서버 코드에서 시간당 1MB씩 메모리가 늘어나는 상황. Valgrind로 추적하는 절차:
#1. 짧은 시나리오로 재현
서버 전체를 24시간 돌릴 수는 없습니다. 짧은 단위 테스트로 누수를 반복 트리거.
for (int i = 0; i < 1000; i++) { process_request("test input");}process_request를 1000번 호출. 누수가 있다면 같은 자리에서 1000개 블록이 잡힐 것.
#2. Valgrind 실행
valgrind --leak-check=full --show-leak-kinds=all \ --track-origins=yes ./test_leak 2>&1 | tee valgrind.out#3. 보고서 분석
40000 bytes in 1000 blocks are definitely lost in loss record 5 of 7 at malloc by parse_header (parser.c:42) by process_request (server.c:88)in 1000 blocks = 1000번 누수 = process_request에서 호출되는 모든 요청에서 누수.
#4. 코드 검토
void process_request(const char* input) { Header* hdr = parse_header(input); // malloc 결과 // ... 처리 ... // ❌ free(hdr) 빠짐}해결: free(hdr) 추가, 또는 RAII (C++의 경우).
#5. 재실행 검증
같은 테스트를 다시 돌려 누수가 0이 됐는지 확인.
#정리
- definitely lost — 진짜 누수. 가장 먼저 고침.
- indirectly lost — definite의 자식. definite를 고치면 자동 해소.
- possibly lost — 케이스 판단. 다중 상속·STL은 무시, 우리 코드면 처리.
- still reachable — 살아 있는 캐시. 보통 무시. 정리하고 싶으면
atexit또는 Singleton. - loss record의
in N blocks로 호출 빈도 파악. 핫 패스의 누수는 우선순위 높음. --leak-resolution으로 트레이스 매칭 정밀도 조절. 재귀 코드는low권장.- 디버깅 흐름: 짧은 재현 → Valgrind → 보고서 분석 → 코드 수정 → 재검증.
#다음 장 예고
Ch 4: Helgrind와 DRD에서는 멀티스레드 분석 두 도구를 비교합니다. Helgrind의 락 추적, DRD의 vector clock 모델, 둘 중 언제 무엇을 선택할지.
#참고 자료
관련 글
Valgrind Memcheck 실전 — 메모리 오류 탐지 워크플로
Memcheck의 모든 핵심 옵션 — leak-check, track-origins, error-limit, 그리고 비용 vs 정확도 트레이드오프.
Valgrind 도구 개요 — Memcheck·Helgrind·DRD 비교
Sanitizer 시대에도 Valgrind가 살아남은 이유, 세 핵심 도구의 역할, 도입 자리.
Valgrind Suppression과 실무 운용 — 노이즈 제거와 CI 통합
Valgrind suppression 문법, 외부 라이브러리 우회, Sanitizer와의 분담, CI 통합 실전.