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

이 글의 핵심

처음엔 댕글링 포인터를 의심했지만 로컬 테스트로는 재현되지 않았고, 타이밍에 따라 결과가 갈린다는 점이 핵심 단서였습니다. 실행을 기록해 되감아 보는 rr로 문제 시나리오의 타임라인을 복원하는 방법, 세 수정안의 트레이드오프, 수정 후 부하 테스트로 성능 영향을 확인하는 과정까지 볼 수 있습니다.

들어가며

“가끔 서버가 죽어요”라는 제보는 디버깅하기 가장 까다로운 유형입니다. 이 글은 특정 서비스의 장애 기록이 아니라, Boost.Asio 기반 채팅 서버에서 흔히 생기는 간헐적 세그폴트를 예제로 꾸며 코어 덤프, gdb, rr 역방향 디버깅, ThreadSanitizer 순서로 추적해 보는 연습입니다. 로그와 도구 출력의 주소·PID·파일 크기는 형식을 보여 주기 위한 예시 값입니다.


증상: 간헐적 Segmentation Fault

문제 상황

프로덕션 서버가 하루에 한두 번 원인 불명의 세그폴트로 종료되었고, 로컬 단일 스레드 테스트로는 재현되지 않았습니다.

# 시스템 로그
$ 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(네임스페이스별이 아니라 호스트 전역)을 확인해야 합니다. 코어 파일에는 프로세스 메모리 내용이 그대로 들어가므로 비밀번호나 토큰 같은 민감 정보가 포함된다는 점도 운영 정책상 고려해야 합니다.

코어 파일이 너무 크다면 systemd-coredump처럼 파이프로 받아 압축하거나, /proc/<pid>/coredump_filter로 파일 매핑 페이지 같은 영역을 제외할 수 있습니다. ulimit -c로 크기를 제한하면 잘린 코어가 남아 분석이 불가능해질 수 있으니 주의합니다.

서버 재시작과 크래시 대기

# 코어 덤프 설정 확인
$ 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입니다. 널 포인터로 멤버 함수를 호출했고, 함수 안에서 첫 멤버(connections_)를 읽는 순간 주소 0 근처에 접근해 죽은 것입니다. 호출한 쪽은 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로 바뀌는 타이밍 문제라는 것입니다. 크래시 시점의 this가 0이라는 점은 두 번째 가설 쪽에 더 가깝습니다. 해제된 객체였다면 this는 0이 아닌 주소였을 것입니다.


재현 시도 실패

로컬 테스트

# 부하 테스트 (1000명 동시 접속, 10분)
$ ./load_test.sh --users=1000 --duration=600
# 결과: 재현 안 됨

재현이 안 되는 이유는 여러 가지가 겹칩니다. 버그가 특정 스레드 스케줄링에서만 드러나고, 프로덕션 서버와 로컬 머신은 코어 수와 부하 패턴이 다르며, 경쟁 구간 자체가 명령어 몇 개 길이라 그 사이에 다른 스레드가 끼어들 확률이 낮습니다. 그래서 재현에 기대지 않고 실제로 발생한 실행을 기록하는 쪽으로 방향을 바꿨고, 그 도구로 rr을 선택했습니다.


rr로 실행 기록

rr 설치 및 설정

# rr 설치
$ sudo apt install rr
# rr은 perf_event_paranoid가 1 이하여야 동작
$ 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(및 일부 ARM) 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=...)

역방향 추적

# room_이 보이는 handleMessage 프레임으로 이동
(rr) up
# room_의 메모리 위치에 워치포인트 설정
(rr) watch -l room_
Hardware watchpoint 1: -location room_
# 시간을 거슬러 room_을 마지막으로 바꾼 지점까지 되감기
(rr) reverse-continue
# 워치포인트에서 멈추면 쓰기를 수행한 스레드의 스택 확인
(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(msg);)만으로는 해결되지 않는다는 점도 중요합니다. 복사 자체가 여전히 경쟁하는 읽기이고, 널 크래시가 사라져도 스레드 B가 ChatRoom을 삭제하는 경로가 있다면 r이 해제된 객체를 가리키는 use-after-free로 바뀔 뿐입니다. 근본적인 문제는 값 하나의 경쟁이 아니라 ChatRoom의 수명을 누가 보장하느냐였습니다.


수정안 세 가지

해결 방법 1: 뮤텍스로 보호

class Connection {
    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);
        }
        // 방이 이미 소멸했으면 lock()이 빈 shared_ptr을 반환
    }

    void setRoom(std::shared_ptr<ChatRoom> room) {
        room_ = std::move(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)로 보호해야 합니다. 다만 std::atomic<std::shared_ptr<T>>·std::atomic<std::weak_ptr<T>> 특수화는 표준 라이브러리 구현이 따라와야 쓸 수 있어서, Apple clang 21의 libc++에서는 is_trivially_copyable static assertion 오류로 컴파일되지 않았습니다. __cpp_lib_atomic_shared_ptr 기능 테스트 매크로로 확인하거나, 이식성이 필요하면 뮤텍스를 쓰세요. “스마트 포인터로 바꿨으니 안전하다”는 판단은 흔한 오해입니다.

해결 방법 3: Strand로 직렬화 (Asio)

class Connection : public std::enable_shared_from_this<Connection> {
    boost::asio::strand<boost::asio::io_context::executor_type> strand_;
    ChatRoom* room_;

public:
    void handleMessage(const std::string& msg) {
        // strand에서 실행 → 같은 strand의 핸들러와 동시에 실행되지 않음
        boost::asio::post(strand_, [self = shared_from_this(), msg]() {
            if (self->room_) {
                self->room_->broadcast(msg);
            }
        });
    }

    void leaveRoom() {
        boost::asio::post(strand_, [self = shared_from_this()]() {
            self->room_ = nullptr;
        });
    }
};

strand는 같은 strand에 올린 핸들러가 동시에 실행되지 않음을 보장하는 Asio의 실행기입니다. room_을 읽고 쓰는 모든 코드가 같은 strand 위에서만 돈다면 락 없이도 데이터 경쟁이 사라지고, 핸들러 사이의 메모리 가시성도 보장됩니다. 조건은 “모든 접근”입니다. 소켓 읽기 완료 핸들러가 strand에 묶여 있지 않은 경로가 하나라도 남아 있으면 보호가 깨지므로, async_read의 완료 핸들러를 bind_executor(strand_, ...)로 묶는 등 연결 단위로 일관되게 적용해야 합니다. 또 람다에서 [this]만 캡처하면 핸들러가 실행되기 전에 Connection이 삭제될 때 같은 종류의 버그가 재발하므로, 위 코드처럼 shared_from_this()를 캡처해 핸들러가 끝날 때까지 연결 객체를 살려 두는 것이 Asio의 관용구입니다. 이 방법은 room_ 값의 경쟁만 없애므로, ChatRoom 자체의 수명은 여전히 따로 관리해야 합니다.


TSan으로 검증

검사 후 사용 사이에 포인터가 바뀌는 최소 예제에 대한 ThreadSanitizer의 데이터 경쟁 보고와 널 역참조 SEGV, 뮤텍스로 보호한 버전의 출력

그림: 실제 실행 결과 — 글의 Connection처럼 if (room_) 뒤에 room_->broadcast()를 호출하는 최소 예제를 두 스레드로 돌리면 ThreadSanitizer가 room_의 데이터 경쟁을 보고하고, 같은 실행에서 널 포인터로 멤버 함수를 호출하는 SEGV(this=0 상황)도 잡는다. 뮤텍스로 검사와 사용을 묶은 버전은 보고 없이 끝난다. 글의 채팅 서버가 아니라 이 패턴만 떼어 낸 예제이고, delivered 값은 실행마다 다르다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · ThreadSanitizer)

TSan 빌드

# ThreadSanitizer로 재컴파일
$ 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이 leaveRoom()의 쓰기와 handleMessage()의 읽기 사이의 경쟁을 그대로 보고했습니다.

TSan의 장점은 크래시가 나지 않아도 경쟁을 보고한다는 점입니다. 두 접근이 happens-before 관계 없이 일어났다는 사실만으로 경고하므로, 프로덕션에서 드물게만 크래시하는 버그도 두 함수가 겹치는 테스트만 있으면 짧은 실행으로 보고서가 나옵니다. 대신 공식 문서 기준으로 실행 속도가 515배 느려지고 메모리 사용량도 510배 늘어나 프로덕션에 올리기는 어렵고, ASan과 한 빌드에 같이 쓸 수 없으므로 CI에서는 별도 빌드로 돌려야 합니다. 또 TSan은 실제로 실행된 경로만 검사하므로, 테스트가 leaveRoom과 handleMessage를 동시에 부르는 시나리오를 만들지 않으면 아무것도 보고하지 않습니다. CI에 TSan 빌드를 추가할 때는 “연결 중 퇴장”, “메시지 처리 중 방 삭제”처럼 수명 경계가 겹치는 테스트를 함께 넣어야 의미가 있습니다.


수정 후 검증

수정한 코드는 같은 TSan 빌드로 수명 경계가 겹치는 부하 테스트를 다시 돌려 경쟁 보고가 사라졌는지 확인한 뒤 배포합니다. 배포 후에는 크래시 모니터링을 유지해 같은 스택의 코어 덤프가 다시 나오지 않는지 지켜봅니다.

수정안별 비용

방법비용이 생기는 곳해결하는 문제
뮤텍스락 경합, 락을 쥔 채 전송하면 메시지 처리 직렬화값 경쟁 (수명 문제는 별도)
weak_ptrlock()마다 원자적 참조 카운트 증감수명 문제 (room_ 교체는 별도 보호 필요)
Strand핸들러 큐잉 지연, 모든 경로를 strand에 묶는 설계 부담연결 단위 직렬화로 값 경쟁 제거 (Asio 전용)

실제 오버헤드는 트래픽 패턴과 코어 수에 따라 크게 달라지므로, 일반화된 숫자를 믿기보다 수정 전후의 p99 지연과 처리량을 같은 부하 테스트로 비교해 회귀가 없는지 확인하는 편이 안전합니다.

이 사례에서는 이미 Asio를 쓰고 있어 연결마다 strand를 두는 구조가 자연스러웠고, 방의 수명은 shared_ptr로 관리하는 조합(방법 2 + 3)을 택했습니다. room_ 교체와 사용이 모두 같은 strand에서 일어나므로 방법 2에서 남았던 weak_ptr 객체 자체의 경쟁도 함께 사라집니다.


간헐적 크래시 디버깅 흐름

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으로 검증]

추가 디버깅 기법

Watchpoint 활용

watch room_처럼 식으로 걸면 해당 프레임을 벗어날 때 워치포인트가 삭제됩니다. 객체 멤버의 메모리 자체를 감시하려면 -l(location) 옵션을 씁니다.

(gdb) watch -l room_
Hardware watchpoint 1: -location 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의 콜스택 확인


같이 보면 좋은 글