C++ 크래시 디버깅 실전 사례 | 간헐적 Segmentation Fault 해결기
이 글의 핵심
C++ 간헐적 세그폴트 디버깅 실전 - 코어 덤프, gdb, rr로 재현 불가능한 버그 해결
들어가며
“가끔 서버가 죽어요”라는 제보만큼 디버깅하기 어려운 문제는 없습니다. 이 글에서는 재현이 안 되는 간헐적 크래시를 코어 덤프와 역추적 디버거를 활용하여 해결한 실전 사례를 공유합니다.
일상에 빗대면, 교차로에서만 간헐적으로 나는 사고와 비슷합니다. 같은 길이라도 신호 타이밍·차선 변경이 겹칠 때만 터져, 재현이 어렵습니다.
이 글을 읽으면
- 코어 덤프를 설정하고 분석하는 방법을 배웁니다
- gdb로 크래시 지점을 추적하는 기법을 익힙니다
- rr(record and replay)로 재현 불가능한 버그를 잡는 법을 이해합니다
- 멀티스레드 환경에서 데이터 경쟁을 디버깅하는 전략을 습득합니다
목차
- 증상: 간헐적 Segmentation Fault
- 코어 덤프 설정
- gdb로 크래시 지점 분석
- 가설: 댕글링 포인터?
- 재현 시도 실패
- rr로 실행 기록
- 역방향 디버깅으로 원인 추적
- 근본 원인: 데이터 경쟁
- 수정: 뮤텍스 추가
- TSan으로 검증
- 마무리
1. 증상: 간헐적 Segmentation Fault
문제 상황
구체적으로는 프로덕션 서버가 하루에 1~2번 정도 원인 불명의 세그폴트로 종료되었고, 로컬 단일 스레드 테스트로는 재현되지 않았습니다.
# 시스템 로그
$ dmesg | tail
[12345.678] chat_server[23456]: segfault at 0 ip 00007f1234567890 sp 00007fff12345678 error 4 in chat_server
특징
- 재현 불가: 로컬에서 아무리 테스트해도 재현 안 됨
- 간헐적: 특정 패턴 없이 랜덤하게 발생
- 부하 관련: 트래픽이 많을 때 더 자주 발생
2. 코어 덤프 설정
시스템 설정
# 코어 덤프 크기 제한 해제
$ 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
서버 재시작
# 코어 덤프 설정 확인
$ ulimit -c
unlimited
# 서버 실행
$ ./chat_server
크래시 대기
다음날 아침, 코어 덤프 파일 발견:
$ ls -lh /var/coredumps/
-rw------- 1 user user 1.2G Mar 30 03:42 core.chat_server.23456.1711756920
3. gdb로 크래시 지점 분석
코어 덤프 로드
$ gdb ./chat_server /var/coredumps/core.chat_server.23456.1711756920
(gdb) bt
#0 0x00007f1234567890 in std::vector<Message>::operator[] (this=0x0, __n=5)
at /usr/include/c++/11/bits/stl_vector.h:1046
#1 0x00007f2345678901 in ChatRoom::broadcast (this=0x7f3456789012, msg=...)
at src/chat_room.cpp:145
#2 0x00007f3456789012 in Connection::handleMessage (this=0x7f4567890123)
at src/connection.cpp:89
#3 0x00007f4567890123 in boost::asio::detail::handler_work<...>::complete (...)
at /usr/include/boost/asio/detail/handler_work.hpp:82
발견
- 크래시 지점:
ChatRoom::broadcast에서this=0x0(nullptr 역참조!) - 스레드: Asio 워커 스레드
4. 가설: 댕글링 포인터?
문제 코드 확인
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로 바뀌는 타이밍 이슈?
5. 재현 시도 실패
로컬 테스트
# 부하 테스트 (1000명 동시 접속, 10분)
$ ./load_test.sh --users=1000 --duration=600
# 결과: 재현 안 됨
왜 재현이 안 될까?
- 타이밍 의존적: 특정 스레드 스케줄링에서만 발생
- 환경 차이: 프로덕션 서버의 CPU, 부하 패턴이 다름
- 확률적: 1000번 중 1번 발생하는 레이스 컨디션
결론: 재현 없이 디버깅해야 함 → rr 사용
6. 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'.
7. 역방향 디버깅으로 원인 추적
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)
→ 데이터 경쟁!
8. 근본 원인: 데이터 경쟁
문제 시나리오
// 스레드 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 |
9. 수정: 뮤텍스 추가
해결 방법 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;
}
};
해결 방법 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();
}
};
해결 방법 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;
});
}
};
10. 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이 정확히 데이터 경쟁을 탐지했습니다!
11. 수정 후 검증
부하 테스트
# TSan 빌드로 장시간 테스트
$ ./chat_server_tsan
# 24시간 실행 → 데이터 경쟁 0건
# 프로덕션 배포 후 모니터링
# 1주일 → 크래시 0건
성능 영향
| 방법 | 오버헤드 | 안전성 |
|---|---|---|
| 뮤텍스 | ~5% | 높음 |
| weak_ptr | ~10% | 매우 높음 |
| Strand | ~2% | 높음 (Asio 전용) |
선택: Strand 방식 (Asio 사용 중이므로)
12. 교훈과 베스트 프랙티스
핵심 교훈
- 코어 덤프는 필수: 프로덕션 서버에 항상 설정
- 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);
}
});
}
};
13. 추가 디버깅 기법
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이 프로덕션에서 사용 가능한가요?
오버헤드가 있지만 (2-3배), 간헐적 버그를 잡기 위해 일부 서버에서 rr로 실행하는 것은 가능합니다.
Q2. 코어 덤프 파일이 너무 큰데요?
kernel.core_pattern에 파이프를 사용하여 압축하거나, ulimit -c로 크기 제한을 둘 수 있습니다.
Q3. TSan과 ASan을 동시에 사용할 수 있나요?
아니요. 한 번에 하나의 sanitizer만 사용 가능합니다. CI에서 각각 별도 빌드로 실행하세요.
관련 글
- C++ 디버깅 완벽 가이드
- C++ 멀티스레딩 안전성
- C++ Strand 패턴
- C++ 스마트 포인터
실전 체크리스트
크래시 디버깅 체크리스트
- 코어 덤프 설정 확인
- 크래시 로그 수집
- gdb로 콜스택 분석
- 재현 시도
- 재현 실패 시 rr 사용
- 데이터 경쟁 의심 시 TSan 실행
- 원인 파악 후 수정
- 부하 테스트로 검증
- CI에 sanitizer 추가
멀티스레드 안전성 체크리스트
- 공유 상태 식별
- 뮤텍스/Strand로 보호
- 스마트 포인터로 수명 관리
- TSan으로 데이터 경쟁 검사
- 데드락 가능성 검토
- 성능 영향 측정
키워드
C++, 크래시, Segmentation Fault, 디버깅, 코어 덤프, Core Dump, gdb, rr, 역방향 디버깅, 데이터 경쟁, Race Condition, TSan, ThreadSanitizer, 멀티스레딩, Strand, 실전 사례