예외를 던지는 언어에서 넘어오면 감각이 갈리는 지점이 여기예요. 배열 밖을 읽으면 그 자리에서 예외가 터져 런타임이 버그 위치를 알려주는 언어와 달리, C++은 밖을 읽어도 조용히 지나가는 경우가 많아요. 런타임이 안 알려주니 알려주는 장비를 직접 챙겨야 해요.
경고는 공짜 코드 리뷰예요
컴파일러는 코드를 짓는 과정에서 어차피 전체를 분석해요. 경고 옵션은 그 분석 결과 중 수상한 것을 보고하게 하는 스위치라 켜는 비용이 사실상 0이에요.
g++ -std=c++17 -Wall -Wextra -o node main.cpp-Wall은 이름과 달리 전부가 아니라 대부분의 유용한 경고이고, -Wextra가 부호 비교 같은 것들을 더해요. 둘을 항상 붙여 쓰는 게 기본값이에요. 실전에서 값어치가 큰 셋만 볼게요.
미사용 변수 경고는 죽은 코드 같지만 버그 신호일 때가 많아요. 관절 각도에 오프셋을 더한 보정값을 계산해 놓고 정작 퍼블리시 줄에는 원본을 넘긴 코드라면, 로직은 다 있는데 결과만 틀려요. 이 경고가 그 자리를 정확히 지목해요.
부호 비교 경고는 부호 있는 정수와 크기 타입을 비교할 때 나요. 비교 전에 부호 있는 쪽이 부호 없는 쪽으로 변환되는데, 음수는 이때 거대한 양수가 돼요. 특히 v.size() - 1은 벡터가 비어 있으면 -1이 아니라 18446744073709551615라서, i <= v.size() - 1 같은 조건이 빈 벡터에서 폭주해요.
미초기화 경고는 값을 넣기 전에 읽는 지역 변수예요. 초기화 없이 읽으면 컴파일 에러인 언어와 달리 C++은 경고에 그치고 그냥 지어져요. 이게 왜 최악의 버그가 되는지는 뒤에서 따로 볼게요.
돌 때 검사하는 그물
경고가 짓기 전 검사라면 sanitizer는 돌 때 검사예요. 컴파일할 때 메모리 접근과 연산마다 검사 코드를 심어 두고, 실행 중에 잘못된 접근이 일어나는 순간 리포트를 뽑고 멈춰요.
| 도구 | 플래그 | 잡는 것 |
|---|---|---|
| AddressSanitizer | -fsanitize=address | 경계 초과, 해제 후 사용, 지역 주소 반환 |
| UBSanitizer | -fsanitize=undefined | 부호 있는 오버플로, 잘못된 시프트, 널 역참조 |
| ThreadSanitizer | -fsanitize=thread | 데이터 레이스 |
사용법은 컴파일 옵션 하나예요. -g를 같이 줘야 리포트에 파일명과 줄번호가 찍혀요.
g++ -std=c++17 -g -fsanitize=address -o torque_filter torque_filter.cpp
./torque_filter # 평소처럼 실행 — 버그가 있으면 리포트 출력이동 평균 필터에서 창 크기 5를 i <= 5로 돌아 한 칸 밖을 읽었다면 출력이 이렇게 나와요.
==1234==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6030...
READ of size 8 at 0x6030... thread T0
#0 0x55... in filter_torque(...) torque_filter.cpp:23
#1 0x55... in main torque_filter.cpp:41
0x6030... is located 0 bytes to the right of 40-byte region읽는 순서는 세 줄이에요. ERROR 줄에서 종류를 보고, READ of size 8에서 무엇을 했는지 보고, 스택에서 내 파일이 나오는 첫 프레임이 사고 지점이에요. 아래의 allocated by는 그 메모리가 어디서 만들어졌는지고, “40바이트 영역의 오른쪽 0바이트 지점”은 double 다섯 개짜리 배열의 바로 다음 칸이라는 뜻이에요. 사고 지점과 메모리 출처를 둘 다 주니까 출력을 심으며 좁혀 가는 시간이 통째로 사라져요.
UBSanitizer는 크래시가 아니라 연산 규칙 위반을 잡아요. 가동 시간을 마이크로초 단위 int에 누적하면 약 35분에서 넘치는데, 그 순간 어떤 값이 어떤 타입에서 표현될 수 없는지를 줄번호와 함께 찍어요. AddressSanitizer와 겹치는 영역이 없어서 둘을 같이 켜는 게 보통이에요.
비용도 알아 둘 만해요. 주소 검사는 대략 두 배, 스레드 검사는 다섯에서 열 배 느려지고 메모리도 몇 배를 써요. 그래서 sanitizer 빌드는 테스트와 재현용이고 배포 바이너리에는 넣지 않아요.
디버그에선 멀쩡하고 릴리즈에서만 나는 버그
최적화 없이 지으면 멀쩡한데 최적화를 켜면 값이 이상하거나 죽는 일이 있어요. 처음 만나면 컴파일러를 의심하게 되는데, 거의 항상 원래 있던 버그가 최적화 때문에 드러난 것이에요. 뿌리는 두 갈래예요.
첫째는 미초기화예요. 초기화 안 한 지역 변수를 읽으면 그 자리 메모리에 우연히 있던 값이 나와요. 디버그 빌드는 변수를 스택의 고정된 자리에 두는데 그 자리가 우연히 0인 경우가 많아서 합산이 우연히 맞아요. 릴리즈 빌드는 변수를 레지스터에 올리고, 거기엔 직전 계산의 찌꺼기가 들어 있어서 매 실행 다른 쓰레기가 나와요.
캘리브레이션 완료 플래그를 bool calibrated;로 선언만 해 두면, 디버그에선 우연히 거짓이라 안전하게 막히고 릴리즈에선 쓰레기 바이트가 참으로 읽혀 캘리브레이션 없이 팔이 움직여요. 버그가 새로 생기는 게 아니라 원래 있던 버그의 주사위가 다르게 굴러가는 거예요.
둘째는 미정의 동작 위의 최적화예요. 최적화기는 “이 프로그램에 미정의 동작은 없다”를 전제로 코드를 바꿔요.
int next = total_us + dt_us; // 가동시간 누적 (마이크로초)
if (next < total_us) { // 오버플로를 감지하려는 방어 코드
reset_uptime();
}작성자는 넘치면 음수로 돌아오니 그걸로 잡자고 썼지만, 최적화기는 “부호 있는 오버플로는 미정의니까 일어나지 않는다, 양수를 더한 값은 항상 더 크다”로 추론해서 조건문 전체를 지울 수 있어요. 디버그에선 방어가 동작하고 릴리즈에선 방어 코드 자체가 사라져요. 감지하려던 바로 그 사건 때문에 감지 코드가 삭제되는 구조예요.
정수 되돌기를 알고 쓰는 쪽도 있어요. 부호 없는 타입의 되돌기는 미정의가 아니라 정의된 규칙이라 16비트 카운터의 차이를 구할 때 이용해요. 센서 패킷의 타임스탬프가 65530에서 3으로 돌았을 때 부호 있는 뺄셈은 -65527이라는 엉뚱한 값이 되지만, 부호 없는 타입으로 빼고 16비트 부호로 다시 읽으면 +9라는 진짜 경과가 나와요. 되돌기가 있는 하드웨어 카운터의 차이는 전부 이 형태로 계산해요.
sanitizer의 값어치가 여기서 나와요. 미정의 동작의 문제는 증상이 언제 어디서 날지 모른다는 건데, 이 도구들은 증상을 기다리지 않고 발생하는 순간 멈춰요. 미초기화가 우연히 0이어서 통과하던 테스트가 그 자리에서 실패로 바뀌어요.
그래서 실전 순서는 이래요. 릴리즈에서만 이상하면 디버그 빌드에 주소·미정의 검사를 켜고 같은 시나리오를 재실행하고, 경고를 다시 훑고(미초기화와 부호 비교가 거의 항상 여기 걸려 있어요), 그래도 안 나오면 미초기화·경계·수명 순으로 최근 변경을 의심해요.
죽은 뒤에 보는 도구
sanitizer가 못 잡는 크래시나 로직 버그는 디버거로 봐요. 명령 수백 개 중 크래시 위치 찾기에 필요한 건 넷이에요. 실행, 호출 스택, 그 스택의 특정 층으로 이동, 그 층의 변수 출력이요.
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
(gdb) bt
#0 ... in std::vector<float>::operator[] (...)
#1 ... in latest_sample (buf=..., idx=2000) at imu_node.cpp:31
#2 ... in main () at imu_node.cpp:52
(gdb) frame 1
(gdb) print idx
$1 = 2000
(gdb) print buf.size()
$2 = 1200읽는 법은 sanitizer 리포트와 같아요. 스택에서 내 파일이 나오는 첫 프레임이 사고 지점이고 라이브러리 내부 프레임은 건너뛰어요. 그 층에 들어가 변수를 찍으면 인덱스가 2000인데 크기가 1200 — 인덱스 계산이 범인이라는 게 세 명령 만에 나와요.
관찰이 목적이면 최적화를 끄고 지어야 변수가 최적화로 사라지지 않아요. 반대로 릴리즈에서만 나는 버그를 볼 때는 최적화를 유지하면서 심볼만 넣는 빌드 타입을 써요. 역할 분담은 이래요. 메모리 사고는 sanitizer 리포트가 더 친절하니 먼저 그쪽, 침묵하는 크래시와 “값이 왜 이렇지”의 상태 관찰이 디버거 몫이에요.
3단 그물
C++ 런타임은 버그를 알려주지 않으니 알려주는 장비를 직접 채워야 해요. 짓기 전에 경고로, 돌 때 sanitizer로, 죽은 뒤 디버거로 잡는 3단이면 릴리즈에서만 터지는 버그까지 디버그 단계로 앞당겨져요.
스레드 코드의 데이터 레이스를 잡는 도구가 이 목록에 있다는 점도 짚어 둘 만해요. C++ 동시성 — 센서 수신과 제어 루프에서 “증상을 쫓지 말고 설계로 보장하라”고 했는데, 그 설계가 실제로 지켜졌는지 확인하는 자리가 여기예요.