C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지

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 위험 패턴을 다뤘습니다.


목차

  1. 문제 시나리오: Valgrind가 필요한 상황
  2. Valgrind 개요 및 설치
  3. Memcheck 완전 예제
  4. 메모리 누수 탐지 상세
  5. Invalid Read/Write 탐지
  6. 초기화 안 된 값(Uninitialized Value)
  7. Suppression 파일
  8. 자주 발생하는 에러와 해결법
  9. 베스트 프랙티스
  10. Valgrind 출력 해석 가이드
  11. 프로덕션 패턴 및 CI 통합
  12. Valgrind vs AddressSanitizer 비교
  13. Suppression 파일 예제 확장
  14. Valgrind Memcheck 동작 흐름
  15. 실제 디버깅 워크플로우
  16. FAQ
  17. 체크리스트

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[], newdelete 짝 맞추기.


2. Valgrind 개요 및 설치

Valgrind란?

Valgrind동적 바이너리 계측 도구입니다. 프로그램을 가상 CPU에서 실행해 모든 메모리 접근·할당·해제를 추적합니다. 재컴파일 없이 기존 바이너리에 적용할 수 있지만, 10~50배 느려지므로 짧은 실행·단위 테스트에 사용합니다.

Valgrind 도구 비교

도구용도명령 예시
Memcheck메모리 누수, 잘못된 접근, 초기화 안 된 값valgrind --tool=memcheck ./app
CallgrindCPU 프로파일링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=alldefinitely/indirectly/possibly/still reachable 모두 표시
--track-origins=yes미초기화 값이 어디서 생성되었는지 추적
--trace-children=yesfork로 생성된 자식 프로세스도 검사
--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 lostdefinitely 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)

해석: headdelete하고 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] = 42reservecapacity만 늘리고 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에서 “이미 해제된 블록을 다시 해제”했다고 보고. 해결: deletep = 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: reachablestill 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, newdelete[] 혼동. 해결: newdelete, new[]delete[] 짝 맞추기.

에러 11: “still reachable만 있는데 exit code 1”

해결: --show-leak-kinds=definite,indirect로 definitely/indirectly만 에러로 처리하거나, still reachable을 suppression으로 억제.


9. 베스트 프랙티스

  1. -g로 컴파일: g++ -g -O0 -std=c++17 -o myapp myapp.cpp
  2. CI error-exitcode: valgrind --leak-check=full --error-exitcode=1 --quiet ./myapp
  3. 로그 저장: --log-file=valgrind_$(date +%Y%m%d_%H%M).log
  4. definitely lost 우선: still reachable은 suppression, 나머지 수정
  5. 테스트 축소: --gtest_filter="*Memory*"
  6. ASan 병행: 개발은 ASan, 릴리스 전 Valgrind
  7. track-origins=yes: 미초기화 값 출처 추적 필수
  8. 최소 권한 실행: 테스트용 계정
  9. 예외 경로 검증: 예외를 던지는 테스트로 Valgrind 실행. 경로 불일치 시 --fullpath-after

Valgrind 출력 해석 가이드

출력 요소의미
==PID==Valgrind가 추적 중인 프로세스 ID
HEAP SUMMARY종료 시점 힙 상태 (할당/해제/손실 바이트)
LEAK SUMMARYdefinitely/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 MemcheckAddressSanitizer
재컴파일불필요-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 출력]

실제 디버깅 워크플로우

  1. 최소 입력으로 재현 → 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 ./myapptarget 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 lost 0 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 |