C++ 디버깅 실전: GDB·LLDB watchpoint와 코어 덤프, ASan·TSan·UBSan, 메모리 누수·데드락, 프로덕션 크래시 추적
이 글의 핵심
프린트 디버깅으로 안 잡히는 C++ 버그를 도구별로 좁혀 가는 방법을 정리합니다. GDB/LLDB watchpoint·코어 덤프, 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
실무에서 마주치는 일곱 가지 디버깅 상황
상황: 개발 환경에서는 정상 동작하는데, 프로덕션 배포 후 랜덤 크래시
증상: Segmentation fault, 코어 덤프 생성
원인: 릴리스 빌드 최적화로 숨겨진 버그 (초기화 안 된 변수, UB)
→ 코어 덤프 분석, UBSan으로 UB 탐지 필요
상황: 서버가 72시간 운영 후 메모리 8GB → 14GB로 증가
증상: 느린 메모리 누수, 재시작 전까지 회복 불가
원인: shared_ptr 순환 참조, 컨테이너에서 제거 안 된 객체
→ Valgrind, Heaptrack, ASan으로 누수 지점 추적
상황: 10번 실행하면 1~2번 잘못된 결과 출력
증상: 데이터 레이스, 비결정적 동작
원인: 공유 변수 동기화 누락, 락 없는 접근
→ TSan으로 레이스 탐지, GDB로 스레드별 상태 확인
관련 글: 멀티스레드 기초에서 스레드 프로그래밍 기본 개념을 학습하세요.
상황: vector 순회 중 erase 호출 후 크래시
증상: iterator 무효화, Segmentation fault
원인: erase 후 반복자 갱신 안 함
→ GDB watchpoint로 컨테이너 수정 지점 추적
상황: 멀티스레드 서버가 가끔 멈춤, CPU 사용률 0%
증상: 모든 스레드가 락 대기 상태
원인: 순환 락 대기 (A→B, B→A)
→ GDB로 스레드별 스택 확인, TSan으로 락 순서 분석
상황: Debug 빌드는 정상, Release 빌드는 크래시
증상: 최적화로 인한 숨겨진 버그 노출
원인: 초기화 안 된 변수, 정의되지 않은 동작 (UB)
→ UBSan으로 UB 탐지, -O1로 중간 최적화 테스트
”특정 입력에서만 크래시해요”
상황: 일반 입력은 정상, 특정 입력 (빈 문자열, 큰 숫자 등)에서만 크래시
증상: 경계 조건 (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
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 -w a.out 으로 DWARF 정보 확인 가능
# -O0 플래그 중요성:
# - 최적화 비활성화 (변수 최적화 제거, 인라인 방지)
# - 소스 코드와 어셈블리 1:1 대응
# - -O2 이상에서는 변수가 최적화로 사라질 수 있음
# GDB 시작
gdb ./buggy
# GDB 내부 동작:
# 1. 실행 파일 로드 (ELF 파싱)
# 2. DWARF 디버그 정보 파싱
# 3. ptrace() 시스템 콜로 프로세스 제어 준비
# 기본 명령어
(gdb) break buggyFunction # 함수에 브레이크포인트
# 내부 동작:
# - DWARF에서 buggyFunction 주소 찾기
# - 해당 주소에 int3 명령어 (0xCC) 삽입
# - 원래 명령어는 백업
(gdb) run # 실행
# 내부 동작:
# - ptrace(PTRACE_TRACEME)로 프로세스 시작
# - GDB가 부모 프로세스로서 자식 프로세스 제어
# - SIGTRAP 시그널 대기
(gdb) next # 다음 줄 (함수 넘어감)
# 내부 동작:
# - 현재 라인의 모든 어셈블리 명령어 실행
# - 함수 호출은 건너뜀 (CALL 명령어 다음에 임시 breakpoint)
(gdb) step # 다음 줄 (함수 안으로)
# 내부 동작:
# - 한 개 어셈블리 명령어 실행
# - CALL 명령어 만나면 함수 내부로 진입
(gdb) print ptr # 변수 출력
# 내부 동작:
# - DWARF에서 변수 위치 찾기 (스택 오프셋 또는 레지스터)
# - ptrace(PTRACE_PEEKDATA)로 메모리 읽기
# - 변수 타입에 맞게 포맷팅
(gdb) backtrace # 스택 트레이스
# 내부 동작:
# - 스택 프레임 포인터 (RBP) 순회
# - 각 프레임의 반환 주소를 DWARF로 함수명/라인 번호 변환
# - 스택 언와인딩 (stack unwinding)
(gdb) continue # 계속 실행
# 내부 동작:
# - ptrace(PTRACE_CONT)로 프로세스 재개
# - 다음 breakpoint나 시그널까지 실행
GDB의 프로세스 제어 메커니즘:
ptrace() 시스템 콜을 통한 제어:
1. Breakpoint 설정:
원래 코드: mov rax, [rbp-8]
↓
GDB가 수정: int3 (0xCC, 소프트웨어 인터럽트)
원래 코드 백업: mov rax, [rbp-8]
2. Breakpoint 도달:
CPU가 int3 실행
↓
SIGTRAP 시그널 발생
↓
커널이 GDB에 알림 (wait() 반환)
↓
GDB가 제어권 획득
3. 명령 실행 (next, step):
GDB가 원래 명령어 복원
↓
ptrace(PTRACE_SINGLESTEP) 호출
↓
CPU가 한 명령어 실행
↓
다시 SIGTRAP 발생
↓
GDB가 다시 제어권 획득
↓
필요시 int3 재삽입
4. Continue:
ptrace(PTRACE_CONT)
↓
프로세스 정상 실행
↓
다음 breakpoint나 시그널까지 계속
Watchpoint: 변수 변경 추적 (Hardware Breakpoint)
문제: “이 변수가 언제 어디서 바뀌는지 모르겠어요”
#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;
}
Watchpoint 내부 동작 메커니즘:
Hardware Watchpoint (권장):
1. CPU 디버그 레지스터 사용:
x86_64 아키텍처:
- DR0, DR1, DR2, DR3: 4개의 디버그 주소 레지스터
- DR7: 디버그 제어 레지스터
watch global_counter 실행 시:
↓
GDB가 ptrace(PTRACE_POKEUSER)로 DR0에 주소 설정
DR0 = &global_counter (예: 0x555555558020)
↓
DR7에 조건 설정 (read/write/execute)
DR7 = 0x... 01 (write 감지)
2. 메모리 접근 감지:
CPU가 global_counter 주소에 쓰기 시도
↓
MMU가 DR0-DR3과 비교
↓
일치하면 디버그 예외 발생 (INT 1)
↓
커널이 SIGTRAP 시그널 발생
↓
GDB가 제어권 획득
Hardware Watchpoint 제약:
- 최대 4개까지만 설정 가능 (DR0-DR3)
- 크기 제한: 1, 2, 4, 8바이트 (CPU 의존)
- 정렬 요구: 주소가 크기에 맞게 정렬되어야 함
Software Watchpoint (폴백):
하드웨어 레지스터 부족 시:
↓
GDB가 모든 명령어를 single-step으로 실행
(ptrace(PTRACE_SINGLESTEP) 반복)
↓
매 명령어마다 메모리 값 확인
↓
변경 감지 시 중단
단점:
- 매우 느림 (10-100배)
- 대규모 구조체에는 비실용적
# GDB Watchpoint 사용
(gdb) break main
(gdb) run
(gdb) watch global_counter # 변수 변경 시 중단
# 내부 동작:
# 1. global_counter 주소 확인 (예: 0x555555558020)
# 2. 하드웨어 디버그 레지스터 설정:
# DR0 = 0x555555558020
# DR7 = write 감지 모드
# 3. 다음 변경까지 빠르게 실행
(gdb) info watchpoints # 설정된 watchpoint 확인
# Num Type Disp Enb Address What
# 2 hw watchpoint keep y global_counter
(gdb) continue
# Hardware watchpoint 2: global_counter
# Old value = 0
# New value = 1
# increment() at test.cpp:7
(gdb) backtrace # 어느 함수에서 변경했는지 확인
# 조건부 watchpoint (변경 조건 지정)
(gdb) watch global_counter if global_counter > 500
# global_counter가 500 초과 시에만 중단
# 읽기 감지
(gdb) rwatch global_counter # 읽기 시 중단
# 읽기/쓰기 모두 감지
(gdb) awatch global_counter # 접근 시 중단
주의사항:
- 하드웨어 워치포인트는 최대 4개까지만 설정 가능 (CPU 제약)
- 거대한 구조체 전체 감시는 소프트웨어 워치포인트로 폴백 (매우 느림)
- 멀티스레드에서는 모든 스레드가 같은 디버그 레지스터 공유
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
ASan·TSan·UBSan·MSan 활용
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
Valgrind·Heaptrack으로 메모리 누수 추적
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
데이터 레이스·데드락·반복자 무효화 디버깅
데이터 레이스 실전 시나리오
#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(),
[](int x) { return x % 2 == 0; }),
vec.end()
);
// 또는 C++20 erase_if
// std::erase_if(vec, [](int x) { return x % 2 == 0; });
return 0;
}
로깅·코어 덤프·원격 디버깅으로 프로덕션 문제 잡기
구조화된 로깅
#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;
}
재현부터 수정 검증까지 디버깅 워크플로
단계별 디버깅 프로세스
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;
}
디버그 심볼 누락·최적화·코어 덤프 제한 같은 실수
디버그 심볼 없이 컴파일
# ❌ 잘못된 방법
g++ -O2 program.cpp -o program
# ✅ 올바른 방법
g++ -g -O0 program.cpp -o program # 디버그 빌드
g++ -g -O2 program.cpp -o program # 릴리스 빌드 (디버그 심볼 포함)
최적화로 인한 변수 최적화
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
Sanitizer와 최적화 레벨
# ❌ -O0은 일부 버그를 숨길 수 있음
g++ -fsanitize=address -g -O0 program.cpp
# ✅ -O1 또는 -O2 권장 (Sanitizer 공식 권장사항)
g++ -fsanitize=address -g -O1 program.cpp
멀티스레드 프로그램에서 -pthread 누락
# ❌ 링크 에러 또는 런타임 오류
g++ -fsanitize=thread -g program.cpp
# ✅ -pthread 추가
g++ -fsanitize=thread -g program.cpp -pthread
코어 덤프 크기 제한
# 코어 덤프가 생성되지 않으면 확인
ulimit -c
# 0이면 비활성화 상태
# 무제한으로 설정
ulimit -c unlimited
# 영구 설정 (/etc/security/limits.conf에 추가)
# * soft core unlimited
# * hard core unlimited
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
개발 환경·CI Sanitizer·어설션 전략
개발 환경 설정
# 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;
}
헬스 체크·메트릭·그레이스풀 셧다운·순환 버퍼 로깅
헬스 체크 엔드포인트
#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}
메트릭 수집
#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;
}
}
};
그레이스풀 셧다운
#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;
}
순환 버퍼 로깅
#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;
부록: macOS·크래시 리포터·로그 운영에서 추가로 알아 둘 것
A. macOS에서 LLDB로 코어 덤프·스레드 보기
macOS는 기본적으로 코어 덤프를 남기지 않습니다. ulimit -c unlimited로 크기 제한을 풀면 /cores/core.<PID>에 생성됩니다(시스템 무결성 보호 설정에 따라 추가 권한이 필요할 수 있음).
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, 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 고급 가이드에서 더 깊이 있는 디버깅 기법 학습
- ASan과 TSan으로 런타임 버그 잡기에서 각 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 브레이크포인트·워치포인트·단계 실행
- ASan과 TSan으로 런타임 버그 잡기
- C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
- C++ 메모리 누수 찾기 | Valgrind·ASan으로 추적하기
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ Sanitizer 동작 원리와 설정: ASan·UBSan·TSan·MSan 오버헤드, CMake 통합