C++ Sanitizer 동작 원리와 설정: ASan·UBSan·TSan·MSan 오버헤드, CMake 통합
들어가며: 테스트는 통과하는데 가끔 터지는 버그
대부분 잘 동작하던 프로그램이 가끔 원인을 알 수 없이 크래시하는 경우가 있습니다. 로그를 추가하면 재현이 안 되고, 디버거를 붙여도 “이번에는 안 터지는” 버그는 대개 해제된 메모리 접근, 범위를 넘는 쓰기, 데이터 레이스처럼 타이밍과 메모리 배치에 따라 증상이 달라지는 종류입니다. Sanitizer는 컴파일러가 코드에 검사 코드를 삽입해 이런 잘못된 접근을 일어나는 바로 그 순간에 잡아 줍니다.
주요 Sanitizer는 네 가지입니다. ASan(AddressSanitizer)은 메모리 오류를, UBSan(UndefinedBehaviorSanitizer)은 정의되지 않은 동작을, TSan(ThreadSanitizer)은 데이터 레이스를, MSan(MemorySanitizer)은 초기화되지 않은 메모리 읽기를 탐지하며, 누수를 찾는 LSan(LeakSanitizer)은 ASan에 포함되어 있습니다. GCC와 Clang 모두 -fsanitize= 옵션으로 켜며(MSan은 Clang 전용), Linux에서 지원이 가장 넓습니다. 디버거 사용법은 16-1 GDB/LLDB에서 다뤘습니다.
| 증상 | 의심 원인 | 맞는 Sanitizer |
|---|---|---|
| 가끔 세그폴트, 재현 어려움 | use-after-free, 버퍼 오버플로 | ASan |
| 멀티스레드 카운터 값이 틀림 | 데이터 레이스 | TSan |
| 장시간 실행 시 메모리 증가 | 메모리 누수 | LSan (ASan에 포함) |
| 특정 입력에서만 이상 동작 | 정수 오버플로, null 역참조, 잘못된 shift | UBSan |
| 최적화 빌드에서만 결과가 달라짐 | 미초기화 메모리, 미정의 동작 | MSan, UBSan |
Sanitizer 동작 원리
Sanitizer는 컴파일 시점에 검사 코드를 삽입하고, 실행 시점에 그 코드가 메모리 접근·연산·동기화를 감시합니다.
flowchart LR
subgraph compile[컴파일 시]
C1[소스 코드] --> C2["-fsanitize 플래그"]
C2 --> C3[검사 코드 삽입]
C3 --> C4[실행 파일]
end
subgraph runtime[실행 시]
R1[메모리 접근] --> R2[검사 코드 실행]
R2 --> R3{오류?}
R3 -->|Yes| R4[에러 보고·종료]
R3 -->|No| R5[정상 진행]
end
ASan은 프로그램 메모리 8바이트마다 섀도 메모리 1바이트를 두고 접근 가능 여부를 기록하며, 힙 블록과 스택 변수 주변에 접근 금지 영역(레드존)을 둬서 범위를 넘는 접근을 잡습니다. 해제된 블록은 바로 재사용하지 않고 격리 큐(quarantine)에 잠시 보관해 use-after-free를 탐지합니다. TSan은 메모리 위치마다 최근 접근한 스레드와 시점을 기록하고, 두 접근 사이에 happens-before 관계(락, atomic, join 등)가 없으면 레이스로 보고합니다. MSan은 비트 단위 섀도로 값이 초기화되었는지를 추적합니다.
| Sanitizer | 탐지 대상 | 속도 (Clang 문서 기준) | 메모리 | 함께 쓸 수 있는 것 |
|---|---|---|---|---|
| ASan | use-after-free, 버퍼 오버플로, 이중 해제, 누수(LSan) | 평균 약 2배 느림 | 증가(섀도+레드존+격리) | UBSan |
| LSan | 메모리 누수 (ASan에 포함, 단독 사용도 가능) | 종료 시 검사 위주 | 적음 | ASan |
| UBSan | 정수 오버플로, null 역참조, 0으로 나누기, 잘못된 shift 등 | 검사 종류에 따라 작음 | 거의 없음 | ASan, TSan, MSan |
| TSan | 데이터 레이스, 락 순서 역전 | 5~15배 느림 | 5~10배 | UBSan |
| MSan | 초기화 안 된 메모리 사용 | 약 3배 느림 | 약 2배 | UBSan |
ASan, TSan, MSan은 서로 함께 쓸 수 없으므로 각각 별도 빌드로 테스트해야 합니다.
켜는 방법과 최소 빌드
# ASan (+ LSan)
g++ -fsanitize=address -g -O1 -fno-omit-frame-pointer main.cpp -o myapp_asan
# UBSan
g++ -fsanitize=undefined -g -O1 main.cpp -o myapp_ubsan
# ASan + UBSan (가장 흔한 조합)
g++ -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer main.cpp -o myapp
# TSan (스레드 라이브러리 링크)
g++ -fsanitize=thread -g -O1 main.cpp -o myapp_tsan -pthread
-g가 없으면 보고서에 파일과 줄 번호가 나오지 않습니다. -O0도 쓸 수 있지만 ASan 문서는 합리적인 성능을 위해 -O1 이상을 권장하고, -fno-omit-frame-pointer를 주면 스택 트레이스가 더 정확해집니다. 컴파일과 링크를 나눠 실행한다면 링크 단계에도 같은 -fsanitize 플래그가 들어가야 합니다.
AddressSanitizer: use-after-free와 버퍼 오버플로
ASan이 잡는 대표적인 오류는 use-after-free, 힙·스택·전역 버퍼 오버플로, 이중 해제, 메모리 누수입니다. 함수가 반환된 뒤 그 지역 변수의 주소를 쓰는 stack-use-after-return은 추가 옵션이 필요합니다(뒤에서 설명).
예제 1: Use-after-free
// use_after_free.cpp
int main() {
int* ptr = new int(42);
delete ptr;
*ptr = 100; // ❌ 해제 후 사용
return 0;
}
해제된 주소는 나중에 다른 객체에 다시 할당될 수 있으므로, 이런 쓰기는 무관한 객체를 조용히 덮어쓰고 공격자에게는 메모리 조작 수단이 됩니다.
$ g++ -fsanitize=address -g use_after_free.cpp -o myapp && ./myapp
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 ...
WRITE of size 4 at 0x602000000010 thread T0
#0 0x... in main use_after_free.cpp:5
0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
#0 0x... in operator delete(void*, unsigned long)
#1 0x... in main use_after_free.cpp:4
previously allocated by thread T0 here:
#0 0x... in operator new(unsigned long)
#1 0x... in main use_after_free.cpp:3
예제 2: 스택 버퍼 오버플로
// buffer_overflow.cpp
int main() {
int arr[10];
arr[10] = 42; // ❌ 인덱스 0~9만 유효
return arr[0];
}
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd... ...
WRITE of size 4 at 0x7ffd... thread T0
#0 0x... in main buffer_overflow.cpp:4
Address 0x7ffd... is located in stack of thread T0 at offset 72 in frame
#0 0x... in main buffer_overflow.cpp:2
This frame has 1 object(s):
[32, 72) 'arr' (line 3) <== Memory access at offset 72 overflows this variable
Valgrind Memcheck가 거의 잡지 못하는 스택 배열 오버플로를 ASan은 지역 변수 사이에 레드존을 넣어서 잡습니다.
예제 3: 메모리 누수
// memory_leak.cpp
void leak() {
int* ptr = new int(42);
// delete 없음
}
int main() {
leak();
return 0;
}
$ g++ -fsanitize=address -g memory_leak.cpp -o myapp && ./myapp
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4 byte(s) in 1 object(s) allocated from:
#0 0x... in operator new(unsigned long)
#1 0x... in leak() memory_leak.cpp:3
#2 0x... in main memory_leak.cpp:7
Linux x86-64에서는 ASan을 켜면 LSan이 기본으로 활성화되어 프로그램 종료 시 누수를 보고합니다. macOS 등 일부 플랫폼에서는 기본으로 꺼져 있으므로 ASAN_OPTIONS=detect_leaks=1로 켭니다.
예제 4: 이중 해제
// double_free.cpp
int main() {
int* ptr = new int(42);
delete ptr;
delete ptr; // ❌ 이중 해제
return 0;
}
ASan은 attempting double-free와 함께 두 번째 해제 위치, 첫 번째 해제 위치, 할당 위치를 모두 보여 줍니다.
stack-use-after-return
int* createArray() {
int arr[10];
return arr; // ❌ 지역 배열의 주소 반환
}
int main() {
int* ptr = createArray();
ptr[0] = 42;
return 0;
}
이 패턴은 컴파일러마다 결과가 다릅니다. GCC는 지역 변수의 주소를 반환하는 코드를 경고(-Wreturn-local-addr)와 함께 널 포인터를 반환하도록 바꾸기 때문에, ASan 빌드에서는 SEGV on unknown address 0x000000000000로 보고됩니다. 컴파일러가 이렇게 바꾸지 못하는 간접적인 경우를 ASan이 stack-use-after-return으로 보고하게 하려면, 지역 변수를 별도의 “가짜 스택”에 두는 모드가 필요합니다. GCC에서는 ASAN_OPTIONS=detect_stack_use_after_return=1로 켜고, Clang 15 이후는 Linux에서 기본으로 켜져 있습니다. 메모리와 속도 비용이 조금 더 듭니다.
UndefinedBehaviorSanitizer: 오버플로와 잘못된 shift
UBSan이 기본 그룹(-fsanitize=undefined)으로 잡는 대표적인 미정의 동작은 부호 있는 정수 오버플로, null 포인터 역참조, 정렬되지 않은 포인터 접근, 정수 0으로 나누기, 비트 폭 이상의 shift, 배열 인덱스 범위 초과(크기를 아는 배열) 등입니다. UBSan은 기본적으로 에러를 출력하고 실행을 계속하므로 한 번에 여러 문제를 볼 수 있습니다.
정수 오버플로
// integer_overflow.cpp
#include <climits>
int main() {
int x = INT_MAX;
x++; // ❌ signed overflow
return 0;
}
integer_overflow.cpp:5:6: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
곱셈도 마찬가지로 INT_MAX * 2는 “2147483647 * 2 cannot be represented in type ‘int‘“로 보고됩니다. 부호 없는 정수의 오버플로는 표준상 정의된 동작(모듈러 연산)이라 기본 그룹에서는 보고하지 않습니다. Clang에서 필요하면 -fsanitize=unsigned-integer-overflow를 따로 켭니다.
Null 포인터 역참조와 0으로 나누기
int* ptr = nullptr;
*ptr = 42; // runtime error: store to null pointer of type 'int'
int x = 10, y = 0;
int z = x / y; // runtime error: division by zero
null 역참조는 UBSan이 보고한 직후 실제 접근으로 SIGSEGV가 나는 경우가 많습니다.
Shift 연산
// shift_overflow.cpp
#include <cstdint>
int main(int argc, char**) {
uint32_t x = 1;
uint32_t n = 31 + argc; // 실행 시 32
uint32_t z = x << n; // ❌ 타입 비트 수 이상 shift
return static_cast<int>(z);
}
shift_overflow.cpp:6:20: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
shift 양이 상수면 컴파일러가 컴파일 시점에 경고하고 값을 미리 계산해 버릴 수 있으므로, 예제에서는 실행 시 값으로 만들었습니다. uint32_t는 보통 unsigned int의 별칭이라 메시지에는 unsigned int로 나옵니다.
배열 인덱스 범위 초과
int arr[5] = {1, 2, 3, 4, 5};
int i = 5;
int x = arr[i]; // ❌ runtime error: index 5 out of bounds for type 'int [5]'
GCC와 Clang 모두 -fsanitize=undefined에 크기를 아는 배열의 인덱스 검사(bounds)가 포함됩니다. 포인터로 받은 배열은 크기를 모르므로 이 검사로는 잡을 수 없고 ASan이 필요합니다.
검사 범위 조절
# ASan과 함께 (가장 흔한 조합)
g++ -fsanitize=address,undefined -g main.cpp -o myapp
# 기본 그룹에서 특정 검사 제외
clang++ -fsanitize=undefined -fno-sanitize=alignment -g main.cpp -o myapp
# 기본 그룹에 없는 검사 추가 (Clang)
clang++ -fsanitize=undefined,unsigned-integer-overflow,implicit-conversion -g main.cpp -o myapp
# 첫 에러에서 중단 (CI용)
g++ -fsanitize=undefined -fno-sanitize-recover=all -g main.cpp -o myapp
-fsanitize=undefined는 “모든 UB”가 아니라 정의된 검사 그룹입니다. 부호 없는 오버플로나 암묵적 변환 손실처럼 표준상 UB가 아닌 검사는 따로 켜야 합니다.
ThreadSanitizer: 데이터 레이스
// data_race.cpp
#include <iostream>
#include <thread>
int counter = 0; // 동기화 없이 여러 스레드가 접근
void increment() {
for (int i = 0; i < 100000; ++i) {
counter++; // ❌ read-modify-write가 원자적이지 않음
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter << "\n"; // 200000이 아닐 수 있음
return 0;
}
counter++는 읽기, 증가, 쓰기의 세 단계라 두 스레드가 겹쳐 실행하면 한쪽의 증가분이 사라집니다. 더 근본적으로는 동기화 없는 동시 접근 자체가 C++ 메모리 모델에서 미정의 동작입니다.
$ g++ -fsanitize=thread -g data_race.cpp -o myapp -pthread && ./myapp
==================
WARNING: ThreadSanitizer: data race (pid=12345)
Read of size 4 at 0x55... by thread T2:
#0 increment() data_race.cpp:7
Previous write of size 4 at 0x55... by thread T1:
#0 increment() data_race.cpp:7
Location is global 'counter' of size 4 at 0x55...
std::mutex와 std::lock_guard로 증가를 감싸거나 std::atomic<int>를 쓰면 경고가 사라집니다.
std::mutex mtx;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
counter++;
}
}
플래그로 신호하는 패턴
// race_condition_flag.cpp
#include <thread>
bool ready = false; // ❌ atomic이 아님
int data = 0;
void producer() {
data = 42;
ready = true;
}
void consumer() {
while (!ready) {
std::this_thread::yield();
}
int x = data; // ❌ data 쓰기가 보인다는 보장이 없음
(void)x;
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
ready와 data 모두 레이스입니다. 최적화 빌드에서는 컴파일러가 while (!ready)의 읽기를 루프 밖으로 끌어내 무한 루프가 될 수도 있습니다. ready만 std::atomic<bool>로 바꾸면 충분합니다. 기본 메모리 순서(seq_cst)의 저장과 읽기가 동기화 관계를 만들어, ready가 true로 보이면 그 전에 쓴 data = 42도 보이는 것이 보장되기 때문입니다. 대기 시간이 길다면 std::mutex와 std::condition_variable이 바쁜 대기보다 낫습니다.
TSan은 레이스 외에 여러 뮤텍스를 서로 다른 순서로 잡는 락 순서 역전(lock-order-inversion)도 보고합니다. 두 뮤텍스를 함께 잡아야 한다면 std::scoped_lock lock(mtxA, mtxB);처럼 한 번에 획득하면 교착을 피할 수 있습니다.
MemorySanitizer
// uninit_memory.cpp
#include <cstdlib>
int main() {
int* ptr = static_cast<int*>(std::malloc(sizeof(int)));
int value = *ptr; // 값 복사만으로는 보고하지 않음
std::free(ptr);
return value; // ❌ 초기화 안 된 값이 결과에 영향 → 보고
}
clang++ -fsanitize=memory -fno-omit-frame-pointer -g -O1 uninit_memory.cpp -o myapp
./myapp
MSan은 Clang에서만 지원되며(GCC 미지원), Linux 등 일부 플랫폼에서만 동작합니다. 표준 라이브러리를 포함한 모든 의존성을 MSan으로 다시 빌드해야 오탐이 없으므로 도입 비용이 큽니다. 보통 ASan·UBSan·TSan을 먼저 적용하고, 초기화 문제가 강하게 의심될 때 MSan이나 Valgrind Memcheck를 추가로 씁니다.
CMake 옵션과 Preset으로 Sanitizer 빌드 구성
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyApp CXX)
set(CMAKE_CXX_STANDARD 17)
option(USE_ASAN "Enable AddressSanitizer" OFF)
option(USE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF)
option(USE_TSAN "Enable ThreadSanitizer" OFF)
if(USE_ASAN AND USE_TSAN)
message(FATAL_ERROR "ASan and TSan cannot be used together")
endif()
if(USE_ASAN)
add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
add_link_options(-fsanitize=address)
endif()
if(USE_UBSAN)
add_compile_options(-fsanitize=undefined)
add_link_options(-fsanitize=undefined)
endif()
if(USE_TSAN)
add_compile_options(-fsanitize=thread)
add_link_options(-fsanitize=thread)
endif()
find_package(Threads REQUIRED)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE Threads::Threads)
cmake -B build -DUSE_ASAN=ON -DUSE_UBSAN=ON && cmake --build build
cmake -B build_tsan -DUSE_TSAN=ON && cmake --build build_tsan
add_compile_options는 이 디렉터리 이후에 정의되는 모든 타깃에 적용되므로, 같은 프로젝트 안의 정적 라이브러리도 같은 플래그로 계측됩니다. CMake 프리셋(CMakePresets.json, version 3은 CMake 3.21 이상)을 쓰면 빌드 종류를 이름으로 전환할 수 있습니다.
{
"version": 3,
"configurePresets": [
{
"name": "asan",
"binaryDir": "${sourceDir}/build-asan",
"cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo", "USE_ASAN": "ON", "USE_UBSAN": "ON" }
},
{
"name": "tsan",
"binaryDir": "${sourceDir}/build-tsan",
"cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo", "USE_TSAN": "ON" }
}
]
}
자주 만나는 문제
undefined reference to __asan_init 같은 링크 에러
-fsanitize를 컴파일에만 주고 링크에서 빠뜨려, 계측된 오브젝트가 요구하는 런타임이 링크되지 않은 경우입니다.
# ❌ 링크 단계에 플래그 누락
g++ -fsanitize=address -c main.cpp -o main.o
g++ main.o -o myapp
# ✅ 링크에도 같은 플래그
g++ -fsanitize=address main.o -o myapp
누수가 보고되지 않음
macOS 등 LSan이 기본으로 꺼진 플랫폼이거나, 프로그램이 _exit()나 시그널로 종료해 종료 시 검사가 돌지 않은 경우입니다. ASAN_OPTIONS=detect_leaks=1을 확인하고, 정상 종료 경로로 끝나는지 봅니다. LSan은 ptrace를 쓰므로 일부 컨테이너나 디버거 아래에서는 동작하지 않을 수 있습니다.
TSan이 시작하자마자 메모리 매핑 오류로 종료
TSan은 큰 가상 주소 영역을 고정된 위치에 예약하므로, ulimit -v로 가상 메모리를 제한했거나 커널의 ASLR 엔트로피 설정(vm.mmap_rnd_bits)이 높으면 “unexpected memory mapping” 같은 오류로 시작하지 못할 수 있습니다. 가상 메모리 제한을 풀고, 오래된 컴파일러라면 최신 LLVM/GCC로 올리거나 테스트 환경에서 sysctl vm.mmap_rnd_bits=28처럼 엔트로피를 낮추는 우회가 알려져 있습니다. TSAN_OPTIONS=history_size=...는 레이스 보고에 쓰는 이력 크기를 조절하는 옵션이라 값을 키우면 메모리를 오히려 더 씁니다.
UBSan 에러에 스택 트레이스가 없음
UBSan은 기본으로 한 줄 메시지만 출력합니다. UBSAN_OPTIONS=print_stacktrace=1을 주면 호출 경로가 함께 나옵니다.
계측되지 않은 라이브러리
ASan은 계측되지 않은 코드와 섞여도 동작하지만, 그 코드 안의 접근은 검사하지 않습니다. 그래서 핵심 모듈만 ASan으로 빌드해 빌드 시간을 줄이는 절충도 가능합니다. 반면 TSan과 MSan은 계측되지 않은 코드의 동기화나 쓰기를 보지 못해 오탐을 내므로, 의존성까지 같은 플래그로 빌드해야 합니다.
ASan runtime does not come first in initial library list
다른 라이브러리를 LD_PRELOAD로 먼저 로드하거나, 계측되지 않은 실행 파일이 ASan으로 빌드된 공유 라이브러리를 dlopen할 때 나옵니다. 실행 파일 자체를 -fsanitize=address로 링크하거나, 불가피하면 ASan 런타임(libasan.so)을 LD_PRELOAD의 맨 앞에 둡니다.
고칠 수 없는 코드에서 나오는 보고
서드파티 라이브러리의 전역 캐시나 의도적으로 해제하지 않는 싱글톤 때문에 LSan이 매번 누수를 보고해 진짜 문제가 묻힐 수 있습니다. 보고를 통째로 끄지 말고 범위를 좁혀 억제합니다.
# lsan.supp — 라이브러리 이름 또는 함수 이름 패턴
leak:libthirdparty.so
leak:LegacyCache::instance
LSAN_OPTIONS=suppressions=lsan.supp ./program
__attribute__((no_sanitize("address")))
void arenaScan(const char* p) { /* 의도적으로 청크 경계를 넘어 읽는 코드 */ }
억제 목록은 저장소에 두고 항목마다 이유를 주석으로 남겨야, 나중에 억제가 진짜 버그까지 가리는 일이 없습니다.
빌드와 CI에서의 운용
Sanitizer 빌드는 -g와 -O1 정도의 최적화로 만들고, 단위 테스트와 통합 테스트를 그 빌드로 돌리는 것이 기본입니다. “가끔만 터지는” 버그는 같은 테스트를 여러 번 반복하거나 부하를 걸어야 드러나는 경우가 많고, 빈 입력·최대 길이·음수 같은 경계값 테스트가 Sanitizer와 함께 있을 때 효과가 큽니다. CI에서는 일반 빌드와 Sanitizer 빌드를 별도 job으로 병렬 실행하고, TSan job은 느리므로 타임아웃을 넉넉히 두며, 실패 시 전체 보고서를 아티팩트로 남깁니다.
export ASAN_OPTIONS=detect_leaks=1:abort_on_error=1:log_path=asan.log
export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
export TSAN_OPTIONS=second_deadlock_stack=1:halt_on_error=1
log_path를 주면 프로세스마다 asan.log.<pid> 파일로 보고서가 남아, 여러 테스트가 병렬로 도는 CI에서 어떤 테스트가 실패했는지 추적하기 쉽습니다. UBSan의 halt_on_error=1은 -fno-sanitize-recover로 빌드했을 때 의미가 있습니다.
# .github/workflows/sanitizers.yml
name: Sanitizers
on: [push, pull_request]
jobs:
asan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cmake -B build -DUSE_ASAN=ON -DUSE_UBSAN=ON && cmake --build build
- run: ctest --test-dir build --output-on-failure
env:
UBSAN_OPTIONS: print_stacktrace=1
tsan:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- run: cmake -B build -DUSE_TSAN=ON && cmake --build build
- run: ctest --test-dir build --output-on-failure
프로덕션 대신 CI와 스테이징에서 돌리는 이유
Sanitizer 빌드는 수 배 느리고 메모리를 많이 쓰며, 런타임이 환경 변수로 동작을 바꾸는 진단 도구라 보안 하드닝 용도로 설계되지 않았습니다. 그래서 배포 바이너리에는 켜지 않고, 개발·CI에서 기본으로 돌리고, 필요하면 실제 트래픽 패턴을 재현하는 스테이징 환경에 Sanitizer 빌드를 따로 띄워 검증합니다. 프로덕션에서는 정적 분석(clang-tidy, 상용 분석기), 로그와 코어 덤프, 모니터링으로 문제를 추적합니다.
“가끔만 터지는” 서버 버그를 좇는다면 순서는 보통 이렇습니다. 먼저 ASan+UBSan 빌드로 재현 스크립트를 수십~수백 번 반복 실행합니다. 보고가 나오면 스택 트레이스의 #0, #1을 따라 문제 함수와 호출 경로를 찾고, “freed by thread … here” 부분으로 언제 누가 해제했는지 확인합니다. 멀티스레드 코드라면 TSan 빌드로 같은 시나리오를 따로 돌립니다. 일반 빌드에서는 메모리 배치나 타이밍에 따라 드물게 증상이 나타나던 오류도, Sanitizer 빌드에서는 잘못된 접근 자체를 검사하므로 증상이 나타나지 않아도 보고됩니다.
flowchart TD
A[버그 종류?] --> B{메모리 오류?}
B -->|Yes| C[ASan + UBSan]
B -->|No| D{멀티스레드?}
D -->|Yes| E[TSan + UBSan]
D -->|No| F[UBSan, 필요 시 MSan]
C --> G[각각 별도 빌드]
E --> G