C++ 디버깅 완벽 가이드 | GDB·Sanitizer·메모리 누수·멀티스레드 디버깅 실전
이 글의 핵심
C++ 디버깅 : GDB·Sanitizer·메모리 누수·멀티스레드 디버깅 실전. 실무에서 겪는 디버깅 상황·GDB/LLDB 고급 기법.
들어가며: “프로덕션에서 크래시가 나는데 재현이 안 돼요”
실무에서 겪는 디버깅 문제들
실제 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
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
# -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
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
일상 비유로 이해하기: 메모리를 아파트 건물로 생각해보세요. 스택은 엘리베이터 같아서 빠르지만 공간이 제한적입니다. 힙은 창고처럼 넓지만 물건을 찾는 데 시간이 걸립니다. 포인터는 “3층 302호”처럼 주소를 가리키는 메모지라고 보면 됩니다.
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 |
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ 디버깅 완벽 가이드 | GDB·Sanitizer·메모리 누수·멀티스레드 디버깅 실전」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ 디버깅 완벽 가이드 | GDB·Sanitizer·메모리 누수·멀티스레드 디버깅 실전」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, debugging, gdb, lldb, sanitizer, valgrind, 메모리누수 등으로 검색하시면 이 글이 도움이 됩니다.