콘텐츠로 이동

C++ 미정의 동작(UB) 완전 가이드

미정의 동작(Undefined Behavior, UB)은 C++ 표준이 동작을 규정하지 않은 코드 영역입니다. 컴파일러는 UB가 발생하지 않는다고 가정하고 공격적으로 최적화하므로, UB가 있는 코드는 디버그 빌드에서 정상 동작해도 릴리스 빌드에서 예상치 못한 결과를 낳습니다.


// 부호 있는 정수 오버플로우 → UB
int 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;
}

int arr[5] = {1, 2, 3, 4, 5};
// UB: 배열 범위 초과
int x = arr[5]; // UB
int y = arr[-1]; // UB
// 포인터 산술 UB
int* 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 예외

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)”
// 다른 타입 포인터를 통한 접근 → UB
float 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_cast
uint32_t bits2 = std::bit_cast<uint32_t>(f); // 정의됨, constexpr 가능
// char/unsigned char는 예외: 어떤 타입이든 앨리어싱 허용
unsigned char* raw = reinterpret_cast<unsigned char*>(&f); // 정의됨

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으로 초기화

int x = 1;
// 음수 시프트 → UB
int y = x << -1; // UB
// 타입 너비 이상 시프트 → UB (32비트 int 기준)
int z = x << 32; // UB
int w = x << 31; // UB (부호 있는 오버플로우)
// 안전한 시프트
uint32_t u = 1u;
uint32_t v = u << 31; // 정의됨: 부호 없는 타입
// 또는 std::rotl 사용 (C++20)
uint32_t rotated = std::rotl(u, 31);

// 컴파일러가 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];
}

Terminal window
# 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-free
valgrind --tool=memcheck --leak-check=full ./main
# MSVC: /RTC1 (런타임 검사), /analyze (정적 분석)
cl /RTC1 /analyze main.cpp

Terminal window
# 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>
)

UB의 핵심 교훈: 컴파일러는 UB가 일어나지 않는다고 가정하고 최적화한다. 따라서 디버그 빌드에서의 ‘우연한 정상 동작’을 믿지 마세요. 개발 중에는 -fsanitize=undefined,address를 항상 활성화하고, std::bit_cast로 타입 펀닝, std::array::at()으로 경계 검사, 정수 오버플로우는 부호 없는 타입이나 __builtin_*_overflow로 처리하세요.