콘텐츠로 이동

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>
)

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 위반 UB
int 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-freestd::unique_ptr, std::shared_ptr
null 역참조null 체크 후 접근, std::optional
strict aliasing 위반std::bit_cast (C++20), memcpy
초기화되지 않은 변수항상 선언 시 초기화 (= 0, = {})
시프트 연산 UB부호 없는 타입, 시프트 양 범위 확인