C++ 서버 메모리 누수 디버깅 사례: Valgrind·ASan·Heaptrack으로 리스너 누적 찾기
이 글의 핵심
Valgrind는 정확하지만 느려서 부하 재현에 쓰기 어렵고, ASan은 빠르지만 종료 시점에 도달 불가능한 메모리만 누수로 보고하며, Heaptrack은 실행 중에 쌓이는 할당 경로를 보여 줍니다. 해제되지 않는 이벤트 리스너를 예로 세 도구가 각각 무엇을 보여 주고 무엇을 놓치는지, 수정 전후를 어떻게 비교해 검증하는지 정리합니다.
들어가며
이 글은 이벤트 리스너를 등록만 하고 해제하지 않는 채팅 서버를 예로, 메모리 증가를 발견한 뒤 원인을 좁히고 고치고 재발을 막는 과정을 따라갑니다. 연결 수와 처리량은 정상인데 메모리만 조금씩 고여 가는, 서버에서 흔한 유형입니다.
먼저 짚어 둘 것은 “메모리 누수”라는 말이 두 가지를 가리킨다는 점입니다. 하나는 도달할 수 없는 누수로, 할당한 메모리를 가리키는 포인터가 모두 사라져 다시는 해제할 수 없는 경우입니다. ASan의 LeakSanitizer나 Valgrind가 “definitely lost”로 보고하는 것이 이쪽입니다. 다른 하나는 논리적 누수로, 포인터는 컨테이너 어딘가에 살아 있지만 프로그램이 더 이상 쓰지 않는 객체가 계속 쌓이는 경우입니다. 이쪽은 프로그램이 종료될 때 정상적으로 해제되므로 누수 검사 도구가 잡지 못하는 경우가 많습니다. 아래 예는 두 가지가 겹친 경우라서, 도구가 무엇을 보여 주고 무엇을 보여 주지 못하는지 구분해 가며 읽어 보세요.
증상: 서버 메모리 사용량이 계속 증가
배포 후 며칠에 걸쳐 채팅 서버의 RSS가 시간에 비례해 늘어나다가 결국 OOM Killer에 종료되는 상황을 생각해 보겠습니다. 프로세스의 RSS는 ps -o rss= -p <PID>(KB 단위)로 볼 수 있고, OOM Killer가 프로세스를 죽였다면 dmesg나 journalctl -k에 Out of memory: Killed process <PID> (chat_server) 같은 커널 메시지가 남습니다.
처음 떠올릴 수 있는 가설은 연결 객체가 해제되지 않는 경우, 로그 버퍼가 계속 쌓이는 경우, 캐시가 상한 없이 커지는 경우 정도입니다.
RSS가 계속 오른다고 해서 곧바로 누수라고 단정하면 안 됩니다. glibc의 malloc은 해제된 메모리를 운영체제에 바로 돌려주지 않고 다음 할당을 위해 보관하는 경우가 많고, 스레드마다 별도의 아레나를 두기 때문에 스레드가 많은 서버에서는 단편화로 RSS가 높게 유지되기도 합니다. 이 경우 메모리는 “누수된” 것이 아니라 “반환되지 않은” 것이라, malloc_trim(0)을 호출하면 줄어들거나 jemalloc·tcmalloc으로 할당자를 바꾸면 양상이 달라집니다. 누수와 구분하는 가장 간단한 기준은 증가가 멈추는가입니다. 단편화나 캐시는 어느 수준에서 평탄해지지만, 진짜 누수는 부하에 비례해 끝없이 증가합니다. 며칠에 걸쳐 선형으로 증가해 OOM Killer까지 갔다면 누수 쪽을 먼저 의심하는 것이 합리적입니다.
초기 분석: 모니터링 데이터 확인
RSS 메트릭 수집
// 서버에 메트릭 수집 코드 추가 (Linux)
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 → bytes
}
}
return 0;
}
};
// 주기적으로 메트릭 전송 (prometheus_gauge_set은 사용하는 클라이언트 라이브러리의 API로 대체)
void reportMetrics() {
auto rss = MemoryMetrics::getCurrentRSS();
prometheus_gauge_set(memory_rss_bytes, rss);
}
패턴 분석
대시보드에서 RSS와 함께 동시 연결 수, 요청 처리량을 같은 시간 축에 올려 봅니다. 이 예에서는 동시 연결 수와 처리량은 일정한데 RSS만 꾸준히 오릅니다.
동시 연결 수가 일정한데 메모리가 늘어난다는 사실은 원인의 범위를 크게 좁혀 줍니다. “연결 하나가 살아 있는 동안 쓰는 메모리”라면 연결 수에 비례해야 하므로, 누적되는 것은 연결이 끝난 뒤에도 남는 무언가, 즉 입장·퇴장이나 요청 처리처럼 반복되는 이벤트의 횟수에 비례하는 것입니다. 그래서 다음 단계의 부하 테스트도 동시 연결 수를 늘리기보다 접속과 종료를 빠르게 반복하는 시나리오로 설계해야 짧은 시간에 누적을 재현할 수 있습니다. 메트릭 쪽에서는 RSS와 함께 입장·퇴장 횟수 같은 이벤트 카운터를 같은 그래프에 올려 보면 상관관계가 바로 드러납니다.
도구 선택: Valgrind vs ASan vs Heaptrack
| 도구 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| Valgrind | 재컴파일 없이 정밀한 누수 탐지 | 매우 느림 (수십 배) | 개발 환경, 작은 재현 케이스 |
| ASan | 비교적 빠름 (문서 기준 약 2배), 다양한 버그 탐지 | 재컴파일 필요, 메모리 사용량 증가 | CI, 통합 테스트 |
| Heaptrack | 할당 패턴·호출 경로 시각화 | 누수만 찾기는 어려움 | 메모리 프로파일링, 논리적 누수 |
세 도구는 동작 원리가 달라서 잘 잡는 문제도 다릅니다. Valgrind Memcheck는 프로그램을 가상 CPU 위에서 명령 단위로 실행하며 모든 메모리 접근을 추적하므로 재컴파일이 필요 없고 정밀하지만, 그만큼 느리고 멀티스레드 프로그램의 스레드를 한 번에 하나씩 실행합니다. ASan은 컴파일 시점에 메모리 접근마다 검사 코드를 넣고 할당자를 교체하는 방식이라 훨씬 빠르지만, 누수 검사(LeakSanitizer)는 기본적으로 프로그램이 종료될 때 한 번 도달 불가능한 블록을 찾습니다. Heaptrack은 malloc/free 호출을 가로채 호출 경로별로 기록하므로, 종료 시점에 해제되더라도 실행 중에 어떤 경로의 할당이 계속 쌓였는지를 보여 준다는 점에서 논리적 누수를 찾는 데 가장 유용합니다.
그래서 순서는 ASan 빌드로 부하 테스트를 돌려 빠르게 재현을 시도하고, Heaptrack으로 실행 중에 쌓이는 할당 경로를 확인하며, Valgrind는 원인을 좁힌 뒤 작은 재현 케이스를 정밀하게 볼 때 쓰는 것이 효율적입니다.
Valgrind를 부하 재현에 쓰기 어려운 이유
# 디버그 심볼 포함 빌드
$ g++ -g -O0 -std=c++17 *.cpp -o chat_server
# 누수 검사 (--track-origins는 초기화되지 않은 값 추적용이라 누수 검사에는 필요 없고 더 느려짐)
$ valgrind --leak-check=full --show-leak-kinds=all \
--log-file=valgrind.log ./chat_server
Valgrind 퀵스타트 문서는 Memcheck로 실행하면 프로그램이 보통 20~30배 느려진다고 설명합니다. 실제 서버를 그 속도로 돌리면 부하 테스트가 요구하는 처리량을 낼 수 없고, 같은 시간 동안 처리하는 입장·퇴장 횟수가 줄어드니 메모리 증가도 잘 보이지 않습니다.
Valgrind를 쓸 때 흔히 놓치는 점은 서버를 정상 종료해야 누수 보고서가 나온다는 것입니다. kill -9로 죽이면 HEAP SUMMARY와 누수 목록이 출력되지 않고, Ctrl+C(SIGINT)도 프로그램이 처리하지 않으면 정리 코드를 거치지 않은 채 끝나 종료 처리 중에 해제됐어야 할 메모리가 모두 “still reachable”로 섞여 나옵니다. 서버에 SIGTERM을 받으면 이벤트 루프를 멈추고 정상 종료하는 경로가 있어야 도구가 의미 있는 결과를 줍니다. 속도 문제는 Valgrind의 한계이지만, 실행 중인 프로세스에 vgdb로 붙어 monitor leak_check 명령을 보내면 종료하지 않고도 중간 누수 상태를 볼 수 있어서, 작은 부하로 오래 돌려 두는 방식에는 여전히 쓸 만합니다.
ASan으로 빠른 재현
그림: 실제 실행 결과 — 글의 EventManager처럼 new한 콜백 포인터를 맵에 쌓는 최소 예제에서, 매니저가 살아 있는 동안 macOS의 leaks 명령은 0건을 보고하고(포인터가 아직 도달 가능한 논리적 누수) 매니저가 소멸한 뒤에야 1000건(32,000바이트)을 보고한다. 퇴장한 User를 캡처한 리스너를 호출하면 ASan이 heap-use-after-free를 잡는다. 같은 리스너를 쌓으면 최대 RSS가 1000개에서 1.7MB, 100만 개에서 63.2MB로 커진다. 글의 서버가 아니라 이 패턴만 떼어 낸 예제다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · macOS leaks 명령·ASan)
# ASan 플래그로 재컴파일
$ g++ -g -O1 -fsanitize=address -fno-omit-frame-pointer \
-std=c++17 *.cpp -o chat_server_asan
# 리포트를 파일로 (실제 파일명은 asan.log.<PID>)
$ export ASAN_OPTIONS=detect_leaks=1:log_path=asan.log
# 입장·퇴장을 반복하는 부하 시나리오 실행 후 서버를 정상 종료
$ ./load_test.sh --connections=200 --duration=600s
정상 종료 후 LeakSanitizer 리포트는 다음 형식으로 나옵니다(바이트 수와 주소는 생략).
=================================================================
==<PID>==ERROR: LeakSanitizer: detected memory leaks
Direct leak of <N> byte(s) in <M> object(s) allocated from:
#0 ... in operator new(unsigned long)
#1 ... in EventManager::subscribe(std::string const&, EventCallback)
#2 ... in ChatRoom::addUser(User*)
#3 ... in Server::handleJoin(Connection*)
...
SUMMARY: AddressSanitizer: <N> byte(s) leaked in <M> allocation(s).
할당 지점이 EventManager::subscribe이고, 객체 수가 부하 테스트 동안의 입장 횟수와 같은 규모라는 점이 단서입니다.
-O1과 -fno-omit-frame-pointer를 함께 준 이유는 속도와 스택 트레이스 품질의 절충입니다. -O0은 느려서 부하 테스트에 불리하고, -O2 이상은 인라이닝 때문에 스택 트레이스에서 중간 함수가 사라질 수 있습니다. 프레임 포인터를 유지하면 ASan이 할당 시점의 호출 스택을 빠르고 정확하게 기록할 수 있습니다. 이 보고서가 나오려면 부하 테스트 후 서버를 정상 종료해야 합니다. 참고로 macOS의 Apple clang에서는 ASAN_OPTIONS=detect_leaks=1이 detect_leaks is not supported on this platform 메시지와 함께 동작하지 않으므로, macOS에서는 leaks <PID> 명령이나 Linux 환경을 씁니다. LeakSanitizer는 종료 시점에 도달할 수 없는 블록만 보고하므로, 앞서 말한 논리적 누수(아직 컨테이너에 포인터가 있는 객체)는 여기에 나타나지 않습니다. 실행 도중에 검사하고 싶다면 <sanitizer/lsan_interface.h>의 __lsan_do_recoverable_leak_check()를 호출하는 방법도 있지만, 이것 역시 도달 불가능한 블록만 찾습니다. 이 예에서 “Direct leak”이 보고되는 것은 EventManager의 소멸자가 맵을 지우면서 그 안의 원시 포인터를 해제하지 않고 잃어버리기 때문이고, 실행 중에 메모리를 늘린 진짜 원인은 그보다 앞선 “퇴장해도 리스너가 남는” 논리적 누적입니다.
Heaptrack으로 할당 패턴 분석
# Heaptrack으로 프로파일링
$ heaptrack ./chat_server
# GUI로 분석 (버전에 따라 .zst 또는 .gz 파일이 생성됨)
$ heaptrack_gui heaptrack.chat_server.<PID>.zst
이 예라면 Heaptrack의 Flame Graph와 “Consumed” 시간 축 그래프에서 ChatRoom::addUser → EventManager::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가 소멸할 때 그 포인터들을 delete하지 않는 것이 종료 시점에 LeakSanitizer가 보고하는 누수입니다.
여기서 더 위험한 문제가 하나 숨어 있습니다. 람다가 User*를 원시 포인터로 캡처하고 있어서, 사용자가 퇴장해 User 객체가 삭제된 뒤에도 리스너는 그 포인터를 들고 있습니다. 다음 publish("message")가 호출되면 이미 해제된 User의 sendMessage를 부르게 되어 use-after-free가 됩니다. 메모리 증가보다 이쪽이 크래시나 보안 문제로 이어질 수 있는 더 심각한 버그이며, ASan은 이런 접근을 heap-use-after-free 오류로 바로 잡아 줍니다. 즉 이 버그의 본질은 “delete를 빠뜨렸다”가 아니라 리스너의 수명이 사용자의 수명과 연결되어 있지 않다는 설계 문제입니다. 그래서 수정도 스마트 포인터로 바꾸는 것만으로는 부족하고, 퇴장 시 구독을 해제하는 경로가 반드시 있어야 합니다.
수정: RAII와 스마트 포인터 적용
해결 방법 1: 스마트 포인터와 구독 해제 API
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 it = listeners_.find(event);
if (it == listeners_.end()) return;
auto& cbs = it->second;
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::unordered_map<User*, Subscription> subscriptions_; // 사용자별 구독
public:
void addUser(User* user) {
auto id = eventMgr_.subscribe("message", [user](const EventData& data) {
user->sendMessage(data);
});
subscriptions_.emplace(user, Subscription(&eventMgr_, "message", id));
}
void removeUser(User* user) {
// Subscription 소멸자가 unsubscribe를 호출하므로
// user가 삭제되기 전에 리스너도 함께 사라짐
subscriptions_.erase(user);
}
};
RAII 래퍼를 쓰면 “구독을 해제하는 코드를 잊는” 문제를 구조적으로 막을 수 있습니다. 구독의 수명을 Subscription 객체의 수명과 같게 만들고, 그 객체를 사용자 세션처럼 구독이 유지되어야 하는 동안만 살아 있는 곳에 두면 됩니다. 위 코드처럼 사용자별 맵에 보관하면 퇴장 시 erase(user) 한 줄로 해제되고, 이때 User 객체를 삭제하기 전에 removeUser를 호출해야 앞에서 본 use-after-free도 사라집니다. 이 Subscription에는 보완할 점이 있습니다. 이동 생성자는 있지만 이동 대입 연산자가 없어서, std::vector<Subscription>에 담고 중간 요소를 erase하면 요소를 앞으로 당기는 대입이 필요해 컴파일 에러가 납니다. 노드 기반인 unordered_map은 요소를 옮기지 않으므로 이동 생성만으로 충분합니다. 또 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는 아무것도 출력하지 않음
검증에서 가장 중요한 것은 수정 전과 똑같은 부하 시나리오로 비교하는 것입니다. 입장·퇴장을 반복하는 부하 스크립트를 고정해 두고, 수정 전에는 peak 메모리가 실행 시간에 비례해 늘어나던 것이 수정 후에는 일정 수준에서 평탄해지는지를 봅니다. “누수 0”은 LeakSanitizer가 아무 출력도 내지 않는 것으로 확인되며, “0 bytes leaked” 같은 메시지가 따로 나오지는 않습니다. 누수가 발견되면 LeakSanitizer가 프로세스를 0이 아닌 종료 코드로 끝내므로, CI에서는 테스트 프로세스의 종료 코드로 판정하는 편이 확실합니다. 운영 환경에서도 수정 배포 후 며칠 동안 RSS 그래프가 평탄한지 확인해야 논리적 누수까지 사라졌다고 말할 수 있습니다.
재발 방지: CI에 ASan 추가
# .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
ASan 빌드는 링크 단계에도 -fsanitize=address가 필요합니다. CMake는 CMAKE_CXX_FLAGS를 링크 명령에도 넘기므로 위 설정으로 충분하지만, target_compile_options로만 플래그를 넣으면 링크에서 빠져 undefined reference to __asan_... 오류가 납니다. 그럴 때는 target_link_options(또는 add_link_options)에도 같은 옵션을 줘야 합니다. 그리고 이 CI는 단위 테스트가 종료될 때 누수를 검사하므로, 이 예처럼 “퇴장 후에도 리스너가 남는” 논리적 누수는 잡지 못합니다. 이런 회귀를 막으려면 “입장 후 퇴장시키면 리스너 수가 원래대로 돌아온다”를 직접 검증하는 테스트를 추가해야 합니다. 도구가 잡지 못하는 종류의 누수는 테스트로 불변식을 확인해야 합니다.
같은 원칙은 다른 코드에도 그대로 적용됩니다. 원시 포인터를 컨테이너에 넣는 대신 std::unique_ptr나 값 자체를 저장하면 도달 불가능한 누수는 구조적으로 사라지고, 콜백이나 리스너처럼 등록하는 API에는 해제 경로를 함께 설계해야 논리적 누수를 막을 수 있습니다.