C++ 크래시 디버깅 실전 사례 | 간헐적 Segmentation Fault 해결기

C++ 크래시 디버깅 실전 사례 | 간헐적 Segmentation Fault 해결기

이 글의 핵심

C++ 간헐적 세그폴트 디버깅 실전 - 코어 덤프, gdb, rr로 재현 불가능한 버그 해결

들어가며

“가끔 서버가 죽어요”라는 제보만큼 디버깅하기 어려운 문제는 없습니다. 이 글에서는 재현이 안 되는 간헐적 크래시를 코어 덤프와 역추적 디버거를 활용하여 해결한 실전 사례를 공유합니다.

일상에 빗대면, 교차로에서만 간헐적으로 나는 사고와 비슷합니다. 같은 길이라도 신호 타이밍·차선 변경이 겹칠 때만 터져, 재현이 어렵습니다.

이 글을 읽으면

  • 코어 덤프를 설정하고 분석하는 방법을 배웁니다
  • gdb로 크래시 지점을 추적하는 기법을 익힙니다
  • rr(record and replay)로 재현 불가능한 버그를 잡는 법을 이해합니다
  • 멀티스레드 환경에서 데이터 경쟁을 디버깅하는 전략을 습득합니다

목차

  1. 증상: 간헐적 Segmentation Fault
  2. 코어 덤프 설정
  3. gdb로 크래시 지점 분석
  4. 가설: 댕글링 포인터?
  5. 재현 시도 실패
  6. rr로 실행 기록
  7. 역방향 디버깅으로 원인 추적
  8. 근본 원인: 데이터 경쟁
  9. 수정: 뮤텍스 추가
  10. TSan으로 검증
  11. 마무리

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);
        }
    }
};

가설

  1. Connectionroom_을 참조 중인데, ChatRoom이 먼저 소멸?
  2. 멀티스레드 환경에서 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. 교훈과 베스트 프랙티스

핵심 교훈

  1. 코어 덤프는 필수: 프로덕션 서버에 항상 설정
  2. rr은 강력함: 재현 불가능한 버그의 구세주
  3. TSan 활용: CI에 추가하여 데이터 경쟁 조기 발견
  4. 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의 콜스택 확인

마무리

간헐적 크래시는 디버깅하기 가장 어려운 버그 중 하나입니다. 이 사례에서는:

  1. 코어 덤프로 크래시 지점을 파악했습니다
  2. gdb로 콜스택을 분석했습니다
  3. rr로 재현 불가능한 버그를 기록하고 역추적했습니다
  4. TSan으로 데이터 경쟁을 검증했습니다
  5. 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, 실전 사례