Valgrind로 메모리 누수 탐지하기

C/C++로 짠 프로그램에서 메모리 누수는 당장 크래시를 내지 않아서 더 골치 아프다. 며칠씩 켜두는 서버 프로세스가 서서히 메모리를 더 먹다가 결국 OOM으로 죽어야 그제서야 문제를 알아차리는 경우가 흔하다. 이 블로그에서는 이미 ASan, LeakSanitizer, gperftools, Efence, KFENCE 같은 도구를 다뤘는데, Valgrind의 Memcheck은 이들과 근본적으로 접근 방식이 다르다. 소스를 특정 플래그로 재컴파일할 필요 없이, 이미 빌드된 바이너리를 그대로 감싸서 실행하는 방식이라 소스가 없는 서드파티 라이브러리나 재빌드가 번거로운 레거시 바이너리를 디버깅할 때 특히 유용하다. 이 글에서는 Valgrind Memcheck으로 메모리 누수를 잡는 방법과 출력을 해석하는 법, 그리고 ASan 계열 도구와 언제 다르게 써야 하는지를 정리한다.

Valgrind Memcheck이 다른 도구와 다른 점

ASan(AddressSanitizer)이나 LeakSanitizer는 컴파일 타임에 -fsanitize=address 같은 플래그로 코드에 계측(instrumentation)을 심어 넣는 방식이라, 소스를 다시 빌드해야 한다. 반면 Valgrind는 프로그램을 자체 CPU 에뮬레이터(JIT 기반 동적 바이너리 계측) 위에서 실행하면서 모든 메모리 접근을 가로챈다. 그래서 소스 코드나 재컴파일 없이 이미 만들어진 실행 파일을 그대로 검사할 수 있다. 대신 대가가 있다 — 실제 실행 속도보다 10~50배 느려질 수 있어서, ASan(대략 2배 느려지는 수준)보다 훨씬 무겁다. 이 트레이드오프 때문에 Valgrind는 상시 운영 서버보다는 CI 단계나 로컬 디버깅에서 쓰는 게 일반적이다.

설치

sudo apt update
sudo apt install -y valgrind

valgrind --version

테스트용 누수 코드 작성

Memcheck이 구분하는 누수 종류를 한 번에 확인할 수 있도록, 의도적으로 서로 다른 패턴의 누수를 만든다.

#include <stdlib.h>

/* definitely lost: 반환된 포인터를 어디에도 저장하지 않는다 */
void leak_definitely(void) {
    malloc(48);
}

/* indirectly lost: root를 잃어버리면 root가 가리키던 블록도 함께 새는 것으로 잡힌다 */
void leak_indirectly(void) {
    int **root = malloc(sizeof(int *));
    *root = malloc(sizeof(int));
    /* root 자체를 free하지 않고 함수가 끝난다 */
}

/* still reachable: 전역 포인터라 프로그램 종료 시점까지 접근은 가능하지만 free는 안 됨 */
int *g_cache;
void leak_still_reachable(void) {
    g_cache = malloc(64);
}

int main(void) {
    leak_definitely();
    leak_indirectly();
    leak_still_reachable();
    return 0;
}
gcc -g -O0 -o leak_demo leak_demo.c
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./leak_demo

-g로 디버그 심볼을 넣지 않으면 리포트에 파일명·라인 번호가 빠져 원인 추적이 어려워지니 꼭 챙긴다. -O0은 컴파일러 최적화로 스택 트레이스가 뒤섞이는 걸 막기 위한 옵션이다.

출력 해석하기

Memcheck은 누수를 네 가지로 분류해서 보여준다.

분류의미
definitely lost해당 블록을 가리키는 포인터가 어디에도 남아있지 않다. 확실한 누수이므로 반드시 고쳐야 한다.
indirectly lost누수된 다른 블록(부모)만 이 블록을 가리키고 있었던 경우. 부모의 누수를 고치면 대개 같이 해결된다.
possibly lost블록의 시작이 아니라 중간을 가리키는 “내부 포인터”만 발견된 경우. 실제 버그일 수도, 우연한 오탐일 수도 있어 코드를 직접 봐야 한다.
still reachable프로그램이 끝나는 시점까지 포인터가 살아있어 기술적으로는 누수가 아니다. 정리 안 된 전역/캐시성 데이터인 경우가 많다.

위 예제를 돌리면 대략 다음과 같은 형태의 LEAK SUMMARY가 출력된다.

LEAK SUMMARY:
   definitely lost: 48 bytes in 1 blocks
   indirectly lost: 4 bytes in 1 blocks
     possibly lost: 0 bytes in 0 blocks
   still reachable: 64 bytes in 1 blocks
        suppressed: 0 bytes in 0 blocks

각 항목 위에는 어느 함수의 몇 번째 줄에서 malloc이 호출됐는지 스택 트레이스가 함께 출력되므로, definitely lost부터 순서대로 스택을 따라가며 고치면 된다.

CI에 통합하기

사람이 매번 출력을 눈으로 확인하는 대신, 누수가 발견되면 빌드 자체를 실패시키는 게 실용적이다.

valgrind --leak-check=full --error-exitcode=1 ./leak_demo
echo "exit code: $?"

--error-exitcode=1을 지정하면 오류(누수 포함)가 하나라도 발견될 때 0이 아닌 값으로 종료하므로, CI 스크립트에서 그대로 실패 조건으로 쓸 수 있다. glibc 내부 등 직접 고칠 수 없는 곳에서 나오는 오탐은 --gen-suppressions=all로 억제 규칙 파일을 생성해 --suppressions=파일.supp 옵션으로 무시할 수 있다.

ASan 계열과 언제 다르게 써야 하는가

항목Valgrind MemcheckASan / LeakSanitizer
재컴파일불필요 (바이너리 그대로 실행)필요 (-fsanitize=address 재빌드)
실행 오버헤드10~50배약 2배
서드파티/클로즈드소스 바이너리소스 없이도 검사 가능소스와 재빌드 권한이 있어야 함
초기화되지 않은 값 추적--track-origins=yes로 지원별도 도구인 MemorySanitizer 필요

소스가 있고 재빌드가 자유로운 자체 프로젝트라면 오버헤드가 작은 ASan/LeakSanitizer를 상시 테스트에 쓰는 게 낫고, 소스 없는 바이너리를 급하게 조사하거나 재빌드 자체가 부담스러운 상황에서는 Valgrind가 유일한 선택지가 된다.

주의사항

  • 오버헤드가 커서 실시간성이 중요한 프로덕션 서비스에 그대로 붙이기엔 부담스럽다. CI나 QA 단계, 로컬 재현 환경에서 쓰는 게 현실적이다.
  • Memcheck은 메모리 누수·잘못된 접근을 잡는 도구이고, 스레드 경합(data race) 탐지는 목적이 다르다. 멀티스레드 동시성 버그를 보려면 같은 Valgrind 툴셋의 Helgrind나 DRD를 따로 써야 한다.
  • still reachable은 대부분 실제 버그가 아니라 종료 시점까지 정리 안 된 전역/캐시 데이터다. 급한 불은 definitely lost 위주로 먼저 끄는 게 우선순위상 맞다.
  • musl libc 기반 배포판(Alpine 등)에서는 glibc 대비 지원이 제한적이거나 알려진 이슈가 있을 수 있으니, 대상 배포판에서 먼저 간단히 동작 확인을 해보는 게 안전하다.

마무리

Valgrind Memcheck은 재컴파일 없이 바이너리를 그대로 실행해 메모리 문제를 잡을 수 있다는 점에서 ASan 계열 도구와 상호 보완적이다. 오버헤드가 크다는 단점은 CI 파이프라인이나 조사가 필요한 순간에만 켜는 식으로 감수할 만하고, --error-exitcode로 자동화하면 사람이 리포트를 일일이 들여다보지 않아도 회귀를 잡아낼 수 있다. 자체 프로젝트라면 평소엔 가벼운 ASan/LeakSanitizer를 쓰고, 서드파티 바이너리나 재빌드가 어려운 상황에만 Valgrind를 꺼내 쓰는 조합이 실용적이다.

참고

답글 남기기