C++ 크래시 디버깅 실전 사례 | 간헐적 Segmentation Fault 해결기
이 글의 핵심
처음엔 댕글링 포인터를 의심했지만 로컬 테스트로는 재현되지 않았고, 타이밍에 따라 결과가 갈린다는 점이 핵심 단서였습니다. 실행을 기록해 되감아 보는 rr로 문제 시나리오의 타임라인을 복원하는 방법, 세 수정안의 트레이드오프, 수정 후 부하 테스트로 성능 영향을 확인하는 과정까지 볼 수 있습니다.
들어가며
“가끔 서버가 죽어요”라는 제보만큼 디버깅하기 어려운 문제는 없습니다. 이 글에서는 재현이 안 되는 간헐적 크래시를 코어 덤프와 역추적 디버거를 활용하여 해결한 실전 사례를 공유합니다. 일상에 빗대면, 교차로에서만 간헐적으로 나는 사고와 비슷합니다. 같은 길이라도 신호 타이밍·차선 변경이 겹칠 때만 터져, 재현이 어렵습니다.
증상: 간헐적 Segmentation Fault
문제 상황
구체적으로는 프로덕션 서버가 하루에 1~2번 정도 원인 불명의 세그폴트로 종료되었으며, 로컬 단일 스레드 테스트로는 재현되지 않았습니다.
# 시스템 로그
$ dmesg | tail
[12345.678] chat_server[23456]: segfault at 0 ip 00007f1234567890 sp 00007fff12345678 error 4 in chat_server
특징
- 재현 불가: 로컬에서 아무리 테스트해도 재현 안 됨
- 간헐적: 특정 패턴 없이 랜덤하게 발생
- 부하 관련: 트래픽이 많을 때 더 자주 발생
dmesg 한 줄에도 단서가 많습니다. segfault at 0은 접근하려던 주소가 0 근처, 즉 널 포인터 역참조라는 뜻이고, error 4는 커널의 페이지 폴트 코드로 “사용자 모드에서 존재하지 않는 페이지를 읽으려다” 실패했음을 나타냅니다(6이면 쓰기). ip는 크래시 당시 명령어 주소라, 바이너리의 로드 주소를 빼면 addr2line으로 소스 줄을 찾을 수도 있습니다. 주소가 0이 아니라 0000000000000018처럼 작은 값이라면 널 포인터에서 멤버 오프셋만큼 떨어진 곳을 읽은 것이고, 0x7f...처럼 그럴듯한 힙 주소인데 크래시가 났다면 이미 해제된 메모리를 쓴 use-after-free를 먼저 의심합니다. “부하가 높을 때 더 자주”라는 특징은 스레드 간 타이밍 문제를 강하게 시사하는 신호였습니다.
코어 덤프 설정
시스템 설정
# 코어 덤프 크기 제한 해제
$ ulimit -c unlimited
# 코어 덤프 경로 설정
$ sudo sysctl -w kernel.core_pattern=/var/coredumps/core.%e.%p.%t
# 디렉토리 생성 및 권한
$ sudo mkdir -p /var/coredumps
$ sudo chmod 1777 /var/coredumps
실무에서는 이 설정이 생각대로 먹지 않는 경우가 많습니다. 최근 배포판은 kernel.core_pattern이 |/usr/lib/systemd/systemd-coredump ...처럼 systemd-coredump로 파이프되어 있어서 코어 파일이 /var/lib/systemd/coredump에 압축 저장되고 coredumpctl list, coredumpctl gdb <PID>로 꺼내 봐야 합니다. 위처럼 직접 경로를 지정하면 이 동작을 덮어쓰게 됩니다. 또 ulimit -c unlimited는 그 셸에서 띄운 프로세스에만 적용되므로 systemd 서비스라면 유닛 파일에 LimitCORE=infinity를, 컨테이너라면 --ulimit core=-1과 호스트 쪽 core_pattern(네임스페이스별이 아니라 호스트 전역)을 확인해야 합니다. 코어 파일에는 메모리 내용 전체가 들어가므로 비밀번호·토큰 같은 민감 정보가 포함된다는 점도 운영 정책상 고려해야 합니다.
서버 재시작
# 코어 덤프 설정 확인
$ ulimit -c
unlimited
# 서버 실행
$ ./chat_server
크래시 대기
다음날 아침, 코어 덤프 파일 발견:
$ ls -lh /var/coredumps/
-rw------- 1 user user 1.2G Mar 30 03:42 core.chat_server.23456.1711756920
gdb로 크래시 지점 분석
코어 덤프 로드
$ gdb ./chat_server /var/coredumps/core.chat_server.23456.1711756920
(gdb) bt
#0 0x00007f1234567890 in ChatRoom::broadcast (this=0x0, msg=...)
at src/chat_room.cpp:145
#1 0x00007f3456789012 in Connection::handleMessage (this=0x7f4567890123)
at src/connection.cpp:89
#2 0x00007f4567890123 in boost::asio::detail::handler_work<...>::complete (...)
at /usr/include/boost/asio/detail/handler_work.hpp:82
발견
- 크래시 지점:
ChatRoom::broadcast에서this=0x0(nullptr 역참조!) - 스레드: Asio 워커 스레드
코어 덤프를 분석할 때는 크래시 당시와 같은 바이너리와 디버그 심볼이 있어야 합니다. 배포 파이프라인에서 strip된 바이너리만 남겨 두면 bt에 ??만 찍히므로, 빌드 시 -g로 만든 심볼을 objcopy --only-keep-debug로 분리 보관하거나 build-id로 찾을 수 있게 debuginfod 서버에 올려 두는 편이 좋습니다. 멀티스레드 서버라면 bt 하나로 끝내지 말고 thread apply all bt로 모든 스레드의 스택을 봐야 합니다. 크래시한 스레드가 피해자일 뿐이고, 원인을 만든 스레드는 다른 곳에서 태연히 다음 일을 하고 있는 경우가 많기 때문입니다. 이 사례에서도 크래시 순간 다른 스레드는 이미 leaveRoom()을 마치고 떠난 뒤라 코어 덤프만으로는 누가 값을 바꿨는지 알 수 없었습니다.
가설: 댕글링 포인터?
문제 코드 확인
class Connection {
ChatRoom* room_; // 🚨 raw 포인터
public:
void handleMessage(const std::string& msg) {
if (room_) {
room_->broadcast(msg); // 크래시 지점
}
}
void leaveRoom() {
room_ = nullptr;
}
};
class ChatRoom {
std::vector<Connection*> connections_;
public:
void broadcast(const std::string& msg) {
for (auto* conn : connections_) { // 여기서 크래시?
conn->send(msg);
}
}
};
가설
Connection이room_을 참조 중인데,ChatRoom이 먼저 소멸?- 멀티스레드 환경에서
room_이 nullptr로 바뀌는 타이밍 이슈?
재현 시도 실패
로컬 테스트
# 부하 테스트 (1000명 동시 접속, 10분)
$ ./load_test.sh --users=1000 --duration=600
# 결과: 재현 안 됨
왜 재현이 안 될까?
- 타이밍 의존적: 특정 스레드 스케줄링에서만 발생
- 환경 차이: 프로덕션 서버의 CPU, 부하 패턴이 다름
- 확률적: 1000번 중 1번 발생하는 레이스 컨디션 결론: 재현 없이 디버깅해야 함 → rr 사용
rr로 실행 기록
rr 설치 및 설정
# rr 설치
$ sudo apt install rr
# perf_event_paranoid 설정
$ echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid
프로덕션 서버에서 rr 기록
# rr로 서버 실행 (약간의 오버헤드)
$ rr record ./chat_server
# 크래시 발생 시 기록 저장됨
rr: Saving execution to trace directory `/root/.local/share/rr/chat_server-0'.
rr을 쓰기 전에 알아야 할 제약이 있습니다. 첫째, rr은 CPU의 하드웨어 성능 카운터로 실행을 기록하므로 Intel/AMD의 특정 세대 이상 CPU가 필요하고, 성능 카운터를 가상화하지 않는 VM이나 클라우드 인스턴스에서는 동작하지 않습니다. 둘째, rr은 모든 스레드를 한 코어에서 번갈아 실행하며 기록합니다. 멀티코어에서 동시에 돌던 서버가 단일 코어로 직렬화되므로 처리량이 크게 떨어지고, 동시 실행이 없어지니 레이스가 오히려 덜 일어날 수 있습니다. 이때 rr record --chaos를 쓰면 스케줄링을 일부러 무작위로 흔들어 드문 인터리빙을 더 자주 만들어 줍니다. 그래서 전체 트래픽이 아니라 일부 트래픽만 받는 카나리 서버 한 대를 rr로 띄우는 방식이 현실적이고, 드문 크래시라면 기록되기까지 오래 걸릴 수 있습니다. 기록 파일도 빠르게 커지므로 디스크 여유를 충분히 확보해야 합니다.
역방향 디버깅으로 원인 추적
rr replay
$ rr replay /root/.local/share/rr/chat_server-0
(rr) c
# 크래시 지점까지 실행됨
Program received signal SIGSEGV, Segmentation fault.
0x00007f1234567890 in ChatRoom::broadcast (this=0x0, msg=...)
역방향 추적
// 크래시 직전으로 역방향 실행
(rr) reverse-continue
// room_이 nullptr이 된 시점 찾기
(rr) watch -l room_
Hardware watchpoint 1: -location room_
(rr) reverse-continue
Old value = (ChatRoom*) 0x7f3456789012
New value = (ChatRoom*) 0x0
// 누가 nullptr로 바꿨는지 확인
(rr) bt
#0 Connection::leaveRoom() at src/connection.cpp:67
#1 ChatRoom::removeConnection() at src/chat_room.cpp:89
#2 Server::handleDisconnect() at src/server.cpp:123
발견!
스레드 A: Connection::handleMessage 실행 중 (room_ 사용)
스레드 B: Connection::leaveRoom 호출 (room_ = nullptr)
→ 데이터 경쟁!
역방향 디버깅의 위력은 이 순서에 있습니다. 일반 gdb라면 크래시 이후의 상태만 볼 수 있지만, rr에서는 크래시 지점에 워치포인트를 걸고 reverse-continue로 시간을 거슬러 올라가 그 메모리를 마지막으로 바꾼 명령어에서 멈출 수 있습니다. 기록은 결정적으로 재생되므로 같은 추적을 몇 번이고 반복해도 매번 똑같은 순서로 크래시가 재현됩니다. “재현이 안 된다”는 문제가 “한 번만 기록되면 된다”는 문제로 바뀌는 것입니다.
근본 원인: 데이터 경쟁
문제 시나리오
// 스레드 A (Asio 워커)
void Connection::handleMessage(const std::string& msg) {
if (room_) { // ✅ room_이 유효함
// ⏰ 여기서 스레드 B가 끼어듦!
room_->broadcast(msg); // 💥 room_이 nullptr이 됨!
}
}
// 스레드 B (다른 Asio 워커)
void Connection::leaveRoom() {
room_ = nullptr; // ⚠️ 스레드 A가 사용 중인데 nullptr로!
}
타임라인
Time | Thread A | Thread B
------|-----------------------------|-----------------------
t0 | if (room_) { // true |
t1 | | room_ = nullptr;
t2 | room_->broadcast(msg); |
| 💥 SIGSEGV |
여기서 한 가지 짚어 둘 점이 있습니다. if (room_) 검사와 room_->broadcast() 호출 사이에 값이 바뀌는 이 버그는, 코드상으로는 room_을 두 번 읽기 때문에 생깁니다. 그런데 room_이 일반 포인터인 한 컴파일러는 “다른 스레드가 바꾸지 않는다”고 가정할 수 있으므로, 최적화 빌드에서는 값을 한 번만 읽어 레지스터에 두기도 하고 두 번 읽기도 합니다. 즉 이 크래시가 나는지 여부 자체가 컴파일러 버전과 최적화 옵션에 따라 달라지는, 전형적인 미정의 동작입니다. 널 체크를 지역 변수에 복사하는 것(auto* r = room_; if (r) r->broadcast())만으로는 해결되지 않는다는 점도 중요합니다. 널 크래시는 사라져도, 스레드 B가 ChatRoom을 삭제하는 경로가 있다면 r이 해제된 객체를 가리키는 use-after-free로 바뀔 뿐입니다. 근본적인 문제는 값 하나의 경쟁이 아니라 ChatRoom의 수명을 누가 보장하느냐였습니다.
수정: 뮤텍스 추가
해결 방법 1: 뮤텍스로 보호
class Connection {
mutable std::mutex roomMutex_;
ChatRoom* room_;
public:
void handleMessage(const std::string& msg) {
std::lock_guard<std::mutex> lock(roomMutex_);
if (room_) {
room_->broadcast(msg);
}
}
void leaveRoom() {
std::lock_guard<std::mutex> lock(roomMutex_);
room_ = nullptr;
}
};
뮤텍스는 가장 직관적이지만 이 코드에는 숨은 위험이 있습니다. roomMutex_를 잡은 채로 broadcast()를 호출하고, broadcast()는 다른 Connection들의 send()를 호출합니다. 만약 send()나 ChatRoom 내부가 다른 뮤텍스를 잡고, 반대 방향으로 락을 잡는 경로(예: 방이 연결을 정리하면서 leaveRoom() 호출)가 있다면 락 순서 역전으로 교착이 생길 수 있습니다. 또 락을 잡은 채로 네트워크 전송까지 하므로 방 하나의 메시지 처리가 직렬화되어 지연이 늘어납니다. 락 안에서는 포인터만 복사하고, 호출은 락 밖에서 하는 편이 낫지만 그러려면 복사한 포인터의 수명을 보장할 수단이 따로 필요합니다. 그것이 방법 2입니다.
해결 방법 2: shared_ptr + weak_ptr
class Connection {
std::weak_ptr<ChatRoom> room_; // weak_ptr로 변경
public:
void handleMessage(const std::string& msg) {
// 사용 시점에 lock으로 유효성 확인
if (auto room = room_.lock()) {
room->broadcast(msg);
}
// room이 소멸되었으면 lock() 실패 → 안전
}
void setRoom(std::shared_ptr<ChatRoom> room) {
room_ = room;
}
void leaveRoom() {
room_.reset();
}
};
weak_ptr::lock()은 성공하면 shared_ptr을 돌려주고, 그 shared_ptr이 살아 있는 동안에는 다른 스레드가 방의 마지막 소유권을 놓아도 ChatRoom이 삭제되지 않습니다. 즉 방법 1이 해결하지 못한 수명 문제를 해결합니다. 하지만 이 코드만으로는 데이터 경쟁이 완전히 사라지지 않는다는 점을 놓치기 쉽습니다. shared_ptr/weak_ptr의 참조 카운트 조작은 스레드 안전하지만, 같은 weak_ptr 객체를 한 스레드에서 lock()하고 다른 스레드에서 reset()하는 것은 여전히 데이터 경쟁입니다. TSan도 이 경우를 잡아 냅니다. C++20의 std::atomic<std::weak_ptr<ChatRoom>>을 쓰거나, room_ 교체 자체를 뮤텍스(방법 1)나 strand(방법 3)로 보호해야 합니다. 저도 처음에는 “스마트 포인터로 바꿨으니 안전하다”고 생각했다가 TSan 보고서를 보고 나서야 이 구분을 제대로 이해했습니다.
해결 방법 3: Strand로 직렬화 (Asio)
class Connection {
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
ChatRoom* room_;
public:
void handleMessage(const std::string& msg) {
// Strand에서 실행 → 직렬화 보장
boost::asio::post(strand_, [this, msg]() {
if (room_) {
room_->broadcast(msg);
}
});
}
void leaveRoom() {
boost::asio::post(strand_, [this]() {
room_ = nullptr;
});
}
};
strand는 같은 strand에 올린 핸들러가 동시에 실행되지 않음을 보장하는 Asio의 실행기입니다. room_을 읽고 쓰는 모든 코드가 같은 strand 위에서만 돈다면 락 없이도 데이터 경쟁이 사라지고, 핸들러 사이에 메모리 가시성도 보장됩니다. 조건은 “모든 접근”입니다. 소켓 읽기 완료 핸들러가 strand에 묶여 있지 않은 경로가 하나라도 남아 있으면 보호가 깨지므로, async_read의 완료 핸들러를 bind_executor(strand_, ...)로 묶는 등 연결 단위로 일관되게 적용해야 합니다. 또 위 예제처럼 [this]를 캡처하면 핸들러가 실행되기 전에 Connection이 삭제될 때 같은 종류의 버그가 재발하므로, 실제 코드에서는 shared_from_this()를 캡처해 핸들러가 실행될 때까지 연결 객체를 살려 두는 것이 Asio의 관용구입니다.
TSan으로 검증
TSan 빌드
# Thread Sanitizer로 재컴파일
$ g++ -g -O1 -fsanitize=thread -std=c++17 *.cpp -o chat_server_tsan
실행 및 결과
$ ./chat_server_tsan
==================
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 8 at 0x7f1234567890 by thread T2:
#0 Connection::leaveRoom() src/connection.cpp:67
Previous read of size 8 at 0x7f1234567890 by thread T1:
#0 Connection::handleMessage() src/connection.cpp:45
Location is heap block of size 256 at 0x7f1234567800 allocated by main thread:
#0 operator new(unsigned long)
#1 Server::createConnection() src/server.cpp:123
SUMMARY: ThreadSanitizer: data race src/connection.cpp:67 in Connection::leaveRoom()
==================
확인: TSan이 정확히 데이터 경쟁을 탐지했습니다!
TSan의 장점은 크래시가 나지 않아도 경쟁을 보고한다는 점입니다. 두 접근이 happens-before 관계 없이 일어났다는 사실만으로 경고하므로, 1000번에 한 번 크래시하는 버그도 부하 테스트 몇 분이면 보고서가 나옵니다. 대신 실행 속도가 수 배~십수 배 느려지고 메모리도 크게 늘어나 프로덕션에 올리기는 어렵고, ASan과 한 빌드에 같이 쓸 수 없습니다. 또 TSan은 실제로 실행된 경로만 검사하므로, 테스트가 leaveRoom과 handleMessage를 동시에 부르는 시나리오를 만들지 않으면 아무것도 보고하지 않습니다. CI에 TSan 빌드를 추가할 때는 “연결 중 퇴장”, “메시지 처리 중 방 삭제”처럼 수명 경계가 겹치는 테스트를 함께 넣어야 의미가 있습니다.
수정 후 검증
부하 테스트
# TSan 빌드로 장시간 테스트
$ ./chat_server_tsan
# 24시간 실행 → 데이터 경쟁 0건
# 프로덕션 배포 후 모니터링
# 1주일 → 크래시 0건
성능 영향
| 방법 | 비용이 생기는 곳 | 해결하는 문제 |
|---|---|---|
| 뮤텍스 | 락 경합, 락을 쥔 채 전송하면 방 단위 직렬화 | 값 경쟁 (수명 문제는 별도) |
| weak_ptr | lock()마다 원자적 참조 카운트 증감 | 수명 문제 (room_ 교체는 별도 보호 필요) |
| Strand | 핸들러 큐잉 지연, 모든 경로를 strand에 묶는 설계 부담 | 연결 단위 직렬화로 값 경쟁 제거 (Asio 전용) |
실제 오버헤드는 트래픽 패턴과 코어 수에 따라 크게 달라지므로, 표의 숫자를 일반화하기보다 수정 전후의 p99 지연과 처리량을 같은 부하 테스트로 비교해 회귀가 없는지 확인하는 편이 안전합니다.
선택: 이미 Asio를 쓰고 있어 연결마다 strand를 두는 구조가 자연스러웠고, 방의 수명은 shared_ptr로 관리하는 조합(방법 2 + 3)을 택했습니다.
교훈과 베스트 프랙티스
핵심 교훈
- 코어 덤프는 필수: 프로덕션 서버에 항상 설정
- rr은 강력함: 재현 불가능한 버그의 구세주
- TSan 활용: CI에 추가하여 데이터 경쟁 조기 발견
- Strand/뮤텍스: 멀티스레드 안전성 확보
간헐적 크래시 디버깅 전략
graph TD
A[크래시 발생] --> B{코어 덤프 있음?}
B -->|Yes| C[gdb로 크래시 지점 확인]
B -->|No| D[코어 덤프 설정 후 대기]
C --> E{재현 가능?}
E -->|Yes| F[gdb로 직접 디버깅]
E -->|No| G[rr로 실행 기록]
G --> H[rr replay로 역추적]
H --> I[원인 파악]
I --> J[수정]
J --> K[TSan/ASan으로 검증]
데이터 경쟁 예방 패턴
// ❌ 나쁜 패턴: 보호되지 않은 공유 상태
class BadConnection {
ChatRoom* room_; // 여러 스레드에서 접근
void handleMessage(const std::string& msg) {
if (room_) {
room_->broadcast(msg); // 💥 데이터 경쟁
}
}
};
// ✅ 좋은 패턴: 뮤텍스로 보호
class GoodConnection {
std::mutex mutex_;
ChatRoom* room_;
void handleMessage(const std::string& msg) {
std::lock_guard<std::mutex> lock(mutex_);
if (room_) {
room_->broadcast(msg);
}
}
};
// ✅ 더 좋은 패턴: Strand로 직렬화 (Asio)
class BestConnection {
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
ChatRoom* room_;
void handleMessage(const std::string& msg) {
boost::asio::post(strand_, [this, msg]() {
if (room_) {
room_->broadcast(msg);
}
});
}
};
추가 디버깅 기법
Watchpoint 활용
(gdb) watch room_
Hardware watchpoint 1: room_
(gdb) continue
# room_이 변경될 때마다 중단됨
Conditional Breakpoint
(gdb) break Connection::handleMessage if room_ == 0
Breakpoint 1 at 0x12345678
(gdb) run
# room_이 nullptr일 때만 중단
스레드 정보 확인
(gdb) info threads
Id Target Id Frame
* 1 Thread 0x7f123456 (LWP 12345) Connection::handleMessage
2 Thread 0x7f234567 (LWP 12346) ChatRoom::removeConnection
3 Thread 0x7f345678 (LWP 12347) boost::asio::io_context::run
(gdb) thread 2
(gdb) bt
# 스레드 2의 콜스택 확인
마무리
간헐적 크래시는 디버깅하기 가장 어려운 버그 중 하나입니다. 이 사례에서는:
- 코어 덤프로 크래시 지점을 파악했습니다
- gdb로 콜스택을 분석했습니다
- rr로 재현 불가능한 버그를 기록하고 역추적했습니다
- TSan으로 데이터 경쟁을 검증했습니다
- Strand로 멀티스레드 안전성을 확보했습니다 핵심: 재현이 안 되어도 포기하지 말고, 적절한 도구를 활용하면 해결할 수 있습니다.
FAQ
Q1. rr이 프로덕션에서 사용 가능한가요? 모든 스레드를 한 코어에서 직렬 실행하므로 멀티스레드 서버는 처리량이 크게 떨어지고, 하드웨어 성능 카운터가 필요해 많은 VM·클라우드 환경에서는 아예 동작하지 않습니다. 전체 트래픽이 아니라 일부 트래픽만 받는 카나리 인스턴스 한 대에서 기록하는 방식이 현실적입니다.
Q2. 코어 덤프 파일이 너무 큰데요?
kernel.core_pattern에 파이프를 사용하여 압축하거나(systemd-coredump가 기본으로 압축합니다), /proc/<pid>/coredump_filter로 파일 매핑 페이지 등을 제외할 수 있습니다. ulimit -c로 크기를 제한하면 잘린 코어가 남아 분석이 불가능해질 수 있으니 주의하세요.
Q3. TSan과 ASan을 동시에 사용할 수 있나요? 아니요. 한 번에 하나의 sanitizer만 사용 가능합니다. CI에서 각각 별도 빌드로 실행하세요.
같이 보면 좋은 글
- printf 디버깅에서 벗어나기: GDB·LLDB 브레이크포인트, 워치포인트, 단계 실행 기초
- C++ 멀티스레딩 안전성
- C++ Strand 패턴
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ 디버깅 실전: GDB·LLDB watchpoint와 코어 덤프, ASan·TSan·UBSan, 메모리 누수·데드락, 프로덕션 크래시 추적
- C++20 std::jthread: 자동 join과 stop_token으로 스레드 협조적 중단하기
- C++ thread_local: 스레드별 카운터·버퍼, 초기화 시점과 소멸 순서