C++ 미정의 동작(UB) 완전 가이드
미정의 동작(Undefined Behavior, UB)은 C++ 표준이 동작을 규정하지 않은 코드 영역입니다. 컴파일러는 UB가 발생하지 않는다고 가정하고 공격적으로 최적화하므로, UB가 있는 코드는 디버그 빌드에서 정상 동작해도 릴리스 빌드에서 예상치 못한 결과를 낳습니다.
1. 부호 있는 정수 오버플로우
섹션 제목: “1. 부호 있는 정수 오버플로우”// 부호 있는 정수 오버플로우 → UBint x = INT_MAX;int y = x + 1; // UB! 컴파일러는 이 경우가 없다고 가정
// 컴파일러 최적화 결과 (GCC -O2)// if (x + 1 > x) → 항상 true로 최적화됨// 부호 없는 정수는 오버플로우가 정의됨 (모듈러 산술)unsigned int u = UINT_MAX;unsigned int v = u + 1; // 정의됨: 0
// 안전한 오버플로우 검사bool safe_add(int a, int b, int* result){ // C++20 return !__builtin_add_overflow(a, b, result); // 또는 직접 검사: // if (b > 0 && a > INT_MAX - b) return false; // if (b < 0 && a < INT_MIN - b) return false; // *result = a + b; return true;}2. 배열 범위 초과 접근
섹션 제목: “2. 배열 범위 초과 접근”int arr[5] = {1, 2, 3, 4, 5};
// UB: 배열 범위 초과int x = arr[5]; // UBint y = arr[-1]; // UB
// 포인터 산술 UBint* p = arr + 5; // 하나 지난 포인터: 정의됨int z = *p; // UB: 역참조는 불가
// 안전 대안std::array<int, 5> safe_arr = {1, 2, 3, 4, 5};int w = safe_arr.at(5); // std::out_of_range 예외3. 허상 포인터와 Use-After-Free
섹션 제목: “3. 허상 포인터와 Use-After-Free”int* make_local(){ int x = 42; return &x; // UB: 지역 변수 주소 반환}
void use_after_free(){ int* p = new int(10); delete p; *p = 20; // UB: 해제된 메모리 접근 delete p; // UB: 이중 해제}
// nullptr 역참조void null_deref(){ int* p = nullptr; *p = 5; // UB}4. 엄격한 앨리어싱 규칙 (Strict Aliasing)
섹션 제목: “4. 엄격한 앨리어싱 규칙 (Strict Aliasing)”// 다른 타입 포인터를 통한 접근 → UBfloat f = 3.14f;int* i = reinterpret_cast<int*>(&f); // UB: float를 int로 앨리어싱*i = 0;
// 올바른 타입 펀닝: memcpy 사용uint32_t bits;std::memcpy(&bits, &f, sizeof(f)); // 정의됨
// C++20: std::bit_castuint32_t bits2 = std::bit_cast<uint32_t>(f); // 정의됨, constexpr 가능
// char/unsigned char는 예외: 어떤 타입이든 앨리어싱 허용unsigned char* raw = reinterpret_cast<unsigned char*>(&f); // 정의됨5. 초기화되지 않은 변수
섹션 제목: “5. 초기화되지 않은 변수”int x; // 초기화되지 않음int y = x + 1; // UB: x 값이 미정
bool flag;if (flag) { } // UB: flag가 0도 1도 아닐 수 있음
// 컴파일러는 flag가 항상 false라고 최적화할 수 있음
// 안전: 항상 초기화int a = 0;bool b = false;int arr[10] = {}; // 0으로 초기화6. 시프트 연산 UB
섹션 제목: “6. 시프트 연산 UB”int x = 1;
// 음수 시프트 → UBint y = x << -1; // UB
// 타입 너비 이상 시프트 → UB (32비트 int 기준)int z = x << 32; // UBint w = x << 31; // UB (부호 있는 오버플로우)
// 안전한 시프트uint32_t u = 1u;uint32_t v = u << 31; // 정의됨: 부호 없는 타입
// 또는 std::rotl 사용 (C++20)uint32_t rotated = std::rotl(u, 31);7. 컴파일러 UB 최적화 예시
섹션 제목: “7. 컴파일러 UB 최적화 예시”// 컴파일러가 UB를 이용해 코드를 제거하는 예void process(int* p){ // 컴파일러: p가 UB 없이 역참조되므로 p != nullptr *p = 42; if (p == nullptr) { // 이 분기는 제거됨! handle_null(); }}
// 루프 무한화 예시for (int i = 0; i < n; ++i){ // i + 1이 오버플로우하면 UB → 컴파일러는 오버플로우 없다고 가정 // → 루프 조건을 항상 true로 최적화할 수 있음 arr[i + 1] = arr[i];}8. UB 탐지 도구
섹션 제목: “8. UB 탐지 도구”# UBSan — 미정의 동작 탐지clang++ -fsanitize=undefined -g -O1 main.cpp -o main./main# runtime error: signed integer overflow: 2147483647 + 1
# ASan — 메모리 오류 탐지 (Use-after-free, OOB)clang++ -fsanitize=address -g -O1 main.cpp -o main./main
# 두 가지 동시 적용clang++ -fsanitize=address,undefined -g -O1 main.cpp -o main
# Valgrind — 메모리 누수, Use-after-freevalgrind --tool=memcheck --leak-check=full ./main
# MSVC: /RTC1 (런타임 검사), /analyze (정적 분석)cl /RTC1 /analyze main.cpp9. 컴파일러 경고 활성화
섹션 제목: “9. 컴파일러 경고 활성화”# GCC/Clang 경고 플래그-Wall -Wextra -Wpedantic-Wshadow-Wconversion-Wnull-dereference-Wformat=2-Wshift-overflow=2-fsanitize=undefined
# CMake 설정target_compile_options(myapp PRIVATE -Wall -Wextra -Wpedantic $<$<CONFIG:Debug>:-fsanitize=undefined,address>)target_link_options(myapp PRIVATE $<$<CONFIG:Debug>:-fsanitize=undefined,address>)10. 미정의 동작 vs 구현 정의 vs 명시되지 않은 동작
섹션 제목: “10. 미정의 동작 vs 구현 정의 vs 명시되지 않은 동작”// 1. Undefined Behavior (UB): 표준이 아무것도 보장하지 않음// 컴파일러가 어떤 코드를 생성해도 됨 — 가장 위험int* p = nullptr;*p = 42; // UB: null 역참조
// 2. Implementation-Defined Behavior: 각 컴파일러가 문서화하여 정의// 이식성 없지만 해당 플랫폼에서는 예측 가능int x = -1;unsigned u = (unsigned)x; // 구현 정의: 대부분 플랫폼에서 UINT_MAX이지만 보장 없음int s = x >> 1; // 구현 정의: 산술/논리 시프트 여부는 컴파일러 결정
// 3. Unspecified Behavior: 표준이 여러 결과를 허용하지만 모두 유효// UB는 아니지만 이식성 없음int a = 1;int b = a++ + a++; // 명시되지 않은 동작: 결과값은 구현마다 다름 // C++17부터 일부 순서가 명확해졌지만 여전히 주의 필요11. 자주 보이는 UB 패턴과 안전한 대체
섹션 제목: “11. 자주 보이는 UB 패턴과 안전한 대체”// 패턴 1: signed overflow → unsigned 타입 사용// 위험int len = strlen(str);if (len + 1 > MAX) { /* ... */ } // len이 INT_MAX면 overflow UB
// 안전size_t len2 = strlen(str);if (len2 + 1 > MAX) { /* ... */ } // size_t는 unsigned, overflow 정의됨
// 패턴 2: 허상 포인터 → RAII + 스마트 포인터// 위험MyObj* obj = new MyObj();delete obj;obj->method(); // UB: use-after-free
// 안전auto obj2 = std::make_unique<MyObj>();obj2->method();// 소멸 후 접근 불가능
// 패턴 3: 타입 펀닝 → std::bit_cast / memcpy// 위험float f = 3.14f;int* ip = reinterpret_cast<int*>(&f); // strict aliasing 위반 UBint bits = *ip;
// 안전 (C++20)int bits2 = std::bit_cast<int>(f);
// 안전 (C++11 이상)int bits3;std::memcpy(&bits3, &f, sizeof(f));
// 패턴 4: 배열 범위 초과 → at() 또는 GSL::span// 위험std::vector<int> v = {1, 2, 3};int x = v[5]; // UB: 범위 초과
// 안전int x2 = v.at(5); // std::out_of_range 예외 발생UB의 핵심 교훈: 컴파일러는 UB가 일어나지 않는다고 가정하고 최적화한다. 따라서 디버그 빌드에서의 ‘우연한 정상 동작’을 믿지 마세요. 개발 중에는 -fsanitize=undefined,address를 항상 활성화하고, std::bit_cast로 타입 펀닝, std::array::at()으로 경계 검사, 정수 오버플로우는 부호 없는 타입이나 __builtin_*_overflow로 처리하세요.
| UB 종류 | 안전한 대안 |
|---|---|
| 부호 있는 정수 오버플로우 | unsigned 타입, __builtin_add_overflow |
| 배열 범위 초과 | std::array::at(), std::span |
| use-after-free | std::unique_ptr, std::shared_ptr |
| null 역참조 | null 체크 후 접근, std::optional |
| strict aliasing 위반 | std::bit_cast (C++20), memcpy |
| 초기화되지 않은 변수 | 항상 선언 시 초기화 (= 0, = {}) |
| 시프트 연산 UB | 부호 없는 타입, 시프트 양 범위 확인 |