C++ 디버깅 실전: GDB·LLDB watchpoint와 코어 덤프, ASan·TSan·UBSan, 메모리 누수·데드락, 프로덕션 크래시 추적

이 글의 핵심

프린트 디버깅으로 안 잡히는 C++ 버그를 도구별로 좁혀 가는 방법을 정리합니다. GDB/LLDB watchpoint·코어 덤프, Sanitizer 조합, 메모리 누수와 데드락 추적, 프로덕션 로깅과 크래시 리포터까지 실제로 겪는 시나리오 순서로 다룹니다.

들어가며

개발 환경에서는 멀쩡하던 프로그램이 배포 후에 가끔 죽는다거나, 서버 메모리가 며칠에 걸쳐 조금씩 늘어난다거나, 멀티스레드 코드가 열 번에 한두 번 이상한 값을 내는 문제는 printf를 몇 줄 넣는 것으로는 잘 잡히지 않습니다. 이런 버그는 증상이 나타나는 위치와 원인이 생긴 위치가 떨어져 있기 때문입니다.

이 글은 그런 버그를 도구로 좁혀 가는 방법을 다룹니다. GDB/LLDB의 watchpoint와 조건부 브레이크포인트, 코어 덤프 분석으로 시작해 ASan·TSan·UBSan·MSan과 Valgrind로 메모리 오류와 데이터 레이스를 찾고, 데드락과 반복자 무효화를 추적한 뒤, 프로덕션에서 크래시 정보를 남기는 방법까지 이어집니다. 예제는 C++17 이상, GDB 8.0 이상, Sanitizer를 지원하는 GCC 또는 Clang을 기준으로 합니다.


증상별로 먼저 의심할 원인과 도구

증상만 보고도 어느 도구부터 꺼낼지 어느 정도 정할 수 있습니다.

증상흔한 원인먼저 쓸 도구
배포 후 랜덤 크래시, Release에서만 크래시초기화 안 된 변수, 정의되지 않은 동작(UB)이 최적화로 드러남코어 덤프 + GDB, UBSan, -O1 빌드로 재현
장시간 운영 후 메모리가 계속 증가shared_ptr 순환 참조, 컨테이너에서 빠지지 않은 객체ASan(LeakSanitizer), Valgrind, Heaptrack
실행할 때마다 결과가 다름공유 변수 동기화 누락TSan, GDB로 스레드별 상태 확인
컨테이너 순회 중 크래시erase 후 무효화된 반복자 사용libstdc++ 디버그 모드, watchpoint
프로세스가 멈추고 CPU 사용률이 0%두 스레드가 서로 반대 순서로 락을 잡음GDB thread apply all bt, TSan의 lock-order 보고
특정 입력(빈 문자열, 큰 숫자)에서만 크래시경계 조건 검증 누락Fuzzing(libFuzzer, AFL++) + ASan
flowchart TB
    subgraph Problems[실무 디버깅 문제]
        P1[프로덕션 크래시]
        P2[메모리 누수]
        P3[데이터 레이스]
        P4[반복자 무효화]
        P5[데드락]
    end
    subgraph Tools[디버깅 도구]
        T1[GDB/LLDB + 코어 덤프]
        T2[Valgrind/ASan]
        T3[TSan]
        T4[디버그 모드/Watchpoint]
        T5[스레드 분석]
    end
    P1 --> T1
    P2 --> T2
    P3 --> T3
    P4 --> T4
    P5 --> T5

GDB/LLDB 고급 기법

기본 사용법과 내부 동작

#include <iostream>
#include <vector>
int buggyFunction(int x) {
    int* ptr = nullptr;
    if (x > 10) {
        ptr = new int(x);
    }
    return *ptr;  // x <= 10이면 크래시
}
int main() {
    std::cout << buggyFunction(5) << std::endl;
    return 0;
}
# 컴파일 (디버그 심볼 포함)
g++ -g -O0 buggy.cpp -o buggy

# -g: DWARF 디버그 정보(.debug_info, .debug_line 등)를 생성합니다.
#     소스 경로, 라인 번호, 변수 타입, 함수 주소 매핑이 여기에 들어 있고
#     readelf --debug-dump=info buggy 로 내용을 볼 수 있습니다.
# -O0: 최적화를 끕니다. 변수가 레지스터로만 옮겨지거나 사라지지 않고,
#      인라인도 되지 않아 소스 라인과 기계어의 대응이 단순해집니다.

gdb ./buggy

(gdb) break buggyFunction    # 함수에 브레이크포인트
(gdb) run                    # 실행
(gdb) next                   # 다음 소스 라인 (함수 호출은 건너뜀)
(gdb) step                   # 다음 소스 라인 (함수 호출이면 안으로 들어감)
(gdb) stepi                  # 기계어 명령 하나만 실행
(gdb) print ptr              # 변수 출력
(gdb) backtrace              # 호출 스택
(gdb) continue               # 다음 브레이크포인트나 시그널까지 실행

next와 step은 둘 다 소스 라인 단위로 움직입니다. 차이는 현재 라인에 함수 호출이 있을 때 그 안으로 들어가느냐뿐이고, 기계어 명령 하나씩 실행하는 것은 stepi(si)입니다.

Linux에서 GDB는 ptrace() 시스템 콜로 프로세스를 제어합니다. run을 하면 GDB가 fork한 자식 프로세스가 ptrace(PTRACE_TRACEME)를 호출한 뒤 대상 프로그램을 exec하고, 이후 자식이 시그널을 받을 때마다 부모인 GDB가 waitpid()로 통지를 받습니다. 브레이크포인트는 이 구조 위에서 다음과 같이 동작합니다.

1. 설정: GDB가 DWARF에서 함수 주소를 찾고, 그 주소의 첫 바이트를
         int3(0xCC)로 덮어씁니다. 원래 바이트는 GDB가 보관합니다.

2. 도달: CPU가 int3을 실행 → SIGTRAP → 커널이 GDB에 알림(waitpid 반환)
         → GDB가 제어권을 얻고 프롬프트를 띄웁니다.

3. 재개: 브레이크포인트 위치에서 계속 실행하려면
         원래 바이트를 복원 → PTRACE_SINGLESTEP으로 그 명령 하나 실행
         → 다시 int3을 써 넣고 → PTRACE_CONT로 실행을 이어 갑니다.

print는 DWARF에 기록된 변수 위치(스택 오프셋이나 레지스터, 또는 위치 표현식)를 읽어 PTRACE_PEEKDATA나 /proc/<pid>/mem으로 값을 가져옵니다. backtrace는 단순히 RBP 체인을 따라가는 게 아니라 .eh_frame/.debug_frame의 호출 프레임 정보(CFI)로 스택을 되감기 때문에, -fomit-frame-pointer로 빌드한 코드에서도 대부분 동작합니다.

Watchpoint: 변수가 언제 바뀌는지 추적

“이 변수가 언제, 어디서 바뀌는지 모르겠다”는 상황에 쓰는 기능입니다.

#include <iostream>
#include <thread>
#include <vector>

int global_counter = 0;

void increment() {
    for (int i = 0; i < 1000; ++i) {
        ++global_counter;  // 어디서 바뀌는지 추적하고 싶음
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Counter: " << global_counter << std::endl;
    return 0;
}

x86-64에서 하드웨어 watchpoint는 CPU의 디버그 레지스터를 씁니다. DR0~DR3에 감시할 주소 네 개까지를 넣고, DR7에 각 주소의 감시 조건(쓰기, 읽기/쓰기, 실행)과 길이(1, 2, 4, 8바이트)를 지정합니다. Linux에서는 GDB가 ptrace(PTRACE_POKEUSER)로 이 레지스터를 설정합니다. 조건에 맞는 접근이 일어나면 CPU가 디버그 예외(#DB, 벡터 1)를 일으키고, 커널이 이를 SIGTRAP으로 바꿔 GDB에 전달합니다. 프로그램은 감시 지점에 닿기 전까지 정상 속도로 돌아갑니다.

제약도 이 구조에서 나옵니다. 동시에 감시할 수 있는 주소는 x86-64 기준 네 개이고, 각 영역은 최대 8바이트이며 크기에 맞게 정렬되어 있어야 합니다. 큰 구조체 전체를 감시하면 여러 레지스터가 필요하고, 그래도 모자라면 GDB는 소프트웨어 watchpoint로 넘어갑니다. 소프트웨어 watchpoint는 프로그램을 한 명령씩 실행하면서 매번 값을 비교하므로 실행이 극단적으로 느려져, 짧은 구간이 아니면 실용적이지 않습니다.

(gdb) break main
(gdb) run
(gdb) watch global_counter              # 값이 바뀌면 중단

(gdb) info watchpoints
# Num     Type           Disp Enb Address            What
# 2       hw watchpoint  keep y                      global_counter

(gdb) continue
# Thread 2 "a.out" hit Hardware watchpoint 2: global_counter
# Old value = 0
# New value = 1
# increment () at test.cpp:9

(gdb) backtrace                         # 어느 함수에서 바꿨는지 확인

# 조건부 watchpoint
(gdb) watch global_counter if global_counter > 500

(gdb) rwatch global_counter             # 읽을 때 중단
(gdb) awatch global_counter             # 읽기/쓰기 모두 중단

디버그 레지스터는 스레드(태스크)마다 따로 저장되므로, GDB는 watchpoint를 모든 스레드에 설정하고 걸렸을 때 어느 스레드가 접근했는지 함께 보여 줍니다. 지역 변수를 감시하면 그 변수가 속한 프레임을 벗어나는 순간 watchpoint가 자동으로 지워진다는 점도 기억해 두세요.

조건부 브레이크포인트

반복문 1000번 중 특정 조건에서만 멈추고 싶을 때 씁니다.

#include <iostream>
#include <vector>
int main() {
    std::vector<int> data(1000);
    for (int i = 0; i < 1000; ++i) {
        data[i] = i * 2;
        // i가 500일 때만 확인하고 싶음
    }
    return 0;
}
(gdb) break 6 if i == 500               # 6번 줄(data[i] = ...)에서 i가 500일 때만 중단
(gdb) run
# 복잡한 조건
(gdb) break myFunction if ptr == 0 && x > 100
# 이미 만든 브레이크포인트의 조건 변경
(gdb) condition 1 i == 750

조건 평가는 브레이크포인트에 걸릴 때마다 GDB가 값을 읽어서 하므로, 매우 자주 지나가는 위치에서는 실행이 눈에 띄게 느려집니다. 최적화 빌드에서는 조건에 쓴 지역 변수가 그 지점에서 <optimized out>이라 평가가 실패할 수 있어 -O0나 -Og 빌드가 편합니다.

코어 덤프 분석

프로덕션에서 크래시가 났을 때 가장 먼저 확보할 자료입니다.

# 현재 셸에서 코어 덤프 크기 제한 해제
ulimit -c unlimited
./myapp
# Segmentation fault (core dumped)

gdb ./myapp core
(gdb) backtrace
(gdb) frame 0
(gdb) info locals              # 지역 변수 확인
(gdb) print *this              # 객체 상태 확인

systemd를 쓰는 배포판에서는 /proc/sys/kernel/core_pattern이 systemd-coredump로 파이프되어 있어 현재 디렉터리에 core 파일이 생기지 않습니다. 이때는 coredumpctl list로 덤프를 찾고 coredumpctl gdb <PID>로 바로 GDB를 띄우면 됩니다. 어느 경우든 크래시가 난 바이너리와 정확히 같은 빌드(또는 그 빌드에서 분리한 디버그 심볼)가 있어야 함수 이름과 라인이 제대로 나옵니다.

멀티스레드 디버깅

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
std::mutex mtx;
int shared_data = 0;
void worker(int id) {
    for (int i = 0; i < 100; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++shared_data;
        std::cout << "Thread " << id << ": " << shared_data << std::endl;
    }
}
int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(worker, i);
    }
    for (auto& t : threads) {
        t.join();
    }
    return 0;
}
(gdb) info threads                      # 모든 스레드 목록
(gdb) thread 2                          # 스레드 2로 전환
(gdb) backtrace                         # 해당 스레드의 스택
(gdb) thread apply all backtrace        # 모든 스레드의 스택
(gdb) break worker thread 3             # 스레드 3에서만 중단

GDB는 기본적으로 한 스레드가 멈추면 나머지 스레드도 모두 멈추는 all-stop 모드로 동작합니다. 한 스레드만 단계 실행하고 싶다면 set scheduler-locking step을 쓰면 됩니다. 스레드 번호는 GDB가 붙인 순번이라 실행할 때마다 달라질 수 있으니, 스크립트에서는 함수 이름이나 스택 내용으로 스레드를 구분하는 편이 안전합니다.

GDB 스크립트 자동화

# debug.gdb
break main
run
print argc
print argv[0]
continue
gdb -x debug.gdb ./myapp
# 또는 GDB 안에서
(gdb) source debug.gdb

스크립트 안의 상대 경로는 GDB의 현재 작업 디렉터리 기준으로 해석됩니다.

Pretty Printing (STL 컨테이너)

#include <map>
#include <string>
#include <vector>
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    std::map<std::string, int> m = {{"a", 1}, {"b", 2}};
    return 0;
}
(gdb) print vec
# $1 = std::vector of length 5, capacity 5 = {1, 2, 3, 4, 5}
(gdb) print m
# $2 = std::map with 2 elements = {["a"] = 1, ["b"] = 2}

대부분의 배포판은 GCC와 함께 libstdc++ pretty printer를 자동으로 로드하므로 별도 설정 없이 위처럼 보입니다. 내부 필드(_M_impl 등)가 그대로 출력된다면 printer가 로드되지 않은 것이니, 배포판의 printer 경로(예: /usr/share/gcc/python)를 ~/.gdbinit에서 sys.path에 추가하고 register_libstdcxx_printers(None)을 호출하면 됩니다. 경로는 배포판과 GCC 버전마다 다릅니다.

LLDB (macOS 기본 디버거)

lldb ./myapp
(lldb) breakpoint set --name main       # GDB: break main
(lldb) run                              # GDB: run
(lldb) next                             # GDB: next
(lldb) step                             # GDB: step
(lldb) print variable                   # GDB: print variable
(lldb) bt                               # GDB: backtrace
(lldb) continue                         # GDB: continue
# Watchpoint
(lldb) watchpoint set variable global_counter
(lldb) watchpoint list

ASan·TSan·UBSan·MSan 활용

Sanitizer는 컴파일러가 메모리 접근이나 연산마다 검사 코드를 끼워 넣고, 런타임 라이브러리가 문제를 발견하면 그 자리에서 스택과 함께 보고하는 방식입니다. 증상이 아니라 원인이 생긴 지점에서 멈춘다는 점이 디버거와 다릅니다.

AddressSanitizer (ASan): 메모리 오류 탐지

ASan이 잡는 대표적인 오류는 use-after-free, 힙·스택·전역 버퍼 오버플로, 이중 해제, new/delete 짝 불일치입니다. 함수 반환 후 스택 주소를 쓰는 use-after-return은 런타임 옵션(detect_stack_use_after_return)에 따라 검사되고, 메모리 누수는 함께 들어 있는 LeakSanitizer가 Linux에서 기본으로 검사합니다.

#include <iostream>
int main() {
    int* arr = new int[10];
    delete[] arr;

    // Use-after-free
    std::cout << arr[0] << std::endl;  // ASan이 탐지

    return 0;
}
g++ -fsanitize=address -g -O1 use_after_free.cpp -o test
# 또는
clang++ -fsanitize=address -g -O1 use_after_free.cpp -o test
./test
# =================================================================
# ==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
# READ of size 4 at 0x... thread T0
#     #0 0x... in main use_after_free.cpp:7
# ...
# freed by thread T0 here:
#     #0 0x... in operator delete[](void*)
#     #1 0x... in main use_after_free.cpp:4

보고서에는 잘못된 접근 위치뿐 아니라 그 메모리를 해제한 위치와 할당한 위치가 함께 나오기 때문에, “누가 먼저 지웠는가”를 추적하는 시간이 거의 들지 않습니다.

힙 버퍼 오버플로와 스택 버퍼 오버플로도 같은 방식으로 잡힙니다.

#include <iostream>
int main() {
    int* arr = new int[10];
    arr[10] = 42;  // 인덱스 범위 초과: heap-buffer-overflow
    delete[] arr;
    return 0;
}
#include <cstring>
int main() {
    char buffer[10];
    strcpy(buffer, "This is too long!");  // 18바이트를 10바이트 버퍼에: stack-buffer-overflow
    return 0;
}

ASan은 할당 영역 앞뒤에 접근 금지 구역(redzone)을 두고 감시하므로, 인덱스가 크게 벗어나 다른 정상 객체 한가운데로 떨어지는 접근은 놓칠 수 있습니다.

ThreadSanitizer (TSan): 데이터 레이스 탐지

#include <iostream>
#include <thread>
int global_counter = 0;  // 보호되지 않은 공유 변수
void increment() {
    for (int i = 0; i < 100000; ++i) {
        ++global_counter;  // TSan이 데이터 레이스로 보고
    }
}
int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Counter: " << global_counter << std::endl;
    return 0;
}
g++ -fsanitize=thread -g -O1 data_race.cpp -o test -pthread
./test
# ==================
# WARNING: ThreadSanitizer: data race (pid=12345)
#   Write of size 4 at 0x... by thread T2:
#     #0 increment() data_race.cpp:6
#   Previous write of size 4 at 0x... by thread T1:
#     #0 increment() data_race.cpp:6

TSan은 실제로 실행된 경로에서 happens-before 관계가 없는 접근 쌍을 찾습니다. 그래서 결과가 우연히 맞게 나온 실행에서도 레이스를 보고하지만, 테스트가 한 번도 지나가지 않은 코드의 레이스는 찾지 못합니다.

뮤텍스로 고친 버전입니다. 단순 카운터라면 std::atomic<int>도 같은 효과를 냅니다.

#include <iostream>
#include <thread>
#include <mutex>
int global_counter = 0;
std::mutex mtx;
void increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++global_counter;
    }
}
int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Counter: " << global_counter << std::endl;
    return 0;
}

UndefinedBehaviorSanitizer (UBSan): 정의되지 않은 동작 탐지

#include <iostream>
#include <limits>
int main() {
    // 부호 있는 정수 오버플로
    int max = std::numeric_limits<int>::max();
    int overflow = max + 1;  // UBSan이 탐지

    // 0으로 나누기
    int x = 10;
    int y = 0;
    int result = x / y;  // UBSan이 탐지 (보고 후 SIGFPE로 종료될 수 있음)

    // 널 포인터 역참조
    int* ptr = nullptr;
    int value = *ptr;  // UBSan이 탐지

    // 잘못된 다운캐스트 (-fsanitize=vptr 검사)
    struct Base { virtual ~Base() {} };
    struct Derived : Base {};
    Base* b = new Base();
    Derived* d = static_cast<Derived*>(b);  // UBSan이 탐지

    return 0;
}
g++ -fsanitize=undefined -g -O1 ub.cpp -o test
./test
# ub.cpp:6:24: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
# ub.cpp:11:20: runtime error: division by zero

UBSan은 기본적으로 오류를 보고한 뒤 실행을 계속합니다. 다만 0으로 나누기나 널 역참조는 보고 직후 실제로 시그널이 나서 프로세스가 끝날 수 있으므로, 위 예제는 한 줄씩 나눠 실험하는 편이 결과를 보기 쉽습니다. 첫 오류에서 멈추게 하려면 -fno-sanitize-recover=all을 함께 줍니다.

MemorySanitizer (MSan): 초기화 안 된 메모리 탐지

#include <iostream>
int main() {
    int x;  // 초기화 안 됨

    if (x > 10) {  // MSan이 탐지: 초기화 안 된 값으로 분기
        std::cout << "x is large" << std::endl;
    }

    int* arr = new int[10];  // 초기화 안 됨
    std::cout << arr[0] << std::endl;  // MSan이 탐지

    delete[] arr;
    return 0;
}
# MSan은 Clang만 지원
clang++ -fsanitize=memory -fsanitize-memory-track-origins -g -O1 uninit.cpp -o test
./test

MSan은 초기화 안 된 값을 복사하는 것만으로는 보고하지 않고, 그 값이 분기 조건이나 시스템 콜 인자, 포인터 역참조에 쓰일 때 보고합니다. 그리고 프로그램에 링크되는 모든 코드(표준 라이브러리 포함)가 MSan으로 빌드되어 있어야 합니다. 그렇지 않으면 계측되지 않은 라이브러리가 초기화한 메모리를 MSan이 모르기 때문에 오탐이 쏟아집니다. 실무에서 MSan 도입이 어려운 가장 큰 이유가 이것입니다.

Sanitizer 조합과 옵션

# ASan + UBSan은 함께 쓸 수 있고, 일상 개발용으로 가장 많이 쓰는 조합입니다.
g++ -fsanitize=address,undefined -g -O1 program.cpp -o test
# TSan과 MSan은 각각 ASan과 함께 쓸 수 없으므로 별도 빌드로 돌립니다.
# ASan 옵션
export ASAN_OPTIONS=detect_leaks=1:check_initialization_order=1
# TSan 옵션 (데드락 보고 시 두 번째 락의 스택도 출력)
export TSAN_OPTIONS=second_deadlock_stack=1
./test

Valgrind·Heaptrack으로 메모리 누수 추적

Valgrind Memcheck

#include <iostream>
void leakyFunction() {
    int* leak = new int[100];
    // delete[] leak;  // 누락
}
int main() {
    for (int i = 0; i < 10; ++i) {
        leakyFunction();
    }
    return 0;
}
g++ -g leak.cpp -o leak
valgrind --leak-check=full --show-leak-kinds=all ./leak
# ==12345== 4,000 bytes in 10 blocks are definitely lost in loss record 1 of 1
# ==12345==    at 0x...: operator new[](unsigned long)
# ==12345==    by 0x...: leakyFunction() (leak.cpp:3)
# ==12345==    by 0x...: main (leak.cpp:8)

Valgrind는 재컴파일 없이 기존 바이너리를 가상 CPU 위에서 실행하며 모든 메모리 접근을 추적합니다. 그래서 소스가 없는 라이브러리까지 검사할 수 있지만 실행이 크게 느려집니다. “definitely lost”는 가리키는 포인터가 하나도 남지 않은 블록이고, “still reachable”은 종료 시점에 아직 포인터가 남아 있는 블록이라 보통은 우선순위가 낮습니다.

shared_ptr 순환 참조

#include <iostream>
#include <memory>
class Node {
public:
    std::shared_ptr<Node> next;
    ~Node() {
        std::cout << "Node destroyed" << std::endl;
    }
};
int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();

    // 순환 참조 생성
    node1->next = node2;
    node2->next = node1;

    // main 종료 시 두 노드의 참조 카운트가 1씩 남아 소멸자가 호출되지 않음
    return 0;
}

순환을 끊으려면 소유 관계를 정해 한쪽 방향만 weak_ptr로 바꿉니다. 이중 연결 리스트라면 앞쪽 노드가 다음 노드를 소유하고(next는 shared_ptr), 뒤로 가는 링크(prev)는 소유하지 않는 weak_ptr로 두는 식입니다.

#include <iostream>
#include <memory>
class Node {
public:
    std::shared_ptr<Node> next;  // 소유
    std::weak_ptr<Node> prev;    // 비소유: 순환을 끊음
    ~Node() {
        std::cout << "Node destroyed" << std::endl;
    }
};
int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();

    node1->next = node2;
    node2->prev = node1;

    // node1, node2 모두 정상적으로 소멸됨
    return 0;
}

prev를 쓸 때는 if (auto p = node->prev.lock())처럼 lock()으로 살아 있는지 확인해야 합니다.

ASan(LeakSanitizer)으로 누수 탐지

# Linux x86-64에서는 ASan 빌드에 LeakSanitizer가 기본으로 켜져 있습니다.
g++ -fsanitize=address -g leak.cpp -o leak
./leak
# ==12345==ERROR: LeakSanitizer: detected memory leaks
#
# Direct leak of 4000 byte(s) in 10 object(s) allocated from:
#     #0 0x... in operator new[](unsigned long)
#     #1 0x... in leakyFunction() leak.cpp:3
#     #2 0x... in main leak.cpp:8

같은 스택에서 할당된 누수는 하나로 묶여 보고됩니다. macOS에서는 LeakSanitizer가 기본으로 꺼져 있거나 지원이 제한적이므로 Linux 환경에서 확인하는 편이 확실합니다.

Heaptrack: 힙 할당 프로파일링

sudo apt install heaptrack
heaptrack ./myapp
heaptrack_gui heaptrack.myapp.12345.gz

Heaptrack은 누수뿐 아니라 어느 호출 경로가 할당을 가장 많이 하는지, 메모리 사용량이 시간에 따라 어떻게 변하는지를 보여 줍니다. 종료 시점에 해제는 되지만 실행 중에 계속 쌓이는 “논리적 누수”(예: 비우지 않는 캐시)는 Valgrind나 LeakSanitizer에 잡히지 않으므로 이런 프로파일러가 필요합니다.


데이터 레이스·데드락·반복자 무효화 디버깅

데이터 레이스: 확인 후 갱신(check-then-act)

#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
class BankAccount {
private:
    int balance = 1000;

public:
    void withdraw(int amount) {
        // 데이터 레이스: 확인과 차감 사이에 다른 스레드가 끼어들 수 있음
        if (balance >= amount) {
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
            balance -= amount;
        }
    }

    int getBalance() const { return balance; }
};
int main() {
    BankAccount account;
    std::vector<std::thread> threads;

    // 10개 스레드가 각각 100원씩 10번 인출 시도
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back([&account]() {
            for (int j = 0; j < 10; ++j) {
                account.withdraw(100);
            }
        });
    }

    for (auto& t : threads) {
        t.join();
    }

    // 올바르게 동기화되었다면 잔액은 항상 0 (10번만 성공)
    // 레이스가 있으면 여러 스레드가 같은 잔액을 보고 통과해 음수가 될 수 있음
    std::cout << "Final balance: " << account.getBalance() << std::endl;

    return 0;
}
g++ -fsanitize=thread -g bank.cpp -o bank -pthread
./bank
# WARNING: ThreadSanitizer: data race

balance를 std::atomic<int>로 바꾸는 것만으로는 해결되지 않는다는 점이 중요합니다. 읽기와 쓰기 각각은 원자적이 되지만, “확인한 뒤 차감한다”는 두 단계 사이의 틈은 그대로 남습니다. 확인과 차감을 하나의 임계 구역으로 묶어야 합니다.

#include <chrono>
#include <mutex>
#include <thread>
class BankAccount {
private:
    int balance = 1000;
    mutable std::mutex mtx;

public:
    void withdraw(int amount) {
        std::lock_guard<std::mutex> lock(mtx);
        if (balance >= amount) {
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
            balance -= amount;
        }
    }

    int getBalance() const {
        std::lock_guard<std::mutex> lock(mtx);
        return balance;
    }
};

데드락

#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mutex1, mutex2;
void thread1() {
    std::lock_guard<std::mutex> lock1(mutex1);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lock2(mutex2);  // thread2가 mutex2를 쥔 채 mutex1을 기다림
    std::cout << "Thread 1" << std::endl;
}
void thread2() {
    std::lock_guard<std::mutex> lock2(mutex2);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lock1(mutex1);  // 반대 순서: 데드락
    std::cout << "Thread 2" << std::endl;
}
int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join();
    t2.join();

    return 0;
}

멈춘 프로세스에 GDB를 붙여 각 스레드가 어디서 기다리는지 봅니다.

g++ -g -pthread deadlock.cpp -o deadlock
./deadlock
# (멈춤)
# 다른 터미널에서:
gdb -p $(pidof deadlock)
(gdb) info threads
(gdb) thread apply all backtrace
# 두 스레드가 각각 pthread_mutex_lock(또는 __lll_lock_wait)에서 멈춰 있고,
# 호출한 줄이 서로 반대 순서의 락 획득임을 확인

Ubuntu 등에서는 kernel.yama.ptrace_scope가 1로 설정되어 있어 자식이 아닌 프로세스에 붙으려면 sudo가 필요할 수 있습니다. TSan으로 빌드하면 실제로 멈추지 않은 실행에서도 “lock-order-inversion (potential deadlock)“으로 이 패턴을 보고해 줍니다.

여러 뮤텍스를 한 번에 잡아야 한다면 C++17의 std::scoped_lock을 씁니다.

#include <iostream>
#include <mutex>
#include <thread>
std::mutex mutex1, mutex2;
void thread1() {
    std::scoped_lock lock(mutex1, mutex2);
    std::cout << "Thread 1" << std::endl;
}
void thread2() {
    std::scoped_lock lock(mutex2, mutex1);  // 인자 순서가 달라도 데드락 없음
    std::cout << "Thread 2" << std::endl;
}
int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join();
    t2.join();

    return 0;
}

scoped_lock은 내부적으로 std::lock과 같은 데드락 회피 알고리즘(하나를 잡고 나머지를 try_lock으로 시도하다 실패하면 모두 풀고 다시 시도)을 쓰므로, 인자 순서를 맞추지 않아도 서로 막히지 않습니다. 다만 락을 여러 함수에 걸쳐 따로따로 잡는 구조라면 이 방법을 쓸 수 없고, 프로젝트 전체에서 락 획득 순서를 정해 지키는 수밖에 없습니다.

반복자 무효화

#include <vector>
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};

    for (auto it = vec.begin(); it != vec.end(); ++it) {
        if (*it % 2 == 0) {
            vec.erase(it);  // erase 후 it는 무효화됨, 이후 ++it는 UB
        }
    }

    return 0;
}

이 코드는 입력에 따라 우연히 크래시 없이 끝나기도 해서 더 위험합니다. libstdc++라면 디버그 모드로 빌드하는 것이 가장 빠른 진단법입니다.

g++ -g -D_GLIBCXX_DEBUG iter.cpp -o iter
./iter
# 무효화된(singular) 반복자를 증가시키려 했다는 오류와 함께 abort

_GLIBCXX_DEBUG는 컨테이너의 ABI를 바꾸므로 프로젝트 전체를 같은 설정으로 빌드해야 합니다. 디버그 모드를 쓸 수 없는 상황이라면 vector의 끝 포인터를 watchpoint로 감시해 크기가 바뀌는 지점을 찾을 수 있습니다.

(gdb) break main
(gdb) run
(gdb) watch vec._M_impl._M_finish  # libstdc++ vector의 끝 포인터
(gdb) continue
# erase가 요소를 당기며 _M_finish를 바꾸는 순간 중단

요소를 지우기만 하는 erase는 재할당을 하지 않으므로 시작 포인터(_M_start)는 바뀌지 않습니다. 감시 대상은 끝 포인터여야 합니다. 이 필드 이름은 libstdc++ 구현 세부 사항이라 libc++에서는 다릅니다.

순회하면서 조건에 맞는 요소를 지우는 올바른 방법은 erase-remove 관용구, 또는 C++20의 std::erase_if입니다.

#include <algorithm>
#include <vector>
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};

    vec.erase(
        std::remove_if(vec.begin(), vec.end(),
            [](int x) { return x % 2 == 0; }),
        vec.end()
    );

    // C++20
    // std::erase_if(vec, [](int x) { return x % 2 == 0; });

    return 0;
}

반복문 형태를 유지해야 한다면 it = vec.erase(it);로 erase가 돌려준 반복자를 받고, 지우지 않은 경우에만 ++it 합니다.


로깅·코어 덤프·원격 디버깅으로 프로덕션 문제 잡기

구조화된 로깅

#include <chrono>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
class Logger {
public:
    enum Level { DEBUG, INFO, WARNING, ERROR, FATAL };

private:
    static std::mutex mtx_;
    static std::ofstream file_;
    static Level min_level_;

public:
    static void init(const std::string& filename, Level min_level = INFO) {
        file_.open(filename, std::ios::app);
        min_level_ = min_level;
    }

    static void log(Level level, const std::string& message,
                    const char* file, int line, const char* func) {
        if (level < min_level_) return;

        std::lock_guard<std::mutex> lock(mtx_);

        auto now = std::chrono::system_clock::now();
        auto time = std::chrono::system_clock::to_time_t(now);
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
            now.time_since_epoch()) % 1000;

        std::ostringstream oss;
        // std::localtime은 스레드 안전하지 않지만 여기서는 mtx_ 안에서만 호출
        oss << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S")
            << "." << std::setfill('0') << std::setw(3) << ms.count()
            << " [" << levelToString(level) << "] "
            << "[" << file << ":" << line << "] "
            << "[" << func << "] "
            << message << '\n';

        std::string log_line = oss.str();
        std::cout << log_line;
        if (file_.is_open()) {
            file_ << log_line;
            file_.flush();
        }
    }

private:
    static const char* levelToString(Level level) {
        switch (level) {
            case DEBUG:   return "DEBUG";
            case INFO:    return "INFO";
            case WARNING: return "WARN";
            case ERROR:   return "ERROR";
            case FATAL:   return "FATAL";
            default:      return "UNKNOWN";
        }
    }
};
std::mutex Logger::mtx_;
std::ofstream Logger::file_;
Logger::Level Logger::min_level_ = Logger::INFO;

#define LOG_DEBUG(msg) Logger::log(Logger::DEBUG, msg, __FILE__, __LINE__, __func__)
#define LOG_INFO(msg) Logger::log(Logger::INFO, msg, __FILE__, __LINE__, __func__)
#define LOG_ERROR(msg) Logger::log(Logger::ERROR, msg, __FILE__, __LINE__, __func__)
int main() {
    Logger::init("app.log", Logger::DEBUG);

    LOG_INFO("프로그램 시작");
    LOG_DEBUG("디버그 정보");
    LOG_ERROR("에러 발생");

    return 0;
}

파일·라인·함수 이름은 매크로에서 __FILE__, __LINE__, __func__로 넘깁니다. C++20 이상이라면 매크로 대신 std::source_location::current()를 기본 인자로 받는 방식이 깔끔합니다. 매 줄 flush()하는 것은 크래시 직전 로그를 잃지 않기 위해서지만 그만큼 느리므로, 처리량이 중요한 서비스라면 spdlog 같은 비동기 로거를 쓰는 편이 낫습니다. ERROR 같은 열거자 이름은 Windows 헤더의 매크로와 충돌할 수 있다는 점도 알아 두세요.

코어 덤프 수집 위치 지정

#!/bin/bash
# core_dump_setup.sh
ulimit -c unlimited
# 코어 파일 이름 규칙: 실행 파일명(%e), PID(%p), 시각(%t)
sudo mkdir -p /var/crash
echo "/var/crash/core.%e.%p.%t" | sudo tee /proc/sys/kernel/core_pattern
./myapp

core_pattern은 시스템 전체 설정이라 systemd-coredump나 apport 같은 기존 수집기를 덮어씁니다. 이미 그런 수집기가 있다면 coredumpctl로 쓰는 편이 충돌이 없습니다. 코어 파일에는 프로세스 메모리 전체가 들어 있으므로 비밀번호나 토큰 같은 민감 정보도 포함될 수 있다는 점을 염두에 두고 접근 권한을 관리해야 합니다.

원격 디버깅 (gdbserver)

# 대상 서버
gdbserver :1234 ./myapp
# 개발 환경
gdb ./myapp
(gdb) target remote server_ip:1234
(gdb) continue

gdbserver 포트에는 인증이 없어서, 접속한 사람이 대상 프로세스의 메모리를 읽고 쓰고 임의 코드를 실행할 수 있습니다. 외부에 포트를 열지 말고 SSH 터널(ssh -L 1234:localhost:1234 server)을 통해 접속하세요.

크래시 시 스택 트레이스 출력

#include <csignal>
#include <execinfo.h>
#include <unistd.h>

void crashHandler(int sig) {
    // 시그널 핸들러 안에서는 async-signal-safe 함수만 써야 합니다.
    // iostream, malloc, exit()는 안전하지 않으므로 write()와 backtrace_symbols_fd()를 씁니다.
    const char msg[] = "Fatal signal, stack trace:\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1);

    void* frames[64];
    int size = backtrace(frames, 64);
    backtrace_symbols_fd(frames, size, STDERR_FILENO);

    // 기본 동작으로 되돌리고 다시 발생시켜 코어 덤프가 남도록 함
    signal(sig, SIG_DFL);
    raise(sig);
}

int main() {
    // backtrace()는 처음 호출될 때 libgcc를 로드하며 메모리를 할당할 수 있으므로
    // 핸들러 등록 전에 한 번 호출해 둡니다.
    void* warmup[1];
    backtrace(warmup, 1);

    signal(SIGSEGV, crashHandler);
    signal(SIGABRT, crashHandler);

    int* ptr = nullptr;
    *ptr = 42;  // 크래시 → 스택 트레이스 출력 후 코어 덤프

    return 0;
}

backtrace_symbols_fd()가 출력하는 것은 함수 이름과 오프셋 정도이고, 정적 함수는 이름 대신 주소만 나옵니다. 함수 이름을 더 잘 보려면 -rdynamic으로 링크하고, 라인 번호는 addr2line -e myapp <주소>로 따로 변환합니다. 핸들러 끝에서 exit(1)로 끝내면 코어 덤프가 남지 않으므로, 기본 동작으로 되돌려 다시 시그널을 일으키는 방식이 운영에 유리합니다. 스택 오버플로로 인한 SIGSEGV까지 처리하려면 sigaltstack()으로 별도 스택을 마련해야 합니다.


재현부터 수정 검증까지 디버깅 워크플로

flowchart TB
    Start[버그 발견] --> Reproduce[재현 가능한가?]
    Reproduce -->|Yes| Minimal[최소 재현 코드 작성]
    Reproduce -->|No| Logging[로깅 추가]
    Logging --> Reproduce

    Minimal --> Hypothesis[가설 수립]
    Hypothesis --> Tool{도구 선택}

    Tool -->|메모리 오류| ASan[AddressSanitizer]
    Tool -->|데이터 레이스| TSan[ThreadSanitizer]
    Tool -->|정의되지 않은 동작| UBSan[UBSan]
    Tool -->|일반 디버깅| GDB[GDB/LLDB]

    ASan --> Verify[검증]
    TSan --> Verify
    UBSan --> Verify
    GDB --> Verify

    Verify -->|해결| Test[테스트 작성]
    Verify -->|미해결| Hypothesis

    Test --> Done[완료]

예제: 여러 버그가 섞인 코드

#include <iostream>
#include <memory>
#include <thread>
#include <vector>
class Resource {
public:
    int* data;

    Resource() : data(new int[100]) {
        std::cout << "Resource created" << std::endl;
    }

    ~Resource() {
        delete[] data;
        std::cout << "Resource destroyed" << std::endl;
    }
};
std::shared_ptr<Resource> global_resource;
void worker() {
    for (int i = 0; i < 1000; ++i) {
        // 1. 여러 스레드가 global_resource(shared_ptr 객체 자체)를 동기화 없이 읽고 씀
        if (!global_resource) {
            global_resource = std::make_shared<Resource>();
        }

        // 2. 같은 배열 원소에 여러 스레드가 동시에 씀
        global_resource->data[i % 100] = i;

        if (i == 500) {
            global_resource.reset();  // 3. 다른 스레드가 아직 쓰는 중일 수 있음
        }
    }
}
int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(worker);
    }

    for (auto& t : threads) {
        t.join();
    }

    return 0;
}

shared_ptr의 참조 카운트 증감은 원자적이지만, 같은 shared_ptr 객체를 여러 스레드가 동시에 대입하거나 reset()하는 것은 데이터 레이스입니다. 흔히 “shared_ptr은 스레드 안전하다”고 오해하는 부분입니다.

# 1단계: TSan으로 데이터 레이스 확인
g++ -fsanitize=thread -g bug.cpp -o bug -pthread
./bug
# WARNING: ThreadSanitizer: data race
# 2단계: ASan으로 메모리 오류 확인 (타이밍에 따라 재현되지 않을 수 있음)
g++ -fsanitize=address -g bug.cpp -o bug -pthread
./bug
# ERROR: AddressSanitizer: heap-use-after-free
# 3단계: GDB로 상세 분석
g++ -g bug.cpp -o bug -pthread
gdb ./bug
(gdb) break worker
(gdb) run
(gdb) info threads
(gdb) thread apply all backtrace

수정 버전에서는 전역 포인터 접근을 뮤텍스로 보호하고, 각 스레드는 지역 복사본을 잡아 객체 수명을 보장합니다. 배열 원소에 대한 동시 쓰기는 지역 복사본으로 해결되지 않으므로 원소를 std::atomic<int>로 바꿉니다.

#include <array>
#include <atomic>
#include <iostream>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>
class Resource {
public:
    std::array<std::atomic<int>, 100> data{};

    Resource() { std::cout << "Resource created" << std::endl; }
    ~Resource() { std::cout << "Resource destroyed" << std::endl; }
};
std::shared_ptr<Resource> global_resource;
std::mutex resource_mutex;
void worker() {
    for (int i = 0; i < 1000; ++i) {
        std::shared_ptr<Resource> local_resource;
        {
            std::lock_guard<std::mutex> lock(resource_mutex);
            if (!global_resource) {
                global_resource = std::make_shared<Resource>();
            }
            local_resource = global_resource;  // 이 스레드가 쓰는 동안 객체가 살아 있도록 보장
        }

        local_resource->data[i % 100].store(i, std::memory_order_relaxed);
    }
}
int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(worker);
    }

    for (auto& t : threads) {
        t.join();
    }

    return 0;
}

C++20에서는 뮤텍스 대신 std::atomic<std::shared_ptr<Resource>>를 쓸 수도 있습니다.


디버그 심볼 누락·최적화·코어 덤프 제한 같은 실수

디버그 심볼 없이 빌드

# 심볼이 없어 코어 덤프를 받아도 함수 이름과 라인을 알 수 없음
g++ -O2 program.cpp -o program
# 디버그 빌드
g++ -g -O0 program.cpp -o program
# 릴리스 빌드에도 -g를 넣고, 배포 전에 심볼을 분리 보관
g++ -g -O2 program.cpp -o program
objcopy --only-keep-debug program program.debug
strip --strip-debug program

-g는 실행 코드 자체를 바꾸지 않으므로 릴리스 빌드에 넣어도 성능에는 영향이 없습니다. 바이너리 크기가 문제라면 위처럼 심볼만 분리해 보관하면 됩니다.

최적화로 사라진 변수

int main() {
    int x = 10;
    int y = x + 5;  // 컴파일러가 상수 15로 접어 버림
    return y;
}
(gdb) print x
# $1 = <optimized out>
# 해결: -O0 또는 -Og로 다시 빌드
g++ -g -Og program.cpp -o program

릴리스에서만 나는 버그라 최적화를 끌 수 없다면, 문제 함수에만 GCC의 __attribute__((optimize("O0")))를 붙이는 방법도 있습니다. 다만 GCC 문서는 이 속성을 디버깅 용도로만 쓰라고 권하며, Clang에서는 __attribute__((optnone))을 씁니다.

Sanitizer와 최적화 레벨

# -O0도 동작하지만 느림
g++ -fsanitize=address -g -O0 program.cpp
# ASan 문서는 적당한 성능을 위해 -O1 이상을 권장
g++ -fsanitize=address -g -O1 program.cpp

-O2 이상에서는 컴파일러가 버그가 있는 메모리 접근 자체를 제거해 ASan이 놓치는 경우가 생길 수 있으므로, -O1이 속도와 탐지력 사이에서 무난한 선택입니다. 스택 트레이스를 깔끔하게 보려면 -fno-omit-frame-pointer도 함께 주는 것이 좋습니다.

멀티스레드 프로그램에서 -pthread 누락

g++ -fsanitize=thread -g program.cpp            # 오래된 glibc에서는 링크 에러
g++ -fsanitize=thread -g program.cpp -pthread   # 항상 명시

glibc 2.34부터는 pthread가 libc에 합쳐져 -pthread 없이도 링크되는 경우가 많지만, -pthread는 링크 옵션뿐 아니라 매크로 정의에도 영향을 주므로 멀티스레드 코드에서는 계속 명시하는 편이 안전합니다.

코어 덤프가 생성되지 않을 때

ulimit -c          # 0이면 비활성화 상태
ulimit -c unlimited
# 영구 설정 (/etc/security/limits.conf)
# * soft core unlimited
# * hard core unlimited
cat /proc/sys/kernel/core_pattern   # 파일이 어디로 가는지 확인

limits.conf는 PAM 로그인 세션에만 적용되고, systemd 서비스는 유닛 파일의 LimitCORE=infinity로 따로 설정해야 합니다. setuid 프로그램은 fs.suid_dumpable 설정에 따라 코어를 남기지 않습니다.

Valgrind와 ASan 동시 사용

# ASan 빌드를 Valgrind로 실행하면 제대로 동작하지 않음
g++ -fsanitize=address program.cpp -o program
valgrind ./program
# 둘 중 하나만 사용
g++ -fsanitize=address program.cpp -o program && ./program
g++ -g program.cpp -o program && valgrind --leak-check=full ./program

ASan은 시작할 때 거대한 섀도 메모리 영역을 예약하고 malloc을 자체 구현으로 바꾸는데, Valgrind도 같은 일을 하므로 둘이 충돌합니다.


개발 환경·CI Sanitizer·어설션 전략

CMake 설정

if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    add_compile_options(-g -O1 -fno-omit-frame-pointer)
    add_compile_options(-fsanitize=address,undefined)
    add_link_options(-fsanitize=address,undefined)
endif()
if(CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo")
    # CMake가 기본으로 -O2 -g를 넣어 줌
endif()

Debug 빌드에 Sanitizer를 상시로 켜 두면 별도 작업 없이도 개발 중에 오류가 드러납니다. 다만 이 설정은 단일 구성 생성기(Makefile, Ninja) 기준이고, Visual Studio처럼 다중 구성 생성기에서는 $<CONFIG:Debug> 생성기 표현식을 써야 합니다.

CI에 Sanitizer 통합

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build with ASan
        run: |
          cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
          cmake --build build

      - name: Run tests
        run: |
          cd build
          export ASAN_OPTIONS=detect_leaks=1
          ctest --output-on-failure

TSan은 ASan과 같은 빌드에 넣을 수 없으므로, 멀티스레드 코드가 많다면 CI에 TSan 빌드 잡을 하나 더 두는 것이 좋습니다.

어설션

#include <cstdlib>
#include <iostream>
// 디버그 빌드에서만 활성화
#ifdef NDEBUG
    #define DEBUG_ASSERT(condition, message) ((void)0)
#else
    #define DEBUG_ASSERT(condition, message) \
        do { \
            if (!(condition)) { \
                std::cerr << "Assertion failed: " << message << '\n' \
                          << "File: " << __FILE__ << ", Line: " << __LINE__ << std::endl; \
                std::abort(); \
            } \
        } while (0)
#endif
// 릴리스 빌드에서도 활성화
#define RELEASE_ASSERT(condition, message) \
    do { \
        if (!(condition)) { \
            std::cerr << "Fatal error: " << message << '\n' \
                      << "File: " << __FILE__ << ", Line: " << __LINE__ << std::endl; \
            std::abort(); \
        } \
    } while (0)
int main() {
    int x = 10;

    DEBUG_ASSERT(x > 0, "x must be positive");     // 개발 중에만 검사
    RELEASE_ASSERT(x < 100, "x must be less than 100");  // 항상 검사

    return 0;
}

do { ... } while (0)로 감싸는 이유는 매크로를 if (a) DEBUG_ASSERT(...); else ...처럼 쓸 때 else가 매크로 안의 if에 붙어 버리는 문제를 막기 위해서입니다. std::abort()는 SIGABRT를 일으키므로 코어 덤프가 활성화되어 있으면 실패 순간의 상태가 그대로 남습니다.

환경별 로그 레벨

// 개발
Logger::init("app.log", Logger::DEBUG);
// 스테이징
Logger::init("app.log", Logger::INFO);
// 프로덕션
Logger::init("app.log", Logger::WARNING);

레벨을 코드에 고정하지 말고 환경 변수나 설정 파일에서 읽도록 해 두면, 장애 중에 재배포 없이 레벨을 올려 정보를 더 모을 수 있습니다.

버그를 재현하는 테스트부터 작성

#include <cassert>
#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
    if (b == 0) throw std::invalid_argument("division by zero");
    return a / b;
}
void test_divide_by_zero() {
    try {
        divide(10, 0);
        assert(false && "Should throw exception");
    } catch (const std::invalid_argument& e) {
        std::cout << "Test passed: " << e.what() << std::endl;
    }
}
int main() {
    test_divide_by_zero();
    return 0;
}

버그를 고치기 전에 그 버그로 실패하는 테스트를 먼저 만들어 두면, 수정이 실제로 효과가 있는지 확인할 수 있고 같은 버그가 다시 들어오는 것도 막을 수 있습니다. assert는 NDEBUG가 정의된 빌드에서 사라지므로 실제 테스트는 GoogleTest나 Catch2 같은 프레임워크로 작성하는 편이 안전합니다.


헬스 체크·메트릭·그레이스풀 셧다운·순환 버퍼 로깅

헬스 체크

#include <atomic>
#include <chrono>
class HealthMonitor {
private:
    using Clock = std::chrono::steady_clock;
    // 여러 스레드가 갱신·조회하므로 원자 변수로 저장
    std::atomic<Clock::rep> last_heartbeat_{Clock::now().time_since_epoch().count()};
    std::atomic<bool> fatal_{false};

public:
    void heartbeat() {
        last_heartbeat_.store(Clock::now().time_since_epoch().count());
    }

    void setUnhealthy() { fatal_ = true; }

    bool isHealthy() const {
        if (fatal_) return false;
        auto last = Clock::time_point(Clock::duration(last_heartbeat_.load()));
        // 10초 이상 heartbeat가 없으면 메인 루프가 멈춘 것으로 판단
        return Clock::now() - last < std::chrono::seconds(10);
    }
};
// HTTP 서버의 /health 엔드포인트에서 isHealthy() 결과를 반환

heartbeat는 메인 루프나 워커가 주기적으로 호출합니다. 데드락으로 루프가 멈추면 heartbeat가 끊기고, 오케스트레이터(Kubernetes liveness probe 등)가 프로세스를 재시작하게 됩니다. 재시작 전에 gcore <PID>로 코어를 떠 두면 나중에 멈춘 원인을 분석할 수 있습니다.

메트릭 수집

#include <atomic>
#include <cstdint>
#include <iostream>
class Metrics {
private:
    std::atomic<uint64_t> request_count_{0};
    std::atomic<uint64_t> error_count_{0};
    std::atomic<uint64_t> total_latency_ms_{0};

public:
    void recordRequest(uint64_t latency_ms, bool is_error = false) {
        ++request_count_;
        total_latency_ms_ += latency_ms;
        if (is_error) {
            ++error_count_;
        }
    }

    void report() const {
        uint64_t requests = request_count_.load();
        uint64_t errors = error_count_.load();
        uint64_t latency = total_latency_ms_.load();

        std::cout << "Requests: " << requests << std::endl;
        std::cout << "Errors: " << errors << std::endl;
        if (requests > 0) {
            std::cout << "Avg latency: " << (latency / requests) << "ms" << std::endl;
            std::cout << "Error rate: " << (errors * 100.0 / requests) << "%" << std::endl;
        }
    }
};

세 카운터를 따로 읽으므로 report()가 보는 값들은 정확히 같은 순간의 스냅숏이 아닐 수 있지만, 모니터링 용도로는 문제가 되지 않습니다. 평균 지연 시간은 꼬리 지연을 가리므로 실제 서비스에서는 히스토그램(Prometheus 클라이언트 등)으로 백분위수를 보는 편이 좋습니다.

그레이스풀 셧다운

#include <atomic>
#include <chrono>
#include <csignal>
#include <iostream>
#include <thread>
std::atomic<bool> shutdown_requested{false};
void signalHandler(int signal) {
    // 핸들러에서는 lock-free atomic 쓰기만 하고, 출력은 메인 루프에서 합니다.
    if (signal == SIGINT || signal == SIGTERM) {
        shutdown_requested = true;
    }
}
int main() {
    std::signal(SIGINT, signalHandler);
    std::signal(SIGTERM, signalHandler);

    std::cout << "Server started. Press Ctrl+C to stop." << std::endl;

    while (!shutdown_requested) {
        std::this_thread::sleep_for(std::chrono::seconds(1));  // 메인 루프
    }

    std::cout << "Shutdown requested, cleaning up..." << std::endl;
    // 새 요청 수락 중단, 진행 중인 작업 마무리, 리소스 정리

    std::cout << "Shutdown complete." << std::endl;
    return 0;
}

시그널 핸들러에서 std::cout을 쓰면 메인 스레드가 출력 중일 때 내부 락이나 버퍼 상태가 꼬일 수 있습니다. 핸들러는 플래그만 세우고, 실제 정리 작업은 정상 코드 흐름에서 하는 것이 원칙입니다.

순환 버퍼 로깅

#include <array>
#include <iostream>
#include <mutex>
#include <string>
template<size_t N>
class CircularLogBuffer {
private:
    std::array<std::string, N> buffer_;
    size_t index_ = 0;
    std::mutex mtx_;

public:
    void log(const std::string& message) {
        std::lock_guard<std::mutex> lock(mtx_);
        buffer_[index_] = message;
        index_ = (index_ + 1) % N;
    }

    void dump() {
        std::lock_guard<std::mutex> lock(mtx_);
        std::cout << "=== Last " << N << " log entries ===" << std::endl;
        for (size_t i = 0; i < N; ++i) {
            size_t idx = (index_ + i) % N;  // 가장 오래된 항목부터
            if (!buffer_[idx].empty()) {
                std::cout << buffer_[idx] << std::endl;
            }
        }
    }
};
CircularLogBuffer<100> recent_log;

평소에는 상세 로그를 메모리에만 남겨 두었다가 오류가 났을 때 최근 N개만 덤프하는 방식이라, 디스크 I/O 없이 장애 직전 맥락을 얻을 수 있습니다. 다만 dump()는 뮤텍스와 iostream을 쓰므로 SIGSEGV 핸들러 안에서 호출하면 안 됩니다. 예외 처리 경로나 std::set_terminate 핸들러처럼 정상 실행 흐름에서 호출하고, 크래시 순간의 내용까지 남기려면 버퍼를 코어 덤프에서 직접 읽는 편이 확실합니다.


부록: macOS·크래시 리포터·로그 운영에서 추가로 알아 둘 것

A. macOS에서 LLDB로 코어 덤프·스레드 보기

macOS는 기본적으로 코어 덤프를 남기지 않습니다. ulimit -c unlimited로 크기 제한을 풀면 /cores/core.<PID>에 생성됩니다(최신 macOS에서는 바이너리에 com.apple.security.get-task-allow 권한이 있어야 하는 등 추가 조건이 붙을 수 있습니다).

lldb -c /cores/core.12345 ./myapp
(lldb) bt all            # 모든 스레드 백트레이스
(lldb) thread list
(lldb) thread select 2   # 특정 스레드로 이동 후
(lldb) frame variable    # 해당 프레임의 지역 변수

GDB의 break file.cpp:42 if x > 10은 LLDB에서 b file.cpp:42 -c "x > 10", info locals는 frame variable에 대응합니다. 명령 체계만 다를 뿐 개념은 같아서, 한쪽을 익혔다면 대응표만 옆에 두고 쓰면 됩니다.

B. 고객 PC 크래시: 크래시 리포터를 붙여야 하는 이유

서버와 달리 데스크톱·게임 클라이언트에서는 코어 덤프를 받을 수 없습니다. 이때는 Crashpad(Google, Breakpad의 후속) 같은 크래시 리포터가 크래시 순간의 minidump(스택·레지스터·로드된 모듈 정도만 담은 작은 덤프)를 만들어 서버로 보냅니다. 운영의 핵심은 코드보다 심볼 관리입니다. 릴리스마다 디버그 심볼(PDB, dSYM, 분리한 DWARF)을 빌드 번호와 함께 심볼 서버에 올려 두지 않으면, 받은 minidump를 함수 이름으로 풀 수 없어 쓸모가 없어집니다. Sentry·Backtrace 같은 서비스도 Crashpad minidump를 받으므로, 직접 서버를 두지 않아도 됩니다. 빌드 방법과 API는 버전마다 바뀌어 왔으니 공식 문서 기준으로 붙이세요.

C. 프로덕션 로그가 디스크를 채울 때

로그 레벨이 debug로 남아 있거나 로테이션 없이 한 파일에 계속 쓰면 결국 디스크가 가득 찹니다. 레벨은 환경 변수로 바꿀 수 있게 두고, 크기·개수 제한이 있는 로테이팅 싱크를 씁니다.

#include <spdlog/sinks/rotating_file_sink.h>
auto logger = spdlog::rotating_logger_mt("app", "logs/app.log",
                                         10 * 1024 * 1024,  // 파일당 10MB
                                         5);                // 최대 5개 유지
logger->set_level(spdlog::level::info);  // 프로덕션은 info 이상

컨테이너 환경이라면 파일 대신 stdout에 JSON 한 줄씩 쓰고, 로테이션과 수집은 Docker 로그 드라이버나 수집기(Loki, ELK)에 맡기는 편이 단순합니다.

D. Sanitizer가 보고하는 것 중 헷갈리는 두 가지

  • alloc-dealloc-mismatch는 new로 할당한 걸 free로, malloc으로 할당한 걸 delete로, new[]로 할당한 걸 delete로 해제한 경우입니다. 증상 없이 지나가는 경우가 많아 ASan이 처음 잡아 주는 버그 중 하나입니다. C 라이브러리가 돌려준 포인터를 unique_ptr에 넣을 때는 해제 함수를 커스텀 삭제자로 지정하세요.
  • TSan 보고의 대부분은 실제 레이스입니다. 정말 의도된 경우(예: 통계용 카운터를 정확하지 않아도 되는 방식으로 읽는 코드)라면 무시 매크로를 만들기보다 std::atomic에 memory_order_relaxed를 쓰는 게 올바른 수정입니다. 외부 라이브러리 내부처럼 고칠 수 없는 곳은 suppression 파일(TSAN_OPTIONS=suppressions=tsan.supp)로 범위를 좁혀 제외하고, 특정 함수만 빼야 한다면 __attribute__((no_sanitize("thread")))를 씁니다. 전역으로 끄는 방식은 진짜 레이스까지 숨깁니다.

도구 선택과 오버헤드

문제 유형별 도구

문제 유형추천 도구사용 시기
메모리 누수ASan(LSan), Valgrind, Heaptrack개발/테스트
데이터 레이스TSan멀티스레드 개발
정의되지 않은 동작UBSan개발/테스트 빌드
일반 크래시GDB/LLDB개발 중
프로덕션 크래시코어 덤프 + GDB, 크래시 리포터프로덕션
성능 병목perf, gprof최적화 단계
초기화 안 된 메모리MSan, ValgrindClang 환경

Sanitizer 오버헤드

아래 수치는 각 도구의 공식 문서가 제시하는 대략적인 값이며, 실제 오버헤드는 프로그램의 메모리 접근 패턴에 따라 크게 달라집니다.

도구속도 저하메모리 증가탐지 범위컴파일러 지원
ASan약 2배수 배메모리 오류, 누수GCC, Clang, MSVC
TSan5~15배5~10배데이터 레이스, 락 순서GCC, Clang
UBSan켠 검사에 따라 다름(대체로 작음)작음정의되지 않은 동작GCC, Clang
MSan약 3배약 2배초기화 안 된 메모리Clang
Valgrind Memcheck20~30배큼메모리 전반재컴파일 불필요

일상 개발에는 ASan과 UBSan을 함께 쓰고, 멀티스레드 코드는 TSan 빌드를 따로 돌리며, 재컴파일할 수 없는 바이너리나 ASan이 놓친 문제를 정밀하게 볼 때 Valgrind를 쓰는 조합이 무난합니다.


같이 보면 좋은 글