C++ 런타임 검증: AddressSanitizer와 ThreadSanitizer 완벽 가이드 [#41-2]
이 글의 핵심
실행 중 메모리 오류와 데이터 경합을 검출하는 ASan, TSan을 빌드 옵션과 CI에 넣어 견고한 C++ 코드를 만드는 방법. 문제 시나리오, 완전한 예제, 자주 발생하는 에러, CI 통합, 프로덕션 패턴까지.
들어가며: 실행할 때만 보이는 버그
”가끔 크래시하는데 재현이 안 돼요”
41-1에서 정적 분석으로 코드 패턴 수준의 문제를 잡았다면, 메모리 오류(버퍼 오버런, use-after-free, 이중 해제)와 데이터 경합(data race)은 실행 경로에 따라만 나타나서 정적 분석만으로는 놓치기 쉽습니다.
AddressSanitizer(ASan, 주소 검사기—메모리 오류를 실행 중에 찾아 주는 도구)와 ThreadSanitizer(TSan, 스레드 검사기—여러 스레드가 같은 메모리를 동시에 접근하는 데이터 경합을 찾아 주는 도구)는 컴파일 시 계측을 넣어, 실행 중 해당 오류가 발생하면 즉시 보고합니다. 디버그/CI 빌드에 -fsanitize=address, -fsanitize=thread를 켜고 테스트를 돌리면 재현하기 어려운 버그를 많이 걸러낼 수 있습니다.
이 글에서 다루는 것:
- 문제 시나리오: 실제 겪는 “재현이 안 되는 크래시” 상황
- ASan: 사용법·오버헤드·발견하는 버그 종류·완전한 예제
- TSan: 사용법·발견하는 버그·ASan과 동시 사용 불가·완전한 예제
- 자주 발생하는 에러와 해결법
- CI 통합: GitHub Actions·별도 job 분리·실패 시 빌드 차단
- 프로덕션 패턴: 테스트 전용 빌드·외부 라이브러리 대응
요구 환경: GCC 또는 Clang(-fsanitize=address, -fsanitize=thread). MSVC는 ASan만 /fsanitize=address(VS 2019 16.9+) 지원. Linux/macOS에서 권장, Debug 또는 RelWithDebInfo 빌드에서 사용.
개념을 잡는 비유
빌드·검사·배포 파이프라인은 공장 검수 라인과 비슷합니다. 같은 입력이면 같은 산출물이 나오게 고정하고, Sanitizer·정적 분석은 출하 전 불량 검사 역할을 합니다.
목차
- 문제 시나리오: 왜 런타임 검증이 필요한가
- AddressSanitizer (ASan)
- ThreadSanitizer (TSan)
- 완전한 ASan/TSan 예제
- 자주 발생하는 에러와 해결법
- CI 통합
- 프로덕션 패턴
- 정리
1. 문제 시나리오: 왜 런타임 검증이 필요한가
시나리오 1: “배포 후 프로덕션에서 가끔 크래시해요”
"로컬에서는 100번 돌려도 안 터지는데, 고객 환경에서 가끔 SIGSEGV가 나요."
"core dump를 받아도 스택이 깨져 있어서 원인을 못 찾아요."
원인: use-after-free, 버퍼 오버런, 이중 free는 특정 메모리 할당 순서나 입력 크기에서만 발생합니다. 정적 분석은 “이 경로에서 널이 될 수 있다”는 패턴은 찾지만, “실제로 이번 실행에서 해제된 메모리를 썼다”는 런타임 사실은 알 수 없습니다. ASan은 실행 중 메모리 접근을 계측해, 오류가 발생한 즉시 정확한 스택과 함께 보고합니다.
시나리오 2: “멀티스레드에서 값이 이상해요”
"단일 스레드로 돌리면 맞는데, 스레드 4개로 돌리면 가끔 잘못된 결과가 나와요."
"race condition인 것 같은데, 어디서 발생하는지 찾기 어려워요."
원인: 데이터 경합(data race)은 스레드 스케줄링에 따라 간헐적으로만 나타납니다. 뮤텍스 없이 공유 변수를 쓰는 실수는 정적 분석으로도 찾기 어렵고, 디버거로 재현하기도 힘듭니다. TSan은 모든 메모리 접근을 추적해, 두 스레드가 동시에 같은 메모리에 접근하고 그중 하나라도 쓰기일 때 정확한 위치를 보고합니다.
시나리오 3: “배열 인덱스가 범위를 벗어났는데 안 터져요”
"vec[i]에서 i가 size()를 넘을 수 있는데, 로컬에서는 운 좋게 안 터졌어요."
"다른 환경에서만 크래시해서 원인 파악이 늦었어요."
원인: 힙·스택 버퍼 오버런은 인접 메모리를 덮어쓰기 때문에 당장 크래시하지 않을 수 있습니다. 나중에 그 메모리를 사용할 때 터지거나, 다른 환경에서 메모리 레이아웃이 달라져서 터질 수 있습니다. ASan은 빨간 존(red zone)을 두어 경계를 넘는 접근을 즉시 감지합니다.
시나리오 4: “메모리 누수가 있는데 Valgrind가 너무 느려요”
"Valgrind로 돌리면 테스트가 20배 느려져서 CI에서 쓰기 어려워요."
"ASan의 LeakSanitizer는 더 가볍다고 들었어요."
원인: Valgrind는 동적 바이너리 변환으로 모든 명령을 해석해 느립니다. LeakSanitizer(LSan)는 ASan과 함께 컴파일 시 계측만 하므로 오버헤드가 상대적으로 적습니다. CI에서 -fsanitize=address,leak으로 테스트를 돌리면 누수도 함께 검출할 수 있습니다.
시나리오 5: “서드파티 라이브러리가 메모리 버그를 숨기고 있어요”
"우리 코드는 괜찮은데, 링크한 C 라이브러리에서 버퍼 오버런이 나는 것 같아요."
"어디서 터지는지 스택이 우리 코드로만 나와서 찾기 어려워요."
원인: 서드파티가 Sanitizer 없이 빌드되면, 해당 코드의 메모리 접근은 계측되지 않습니다. 그래도 우리 코드에 ASan을 켜면, 우리 쪽 할당·해제·접근은 모두 추적됩니다. 서드파티가 우리가 넘긴 버퍼를 잘못 쓰면, 우리 쪽에서 할당한 메모리의 경계를 넘는 접근으로 감지될 수 있습니다. 가능하면 소스가 있는 라이브러리는 Sanitizer로 함께 빌드하는 것이 좋습니다.
해결 방향
flowchart LR
subgraph static["정적 분석 (41-1)"]
S1[Clang-Tidy] --> S2[패턴 검사]
S2 --> S3[컴파일 전]
end
subgraph runtime["런타임 검증 (41-2)"]
R1[ASan/TSan] --> R2[실행 중 계측]
R2 --> R3[실제 오류 발생 시]
end
static --> runtime
정적 분석은 코드 패턴을 보고, 런타임 검증은 실제 실행 경로에서 발생하는 오류를 잡습니다. 둘 다 CI에 넣어야 견고한 C++ 코드를 만들 수 있습니다.
2. AddressSanitizer (ASan)
메모리 오류 검출 개요
ASan은 컴파일 시 모든 메모리 접근(할당, 해제, 읽기, 쓰기)에 계측 코드를 삽입합니다. 힙 할당 주변에 빨간 존(red zone)을 두어, 버퍼 오버런/언더런이 발생하면 즉시 감지합니다. 해제된 메모리 영역은 quarantine에 넣어 두어, use-after-free 접근 시 감지합니다.
flowchart TB
subgraph heap["힙 메모리 레이아웃 (ASan)"]
R1[Red Zone] --> A[할당 영역]
A --> R2[Red Zone]
R1 -.->|오버런 시 감지| DETECT1[감지]
R2 -.->|언더런 시 감지| DETECT1
end
subgraph free["해제된 메모리"]
Q[Quarantine] -.->|use-after-free 시 감지| DETECT2[감지]
end
빌드 옵션
| 옵션 | 설명 |
|---|---|
| -fsanitize=address | ASan 활성화 (컴파일·링크 모두 필요) |
| -fno-omit-frame-pointer | 스택 트레이스 정확도 향상 |
| -g | 디버그 심볼 (스택에 파일:행 표시) |
| -O1 이상 | 최적화 시 인라인 등으로 스택이 짧아질 수 있음 |
컴파일과 링크 모두에 -fsanitize=address를 넣어야 런타임에 계측이 동작합니다. -fno-omit-frame-pointer를 함께 쓰면 스택 프레임이 유지되어 크래시 시 스택 트레이스가 읽기 좋습니다.
검출하는 버그 종류
| 버그 유형 | 설명 | ASan 동작 |
|---|---|---|
| 힙 버퍼 오버런 | 할당된 영역 끝을 넘어 쓰기 | Red zone 접근 시 즉시 보고 |
| 힙 버퍼 언더런 | 할당된 영역 앞을 넘어 쓰기 | Red zone 접근 시 즉시 보고 |
| 스택 버퍼 오버런 | 스택 배열 경계 초과 | Red zone 접근 시 즉시 보고 |
| use-after-free | 해제된 메모리 접근 | Quarantine 접근 시 즉시 보고 |
| 이중 free | 같은 포인터 두 번 delete | Quarantine 상태로 감지 |
| 메모리 누수 | -fsanitize=leak 또는 LSan | 종료 시 미해제 메모리 보고 |
오버헤드
- 메모리: 약 2~3배 증가 (Red zone, Quarantine 등)
- 실행 속도: 약 2~3배 느려짐
- 권장: 테스트·CI 전용 빌드. 프로덕션에는 사용하지 않습니다.
MSVC
/fsanitize=address (VS 2019 16.9+). Linux/macOS만 지원하는 기능이 있으므로 문서를 확인합니다.
기본 ASan 예제
// uaf.cpp - use-after-free 예제
// 빌드: g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -o uaf uaf.cpp
// 실행: ./uaf
#include <iostream>
int main() {
int* p = new int(42);
delete p;
std::cout << *p << "\n"; // use-after-free → ASan이 감지
return 0;
}
실행 결과 (ASan이 감지하는 경우):
=================================================================
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000000010
READ of size 4 at 0x603000000010 thread T0
#0 0x... in main uaf.cpp:8
...
==12345==ABORTING
버퍼 오버런 예제
// overflow.cpp - 힙 버퍼 오버런 예제
// 빌드: g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -o overflow overflow.cpp
#include <cstring>
int main() {
char* buf = new char[10];
strcpy(buf, "0123456789"); // null 포함 11바이트 → 오버런
delete[] buf;
return 0;
}
스택 버퍼 오버런 예제
// stack_overflow.cpp - 스택 버퍼 오버런
// 빌드: g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -o stack_overflow stack_overflow.cpp
#include <cstring>
int main() {
char buf[5];
strcpy(buf, "hello"); // 6바이트 → 오버런
return 0;
}
LeakSanitizer (메모리 누수 검출)
// leak.cpp - 메모리 누수 예제
// 빌드: g++ -std=c++17 -fsanitize=address,leak -fno-omit-frame-pointer -g -o leak leak.cpp
#include <cstdlib>
int main() {
void* p = malloc(100);
// free(p); // 누락 → LSan이 감지
return 0;
}
실행 결과 (LSan):
=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 100 byte(s) in 1 object(s) allocated from:
#0 0x... in malloc
#1 0x... in main leak.cpp:6
이중 free 예제
// double_free.cpp - 이중 free 예제
// 빌드: g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -o double_free double_free.cpp
#include <cstdlib>
int main() {
int* p = (int*)malloc(sizeof(int));
*p = 42;
free(p);
free(p); // 이중 free → ASan이 감지
return 0;
}
실행 결과 (ASan):
=================================================================
==12345==ERROR: AddressSanitizer: attempting double-free on address 0x...
#0 0x... in free
#1 0x... in main double_free.cpp:9
ASan 환경 변수 (선택)
# 할당/해제 로그를 파일로 (디버깅 시)
ASAN_OPTIONS=log_path=asan.log ./myapp
# 오류 시 즉시 중단 (기본값)
ASAN_OPTIONS=abort_on_error=1 ./myapp
# 스택 트레이스 깊이
ASAN_OPTIONS=symbolize=1:print_legend=1 ./myapp
3. ThreadSanitizer (TSan)
데이터 경합 검출 개요
TSan은 두 스레드가 같은 메모리를 동시에 접근하고, 그중 하나라도 쓰기일 때 데이터 경합(data race)으로 보고합니다. 뮤텍스·atomic 없이 공유 변수를 쓰는 실수를 찾는 데 유용합니다.
sequenceDiagram participant T1 as 스레드 1 participant MEM as 공유 메모리 participant T2 as 스레드 2 T1->>MEM: 쓰기 (뮤텍스 없음) T2->>MEM: 읽기 (동시 접근) Note over T1,T2: 데이터 경합! TSan 감지
빌드 옵션
| 옵션 | 설명 |
|---|---|
| -fsanitize=thread | TSan 활성화 (컴파일·링크 모두 필요) |
| -fno-omit-frame-pointer | 스택 트레이스 정확도 향상 |
| -g | 디버그 심볼 |
ASan과 동시 사용 불가
ASan과 TSan은 같은 프로그램에서 동시에 켤 수 없습니다. 둘 다 링크 시 런타임 라이브러리를 넣는데, 같은 메모리 계측을 서로 다른 방식으로 하기 때문에 충돌합니다. CI에서는 job을 나누어 한 번은 ASan, 한 번은 TSan으로 각각 테스트를 돌립니다.
검출하는 버그
| 버그 유형 | 설명 |
|---|---|
| 데이터 경합 | 두 스레드가 동시에 같은 메모리에 접근, 하나 이상 쓰기 |
| 잠금 순서 역전 | TSan은 직접 검출하지 않음 (Helgrind 등 별도 도구) |
오버헤드
- 메모리: 5~15배 증가 가능
- 실행 속도: 5~15배 느려짐
- 권장: 테스트/CI 전용. 프로덕션에는 사용하지 않습니다.
헤더 전용 라이브러리
헤더만 포함하는 라이브러리(예: 일부 Boost 헤더)는 TSan이 제대로 계측하지 못할 수 있습니다. 가능하면 정적 링크하거나, 동적 링크 시 TSan 라이브러리가 모든 코드를 감싸도록 합니다. 서드파티가 TSan 없이 빌드된 경우, 해당 코드의 경합은 놓칠 수 있습니다.
기본 TSan 예제
// race.cpp - 데이터 경합 예제
// 빌드: g++ -std=c++17 -fsanitize=thread -fno-omit-frame-pointer -g -o race race.cpp -pthread
// 실행: ./race
#include <thread>
#include <iostream>
int counter = 0; // 공유 변수, 뮤텍스 없음
void increment() {
for (int i = 0; i < 100000; ++i) {
++counter; // 데이터 경합!
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n";
return 0;
}
실행 결과 (TSan이 감지하는 경우):
==================
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x... by thread T1:
#0 increment() race.cpp:10
...
Previous write of size 4 at 0x... by thread T2:
#0 increment() race.cpp:10
...
==================
올바른 수정 예 (atomic)
// race_fixed.cpp - atomic으로 수정
#include <thread>
#include <atomic>
#include <iostream>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1);
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter.load() << "\n";
return 0;
}
뮤텍스로 수정한 예
// race_mutex.cpp - 뮤텍스로 수정
#include <thread>
#include <mutex>
#include <iostream>
int counter = 0;
std::mutex mtx;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n";
return 0;
}
TSan 환경 변수 (선택)
# 경합 감지 시 스택 트레이스 깊이
TSAN_OPTIONS=second_deadlock_stack=1 ./myapp
# 히스토리 크기 (경합 분석 정확도)
TSAN_OPTIONS=history_size=7 ./myapp
# 특정 경합 무시 (주의: 꼭 필요한 경우만)
# TSAN_OPTIONS=ignore_noninstrumented_modules=1
TSan이 놓칠 수 있는 경우
- 헤더 전용 라이브러리: 인라인 코드가 TSan 없이 빌드된 바이너리에 포함되면 계측되지 않음
- 시그널 핸들러: 시그널 핸들러 내부의 메모리 접근은 별도 주의 필요
- lock-free 자료구조:
std::atomic을 올바르게 쓰면 TSan이 경합으로 보고하지 않음. 잘못된 메모리 순서는 TSan이 잡지 못할 수 있음
4. 완전한 ASan/TSan 예제
CMake로 ASan/TSan 빌드 분리
# CMakeLists.txt - Sanitizer 빌드 타겟 분리
cmake_minimum_required(VERSION 3.16)
project(sanitizer_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
# 공통 소스
set(DEMO_SOURCES
src/main.cpp
src/utils.cpp
)
# 기본 빌드 (Release)
add_executable(demo ${DEMO_SOURCES})
target_compile_options(demo PRIVATE -O2)
# ASan 빌드 (테스트 전용)
add_executable(demo_asan ${DEMO_SOURCES})
target_compile_options(demo_asan PRIVATE
-g -O1
-fsanitize=address
-fno-omit-frame-pointer
)
target_link_options(demo_asan PRIVATE -fsanitize=address)
# TSan 빌드 (테스트 전용)
add_executable(demo_tsan ${DEMO_SOURCES})
target_compile_options(demo_tsan PRIVATE
-g -O1
-fsanitize=thread
-fno-omit-frame-pointer
)
target_link_options(demo_tsan PRIVATE -fsanitize=thread)
테스트 타겟만 Sanitizer 적용
# 테스트 타겟에만 ASan 적용, 메인 앱은 일반 빌드
add_executable(myapp src/main.cpp)
target_compile_options(myapp PRIVATE -O2)
add_executable(test_myapp tests/test_myapp.cpp)
target_compile_options(test_myapp PRIVATE
-g -O1
-fsanitize=address
-fno-omit-frame-pointer
)
target_link_options(test_myapp PRIVATE -fsanitize=address)
target_link_libraries(test_myapp PRIVATE myapp)
완전한 ASan 검증 예제 (벡터 구현)
// vector_simple.cpp - 경계 검사 없는 단순 벡터 (ASan이 버그 감지)
// 빌드: g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -o vec vec.cpp
#include <iostream>
#include <cstring>
class SimpleVector {
int* data_;
size_t size_;
size_t capacity_;
public:
SimpleVector() : data_(nullptr), size_(0), capacity_(0) {}
~SimpleVector() { delete[] data_; }
void push_back(int v) {
if (size_ >= capacity_) {
size_t new_cap = (capacity_ == 0) ? 4 : capacity_ * 2;
int* new_data = new int[new_cap];
memcpy(new_data, data_, size_ * sizeof(int));
delete[] data_;
data_ = new_data;
capacity_ = new_cap;
}
data_[size_++] = v;
}
int& operator { return data_[i]; } // 경계 검사 없음!
};
int main() {
SimpleVector v;
v.push_back(1);
v.push_back(2);
std::cout << v[0] << ", " << v[1] << "\n";
std::cout << v[10] << "\n"; // 버퍼 오버런! ASan 감지
return 0;
}
완전한 TSan 검증 예제 (캐시)
// cache_race.cpp - 스레드 안전하지 않은 캐시 (TSan이 경합 감지)
// 빌드: g++ -std=c++17 -fsanitize=thread -fno-omit-frame-pointer -g -o cache cache.cpp -pthread
#include <unordered_map>
#include <thread>
#include <string>
#include <iostream>
class UnsafeCache {
std::unordered_map<std::string, int> map_;
public:
void put(const std::string& k, int v) {
map_[k] = v; // 쓰기 - 뮤텍스 없음
}
int get(const std::string& k) {
return map_.count(k) ? map_[k] : -1; // 읽기 - 뮤텍스 없음
}
};
int main() {
UnsafeCache cache;
std::thread t1([&]() {
for (int i = 0; i < 1000; ++i)
cache.put("key", i);
});
std::thread t2([&]() {
for (int i = 0; i < 1000; ++i)
(void)cache.get("key");
});
t1.join();
t2.join();
std::cout << "done\n";
return 0;
}
5. 자주 발생하는 에러와 해결법
에러 1: “undefined symbol: __asan_init”
원인: 컴파일에는 -fsanitize=address를 넣었지만 링크에 넣지 않음.
해결법:
# ❌ 잘못된 예 (링크 옵션 누락)
g++ -fsanitize=address -c main.cpp -o main.o
g++ main.o -o app # 링크 시 -fsanitize=address 없음
# ✅ 올바른 예
g++ -fsanitize=address -c main.cpp -o main.o
g++ main.o -o app -fsanitize=address
에러 2: “AddressSanitizer and ThreadSanitizer cannot be used together”
원인: 같은 타겟에 -fsanitize=address와 -fsanitize=thread를 동시에 지정.
해결법: CI에서 job을 분리합니다. 한 job은 ASan 빌드+테스트, 다른 job은 TSan 빌드+테스트.
# GitHub Actions 예시
jobs:
test-asan:
steps:
- run: cmake -DSANITIZER=address ...
- run: ctest
test-tsan:
steps:
- run: cmake -DSANITIZER=thread ...
- run: ctest
에러 3: “LeakSanitizer: detected memory leaks” - 의도된 누수
원인: 프로그램 종료 시 해제하지 않는 전역 객체(예: 싱글톤)가 LSan에 의해 누수로 보고됨.
해결법 1: 환경 변수로 특정 누수 무시 (주의해서 사용)
# 특정 스택의 누수만 무시 (예: third_party에서 할당)
LSAN_OPTIONS=suppressions=leak.supp ./myapp
leak.supp 예시:
leak:libthird_party.so
해결법 2: __lsan_do_leak_check() 호출 시점 조절. 또는 프로그램 종료 직전에 해당 객체를 명시적으로 해제.
에러 4: TSan “failed to allocate” 또는 매우 느린 실행
원인: TSan은 메모리 사용량이 크게 늘어남. CI 러너 메모리 부족.
해결법:
- CI 러너 메모리 증설
- TSan 테스트를 별도 job으로 두고, 병렬 수를 줄임
TSAN_OPTIONS=memory_limit_mb=4096등으로 제한 (문서 확인)
에러 5: “SUMMARY: AddressSanitizer: SEGV” - 스택 오버플로우
원인: ASan 자체가 스택을 더 사용함. 재귀 깊이가 큰 경우 스택 오버플로우 발생.
해결법:
# 스택 크기 증가
ulimit -s 65536 # 64MB
./myapp_asan
에러 6: 서드파티 라이브러리와 링크 시 “multiple definition” 또는 크래시
원인: 서드파티가 Sanitizer 없이 빌드되었고, ABI나 런타임 동작이 맞지 않음.
해결법:
- 우리 코드만 Sanitizer 적용. 서드파티는 일반 빌드로 링크.
- 가능하면 소스가 있는 라이브러리는 Sanitizer로 함께 빌드.
- Sanitizer와 호환되지 않는 라이브러리는 테스트에서 제외하거나, 해당 부분만 일반 빌드로 분리.
에러 7: ASan/TSan 빌드에서 “optimization may have produced incorrect code”
원인: -O0에서만 발생하는 버그가 -O1 이상에서 사라지거나, 반대로 최적화로 인한 미정의 동작이 드러남.
해결법: 디버그 시 -O0 -g로 빌드. CI에서는 -O1 또는 -O2로 충분한 경우가 많음. 최적화 수준을 문서화하고, 팀에서 합의.
에러 8: macOS에서 “dyld: Symbol not found: _asan*”
원인: macOS의 Clang이 ASan 라이브러리 경로를 찾지 못함. Xcode 버전에 따라 동작이 다를 수 있음.
해결법:
# Homebrew Clang 사용 시
export CC=/usr/local/opt/llvm/bin/clang
export CXX=/usr/local/opt/llvm/bin/clang++
cmake -DSANITIZER=address ...
또는 최신 Xcode Command Line Tools 설치 후 xcode-select --install 확인.
에러 9: TSan “ThreadSanitizer: clock allocator overflow”
원인: 매우 많은 스레드 생성/종료로 TSan 내부 클록 할당자가 오버플로우.
해결법: TSAN_OPTIONS=history_size=5 등으로 히스토리 크기 줄이기. 또는 스레드 풀 크기를 줄여 테스트.
에러 10: “AddressSanitizer: alloc-dealloc-mismatch”
원인: new로 할당하고 free로 해제, 또는 malloc로 할당하고 delete로 해제.
해결법: new/delete, malloc/free 쌍을 맞춤. C++에서는 new/delete 또는 스마트 포인터 사용 권장.
// ❌ 잘못된 예
int* p = (int*)malloc(sizeof(int));
delete p; // alloc-dealloc-mismatch
// ✅ 올바른 예
int* p = (int*)malloc(sizeof(int));
free(p);
6. CI 통합
GitHub Actions: ASan + TSan job 분리
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Release
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
- name: Test Release
run: ctest --test-dir build -C Release
build-asan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build ASan
run: |
cmake -B build-asan \
-DCMAKE_BUILD_TYPE=Debug \
-DSANITIZER=address
cmake --build build-asan
- name: Test ASan
run: ctest --test-dir build-asan --output-on-failure
build-tsan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build TSan
run: |
cmake -B build-tsan \
-DCMAKE_BUILD_TYPE=Debug \
-DSANITIZER=thread
cmake --build build-tsan
- name: Test TSan
run: ctest --test-dir build-tsan --output-on-failure
CMake에서 SANITIZER 변수 처리
# CMakeLists.txt
if(SANITIZER STREQUAL "address")
add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
add_link_options(-fsanitize=address)
elseif(SANITIZER STREQUAL "thread")
add_compile_options(-fsanitize=thread -fno-omit-frame-pointer)
add_link_options(-fsanitize=thread)
endif()
실패 시 동작
Sanitizer가 오류를 감지하면 프로세스가 비정상 종료(exit code != 0)합니다. CI는 이를 실패로 처리하고, 로그에 스택 트레이스가 포함되므로 해당 경로를 수정한 뒤 다시 푸시하면 됩니다.
로그 보존
# Sanitizer 출력이 길 수 있으므로 로그 보존
- name: Test ASan
run: ctest --test-dir build-asan --output-on-failure 2>&1 | tee asan.log
- uses: actions/upload-artifact@v4
if: failure()
with:
name: asan-log
path: asan.log
GitLab CI 예시
# .gitlab-ci.yml
stages:
- build
- test
build-asan:
stage: build
script:
- cmake -B build-asan -DSANITIZER=address
- cmake --build build-asan
artifacts:
paths:
- build-asan/
test-asan:
stage: test
needs: [build-asan]
script:
- ctest --test-dir build-asan --output-on-failure
build-tsan:
stage: build
script:
- cmake -B build-tsan -DSANITIZER=thread
- cmake --build build-tsan
artifacts:
paths:
- build-tsan/
test-tsan:
stage: test
needs: [build-tsan]
script:
- ctest --test-dir build-tsan --output-on-failure
CI job 순서와 병렬화
ASan job과 TSan job은 서로 독립이므로 병렬 실행해도 됩니다. Release 빌드가 성공한 뒤에 Sanitizer 테스트를 돌릴지, 아니면 동시에 돌릴지는 CI 시간과 리소스에 따라 결정합니다. 보통은 모두 병렬로 돌려서 전체 시간을 줄입니다.
flowchart TB
subgraph parallel["병렬 실행"]
A[Release 빌드] --> B[Release 테스트]
C[ASan 빌드] --> D[ASan 테스트]
E[TSan 빌드] --> F[TSan 테스트]
end
B --> G[모두 통과 시 병합]
D --> G
F --> G
7. 프로덕션 패턴
패턴 1: Sanitizer는 테스트 전용, 프로덕션은 일반 빌드
프로덕션에서는 절대 ASan/TSan을 켜지 않습니다. 메모리·속도 오버헤드가 크고, 고객 환경에서 불필요한 로그가 노출될 수 있습니다. CI·로컬 테스트에서만 사용합니다.
패턴 2: 점진적 도입
기존 대규모 프로젝트에서는 전체에 Sanitizer를 한 번에 켜면 실패가 폭주할 수 있습니다. 테스트 타겟만 먼저 적용하고, 점차 범위를 넓혀 갑니다.
# 1단계: unit test만 ASan
target_compile_options(unit_tests PRIVATE -fsanitize=address ...)
# 2단계: integration test도 ASan
target_compile_options(integration_tests PRIVATE -fsanitize=address ...)
# 3단계: 전체 테스트 타겟
패턴 3: 외부 라이브러리 분리
서드파티가 Sanitizer와 호환되지 않을 때는 우리 코드만 Sanitizer로 빌드하고, 서드파티는 일반 빌드로 링크합니다. 가능하면 소스가 있는 라이브러리는 Sanitizer로 함께 빌드해 전체 커버리지를 높입니다.
패턴 4: Debug vs RelWithDebInfo
- Debug:
-O0, 스택 트레이스 가장 정확. CI에서 디버깅 시 유리. - RelWithDebInfo:
-O2 -g, 일부 최적화로 인한 버그도 검출 가능. CI 기본값으로 많이 사용.
패턴 5: 주기적 Sanitizer 실행
매 커밋마다 ASan+TSan을 돌리면 CI 시간이 길어질 수 있습니다. 매일 밤 또는 주 1회 전체 Sanitizer 테스트를 돌리는 정책도 있습니다. 중요한 건 최소한 주기적으로 돌려서 회귀를 막는 것입니다.
패턴 6: 스크립트로 로컬 Sanitizer 검증
#!/bin/bash
# scripts/run_sanitizers.sh
set -e
echo "=== ASan ==="
cmake -B build-asan -DSANITIZER=address
cmake --build build-asan
ctest --test-dir build-asan --output-on-failure
echo "=== TSan ==="
cmake -B build-tsan -DSANITIZER=thread
cmake --build build-tsan
ctest --test-dir build-tsan --output-on-failure
echo "All sanitizer tests passed."
8. 정리
| 도구 | 검출 대상 | 비고 |
|---|---|---|
| ASan | 버퍼 오버런, use-after-free, 이중 free, (LSan) 누수 | 메모리·실행 시간 오버헤드 있음 |
| TSan | 데이터 경합 | ASan과 동시 사용 불가, 테스트 job 분리 |
41-2로 런타임 검증을 CI에 넣으면, 정적 분석(41-1)으로 못 잡는 메모리·경합 버그를 실행 단계에서 차단할 수 있습니다.
체크리스트
- ASan 빌드 타겟 추가 (
-fsanitize=address) - TSan 빌드 타겟 추가 (
-fsanitize=thread) - CI에서 ASan job, TSan job 분리
-
-fno-omit-frame-pointer로 스택 트레이스 품질 확보 - 테스트 타겟에만 Sanitizer 적용 (선택)
- 서드파티 호환성 확인
- 프로덕션 빌드에는 Sanitizer 미사용
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지
- C++ Sanitizers | ASan·TSan으로 메모리 버그·data race 자동 탐지
- C++ Segmentation fault | core dump
이 글에서 다루는 키워드 (관련 검색어)
AddressSanitizer, ThreadSanitizer, ASan TSan, 메모리 오류 검출, 데이터 경합, Sanitizer CI 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 실행 중 메모리 오류와 데이터 경합을 검출하는 ASan, TSan을 빌드 옵션과 CI에 넣어 견고한 C++ 코드를 만드는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. ASan과 TSan을 동시에 쓸 수 있나요?
A. 아닙니다. 같은 프로그램에서 둘 다 켜면 링크 에러나 미정의 동작이 됩니다. CI에서 job을 나누어 한 번은 ASan, 한 번은 TSan으로 각각 테스트를 돌립니다.
Q. 프로덕션에서 Sanitizer를 써도 되나요?
A. 권장하지 않습니다. 메모리·속도 오버헤드가 크고, 고객 환경에서 불필요한 로그가 노출될 수 있습니다. 테스트·CI 전용으로 사용하세요.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. AddressSanitizer 공식 문서, ThreadSanitizer 공식 문서를 참고하세요.
한 줄 요약: ASan·TSan으로 메모리 오류·데이터 레이스를 런타임에 찾을 수 있습니다. 다음으로 Fuzz Testing(#41-3)를 읽어보면 좋습니다.
이전 글: 안정성 확보 #41-1: Clang-Tidy·Cppcheck
다음 글: [안정성 확보 #41-3] Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기
관련 글
- C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기 [#41-1]
- C++ volatile 완벽 가이드 | MMIO·ISR·메모리 맵 레지스터·atomic과의 차이 [실전]
- C++ [[nodiscard]] 완벽 가이드 | 반환값 무시 방지·에러 코드·RAII·사유 메시지 [실전]
- C++ Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기 [#41-3]
- C++ 디버깅 기법 완벽 가이드 | GDB·LLDB·ASan·TSan·로깅 실전 [#55-8]