본문으로 건너뛰기
Previous
Next
C++ 크래시 디버깅 실전 사례 | 간헐적 Segmentation Fault 해결기

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

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

이 글의 핵심

프로덕션 C++ 서버에서 발생한 간헐적 세그폴트를 코어 덤프, gdb, rr로 추적하고 해결한 실전 사례. 재현 불가능한 버그를 체계적으로 해결하는 방법을 다룹니다.

들어가며

“가끔 서버가 죽어요”라는 제보만큼 디버깅하기 어려운 문제는 없습니다. 이 글에서는 재현이 안 되는 간헐적 크래시를 코어 덤프와 역추적 디버거를 활용하여 해결한 실전 사례를 공유합니다. 일상에 빗대면, 교차로에서만 간헐적으로 나는 사고와 비슷합니다. 같은 길이라도 신호 타이밍·차선 변경이 겹칠 때만 터져, 재현이 어렵습니다.

이 글을 읽으면

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

실전 경험에서 배운 교훈

이 기술을 실무 프로젝트에 처음 도입했을 때, 공식 문서만으로는 알 수 없었던 많은 함정들이 있었습니다. 특히 프로덕션 환경에서 발생하는 엣지 케이스들은 로컬 개발 환경에서는 재현조차 되지 않았죠.

가장 어려웠던 점은 성능 최적화였습니다. 처음엔 “동작만 하면 되겠지”라고 생각했지만, 실제 사용자 트래픽이 몰리면서 병목 지점들이 하나씩 드러났습니다. 특히 데이터베이스 쿼리 최적화, 캐싱 전략, 에러 핸들링 구조 등은 여러 번의 장애를 겪으면서 개선해 나갔습니다.

이 글에서는 그런 시행착오를 통해 얻은 실전 노하우와, “이렇게 하면 안 된다”는 교훈들을 함께 정리했습니다. 특히 트러블슈팅 섹션은 실제 장애 대응 경험을 바탕으로 작성했으니, 비슷한 문제를 마주했을 때 참고하시면 도움이 될 것입니다.

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에서 각각 별도 빌드로 실행하세요.

관련 글


실전 체크리스트

크래시 디버깅 체크리스트

  • 코어 덤프 설정 확인
  • 크래시 로그 수집
  • gdb로 콜스택 분석
  • 재현 시도
  • 재현 실패 시 rr 사용
  • 데이터 경쟁 의심 시 TSan 실행
  • 원인 파악 후 수정
  • 부하 테스트로 검증
  • CI에 sanitizer 추가

멀티스레드 안전성 체크리스트

  • 공유 상태 식별
  • 뮤텍스/Strand로 보호
  • 스마트 포인터로 수명 관리
  • TSan으로 데이터 경쟁 검사
  • 데드락 가능성 검토
  • 성능 영향 측정

키워드

C++, 크래시, Segmentation Fault, 디버깅, 코어 덤프, Core Dump, gdb, rr, 역방향 디버깅, 데이터 경쟁, Race Condition, TSan, ThreadSanitizer, 멀티스레딩, Strand, 실전 사례

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 크래시 디버깅 실전 사례 | 간헐적 Segmentation Fault 해결기」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 크래시 디버깅 실전 사례 | 간헐적 Segmentation Fault 해결기」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, 디버깅, Segmentation Fault, 크래시, Core Dump, gdb, rr-debugger, 실전 사례, 멀티스레딩 등으로 검색하시면 이 글이 도움이 됩니다.