C++ 디버깅 완벽 가이드 | GDB·Sanitizer·메모리 누수·멀티스레드 디버깅 실전
이 글의 핵심
C++ 디버깅 완벽 가이드에 대한 실전 가이드입니다. GDB·Sanitizer·메모리 누수·멀티스레드 디버깅 실전 등을 예제와 함께 상세히 설명합니다.
들어가며: “프로덕션에서 크래시가 나는데 재현이 안 돼요”
실무에서 겪는 디버깅 문제들
실제 C++ 개발 현장에서는 이런 문제를 겪습니다:
- 프로덕션 크래시 — 개발 환경에서는 멀쩡한데 배포하면 랜덤 크래시
- 메모리 누수 — 3일째 메모리 사용량이 계속 증가, 어디서 새는지 모름
- 데이터 레이스 — 멀티스레드 환경에서 가끔 이상한 값이 나옴
- 성능 저하 — 특정 함수에서 병목이 있는데 프로파일러 없이 찾기 어려움
- 반복자 무효화 — 컨테이너 순회 중 크래시, 어디서 수정됐는지 추적 불가
이 글에서는 실전 시나리오를 기반으로 GDB 고급 기법, Sanitizer 활용, 멀티스레드 디버깅, 프로덕션 환경 디버깅까지 다룹니다.
목표:
- GDB/LLDB 고급 기법 (watchpoint, conditional breakpoint, 코어 덤프 분석)
- Sanitizer 완전 활용 (ASan, TSan, UBSan, MSan)
- 메모리 누수 추적 (Valgrind, Heaptrack, 실전 패턴)
- 멀티스레드 디버깅 (데이터 레이스, 데드락 탐지)
- 프로덕션 디버깅 (로깅, 코어 덤프, 원격 디버깅)
- 자주 하는 실수와 해결법
- 프로덕션 패턴
요구 환경: C++17 이상, GDB 8.0+, Clang/GCC with Sanitizers
목차
- 문제 시나리오: 실무에서 겪는 디버깅 상황
- GDB/LLDB 고급 기법
- Sanitizer 완전 활용
- 메모리 누수 추적
- 멀티스레드 디버깅
- 프로덕션 환경 디버깅
- 완전한 디버깅 워크플로우
- 자주 발생하는 실수와 해결법
- 모범 사례·베스트 프랙티스
- 프로덕션 패턴
- 정리 및 체크리스트
1. 문제 시나리오: 실무에서 겪는 디버깅 상황
시나리오 1: “프로덕션에서만 크래시가 나요”
상황: 개발 환경에서는 정상 동작하는데, 프로덕션 배포 후 랜덤 크래시
증상: Segmentation fault, 코어 덤프 생성
원인: 릴리스 빌드 최적화로 숨겨진 버그 (초기화 안 된 변수, UB)
→ 코어 덤프 분석, UBSan으로 UB 탐지 필요
시나리오 2: “메모리가 계속 증가해요”
상황: 서버가 72시간 운영 후 메모리 8GB → 14GB로 증가
증상: 느린 메모리 누수, 재시작 전까지 회복 불가
원인: shared_ptr 순환 참조, 컨테이너에서 제거 안 된 객체
→ Valgrind, Heaptrack, ASan으로 누수 지점 추적
시나리오 3: “멀티스레드에서 가끔 이상한 값이 나와요”
상황: 10번 실행하면 1~2번 잘못된 결과 출력
증상: 데이터 레이스, 비결정적 동작
원인: 공유 변수 동기화 누락, 락 없는 접근
→ TSan으로 레이스 탐지, GDB로 스레드별 상태 확인
관련 글: 멀티스레드 기초에서 스레드 프로그래밍 기본 개념을 학습하세요.
시나리오 4: “컨테이너 순회 중 크래시해요”
상황: vector 순회 중 erase 호출 후 크래시
증상: iterator 무효화, Segmentation fault
원인: erase 후 반복자 갱신 안 함
→ GDB watchpoint로 컨테이너 수정 지점 추적
시나리오 5: “데드락이 발생해요”
상황: 멀티스레드 서버가 가끔 멈춤, CPU 사용률 0%
증상: 모든 스레드가 락 대기 상태
원인: 순환 락 대기 (A→B, B→A)
→ GDB로 스레드별 스택 확인, TSan으로 락 순서 분석
시나리오 6: “릴리스 빌드에서만 크래시해요”
상황: Debug 빌드는 정상, Release 빌드는 크래시
증상: 최적화로 인한 숨겨진 버그 노출
원인: 초기화 안 된 변수, 정의되지 않은 동작 (UB)
→ UBSan으로 UB 탐지, -O1로 중간 최적화 테스트
시나리오 7: “특정 입력에서만 크래시해요”
상황: 일반 입력은 정상, 특정 입력 (빈 문자열, 큰 숫자 등)에서만 크래시
증상: 경계 조건 (edge case) 처리 누락
원인: 입력 검증 부족, 배열 범위 체크 누락
→ Fuzzing (AFL, libFuzzer)으로 자동 테스트 케이스 생성
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
2. 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
# GDB 시작
gdb ./buggy
# 기본 명령어
(gdb) break buggyFunction # 함수에 브레이크포인트
(gdb) run # 실행
(gdb) next # 다음 줄 (함수 넘어감)
(gdb) step # 다음 줄 (함수 안으로)
(gdb) print ptr # 변수 출력
(gdb) backtrace # 스택 트레이스
(gdb) continue # 계속 실행
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;
}
주의사항: 하드웨어 워치포인트 개수는 CPU마다 제한이 있어, 거대한 구조체 전체 감시는 느리거나 실패할 수 있습니다.
# GDB Watchpoint 사용
(gdb) break main
(gdb) run
(gdb) watch global_counter # 변수 변경 시 중단
(gdb) continue
# global_counter가 변경될 때마다 멈춤
(gdb) backtrace # 어느 함수에서 변경했는지 확인
Conditional Breakpoint: 조건부 중단
문제: “반복문 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 7 if i == 500 # i가 500일 때만 중단
(gdb) run
# 복잡한 조건
(gdb) break myFunction if ptr == nullptr && x > 100
# 조건 변경
(gdb) condition 1 i == 750 # 브레이크포인트 1번의 조건 변경
주의사항: 일부 최적화로 지역 변수가 레지스터만 쓰이면 조건식이 기대대로 동작하지 않을 수 있어 -O0를 권장합니다.
코어 덤프 분석
프로덕션에서 크래시 발생 시:
# 코어 덤프 활성화
ulimit -c unlimited
# 프로그램 실행 (크래시 시 core 파일 생성)
./myapp
# Segmentation fault (core dumped)
# 코어 덤프로 디버깅
gdb ./myapp core
# 크래시 지점 확인
(gdb) backtrace
(gdb) frame 0
(gdb) info locals # 지역 변수 확인
(gdb) print this # 객체 상태 확인
멀티스레드 디버깅
#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 스크립트 자동화
# 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 <vector>
#include <map>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
std::map<std::string, int> m = {{"a", 1}, {"b", 2}};
return 0;
}
# GDB에서 STL 예쁘게 출력
(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}
# Pretty printer 활성화 (없으면)
# ~/.gdbinit에 추가:
# python
# import sys
# sys.path.insert(0, '/usr/share/gcc/python')
# from libstdcxx.v6.printers import register_libstdcxx_printers
# register_libstdcxx_printers(None)
# end
LLDB (macOS 기본 디버거)
# LLDB 기본 사용법 (GDB와 유사)
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
3. Sanitizer 완전 활용
AddressSanitizer (ASan): 메모리 오류 탐지
탐지 가능한 버그:
- Use-after-free
- Heap buffer overflow
- Stack buffer overflow
- Use-after-return
- Memory leaks
#include <iostream>
int main() {
int* arr = new int[10];
delete[] arr;
// Use-after-free
std::cout << arr[0] << std::endl; // 💥 ASan이 탐지
return 0;
}
# ASan 활성화 컴파일
g++ -fsanitize=address -g -O1 use_after_free.cpp -o test
# 또는 Clang
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
# #1 0x... in main use_after_free.cpp:5
Heap Buffer Overflow 탐지:
#include <iostream>
int main() {
int* arr = new int[10];
// Buffer overflow
arr[10] = 42; // 💥 ASan이 탐지 (인덱스 범위 초과)
delete[] arr;
return 0;
}
Stack Buffer Overflow 탐지:
#include <cstring>
int main() {
char buffer[10];
strcpy(buffer, "This is too long!"); // 💥 ASan이 탐지
return 0;
}
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;
}
# TSan 활성화 컴파일
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:7
# Previous write of size 4 at 0x... by thread T1:
# #0 increment() data_race.cpp:7
수정 버전 (뮤텍스 사용):
#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이 탐지
// 널 포인터 역참조
int* ptr = nullptr;
int value = *ptr; // 💥 UBSan이 탐지
// 잘못된 캐스팅
class Base { virtual ~Base() {} };
class Derived : public Base {};
Base* b = new Base();
Derived* d = static_cast<Derived*>(b); // 💥 UBSan이 탐지
return 0;
}
# UBSan 활성화 컴파일
g++ -fsanitize=undefined -g -O1 ub.cpp -o test
# 실행
./test
# 출력 예시:
# ub.cpp:7:20: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
# ub.cpp:11:19: runtime error: division by zero
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 -g -O1 uninit.cpp -o test
# 실행
./test
Sanitizer 조합 사용
# ASan + UBSan 조합 (권장)
g++ -fsanitize=address,undefined -g -O1 program.cpp -o test
# 프로덕션 빌드에는 사용하지 말 것 (성능 오버헤드 큼)
# 개발/테스트 환경에서만 사용
Sanitizer 옵션 설정
# ASan 옵션
export ASAN_OPTIONS=detect_leaks=1:check_initialization_order=1
# TSan 옵션
export TSAN_OPTIONS=second_deadlock_stack=1
# 실행
./test
4. 메모리 누수 추적
Valgrind: 메모리 프로파일링
#include <iostream>
void leakyFunction() {
int* leak = new int[100];
// delete[] leak; // 누락! 💥
}
int main() {
for (int i = 0; i < 10; ++i) {
leakyFunction();
}
return 0;
}
# Valgrind로 메모리 누수 탐지
g++ -g leak.cpp -o leak
valgrind --leak-check=full --show-leak-kinds=all ./leak
# 출력 예시:
# ==12345== HEAP SUMMARY:
# ==12345== in use at exit: 4,000 bytes in 10 blocks
# ==12345== total heap usage: 10 allocs, 0 frees, 4,000 bytes allocated
# ==12345==
# ==12345== 4,000 bytes in 10 blocks are definitely lost in loss record 1 of 1
# ==12345== at 0x...: operator new
# ==12345== by 0x...: leakyFunction() (leak.cpp:4)
# ==12345== by 0x...: main (leak.cpp:9)
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 종료 시 소멸자가 호출되지 않음 (메모리 누수)
return 0;
}
해결: weak_ptr 사용:
#include <iostream>
#include <memory>
class Node {
public:
std::weak_ptr<Node> next; // ✅ weak_ptr로 변경
~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;
// ✅ 이제 정상적으로 소멸됨
return 0;
}
ASan으로 메모리 누수 탐지
# ASan은 기본적으로 메모리 누수도 탐지
g++ -fsanitize=address -g leak.cpp -o leak
./leak
# 출력:
# =================================================================
# ==12345==ERROR: LeakSanitizer: detected memory leaks
#
# Direct leak of 400 byte(s) in 1 object(s) allocated from:
# #0 0x... in operator new
# #1 0x... in leakyFunction() leak.cpp:4
# #2 0x... in main leak.cpp:9
Heaptrack: 힙 메모리 프로파일링
# Heaptrack 설치 (Linux)
sudo apt install heaptrack
# 프로그램 실행
heaptrack ./myapp
# 결과 분석
heaptrack_gui heaptrack.myapp.12345.gz
5. 멀티스레드 디버깅
데이터 레이스 실전 시나리오
#include <iostream>
#include <thread>
#include <vector>
class BankAccount {
private:
int balance = 1000;
public:
void withdraw(int amount) {
// 💥 데이터 레이스: balance 읽기/쓰기가 원자적이지 않음
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원씩 인출
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();
}
// 예상: 1000 - (10 * 10 * 100) = -9000 또는 음수
// 실제: 매번 다른 값 (데이터 레이스)
std::cout << "Final balance: " << account.getBalance() << std::endl;
return 0;
}
TSan으로 탐지:
g++ -fsanitize=thread -g bank.cpp -o bank -pthread
./bank
# WARNING: ThreadSanitizer: data race
해결: 뮤텍스 사용:
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
class BankAccount {
private:
int balance = 1000;
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() {
std::lock_guard<std::mutex> lock(mtx);
return balance;
}
};
데드락 탐지
#include <iostream>
#include <thread>
#include <mutex>
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); // 💥 데드락
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로 데드락 분석:
# 프로그램이 멈추면 Ctrl+C로 중단
g++ -g -pthread deadlock.cpp -o deadlock
./deadlock
# (멈춤)
# 다른 터미널에서:
gdb -p $(pidof deadlock)
(gdb) info threads
(gdb) thread apply all backtrace
# 각 스레드가 어떤 락을 기다리는지 확인
해결: std::scoped_lock (C++17):
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex1, mutex2;
void thread1() {
std::scoped_lock lock(mutex1, mutex2); // ✅ 데드락 방지
std::cout << "Thread 1" << std::endl;
}
void thread2() {
std::scoped_lock lock(mutex1, mutex2); // ✅ 항상 같은 순서로 락 획득
std::cout << "Thread 2" << std::endl;
}
int main() {
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
return 0;
}
반복자 무효화 디버깅
#include <iostream>
#include <vector>
#include <algorithm>
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가 무효화됨
}
}
return 0;
}
GDB Watchpoint로 추적:
(gdb) break main
(gdb) run
(gdb) watch vec._M_impl._M_start # vector 내부 포인터 감시
(gdb) continue
# erase 호출 시 중단됨
해결: erase-remove idiom:
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
// ✅ erase-remove idiom
vec.erase(
std::remove_if(vec.begin(), vec.end(),
{ return x % 2 == 0; }),
vec.end()
);
// 또는 C++20 erase_if
// std::erase_if(vec, { return x % 2 == 0; });
return 0;
}
6. 프로덕션 환경 디버깅
구조화된 로깅
#include <iostream>
#include <fstream>
#include <chrono>
#include <iomanip>
#include <sstream>
#include <mutex>
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 = __builtin_FILE(),
int line = __builtin_LINE(),
const char* func = __builtin_FUNCTION()) {
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;
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 << std::endl;
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;
}
코어 덤프 자동 수집
#!/bin/bash
# core_dump_setup.sh
# 코어 덤프 활성화
ulimit -c unlimited
# 코어 덤프 파일 위치 설정
echo "/var/crash/core.%e.%p.%t" | sudo tee /proc/sys/kernel/core_pattern
# 프로그램 실행
./myapp
# 크래시 발생 시 /var/crash/에 코어 덤프 생성
원격 디버깅 (gdbserver)
# 서버 (프로덕션 환경)
gdbserver :1234 ./myapp
# 클라이언트 (개발 환경)
gdb ./myapp
(gdb) target remote server_ip:1234
(gdb) continue
프로덕션 크래시 리포트
#include <csignal>
#include <cstdlib>
#include <iostream>
#include <execinfo.h>
#include <unistd.h>
void signalHandler(int sig) {
std::cerr << "Error: signal " << sig << std::endl;
// 스택 트레이스 출력
void* array[10];
size_t size = backtrace(array, 10);
std::cerr << "Stack trace:" << std::endl;
backtrace_symbols_fd(array, size, STDERR_FILENO);
exit(1);
}
int main() {
// 시그널 핸들러 등록
signal(SIGSEGV, signalHandler);
signal(SIGABRT, signalHandler);
// 프로그램 로직
int* ptr = nullptr;
*ptr = 42; // 크래시 발생 시 스택 트레이스 출력
return 0;
}
7. 완전한 디버깅 워크플로우
단계별 디버깅 프로세스
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 <thread>
#include <vector>
#include <memory>
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) {
if (!global_resource) {
global_resource = std::make_shared<Resource>();
}
// 데이터 레이스
global_resource->data[i % 100] = i;
if (i == 500) {
global_resource.reset(); // 다른 스레드가 사용 중일 수 있음
}
}
}
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;
}
디버깅 단계:
# 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
수정 버전:
#include <iostream>
#include <thread>
#include <vector>
#include <memory>
#include <mutex>
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;
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] = i;
}
}
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;
}
8. 자주 발생하는 실수와 해결법
실수 1: 디버그 심볼 없이 컴파일
# ❌ 잘못된 방법
g++ -O2 program.cpp -o program
# ✅ 올바른 방법
g++ -g -O0 program.cpp -o program # 디버그 빌드
g++ -g -O2 program.cpp -o program # 릴리스 빌드 (디버그 심볼 포함)
실수 2: 최적화로 인한 변수 최적화
int main() {
int x = 10;
int y = x + 5; // 컴파일러가 y = 15로 최적화
return y;
}
# GDB에서 x를 출력하려 하면 "optimized out" 메시지
(gdb) print x
# $1 = <optimized out>
# 해결: -O0으로 컴파일
g++ -g -O0 program.cpp -o program
실수 3: Sanitizer와 최적화 레벨
# ❌ -O0은 일부 버그를 숨길 수 있음
g++ -fsanitize=address -g -O0 program.cpp
# ✅ -O1 또는 -O2 권장 (Sanitizer 공식 권장사항)
g++ -fsanitize=address -g -O1 program.cpp
실수 4: 멀티스레드 프로그램에서 -pthread 누락
# ❌ 링크 에러 또는 런타임 오류
g++ -fsanitize=thread -g program.cpp
# ✅ -pthread 추가
g++ -fsanitize=thread -g program.cpp -pthread
실수 5: 코어 덤프 크기 제한
# 코어 덤프가 생성되지 않으면 확인
ulimit -c
# 0이면 비활성화 상태
# 무제한으로 설정
ulimit -c unlimited
# 영구 설정 (/etc/security/limits.conf에 추가)
# * soft core unlimited
# * hard core unlimited
실수 6: Valgrind와 ASan 동시 사용
# ❌ 충돌 발생
g++ -fsanitize=address program.cpp -o program
valgrind ./program
# ✅ 하나만 사용
# ASan 사용 시
g++ -fsanitize=address program.cpp -o program
./program
# Valgrind 사용 시
g++ -g program.cpp -o program
valgrind --leak-check=full ./program
9. 모범 사례·베스트 프랙티스
개발 환경 설정
# CMakeLists.txt에 디버그 옵션 추가
if(CMAKE_BUILD_TYPE MATCHES Debug)
add_compile_options(-g -O0)
add_compile_options(-fsanitize=address,undefined)
add_link_options(-fsanitize=address,undefined)
endif()
if(CMAKE_BUILD_TYPE MATCHES RelWithDebInfo)
add_compile_options(-g -O2)
endif()
CI/CD에 Sanitizer 통합
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build with ASan
run: |
mkdir build
cd build
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
- name: Run tests
run: |
cd build
export ASAN_OPTIONS=detect_leaks=1
ctest --output-on-failure
어설션 전략
#include <cassert>
#include <iostream>
// 디버그 빌드에서만 활성화되는 어설션
#ifdef NDEBUG
#define DEBUG_ASSERT(condition, message) ((void)0)
#else
#define DEBUG_ASSERT(condition, message) \
if (!(condition)) { \
std::cerr << "Assertion failed: " << message << std::endl; \
std::cerr << "File: " << __FILE__ << ", Line: " << __LINE__ << std::endl; \
std::abort(); \
}
#endif
// 릴리스 빌드에서도 활성화되는 어설션
#define RELEASE_ASSERT(condition, message) \
if (!(condition)) { \
std::cerr << "Fatal error: " << message << std::endl; \
std::cerr << "File: " << __FILE__ << ", Line: " << __LINE__ << std::endl; \
std::abort(); \
}
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;
}
로깅 레벨 전략
// 개발: DEBUG 레벨
Logger::init("app.log", Logger::DEBUG);
// 스테이징: INFO 레벨
Logger::init("app.log", Logger::INFO);
// 프로덕션: WARNING 레벨
Logger::init("app.log", Logger::WARNING);
테스트 주도 디버깅
#include <cassert>
#include <iostream>
// 버그 재현 테스트 작성
void test_divide_by_zero() {
try {
int result = 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;
}
10. 프로덕션 패턴
패턴 1: 헬스 체크 엔드포인트
#include <iostream>
#include <chrono>
#include <thread>
#include <atomic>
class HealthMonitor {
private:
std::atomic<bool> is_healthy_{true};
std::chrono::steady_clock::time_point last_heartbeat_;
public:
void heartbeat() {
last_heartbeat_ = std::chrono::steady_clock::now();
}
bool isHealthy() {
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - last_heartbeat_).count();
// 10초 이상 heartbeat 없으면 비정상
return elapsed < 10;
}
void setUnhealthy() {
is_healthy_ = false;
}
};
// HTTP 서버에서 /health 엔드포인트 제공
// GET /health -> {"status": "ok", "uptime": 12345}
패턴 2: 메트릭 수집
#include <iostream>
#include <atomic>
#include <chrono>
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() {
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;
}
}
};
패턴 3: 그레이스풀 셧다운
#include <csignal>
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<bool> shutdown_requested{false};
void signalHandler(int signal) {
if (signal == SIGINT || signal == SIGTERM) {
std::cout << "Shutdown requested..." << std::endl;
shutdown_requested = true;
}
}
int main() {
signal(SIGINT, signalHandler);
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 << "Shutting down gracefully..." << std::endl;
// 리소스 정리
std::cout << "Shutdown complete." << std::endl;
return 0;
}
패턴 4: 순환 버퍼 로깅
#include <array>
#include <string>
#include <mutex>
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> crash_log;
11. 정리 및 체크리스트
디버깅 도구 선택 가이드
| 문제 유형 | 추천 도구 | 사용 시기 |
|---|---|---|
| 메모리 누수 | ASan, Valgrind | 개발/테스트 |
| 데이터 레이스 | TSan | 멀티스레드 개발 |
| 정의되지 않은 동작 | UBSan | 모든 빌드 |
| 일반 크래시 | GDB/LLDB | 개발 중 |
| 프로덕션 크래시 | 코어 덤프 + GDB | 프로덕션 |
| 성능 병목 | perf, gprof | 최적화 단계 |
| 초기화 안 된 메모리 | MSan | Clang 환경 |
Sanitizer 성능 비교
| 도구 | 속도 오버헤드 | 메모리 오버헤드 | 탐지 범위 | 컴파일러 지원 |
|---|---|---|---|---|
| ASan | 2x | 2~3x | 메모리 오류, 누수 | GCC, Clang, MSVC |
| TSan | 5~15x | 5~10x | 데이터 레이스 | GCC, Clang |
| UBSan | 1.2x | 최소 | 정의되지 않은 동작 | GCC, Clang, MSVC |
| MSan | 3x | 2x | 초기화 안 된 메모리 | Clang만 |
| Valgrind | 10~50x | 최소 | 메모리 전반 | 모든 바이너리 |
권장 조합: ASan + UBSan (일상 개발), TSan (멀티스레드), Valgrind (정밀 분석)
개발 환경 체크리스트
# ✅ 디버그 빌드 설정
- [ ] -g 플래그 추가
- [ ] -O0 또는 -O1 사용
- [ ] Sanitizer 활성화 (-fsanitize=address,undefined)
- [ ] 컴파일 경고 최대화 (-Wall -Wextra -Wpedantic)
# ✅ 테스트 환경
- [ ] 단위 테스트 작성
- [ ] CI/CD에 Sanitizer 통합
- [ ] 코드 커버리지 측정
- [ ] Fuzzing 테스트 (AFL, libFuzzer)
# ✅ 프로덕션 준비
- [ ] 로깅 시스템 구축 (레벨별, 파일 로테이션)
- [ ] 코어 덤프 활성화 (ulimit -c unlimited)
- [ ] 헬스 체크 엔드포인트
- [ ] 메트릭 수집 (요청 수, 에러율, 레이턴시)
- [ ] 그레이스풀 셧다운 (SIGTERM 핸들러)
- [ ] 모니터링 알림 (Prometheus, Grafana)
실전 팁: 디버깅 시간 단축
-
항상 Sanitizer와 함께 개발
# .bashrc 또는 .zshrc에 추가 alias g++debug='g++ -g -O1 -fsanitize=address,undefined -Wall -Wextra' # 사용 g++debug myapp.cpp -o myapp -
GDB 설정 파일 (.gdbinit)
# ~/.gdbinit set print pretty on set print array on set print array-indexes on set pagination off # 자주 쓰는 명령어 단축 define pv print $arg0 end -
빠른 재현 스크립트
#!/bin/bash # reproduce_bug.sh # 디버그 빌드 g++ -g -O0 -fsanitize=address bug.cpp -o bug # 여러 번 실행 (재현 확인) for i in {1..10}; do echo "Run $i" ./bug || break done -
로그 레벨 동적 변경
// 환경 변수로 로그 레벨 제어 const char* log_level = std::getenv("LOG_LEVEL"); if (log_level && std::string(log_level) == "DEBUG") { Logger::setLevel(Logger::DEBUG); } // 실행 시 // LOG_LEVEL=DEBUG ./myapp
디버깅 워크플로우 요약
- 재현: 버그를 재현 가능한 최소 코드로 만들기
- 가설: 원인 가설 수립
- 도구: 적절한 디버깅 도구 선택
- 분석: GDB, Sanitizer로 원인 분석
- 수정: 버그 수정
- 검증: 테스트 작성 및 실행
- 문서화: 버그 원인과 해결법 기록
빠른 참조: 디버깅 체크리스트
# 🔍 버그 발견 시 즉시 확인할 것
□ 재현 가능한가? (재현 불가 → 로깅 추가)
□ 최소 재현 코드 작성 완료?
□ 컴파일 경고 모두 확인? (gcc -Wall -Wextra)
□ Sanitizer 활성화? (ASan + UBSan)
# 🛠️ 도구 선택
□ 메모리 오류 → ASan 또는 Valgrind
□ 데이터 레이스 → TSan
□ 크래시 → GDB + 코어 덤프
□ 성능 병목 → perf, gprof
# ✅ 해결 후
□ 테스트 케이스 작성
□ 코드 리뷰 요청
□ 문서화 (버그 원인과 해결법)
트러블슈팅: 빠른 문제 해결
| 증상 | 원인 | 해결법 |
|---|---|---|
| Segfault | nullptr 역참조, 배열 범위 초과 | ASan 활성화, GDB로 스택 확인 |
| 메모리 누수 | delete 누락, 순환 참조 | Valgrind 또는 ASan leak 탐지 |
| 데이터 레이스 | 동기화 누락 | TSan 활성화, 뮤텍스 추가 |
| 데드락 | 순환 락 대기 | GDB로 스레드 스택 확인, scoped_lock 사용 |
| 랜덤 크래시 | UB, 초기화 안 된 변수 | UBSan, MSan 활성화 |
| 느린 빌드 | PCH 미사용, 단일 스레드 | ccache, Ninja, 병렬 빌드 |
다음 단계
- GDB 고급 가이드에서 더 깊이 있는 디버깅 기법 학습
- Sanitizer 완전 가이드에서 각 Sanitizer의 고급 기능 활용
- 메모리 누수 디버깅에서 복잡한 메모리 문제 해결법 학습
- 멀티스레드 기초에서 스레드 프로그래밍 기초 학습
- Segfault 디버깅에서 크래시 원인 분석 심화
FAQ
Q1: 디버깅을 어디서부터 시작해야 하나요?
A:
- 버그를 재현 가능한 최소 코드로 만들기
- ASan + UBSan으로 컴파일해서 실행 (대부분의 버그 자동 탐지)
- 여전히 문제가 있으면 GDB로 단계별 실행
Q2: GDB vs LLDB 어떤 걸 써야 하나요?
A:
- Linux: GDB (표준)
- macOS: LLDB (기본 제공, Xcode 통합)
- Windows: Visual Studio Debugger 또는 GDB (MinGW)
- 명령어는 거의 유사하므로 하나만 익히면 됩니다.
Q3: 프로덕션에서 Sanitizer를 사용해도 되나요?
A:
- ❌ 권장하지 않음: 성능 오버헤드가 크고 (2~5배), 메모리 사용량 증가
- 개발/테스트 환경에서만 사용
- 프로덕션에서는 로깅 + 코어 덤프 + 모니터링 사용
Q4: Valgrind vs ASan 어떤 게 더 좋나요?
A:
- ASan: 빠름 (2~3배 느림), 컴파일 타임에 통합, 더 많은 버그 탐지
- Valgrind: 느림 (10~50배 느림), 별도 실행, 정밀한 메모리 추적
- 권장: 일상적으로는 ASan, 정밀 분석이 필요하면 Valgrind
Q5: 멀티스레드 버그를 어떻게 재현하나요?
A:
- TSan으로 컴파일 (대부분의 레이스 자동 탐지)
std::this_thread::sleep_for로 타이밍 조절- 스레드 수를 늘려서 경합 증가
stress도구로 부하 생성
Q6: 디버깅 학습 리소스는?
A:
- 책: “The Art of Debugging with GDB, DDD, and Eclipse”
- 문서: GDB 공식 문서, Sanitizer 문서
- 실습: 의도적으로 버그를 만들고 디버깅 연습
- 오픈소스: 유명 프로젝트의 이슈 트래커에서 버그 수정 과정 학습
Q7: 프로덕션 환경에서 디버깅할 때 주의사항은?
A:
- 성능 영향 최소화: 로깅 레벨을 적절히 설정 (WARNING 이상만)
- 민감 정보 보호: 비밀번호, API 키 등을 로그에 남기지 않기
- 코어 덤프 크기 제한:
ulimit -c설정으로 디스크 공간 관리 - 원격 디버깅 보안: gdbserver 사용 시 방화벽 설정
- 재현 환경 구축: 프로덕션과 동일한 환경에서 테스트
Q8: ASan을 켰는데 메모리 사용량이 너무 많아요
A: ASan은 메모리 사용량을 2~3배 증가시킵니다. 이는 정상입니다.
- 개발/테스트 환경에서만 사용하세요
- 메모리가 부족하면 작은 테스트 케이스로 분할
- 또는 Valgrind 사용 (느리지만 메모리 사용량 적음)
- CI/CD에서는 메모리 충분한 인스턴스 사용
Q9: GDB에서 “optimized out” 메시지가 나와요
A: 컴파일러 최적화로 변수가 제거되었습니다.
# 해결법: 최적화 비활성화
g++ -g -O0 program.cpp # -O0 사용
# 또는 특정 함수만 최적화 비활성화
__attribute__((optimize("O0")))
void debugFunction() {
// 이 함수는 최적화되지 않음
}
Q10: 데드락을 어떻게 예방하나요?
A:
- 락 순서 일관성: 항상 같은 순서로 락 획득
- std::scoped_lock 사용 (C++17, 데드락 방지)
- 타임아웃 설정:
try_lock_for()사용 - 락 홀딩 시간 최소화: 락 안에서 긴 작업 피하기
- TSan으로 검증: 개발 중 항상 TSan 활성화
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ GDB/LLDB | cout 100개 찍어도 못 찾은 버그, 디버거로 5분 만에 해결
- C++ 런타임 검증: AddressSanitizer와 ThreadSanitizer 완벽 가이드 [#41-2]
- C++ Segmentation fault | core dump
- C++ Memory Leak | “메모리 누수” 가이드
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ Sanitizers | “새니타이저” 가이드
관련 글
- C++ 디버깅 실전 가이드 | gdb, LLDB, Visual Studio 완벽 활용
- C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
- C++ 메모리 누수 찾기 | Valgrind·ASan으로
- C++ GDB |
- C++ Memory Leak |