ASan과 TSan으로 런타임 버그 잡기: 메모리 오류·데이터 레이스 예제와 CI 통합
들어가며: 실행할 때만 보이는 버그
41-1에서 정적 분석으로 코드 패턴 수준의 문제를 잡았다면, 메모리 오류(버퍼 오버런, use-after-free, 이중 해제)와 데이터 경합(data race)은 실행 경로에 따라만 나타나서 정적 분석만으로는 놓치기 쉽습니다.
AddressSanitizer(ASan, 주소 검사기—메모리 오류를 실행 중에 찾아 주는 도구)와 ThreadSanitizer(TSan, 스레드 검사기—여러 스레드가 같은 메모리를 동시에 접근하는 데이터 경합을 찾아 주는 도구)는 컴파일 시 계측을 넣어, 실행 중 해당 오류가 발생하면 즉시 보고합니다. 디버그/CI 빌드에 -fsanitize=address, -fsanitize=thread를 켜고 테스트를 돌리면 재현하기 어려운 버그를 많이 걸러낼 수 있습니다.
ASan과 TSan의 사용법·오버헤드·잡아내는 버그 종류를 예제로 보고, 둘을 한 빌드에서 함께 켤 수 없다는 제약 때문에 CI에서 job을 나누는 방법과 외부 라이브러리에서 나오는 보고를 다루는 방법을 정리합니다.
요구 환경: GCC 또는 Clang(-fsanitize=address, -fsanitize=thread). MSVC는 ASan만 /fsanitize=address(VS 2019 16.9+) 지원. Linux/macOS에서 권장, Debug 또는 RelWithDebInfo 빌드에서 사용.
가끔 터지는 크래시, 이상한 멀티스레드 값: 런타임 검증이 필요한 상황
시나리오 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과 함께 컴파일 시 계측만 하므로 오버헤드가 상대적으로 적습니다. Linux x86_64에서는 -fsanitize=address만 켜도 LSan이 기본으로 함께 동작해, 테스트 종료 시 누수를 보고합니다(macOS에서는 기본으로 꺼져 있습니다).
시나리오 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++ 코드를 만들 수 있습니다.
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:5
이중 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:8
ASan 환경 변수 (선택)
# 오류 보고를 파일로 저장 (asan.log.<pid> 형태로 생성)
ASAN_OPTIONS=log_path=asan.log ./myapp
# 오류 시 exit 대신 abort()로 종료 (코어 덤프를 남길 때, Linux 기본값은 0)
ASAN_OPTIONS=abort_on_error=1 ./myapp
# 첫 오류 후에도 계속 실행 (-fsanitize-recover=address로 빌드했을 때만 의미 있음)
ASAN_OPTIONS=halt_on_error=0 ./myapp
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으로 각각 테스트를 돌립니다.
검출하는 버그
| 버그 유형 | 설명 |
|---|---|
| 데이터 경합 | 두 스레드가 동시에 같은 메모리에 접근, 하나 이상 쓰기 |
| 잠금 순서 역전 | 두 뮤텍스를 서로 다른 순서로 잡는 경로가 있으면 lock-order-inversion (potential deadlock)으로 보고 |
| 기타 | 파괴된 뮤텍스 사용, 시그널 핸들러 안의 시그널 비안전 호출, join되지 않은 스레드(thread leak) |
오버헤드
- 메모리: 5~10배 증가 가능
- 실행 속도: 5~15배 느려짐
- 권장: 테스트/CI 전용. 프로덕션에는 사용하지 않습니다.
계측되지 않은 라이브러리
헤더 전용 라이브러리는 우리 소스와 함께 컴파일되므로 -fsanitize=thread로 빌드하면 함께 계측됩니다. 문제는 미리 컴파일된 라이브러리입니다. TSan 없이 빌드된 .so나 .a 안에서 일어나는 메모리 접근과 동기화는 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:9
...
Previous write of size 4 at 0x... by thread T2:
#0 increment() race.cpp:9
...
==================
올바른 수정 예 (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
# 이전 접근 기록을 더 길게 보관 (0~7, 기본 2). "failed to restore the stack"이 나오면 늘림
TSAN_OPTIONS=history_size=7 ./myapp
# suppressions 파일로 특정 함수·라이브러리의 보고 억제 (주의: 꼭 필요한 경우만)
TSAN_OPTIONS=suppressions=tsan.supp ./myapp
TSan이 놓칠 수 있는 경우
- 실행되지 않은 경로: TSan은 실제로 실행된 접근만 봅니다. 테스트가 두 스레드를 동시에 그 코드로 보내지 않으면 경합은 보고되지 않습니다.
- 계측되지 않은 코드: TSan 없이 컴파일된 라이브러리 안의 접근은 보이지 않습니다.
- lock-free 자료구조: TSan은
memory_order에 따른 happens-before 관계를 추적하므로,relaxed를 잘못 써서 비원자 데이터 접근 사이의 순서가 보장되지 않으면 그 비원자 접근을 경합으로 보고합니다. 다만 원자 연산만으로 이루어진 알고리즘의 논리 오류(ABA 문제 등)는 데이터 경합이 아니므로 잡지 못합니다.
CMake로 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_library(core src/utils.cpp)
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE core)
add_library(core_asan src/utils.cpp)
add_executable(test_myapp tests/test_myapp.cpp)
foreach(t core_asan test_myapp)
target_compile_options(${t} PRIVATE -g -O1 -fsanitize=address -fno-omit-frame-pointer)
target_link_options(${t} PRIVATE -fsanitize=address)
endforeach()
target_link_libraries(test_myapp PRIVATE core_asan)
완전한 ASan 검증 예제 (벡터 구현)
// vector_simple.cpp - 경계 검사 없는 단순 벡터 (ASan이 버그 감지)
// 빌드: g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -o vec vector_simple.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[](size_t i) { 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[3] << "\n"; // size는 2지만 capacity 4 안쪽이라 ASan은 못 잡음
std::cout << v[4] << "\n"; // 할당 영역 바로 뒤 → heap-buffer-overflow로 감지
return 0;
}
v[3]은 논리적으로는 범위 밖이지만 new int[4]로 할당한 메모리 안이므로 ASan 입장에서는 정상 접근입니다. ASan은 할당 단위의 경계만 알기 때문에, 이런 논리적 범위 검사는 operator[]에 assert를 넣거나 libstdc++의 _GLIBCXX_ASSERTIONS 같은 디버그 검사로 보완해야 합니다. 반대로 할당 끝에서 아주 멀리 떨어진 주소는 다른 할당의 영역에 들어갈 수 있어 감지되지 않을 수도 있습니다.
완전한 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;
}
__asan_init 없음, ASan+TSan 동시 사용, 의도된 누수 보고 같은 에러
에러 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: ”‘-fsanitize=address’ and ‘-fsanitize=thread’ are incompatible”
원인: 같은 타겟에 -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은 종료 시점에 전역 변수나 스택에서 더 이상 도달할 수 없는 메모리만 누수로 보고합니다. 그래서 전역 포인터가 계속 가리키는 싱글톤은 보고되지 않고, 포인터를 다른 값으로 덮어썼거나 LSan이 훑지 않는 곳(예: 정수로 변환해 저장한 주소)에만 남긴 할당이 보고됩니다. 의도적으로 해제하지 않는 할당이 이런 형태라면 억제가 필요합니다. 해결법 1: suppressions 파일로 특정 누수 무시 (주의해서 사용)
# 특정 스택의 누수만 무시 (예: third_party에서 할당)
LSAN_OPTIONS=suppressions=leak.supp ./myapp
leak.supp 예시:
leak:libthird_party.so
해결법 2: 코드에서 __lsan_ignore_object(ptr)로 특정 할당을 제외하거나, 종료 전에 해당 객체를 명시적으로 해제합니다.
에러 4: TSan “failed to allocate” 또는 매우 느린 실행
원인: TSan은 메모리 사용량이 크게 늘어남. CI 러너 메모리 부족. 해결법:
- CI 러너 메모리 증설
- TSan 테스트를 별도 job으로 두며, 병렬 수를 줄임
TSAN_OPTIONS=memory_limit_mb=4096등으로 제한 (문서 확인)
에러 5: “AddressSanitizer: stack-overflow”
원인: ASan 자체가 스택을 더 사용함. 재귀 깊이가 큰 경우 스택 오버플로우 발생. 해결법:
# 스택 크기 증가
ulimit -s 65536 # 64MB
./myapp_asan
에러 6: 서드파티 라이브러리와 링크 시 “multiple definition” 또는 크래시
원인: 서드파티가 Sanitizer 없이 빌드되었으며, ABI나 런타임 동작이 맞지 않음. 해결법:
- 우리 코드만 Sanitizer 적용. 서드파티는 일반 빌드로 링크.
- 가능하면 소스가 있는 라이브러리는 Sanitizer로 함께 빌드.
- Sanitizer와 호환되지 않는 라이브러리는 테스트에서 제외하거나, 해당 부분만 일반 빌드로 분리.
에러 7: 최적화 수준에 따라 보고가 나타나거나 사라짐
원인: 미정의 동작이 있는 코드는 최적화 수준에 따라 다르게 컴파일되므로, -O0에서 보이던 오류가 -O1 이상에서 사라지거나 그 반대가 될 수 있습니다. 최적화로 불필요한 메모리 접근이 제거되면 ASan이 검사할 접근 자체가 없어지기도 합니다.
해결법: 디버그 시 -O0 -g로 빌드. CI에서는 -O1 또는 -O2로 충분한 경우가 많음. 최적화 수준을 문서화하며, 팀에서 합의.
에러 8: macOS에서 “dyld: Symbol not found: _asan*”
원인: macOS의 Clang이 ASan 라이브러리 경로를 찾지 못함. Xcode 버전에 따라 동작이 다를 수 있음. 해결법:
# Homebrew Clang 사용 시 (Apple Silicon은 /opt/homebrew/opt/llvm, Intel은 /usr/local/opt/llvm)
export CC="$(brew --prefix llvm)/bin/clang"
export CXX="$(brew --prefix 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);
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
Sanitizer는 테스트 빌드에만, 점진적 도입, 주기적 실행
패턴 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 -DNDEBUG, 최적화된 코드에서만 드러나는 버그도 검출 가능. 다만NDEBUG때문에assert가 꺼진다는 점을 감안해야 합니다.
패턴 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."
같이 보면 좋은 글
- Valgrind Memcheck로 C++ 메모리 누수 찾아내기
- C++ Sanitizers | ASan·TSan으로 메모리 버그·data race 자동 탐지
- C++ Segmentation fault | core dump
- C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기 [#41-1]
- C++ volatile의 정확한 의미
- [[nodiscard]]로 반환값 무시 막기
- C++ Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기 [#41-3]
- C++ 디버깅 실전
자주 묻는 질문 (FAQ)
Q. ASan 보고서에 heap-buffer-overflow가 나오면 어디부터 봐야 하나요?
A. 보고서의 첫 번째 스택 트레이스가 잘못된 읽기·쓰기가 실제로 일어난 위치이고, 그 아래의 allocated by 부분이 해당 메모리를 할당한 위치입니다. 두 위치를 비교하면 어떤 버퍼의 크기를 넘었는지 알 수 있고, 몇 바이트를 넘었는지도 함께 표시됩니다. 소스 줄 번호가 제대로 나오도록 -g와 -fno-omit-frame-pointer를 함께 켜 두고, 최적화 수준은 -O1 정도로 낮추는 것이 원인 추적에 도움이 됩니다.
이전 글: 안정성 확보 #41-1: Clang-Tidy·Cppcheck 다음 글: [안정성 확보 #41-3] Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기