C++ 메모리 누수 심화 디버깅 | 힙 프로파일링·패턴·스마트포인터·순환참조·프로덕션
이 글의 핵심
기본적인 new/delete 누수를 넘어서면, 도구 리포트에는 아무것도 없는데 RSS가 계속 오르거나 누수처럼 보이지만 실제로는 캐시인 경우를 구분해야 합니다. 동일 스택에서 반복되는 할당을 찾는 법, weak_ptr로 객체 그래프의 순환을 끊는 기준, 프로덕션에서 메트릭과 샘플링 프로파일러, 계측 빌드로 재현하는 절차를 결정 트리로 정리했습니다.
들어가며
C++ 메모리 누수에서 다룬 원인·예방·기본 탐지를 전제로, 이 글은 도구가 내부적으로 무엇을 보는지, 리포트를 어떻게 해석할지, 스마트 포인터와 객체 그래프에서만 생기는 누수, 실서비스에서의 재현·관측 방법에 초점을 맞춥니다. 기초 설명이 필요하면 누수 탐지 실전, Valgrind, Sanitizer 개요를 함께 보시면 흐름이 이어집니다.
힙 프로파일링 도구의 내부: Valgrind와 AddressSanitizer
Valgrind Memcheck가 추적하는 것
Memcheck는 실행 파일의 기계어를 Valgrind 내부 중간 표현(VEX IR)으로 동적 변환하고, 검사 코드를 끼워 넣은 뒤 다시 기계어로 만들어 실행합니다. 동시에 malloc/new 계열 호출을 가로채 블록마다 크기, 할당 스택, 해제 여부를 기록하고, 모든 바이트에 대해 “주소로 접근 가능한가”, “값이 초기화되었는가”를 섀도 비트로 추적합니다. 그래서 원본 바이너리를 다시 빌드하지 않아도 되지만, 공식 문서 기준으로 대략 10~50배 느려집니다.
누수 점검에서 자주 쓰는 옵션은 다음과 같습니다.
# 누수마다 할당 스택을 보고, 모든 종류(still reachable 포함)를 출력
valgrind --leak-check=full --show-leak-kinds=all ./your_app
# 자식 프로세스까지 추적 (워커 포크 모델)
valgrind --trace-children=yes --leak-check=full ./your_app
--track-origins=yes는 초기화되지 않은 값이 어디서 왔는지 추적하는 옵션이라 누수 분석과는 관계가 없고, 실행을 더 느리게 만듭니다. “Conditional jump depends on uninitialised value” 같은 오류를 볼 때만 켜면 됩니다.
누수 판단은 종료 시점에 루트 집합(전역 변수, 스택, 레지스터)에서 시작해 힙 블록을 가리키는 포인터를 따라가는 방식으로 이루어집니다. 그래서 리포트 분류를 정확히 읽어야 합니다. definitely lost는 어떤 포인터도 블록을 가리키지 않는 상태로, 복구할 수 없는 진짜 누수입니다. indirectly lost는 그 블록을 가리키는 포인터가 이미 누수된 블록 안에만 있는 경우라서, 상위 블록의 누수를 고치면 함께 사라집니다. still reachable은 종료 시점에 여전히 시작 포인터로 도달할 수 있는 할당이라 반드시 버그는 아니지만, 요청마다 늘어난다면 RSS를 키우는 원인이 됩니다. possibly lost는 블록의 시작이 아니라 중간을 가리키는 포인터(interior pointer)만 남아 있어 도구가 판단을 보류한 경우로, 실제 누수인지 따로 확인해야 합니다.
AddressSanitizer(ASan)와 LeakSanitizer(LSan)
ASan은 컴파일러가 메모리 접근마다 섀도 메모리 검사를 끼워 넣고, 할당 블록 양옆에 레드존을 둬서 버퍼 오버플로·use-after-free 같은 접근 오류를 그 자리에서 잡습니다. LSan은 프로세스 종료 시 위와 비슷한 도달 가능성 스캔으로 누수를 찾는 도구입니다. Linux x86_64에서는 ASan 빌드에 LSan이 기본으로 켜져 있고, macOS 등 일부 플랫폼에서는 기본값이 꺼져 있거나 지원이 제한적이므로 detect_leaks=1을 명시하는 편이 확실합니다. ASan 없이 LSan만 쓰려면 -fsanitize=leak으로 빌드할 수도 있습니다.
# Clang/GCC: 누수 검사 + 읽을 수 있는 스택
clang++ -O1 -g -fsanitize=address -fno-omit-frame-pointer main.cpp -o app
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./app
ASan의 장점은 실행 오버헤드가 Valgrind보다 훨씬 작고(대략 2배 수준), 디버그 정보와 함께 빌드하면 소스 줄 번호가 정확하게 붙는다는 점입니다. 반면 반드시 계측 빌드가 필요하고, 계측되지 않은 라이브러리 내부의 접근 오류는 잡지 못합니다. 누수 리포트의 할당 스택은 기본적으로 빠른 프레임 포인터 언와인더로 수집하므로, -fno-omit-frame-pointer를 빼면 스택이 짧게 잘리는 경우가 많습니다.
둘을 함께 쓰는 기준
CI와 개발 루프에서는 ASan(+LSan) 빌드를 기본으로 두고 PR·야간 빌드에서 회귀를 잡습니다. Valgrind는 소스가 없는 서드파티 라이브러리가 섞여 있거나 계측 빌드를 따로 만들기 어려운 상황에서, 실제 배포 바이너리를 그대로 검증할 때 씁니다. 같은 프로세스에서 둘을 동시에 쓸 수는 없습니다. ASan 빌드를 Valgrind 아래에서 실행하면 섀도 메모리 매핑이 충돌합니다.
Windows에는 Valgrind가 없으므로 MSVC의 ASan 지원(/fsanitize=address), CRT 디버그 힙(_CrtDumpMemoryLeaks), Dr. Memory 같은 도구를 씁니다.
메모리 누수 증상 패턴
도구가 할당 스택을 찍어 주기 전에, 관측 가능한 증상으로 범위를 줄이면 디버깅 비용이 줄어듭니다.
시간에 따른 RSS의 단조 증가
리눅스에서 /proc/<pid>/status의 VmRSS가 처리한 요청 수에 비례해 계속 증가하면 누수나 상한 없는 캐시를 의심합니다. 반대로 한 번 크게 튀었다가 평탄해지면 메모리 풀, 아레나, 큰 mmap 같은 의도된 예약일 가능성이 높습니다. 같은 입력을 반복해서 넣고 곡선 모양을 비교하면 둘을 구분할 수 있습니다.
동일 스택에서의 반복 할당
heaptrack이나 jemalloc/tcmalloc의 힙 프로파일러처럼 할당 스택을 모아 주는 도구에서, “해제되지 않은 채 남아 있는 메모리”의 상위 스택이 시간이 지날수록 같은 경로로 수렴하면 그 코드 경로가 요청마다 객체를 붙잡고 있을 가능성이 큽니다. 전형적인 원인은 컨테이너에 raw 포인터만 넣고 해제를 빠뜨리는 경우, 이벤트 구독을 해제하지 않아 관찰자 리스트가 계속 길어지는 경우입니다.
누수처럼 보이지만 누수가 아닌 경우
전역 또는 스레드 로컬 캐시가 종료 때까지 유지되는 경우, 도구는 이를 still reachable로 보고하거나 아예 보고하지 않습니다. 메모리 풀이나 아레나가 메모리를 운영체제에 돌려주지 않고 재사용만 하는 경우에도 RSS는 높게 유지되지만, 실제로 쓰는 양은 안정적입니다.
할당기 동작도 자주 오해를 부릅니다. glibc malloc은 해제된 작은 블록을 아레나 안에 남겨 두고 재사용하며, 힙 꼭대기가 비지 않으면 운영체제에 돌려주지 못합니다. 스레드가 많은 서버는 스레드별로 아레나가 생기므로 이 현상이 더 커집니다. 이 경우 누수 도구는 아무것도 보고하지 않는데 RSS만 높게 유지됩니다. malloc_stats()나 mallinfo2()로 할당기 내부의 “사용 중”과 “보유 중” 크기를 비교하고, MALLOC_ARENA_MAX로 아레나 수를 제한하거나 jemalloc·tcmalloc으로 바꿔 보면 단편화 때문인지 확인할 수 있습니다.
이 구분은 결국 장시간 부하 테스트에서 같은 시나리오를 반복한 뒤 안정 상태를 비교해야 내릴 수 있습니다.
스마트 포인터가 남기는 누수와 소유권 실수
스마트 포인터는 대부분의 소유권 실수를 줄이지만, 논리적 누수와 이중 소유 문제는 여전히 생깁니다.
unique_ptr::release() 후 소유권 상실
std::unique_ptr<Widget> u = std::make_unique<Widget>();
Widget* raw = u.release(); // unique_ptr는 더 이상 해제하지 않음
// 이후 경로에서 delete 또는 해당 풀 반환을 반드시 짝지어야 함
release()는 해제하지 않고 소유권만 내려놓는 함수입니다. 이후 경로에서 예외나 조기 반환이 생기면 raw 포인터가 그대로 누수됩니다. reset()(해제함)과 이름이 비슷해 혼동하기 쉽고, C API에 소유권을 넘길 때 자주 쓰입니다. 가능하면 C API 호출이 성공한 직후에 release()를 호출해 실패 경로에서는 여전히 unique_ptr가 해제하도록 하고, 넘겨받은 쪽이 어떤 함수로 해제하는지 문서화해야 합니다.
같은 raw 포인터로 shared_ptr를 두 번 생성
int* p = new int(42);
std::shared_ptr<int> a(p);
std::shared_ptr<int> b(p); // UB: 서로 다른 제어 블록 두 개가 같은 객체를 소유 → 이중 해제
shared_ptr는 제어 블록 단위로 수명을 관리합니다. 같은 raw 포인터로 독립적인 shared_ptr를 두 번 만들면 각자 참조 카운트가 0이 될 때 따로 delete를 호출하므로 정의되지 않은 동작이 됩니다. 이 실수는 누수보다는 이중 해제 크래시로 나타납니다. 처음부터 std::make_shared로 만들고, 이후에는 기존 shared_ptr를 복사해서 공유하면 제어 블록이 하나로 유지됩니다.
enable_shared_from_this 오용
shared_from_this()는 객체가 이미 shared_ptr로 관리되고 있을 때만 쓸 수 있습니다. 스택 객체나 new로만 만든 객체에서 호출하거나 생성자 안에서 호출하면 내부 weak_ptr가 아직 비어 있으므로, C++17부터는 std::bad_weak_ptr 예외가 발생하고 그 이전 표준에서는 정의되지 않은 동작입니다.
누수와 직접 관련된 것은 반대 방향의 실수입니다. 객체가 자기 멤버로 저장하는 콜백(타이머, 이벤트 핸들러)에 shared_from_this()를 캡처하면 객체가 자기 자신을 강하게 붙잡는 순환이 생겨 영원히 해제되지 않습니다. 저장되는 콜백에는 weak_from_this()(C++17)를 캡처하고 실행 시점에 lock()으로 확인하는 편이 안전합니다. 비동기 작업이 끝날 때까지만 객체를 살려 두려는 목적이라면, 작업이 완료되면 콜백이 버려지는지 확인해야 합니다.
커스텀 딜리터와 수동 delete 혼용
shared_ptr에 커스텀 딜리터를 달아 두고 바깥에서 다시 delete를 호출하면 이중 해제가 됩니다. 딜리터가 유일한 해제 경로가 되도록 raw 포인터를 밖으로 넘기지 않는 것이 원칙입니다.
순환 참조 진단: 객체 그래프 관점
shared_ptr 순환은 참조 카운트가 영원히 0이 되지 않는 전형적인 논리적 누수입니다. 순환에 묶인 객체들은 서로를 가리키므로, Valgrind나 LSan 입장에서는 루트에서 도달할 수 없는 블록 묶음이 되어 definitely lost와 indirectly lost로 함께 보고됩니다. 진단은 소유 관계를 방향 그래프로 그려 보는 것이 가장 빠릅니다.
weak_ptr로 순환 끊기
부모와 자식이 서로를 shared_ptr로 잡으면 강한 순환이 됩니다. 한쪽을 std::weak_ptr로 바꾸면 소유는 한 방향, 관찰은 반대 방향으로 분리됩니다. weak_ptr::lock()은 객체가 이미 파괴되었으면 빈 shared_ptr를 돌려주므로 결과를 항상 확인해야 합니다.
struct Child;
struct Parent { std::shared_ptr<Child> child_; };
struct Child { std::weak_ptr<Parent> parent_; }; // 역방향은 weak_ptr
auto parent = std::make_shared<Parent>();
auto child = std::make_shared<Child>();
parent->child_ = child;
child->parent_ = parent; // weak_ptr이므로 parent의 참조 카운트는 늘지 않음
if (auto p = child->parent_.lock()) {
// 부모가 아직 살아 있을 때만 사용
}
어느 쪽을 약하게 할지는 “누가 누구의 수명을 결정하는가”로 정합니다. 부모가 자식을 만들고 없앤다면 자식→부모 간선이 약한 간선입니다.
순환이 자주 생기는 곳
이벤트·시그널 슬롯에서 발행자와 구독자가 서로를 shared_ptr로 연결하면 종료 시까지 서로를 붙잡습니다. 캐시도 마찬가지로, 값 객체가 다시 캐시를 shared_ptr로 참조하면 순환이 생기므로 값 쪽은 weak_ptr로 두거나 TTL·LRU 같은 정책으로 강제로 끊어야 합니다. 비동기 작업에서는 작업 큐가 대상 객체를 shared_ptr로 붙잡고, 그 객체가 다시 큐를 shared_ptr로 붙잡으면 의도하지 않게 수명이 길어집니다. 작업 단위로 꼭 필요한 강한 참조만 유지하는지 검토합니다.
도구 리포트에서 여러 객체가 같은 묶음으로 보고되면, 그래프를 그린 뒤 어느 간선을 약하게 할지부터 결정합니다. 자세한 패턴은 C++ shared_ptr 순환 참조와 weak_ptr: 부모-자식·옵저버·그래프·캐시에서 끊는 법을 참고할 수 있습니다.
프로덕션 메모리 디버깅
프로덕션 프로세스 전체를 Valgrind로 감싸거나 ASan을 상시 켜 두는 방식은 보통 비현실적입니다. 대신 관측, 샘플링, 스테이징 재현을 조합합니다.
메트릭
프로세스 RSS, 컨테이너 cgroup 메모리 사용량(memory.current), OOM kill 횟수를 시계열로 저장합니다. 배포 시점을 메모리 곡선에 겹쳐 보면 어느 배포부터 증가 기울기가 바뀌었는지 빠르게 찾을 수 있습니다.
샘플링 힙 프로파일러
jemalloc의 prof:true(jeprof로 분석)나 gperftools tcmalloc의 힙 프로파일러는 일정 바이트마다 한 번씩만 할당 스택을 기록하는 샘플링 방식이라, 오버헤드가 낮아 프로덕션에 가까운 환경에서도 켜 둘 수 있습니다. 두 시점의 프로파일을 비교(diff)하면 그 사이에 늘어난 메모리가 어느 스택에서 할당되었는지 보입니다. heaptrack은 모든 할당을 기록하므로 더 정확하지만 오버헤드가 커서, 특정 워커 프로세스 하나에만 짧게 붙이는 식으로 씁니다.
스테이징에서의 계측 빌드
ASan/LSan 빌드를 스테이징에 배포하고 프로덕션과 비슷한 트래픽(리플레이 또는 합성 부하)을 돌립니다. LSan은 종료 시점에만 검사하므로, 장시간 실행 서버라면 프로세스를 정상 종료시키거나 __lsan_do_recoverable_leak_check()를 주기적으로 호출해 중간 리포트를 받습니다. 디버그 심볼은 별도로 보관해 두어야 리포트와 코어 덤프의 스택을 읽을 수 있습니다.
진단 순서
메트릭으로 이상 징후를 확인하고, 스테이징에서 재현한 뒤, ASan/LSan 또는 힙 프로파일러로 할당 스택을 확보합니다. 그다음 소유권과 객체 그래프를 수정하고, 장시간 부하 테스트로 RSS 곡선이 평탄해졌는지 확인합니다.
트러블슈팅 결정 트리
-
RSS가 시간에 비례해 선형으로 증가한다
전역·싱글톤 컨테이너에 요청 객체가 쌓이는지 확인합니다. 캐시에 상한이나 TTL이 없다면 그 성장이 의도된 것인지 먼저 정합니다. 누수 도구가 아무것도 보고하지 않는다면 할당기 단편화도 의심합니다. -
도구가 definitely lost를 보고한다
스택에 찍힌 할당 지점과 마지막 사용 지점을 연결해 소유권 전달이 빠진 곳을 찾습니다.shared_ptr로 관리되는 객체라면 순환 참조 후보를 그래프로 그립니다. -
still reachable만 보인다
전역·정적 객체와 스레드 로컬 캐시를 의심합니다. 누수가 아니라 프로세스 종료까지 유지되는 의도된 할당일 수 있습니다. -
멀티스레드에서만 재현된다
경쟁 조건 때문에 해제 경로가 건너뛰어지는 경우(예: 두 스레드가 동시에 포인터를 교체하면서 이전 값을 잃어버림)를 의심합니다. TSan 빌드로 데이터 레이스부터 정리하면 원인이 드러나는 경우가 많습니다. -
FFI·C API 경계가 있다
release()와 해제 함수,malloc/free의 짝이 맞는지, 예외 경로에서 해제가 빠지지 않았는지 확인합니다.