C++ 메모리 누수 디버깅 실전 사례 | 프로덕션 서버 메모리 사용량 급증 해결기
이 글의 핵심
Valgrind는 정확하지만 너무 느려서 프로덕션 부하를 재현하지 못했고, 그래서 ASan으로 빠르게 재현하고 Heaptrack으로 할당 패턴을 좁혀 가는 전략으로 바꿨습니다. 도구마다 잘 잡는 문제가 어떻게 다른지, 해제되지 않은 리스너가 왜 누수로 이어졌는지, 수정 전후 메모리 프로파일을 어떻게 비교해 검증했는지 순서대로 따라갈 수 있습니다.
들어가며
프로덕션 환경에서 메모리 누수는 서버를 서서히 죽이는 무서운 버그입니다. 이 글에서는 실제로 겪었던 메모리 누수 사례를 통해 증상 발견부터 근본 원인 파악, 수정, 재발 방지까지 전 과정을 다룹니다. 겉보기 연결 수와 처리량은 정상인데 메모리만 어딘가에 조금씩 고여 가는 패턴입니다.
이 사례에서 먼저 짚어 둘 것은 “메모리 누수”라는 말이 두 가지를 가리킨다는 점입니다. 하나는 도달할 수 없는 누수로, 할당한 메모리를 가리키는 포인터가 모두 사라져 다시는 해제할 수 없는 경우입니다. ASan의 LeakSanitizer나 Valgrind가 “definitely lost”로 보고하는 것이 이쪽입니다. 다른 하나는 논리적 누수로, 포인터는 컨테이너 어딘가에 살아 있지만 프로그램이 더 이상 쓰지 않는 객체가 계속 쌓이는 경우입니다. 이쪽은 프로그램이 종료될 때 정상적으로 해제되므로 누수 검사 도구가 잡지 못하는 경우가 많습니다. 아래 사례는 두 가지가 겹친 경우라서, 도구가 무엇을 보여 주고 무엇을 보여 주지 못하는지 구분해 가며 읽으면 도움이 됩니다.
증상: 서버 메모리 사용량이 계속 증가
문제 상황
구체적으로는 배포 후 3일째부터 채팅 서버의 메모리 사용량이 시간에 비례해 계속 불어났습니다. 연결 수·처리량은 안정적인데 RSS만 커지는 누적 누수 패턴이었습니다.
# 배포 직후
$ ps aux | grep chat_server
user 12345 0.5 2.1 524288 ... ./chat_server
# 3일 후
$ ps aux | grep chat_server
user 12345 0.5 8.7 2162688 ... ./chat_server
# 7일 후 (OOM Killer에 의해 종료됨)
[ 123.456] Out of memory: Killed process 12345 (chat_server)
초기 가설
- 연결 객체가 제대로 해제되지 않는가?
- 로그 버퍼가 계속 쌓이는가?
- 캐시가 무한정 커지는가?
RSS가 계속 오른다고 해서 곧바로 누수라고 단정하면 안 됩니다. glibc의 malloc은 해제된 메모리를 운영체제에 바로 돌려주지 않고 다음 할당을 위해 보관하는 경우가 많고, 스레드마다 별도의 아레나를 두기 때문에 스레드가 많은 서버에서는 단편화로 RSS가 높게 유지되기도 합니다. 이 경우 메모리는 “누수된” 것이 아니라 “반환되지 않은” 것이라, malloc_trim(0)을 호출하면 줄어들거나 jemalloc·tcmalloc으로 할당자를 바꾸면 양상이 달라집니다. 누수와 구분하는 가장 간단한 기준은 증가가 멈추는가입니다. 단편화나 캐시는 어느 수준에서 평탄해지지만, 진짜 누수는 부하에 비례해 끝없이 증가합니다. 이 사례처럼 며칠에 걸쳐 선형으로 증가해 OOM Killer까지 갔다면 누수 쪽을 먼저 의심하는 것이 합리적입니다.
초기 분석: 모니터링 데이터 확인
Prometheus 메트릭 확인
// 서버에 메트릭 수집 코드 추가
class MemoryMetrics {
public:
static size_t getCurrentRSS() {
std::ifstream stat("/proc/self/status");
std::string line;
while (std::getline(stat, line)) {
if (line.find("VmRSS:") == 0) {
std::istringstream iss(line);
std::string key, value, unit;
iss >> key >> value >> unit;
return std::stoull(value) * 1024; // KB to bytes
}
}
return 0;
}
};
// 주기적으로 메트릭 전송
void reportMetrics() {
auto rss = MemoryMetrics::getCurrentRSS();
prometheus_gauge_set(memory_rss_bytes, rss);
}
패턴 분석
Grafana 대시보드를 보니:
- 메모리 증가율: 시간당 약 50MB
- 연결 수: 안정적 (100~200개)
- 요청 처리량: 변화 없음 결론: 연결당 메모리가 아니라, 시간이 지날수록 누적되는 무언가가 있습니다.
동시 연결 수가 일정한데 메모리가 늘어난다는 사실은 원인의 범위를 크게 좁혀 줍니다. “연결 하나가 살아 있는 동안 쓰는 메모리”라면 연결 수에 비례해야 하므로, 누적되는 것은 연결이 끝난 뒤에도 남는 무언가, 즉 입장·퇴장이나 요청 처리처럼 반복되는 이벤트의 횟수에 비례하는 것입니다. 그래서 다음 단계의 부하 테스트도 동시 연결 수를 늘리기보다 접속과 종료를 빠르게 반복하는 시나리오로 설계해야 짧은 시간에 누적을 재현할 수 있습니다. 메트릭 쪽에서는 RSS와 함께 입장·퇴장 횟수 같은 이벤트 카운터를 같은 그래프에 올려 보면 상관관계가 바로 드러납니다.
도구 선택: Valgrind vs ASan vs Heaptrack
도구 비교
| 도구 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| Valgrind | 재컴파일 없이 정밀한 누수 탐지 | 매우 느림 (수십 배) | 개발 환경, 작은 재현 케이스 |
| ASan | 비교적 빠름 (문서 기준 약 2배), 다양한 버그 탐지 | 재컴파일 필요, 메모리 사용량 증가 | CI, 통합 테스트 |
| Heaptrack | 할당 패턴·호출 경로 시각화 | 누수만 찾기는 어려움 | 메모리 프로파일링, 논리적 누수 |
세 도구는 동작 원리가 달라서 잘 잡는 문제도 다릅니다. Valgrind Memcheck는 프로그램을 가상 CPU 위에서 명령 단위로 실행하며 모든 메모리 접근을 추적하므로 재컴파일이 필요 없고 정밀하지만, 그만큼 느리고 멀티스레드 프로그램을 사실상 한 스레드씩 실행합니다. ASan은 컴파일 시점에 메모리 접근마다 검사 코드를 넣고 할당자를 교체하는 방식이라 훨씬 빠르지만, 누수 검사(LeakSanitizer)는 프로그램이 종료될 때 한 번 도달 불가능한 블록을 찾습니다. Heaptrack은 malloc/free 호출을 가로채 호출 경로별로 기록하므로, 종료 시점에 해제되더라도 실행 중에 어떤 경로의 할당이 계속 쌓였는지를 보여 준다는 점에서 논리적 누수를 찾는 데 가장 유용합니다.
전략
- ASan으로 빠르게 재현 시도
- 재현 안 되면 Valgrind로 정밀 분석
- Heaptrack으로 할당 핫스팟 확인
Valgrind로 첫 추적 시도
빌드 및 실행
# 디버그 심볼 포함 빌드
$ g++ -g -O0 -std=c++17 *.cpp -o chat_server
# Valgrind 실행
$ valgrind --leak-check=full --show-leak-kinds=all \
--track-origins=yes --log-file=valgrind.log \
./chat_server
문제점
서버가 너무 느려서 실제 부하를 재현할 수 없었습니다. 10분 실행해도 메모리 증가가 미미했습니다.
==12345== HEAP SUMMARY:
==12345== in use at exit: <N> bytes in <N> blocks
==12345== total heap usage: <N> allocs, <N> frees, <N> bytes allocated
결론: Valgrind는 프로덕션 부하를 재현하기엔 너무 느립니다.
Valgrind를 쓸 때 흔히 놓치는 점은 서버를 정상 종료해야 누수 보고서가 나온다는 것입니다. Ctrl+C로 끊거나 kill -9로 죽이면 HEAP SUMMARY와 누수 목록이 출력되지 않거나, 종료 처리가 끝나지 않은 상태의 메모리가 모두 “still reachable”로 섞여 나옵니다. 서버에 SIGTERM을 받으면 이벤트 루프를 멈추고 정상 종료하는 경로가 있어야 도구가 의미 있는 결과를 줍니다. 속도 문제는 Valgrind의 한계이지만, 실행 중인 프로세스에 vgdb로 붙어 monitor leak_check 명령을 보내면 종료하지 않고도 중간 누수 상태를 볼 수 있어서, 작은 부하로 오래 돌려 두는 방식에는 여전히 쓸 만합니다.
ASan으로 빠른 재현
ASan 빌드
# ASan 플래그로 재컴파일
$ g++ -g -O1 -fsanitize=address -fno-omit-frame-pointer \
-std=c++17 *.cpp -o chat_server_asan
# 환경 변수 설정
$ export ASAN_OPTIONS=detect_leaks=1:log_path=asan.log
부하 테스트
# 실제 트래픽 시뮬레이션
$ ./load_test.sh --connections=200 --duration=600s
결과
10분 만에 누수가 재현되었으며, ASan이 리포트를 생성했습니다:
=================================================================
==23456==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 48000 byte(s) in 1000 object(s) allocated from:
#0 0x7f123456 in operator new(unsigned long)
#1 0x7f234567 in EventManager::subscribe(std::string const&, EventCallback)
#2 0x7f345678 in ChatRoom::addUser(User*)
#3 0x7f456789 in Server::handleJoin(Connection*)
...
SUMMARY: AddressSanitizer: 48000 byte(s) leaked in 1000 allocations.
발견: EventManager::subscribe 에서 누수 발생!
-O1과 -fno-omit-frame-pointer를 함께 준 이유는 속도와 스택 트레이스 품질의 절충입니다. -O0은 너무 느려 부하 테스트에 불리하고, -O2 이상은 인라이닝 때문에 스택 트레이스에서 중간 함수가 사라질 수 있습니다. 프레임 포인터를 유지하면 ASan이 할당 시점의 호출 스택을 빠르고 정확하게 기록할 수 있습니다. 이 보고서가 나온 것도 부하 테스트 후 서버를 정상 종료했기 때문입니다. LeakSanitizer는 종료 시점에 도달할 수 없는 블록만 보고하므로, 앞서 말한 논리적 누수(아직 컨테이너에 포인터가 있는 객체)는 여기에 나타나지 않습니다. 이 사례에서 “Direct leak”이 보고된 것은 EventManager의 소멸자가 맵을 지우면서 그 안의 원시 포인터를 해제하지 않고 잃어버렸기 때문이고, 실행 중에 메모리를 늘린 진짜 원인은 그보다 앞선 “퇴장해도 리스너가 남는” 논리적 누적입니다.
Heaptrack으로 할당 패턴 분석
Heaptrack 실행
# Heaptrack으로 프로파일링
$ heaptrack ./chat_server
# GUI로 분석
$ heaptrack_gui heaptrack.chat_server.12345.gz
발견 사항
Heaptrack GUI의 “Flame Graph”를 보니:
- EventManager::subscribe 경로가 할당의 큰 비중을 차지
- 할당은 계속 증가하는데 해제는 거의 없음
- 콜스택:
ChatRoom::addUser→subscribe
Heaptrack에서 볼 지표는 전체 할당량(allocated)보다 “leaked”와 “peak” 뷰, 그리고 시간 축 그래프입니다. 할당량이 큰 경로는 대개 정상적으로 할당과 해제를 반복하는 핫 패스이고, 누적의 원인은 할당 횟수가 적더라도 해제가 따라오지 않는 경로입니다. 시간 축 그래프에서 특정 호출 경로의 메모리만 계단식으로 계속 올라간다면 그곳이 범인입니다. Heaptrack은 실행 중인 프로세스에 heaptrack -p <PID>로 붙일 수도 있어서, 재시작 없이 운영에 가까운 환경에서 프로파일을 얻을 수 있다는 점이 큰 장점입니다.
근본 원인: 이벤트 리스너 누적
문제 코드
class EventManager {
std::unordered_map<std::string, std::vector<EventCallback*>> listeners_;
public:
void subscribe(const std::string& event, EventCallback callback) {
// 🚨 문제: new로 할당하지만 해제 코드가 없음
auto* cb = new EventCallback(std::move(callback));
listeners_[event].push_back(cb);
}
void publish(const std::string& event, const EventData& data) {
if (auto it = listeners_.find(event); it != listeners_.end()) {
for (auto* cb : it->second) {
(*cb)(data);
}
}
}
// 🚨 소멸자에서 해제하지 않음!
~EventManager() = default;
};
class ChatRoom {
EventManager& eventMgr_;
public:
void addUser(User* user) {
// 사용자가 입장할 때마다 리스너 등록
eventMgr_.subscribe("message", [user](const EventData& data) {
user->sendMessage(data);
});
// 🚨 사용자가 퇴장해도 리스너는 남아있음!
}
};
왜 누수가 발생했나?
addUser호출 시마다new EventCallback할당- 사용자가 퇴장해도
listeners_벡터에 포인터가 남아있음 EventManager소멸자에서 해제하지 않음- 1000명 입장 → 1000개 할당 → 0개 해제 = 48KB 누수
여기서 더 위험한 문제가 하나 숨어 있습니다. 람다가 User*를 원시 포인터로 캡처하고 있어서, 사용자가 퇴장해 User 객체가 삭제된 뒤에도 리스너는 그 포인터를 들고 있습니다. 다음 publish("message")가 호출되면 이미 해제된 User의 sendMessage를 부르게 되어 use-after-free가 됩니다. 메모리 증가보다 이쪽이 크래시나 보안 문제로 이어질 수 있는 더 심각한 버그이며, ASan은 이런 접근을 heap-use-after-free 오류로 바로 잡아 줍니다. 즉 이 버그의 본질은 “delete를 빠뜨렸다”가 아니라 리스너의 수명이 사용자의 수명과 연결되어 있지 않다는 설계 문제입니다. 그래서 수정도 스마트 포인터로 바꾸는 것만으로는 부족하고, 퇴장 시 구독을 해제하는 경로가 반드시 있어야 합니다.
수정: RAII와 스마트 포인터 적용
해결 방법 1: 스마트 포인터 사용
class EventManager {
using CallbackPtr = std::shared_ptr<EventCallback>;
std::unordered_map<std::string, std::vector<CallbackPtr>> listeners_;
public:
// 구독 ID 반환 (나중에 해제 가능)
size_t subscribe(const std::string& event, EventCallback callback) {
auto cb = std::make_shared<EventCallback>(std::move(callback));
listeners_[event].push_back(cb);
return reinterpret_cast<size_t>(cb.get()); // ID로 사용
}
void unsubscribe(const std::string& event, size_t id) {
auto& cbs = listeners_[event];
cbs.erase(
std::remove_if(cbs.begin(), cbs.end(),
[id](const CallbackPtr& cb) {
return reinterpret_cast<size_t>(cb.get()) == id;
}),
cbs.end()
);
}
// 소멸자에서 자동 해제 (shared_ptr 덕분에)
~EventManager() = default;
};
스마트 포인터로 바꾸면 EventManager가 소멸될 때의 도달 불가능한 누수는 사라지지만, unsubscribe를 호출하지 않는 한 실행 중에 리스너가 쌓이는 논리적 누수는 그대로라는 점을 분명히 해 두어야 합니다. 이 수정의 진짜 가치는 subscribe가 해제에 쓸 ID를 돌려준다는 데 있습니다. 다만 포인터 주소를 ID로 쓰는 방식에는 함정이 있습니다. 리스너를 해제한 뒤 새 리스너가 같은 주소에 할당되면, 옛 ID로 unsubscribe를 호출했을 때 엉뚱한 새 리스너가 지워집니다. 실무에서는 std::atomic<uint64_t> 카운터로 증가하는 ID를 발급하고 unordered_map<id, callback>으로 저장하는 편이 안전합니다. 또 publish가 콜백을 실행하는 도중에 콜백 안에서 unsubscribe가 호출되면 순회 중인 벡터가 수정되어 반복자가 무효화되므로, 콜백 목록을 복사한 뒤 실행하거나 삭제를 지연시키는 처리가 필요합니다. shared_ptr를 쓴 이유도 여기서 드러나는데, 복사본을 들고 실행하는 동안 원본에서 지워져도 콜백 객체가 살아 있기 때문입니다.
해결 방법 2: RAII 래퍼
class Subscription {
EventManager* mgr_;
std::string event_;
size_t id_;
public:
Subscription(EventManager* mgr, std::string event, size_t id)
: mgr_(mgr), event_(std::move(event)), id_(id) {}
~Subscription() {
if (mgr_) {
mgr_->unsubscribe(event_, id_);
}
}
// 이동만 허용
Subscription(Subscription&& other) noexcept
: mgr_(other.mgr_), event_(std::move(other.event_)), id_(other.id_) {
other.mgr_ = nullptr;
}
Subscription(const Subscription&) = delete;
Subscription& operator=(const Subscription&) = delete;
};
class ChatRoom {
EventManager& eventMgr_;
std::vector<Subscription> subscriptions_; // RAII로 관리
public:
void addUser(User* user) {
auto id = eventMgr_.subscribe("message", [user](const EventData& data) {
user->sendMessage(data);
});
// Subscription 객체가 소멸 시 자동 해제
subscriptions_.emplace_back(&eventMgr_, "message", id);
}
void removeUser(User* user) {
// subscriptions_ 벡터에서 해당 항목 제거하면
// Subscription 소멸자가 자동으로 unsubscribe 호출
// (실제로는 user와 subscription을 매핑해야 함)
}
};
RAII 래퍼를 쓰면 “구독을 해제하는 코드를 잊는” 문제를 구조적으로 막을 수 있습니다. 구독의 수명을 Subscription 객체의 수명과 같게 만들고, 그 객체를 사용자 세션처럼 구독이 유지되어야 하는 동안만 살아 있는 곳에 두면 됩니다. 실무에서는 std::unordered_map<User*, Subscription>처럼 사용자별로 보관하고 퇴장 시 erase(user) 한 줄로 해제하는 형태가 흔합니다. 이 예제의 Subscription에는 보완할 점이 있습니다. 이동 생성자는 있지만 이동 대입 연산자가 없어서, std::vector<Subscription>에서 중간 요소를 erase하면 요소를 앞으로 당기는 대입이 필요해 컴파일 에러가 납니다. 이동 대입을 직접 구현(기존 구독을 먼저 해제한 뒤 가져오기)하거나 맵에 보관해야 합니다. 또 Subscription이 EventManager보다 오래 살면 소멸자가 이미 파괴된 관리자를 호출하므로, 두 객체의 소멸 순서를 보장하거나 관리자 쪽을 weak_ptr로 참조하는 설계가 필요합니다.
검증: 메모리 프로파일 비교
수정 전
$ heaptrack ./chat_server_before
# 같은 부하 시나리오로 실행 후 heaptrack_print로 요약
# 확인할 항목: peak heap memory, 할당 대비 해제 횟수, leaked 항목의 호출 경로
수정 후
$ heaptrack ./chat_server_after
# 같은 부하 시나리오로 실행
# 기대 결과: peak가 부하 시간과 무관하게 평탄해지고,
# subscribe 경로의 할당과 해제 횟수가 거의 같아짐
ASan 최종 확인
$ ./chat_server_asan
# 부하 테스트 후 정상 종료
# 누수가 없으면 LeakSanitizer는 아무것도 출력하지 않음
# (ASAN_OPTIONS에 log_path를 지정했다면 로그 파일이 생성되지 않음)
성공! 같은 부하에서 메모리가 더 이상 누적되지 않았습니다.
검증에서 가장 중요한 것은 수정 전과 똑같은 부하 시나리오로 비교하는 것입니다. 입장·퇴장을 반복하는 부하 스크립트를 고정해 두고, 수정 전에는 peak 메모리가 실행 시간에 비례해 늘어나던 것이 수정 후에는 일정 수준에서 평탄해지는지를 봅니다. “누수 0”은 LeakSanitizer가 아무 출력도 내지 않는 것으로 확인되며, 에러 형식의 “0 bytes leaked” 메시지가 따로 나오지는 않습니다. CI에서 이를 판정하려면 ASAN_OPTIONS=exitcode=... 설정과 함께 테스트 프로세스의 종료 코드를 확인하는 편이 확실합니다. 운영 환경에서도 수정 배포 후 며칠 동안 RSS 그래프가 평탄한지 확인해야 논리적 누수까지 사라졌다고 말할 수 있습니다.
재발 방지: CI에 ASan 추가
GitHub Actions 설정
# .github/workflows/sanitizers.yml
name: Memory Sanitizers
on: [push, pull_request]
jobs:
asan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build with ASan
run: |
cmake -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
-B build
cmake --build build
- name: Run tests with ASan
run: |
export ASAN_OPTIONS=detect_leaks=1:halt_on_error=1
cd build && ctest --output-on-failure
CI에 ASan을 넣을 때 알아 둘 점이 있습니다. ASan 빌드는 링크 단계에도 -fsanitize=address가 필요하므로, CMAKE_CXX_FLAGS만 설정하면 링크 옵션이 빠지는 구성에서는 undefined reference to __asan_... 오류가 납니다. CMAKE_EXE_LINKER_FLAGS에도 같은 옵션을 주거나 add_compile_options와 add_link_options를 함께 쓰는 편이 안전합니다. 그리고 이 CI는 단위 테스트가 종료될 때 누수를 검사하므로, 이번 사례처럼 “퇴장 후에도 리스너가 남는” 논리적 누수는 잡지 못합니다. 이런 회귀를 막으려면 “입장 후 퇴장시키면 리스너 수가 원래대로 돌아온다”를 직접 검증하는 테스트를 추가해야 합니다. 도구가 잡지 못하는 종류의 누수는 테스트로 불변식을 확인하는 것이 유일한 방법입니다.
코드 리뷰 체크리스트
팀 코드 리뷰 가이드에 추가:
-
new를 사용했다면 대응하는delete가 있는가? - 스마트 포인터를 사용할 수 있는가?
- 리소스 획득 시 RAII 패턴을 적용했는가?
- 콜백/리스너 등록 시 해제 메커니즘이 있는가?
교훈과 베스트 프랙티스
핵심 교훈
- 조기 발견: 메모리 모니터링을 배포 초기부터 설정
- 도구 조합: Valgrind, ASan, Heaptrack을 상황에 맞게 활용
- RAII 원칙: 리소스 획득은 초기화, 해제는 소멸자
- 자동화: CI에 sanitizer를 추가하여 회귀 방지
메모리 누수 예방 패턴
// ❌ 나쁜 패턴: 수동 메모리 관리
class BadCache {
std::map<std::string, Data*> cache_;
public:
void add(const std::string& key, Data* data) {
cache_[key] = data; // 누가 해제하나?
}
};
// ✅ 좋은 패턴: 스마트 포인터
class GoodCache {
std::map<std::string, std::unique_ptr<Data>> cache_;
public:
void add(const std::string& key, std::unique_ptr<Data> data) {
cache_[key] = std::move(data); // 자동 해제
}
};
// ✅ 더 좋은 패턴: 값 의미론
class BestCache {
std::map<std::string, Data> cache_;
public:
void add(const std::string& key, Data data) {
cache_[key] = std::move(data); // 포인터 불필요
}
};
마무리
이 사례를 통해 배운 점:
- 메모리 누수는 증상이 서서히 나타나므로 모니터링이 필수입니다
- 도구를 상황에 맞게 선택하면 디버깅 시간을 크게 단축할 수 있습니다
- RAII와 스마트 포인터는 메모리 안전성의 기본입니다
- CI에 sanitizer를 추가하면 회귀를 조기에 발견할 수 있습니다 프로덕션 환경에서 메모리 문제를 겪고 계신다면, 이 글의 접근 방식을 참고하여 체계적으로 해결해보세요.
FAQ
Q1. 프로덕션에서 ASan을 켜도 되나요? 권장하지 않습니다. 속도 오버헤드뿐 아니라 섀도 메모리 때문에 메모리 사용량이 크게 늘고, ASan 런타임은 보안 강화를 목표로 설계되지 않아 운영 환경에서의 사용이 권장되지 않습니다. 스테이징에서 프로덕션 트래픽을 리플레이하는 것이 현실적이고, 운영 환경에서 할당을 추적해야 한다면 jemalloc·tcmalloc의 힙 프로파일링 기능이나 실행 중인 프로세스에 붙이는 Heaptrack처럼 오버헤드를 조절할 수 있는 도구가 더 적합합니다.
Q2. Valgrind가 “still reachable”이라고 하는데 누수인가요? “still reachable”은 프로그램 종료 시 여전히 포인터가 있는 메모리입니다. 정적 객체나 싱글톤이면 정상이지만, 증가한다면 누수입니다.
Q3. 스마트 포인터를 쓰면 순환 참조로 누수가 생기지 않나요?
shared_ptr 순환 참조는 weak_ptr로 해결합니다. 가능하면 unique_ptr로 소유권을 명확히 하세요.
같이 보면 좋은 글
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ RAII 패턴
- Valgrind Memcheck로 C++ 메모리 누수 찾아내기
- C++ ASan 디버깅
- C++ 메모리 누수 찾기: Valgrind·ASan·CRT 디버그 힙 사용법과 누수 패턴
- C++ 메모리 누수 심화 디버깅 | 힙 프로파일링·패턴·스마트포인터·순환참조·프로덕션
메모리 누수 디버깅·예방 체크리스트
메모리 누수 디버깅 체크리스트
- 메모리 사용량 모니터링 설정 (Prometheus, Grafana)
- 증가 패턴 분석 (선형, 계단, 주기적)
- 도구 선택 (Valgrind, ASan, Heaptrack)
- 재현 환경 구축 (부하 테스트)
- 콜스택 분석 (할당 위치 추적)
- 근본 원인 파악 (왜 해제되지 않는가)
- 수정 (RAII, 스마트 포인터)
- 검증 (메모리 프로파일 비교)
- CI에 sanitizer 추가
- 코드 리뷰 가이드 업데이트
메모리 안전 코딩 체크리스트
-
new사용 시 대응하는delete확인 - 가능하면 스마트 포인터 사용
- 리소스 획득 시 RAII 패턴 적용
- 콜백/리스너 등록 시 해제 메커니즘 구현
- 순환 참조 가능성 검토 (
weak_ptr고려) - 예외 안전성 확인 (예외 발생 시에도 해제되는가)