C++ Undefined Behavior | '미정의 동작' 완벽 가이드
이 글의 핵심
C++ Undefined Behavior: "미정의 동작" Undefined Behavior란?·주요 UB 종류.
Undefined Behavior란?
미정의 동작(Undefined Behavior, UB)이란 C++ 표준이 특정 코드의 실행 결과를 전혀 규정하지 않는다는 것을 의미하며, 이런 코드를 실행하면 문자 그대로 “무슨 일이든” 일어날 수 있습니다. 프로그램이 정상적으로 동작하는 것처럼 보일 수도, 크래시가 날 수도, 완전히 엉뚱한 값을 출력할 수도, 심지어 보안 취약점으로 이어질 수도 있는데, 표준은 이 중 어느 것도 보장하거나 강제하지 않습니다. 아래 세 가지 예시는 각각 초기화되지 않은 변수를 읽는 경우, 배열의 유효 범위를 벗어난 인덱스에 접근하는 경우, nullptr을 역참조하는 경우로, 모두 겉보기에는 문법적으로 문제없어 컴파일이 되지만 표준상 결과가 전혀 정의되지 않은 대표적인 UB 사례입니다. C++가 이런 위험을 감수하면서까지 UB를 허용하는 이유는 컴파일러가 “이런 상황은 절대 일어나지 않는다”고 가정하고 훨씬 공격적인 최적화를 적용할 수 있게 하기 위해서입니다.
// UB 예시
int x;
cout << x << endl; // 초기화 안된 변수 (UB)
int arr[5];
arr[10] = 0; // 범위 초과 (UB)
int* ptr = nullptr;
*ptr = 10; // nullptr 역참조 (UB)
결과
- 컴파일러마다 다른 동작
- 최적화에 따라 다른 동작
- 예측 불가능
주요 UB 종류
UB는 그 종류가 매우 다양하지만, 실무에서 실제로 자주 마주치는 패턴은 몇 가지로 좁혀집니다. 아래 여덟 가지는 C++ 코드베이스에서 특히 빈번하게 등장하는 UB 유형으로, 각각 왜 문제인지와 어떻게 고쳐야 하는지를 함께 다룹니다.
1. 초기화 안된 변수
지역 변수를 선언만 하고 초기화하지 않으면 그 변수는 스택에 남아 있던 임의의 쓰레기 값을 갖게 되며, 이 값을 읽는 것 자체가 UB입니다. 문제는 이 쓰레기 값이 실행할 때마다, 심지어 같은 프로그램을 두 번 실행해도 다를 수 있어서 버그가 재현되지 않는 경우가 많다는 점입니다. 해결책은 간단한데, 변수를 선언하는 즉시 명시적인 초기값을 지정하는 습관을 들이는 것만으로 이 종류의 UB는 거의 완전히 예방할 수 있습니다.
// ❌ UB
int x;
if (x == 0) { // UB
// ...
}
// ✅ 초기화
int x = 0;
if (x == 0) {
// ...
}
2. 배열 범위 초과
C 스타일 배열이나 operator[]로 접근하는 std::vector는 인덱스가 유효 범위를 벗어나도 즉시 에러를 내지 않고 그냥 배열 뒤의 임의 메모리에 접근해 버립니다. 이 접근이 우연히 다른 변수의 메모리를 덮어써도 프로그램이 한동안 정상적으로 동작하는 것처럼 보일 수 있어, 실제 원인과 증상이 나타나는 시점이 멀리 떨어져 디버깅이 매우 까다로워집니다. 인덱스가 항상 안전한 범위 안에 있다고 직접 검증하거나, 범위를 벗어나면 예외를 던지는 vector::at()을 사용해 최소한 조용한 UB 대신 명확한 예외로 실패하도록 만드는 것이 안전합니다.
// ❌ UB
int arr[5];
arr[10] = 0; // 범위 초과
// ✅ 범위 체크
if (index < 5) {
arr[index] = 0;
}
// ✅ vector 사용
vector<int> v(5);
v.at(10); // 예외 발생 (UB 아님)
3. nullptr 역참조
nullptr은 애초에 아무것도 가리키지 않는다는 것을 나타내는 특수한 값이므로, 이를 역참조해서 값을 읽거나 쓰려는 시도는 명백히 유효하지 않은 메모리 접근이 됩니다. 많은 환경에서 이런 접근은 세그멘테이션 폴트로 즉시 크래시가 나기 때문에 비교적 발견하기 쉬운 편이지만, 표준상으로는 UB이므로 항상 크래시가 보장되는 것은 아니며 최적화 수준에 따라 다른 동작을 보일 수도 있습니다. 포인터를 역참조하기 전에는 항상 nullptr 여부를 확인하거나, 애초에 nullptr이 될 수 없는 참조나 스마트 포인터로 설계를 바꿔 이 문제를 원천적으로 방지하는 것이 바람직합니다.
// ❌ UB
int* ptr = nullptr;
*ptr = 10;
// ✅ nullptr 체크
if (ptr != nullptr) {
*ptr = 10;
}
4. 댕글링 포인터
댕글링 포인터는 가리키던 대상이 이미 소멸되었는데도 여전히 그 주소를 들고 있는 포인터를 말하며, 아래 예시처럼 함수의 지역 변수 주소를 반환하는 경우가 대표적인 원인입니다. getPointer()가 반환하는 시점에 지역 변수 x는 스택 프레임과 함께 이미 소멸되었으므로, 반환된 포인터는 더 이상 유효하지 않은 메모리를 가리키게 되고 이후 이를 역참조하는 것은 UB입니다. 이런 문제는 값을 힙에 동적으로 할당해 함수 스코프를 벗어나도 살아있게 만들거나, 함수 호출 사이에 값이 유지되어야 하는 경우라면 static 변수로 선언해 애초에 댕글링이 발생할 수 없는 구조로 설계를 바꾸는 것이 해결책입니다.
// ❌ UB
int* getPointer() {
int x = 10;
return &x; // 지역 변수 주소 반환
}
int* ptr = getPointer();
*ptr = 20; // UB
// ✅ 동적 할당 또는 static
int* getPointer() {
static int x = 10;
return &x;
}
5. 부호 있는 정수 오버플로우
C++ 표준은 int처럼 부호 있는 정수 타입의 오버플로를 UB로 규정하고 있는데, 이는 하드웨어마다 오버플로 시 동작(2의 보수 감싸기, 포화, 트랩 등)이 다를 수 있기 때문입니다. 이 때문에 컴파일러는 “부호 있는 정수는 절대 오버플로되지 않는다”고 가정하고 최적화를 수행하는데, 실제로 오버플로가 발생하는 코드가 있으면 컴파일러의 가정과 실제 실행이 어긋나 예상치 못한 결과가 나올 수 있습니다. 반면 부호 없는 정수 타입은 오버플로가 모듈로 연산으로 표준에 명확히 정의되어 있으므로, 오버플로가 발생할 수 있는 코드라면 값의 범위를 사전에 검사하거나 부호 없는 타입을 사용하는 것이 안전합니다.
// ❌ UB
int x = INT_MAX;
x++; // 부호 있는 오버플로우 (UB)
// ✅ 체크
if (x < INT_MAX) {
x++;
}
// ✅ 부호 없는 타입 (오버플로우 정의됨)
unsigned int x = UINT_MAX;
x++; // 0 (UB 아님)
6. 데이터 레이스
여러 스레드가 동기화 없이 동시에 같은 변수를 읽고 쓰면 데이터 레이스가 발생하며, C++ 표준은 이를 명시적으로 UB로 규정하고 있습니다. 아래 예시에서 일반 int counter를 여러 스레드가 동기화 장치 없이 counter++로 증가시키면, ++ 연산이 실제로는 읽기-더하기-쓰기의 세 단계로 이루어지기 때문에 두 스레드의 연산이 서로 겹쳐 증가분이 유실될 수 있고 표준상으로는 그 결과가 아예 정의되지 않습니다. mutex로 임계 구역을 보호하거나, 이 예시처럼 단순 카운터라면 std::atomic<int>로 타입 자체를 바꿔 컴파일러와 하드웨어가 동시 접근을 안전하게 처리하도록 만드는 것이 해결책입니다.
// ❌ UB
int counter = 0;
void increment() {
counter++; // 여러 스레드에서 (UB)
}
// ✅ mutex 또는 atomic
atomic<int> counter(0);
void increment() {
counter++;
}
7. 잘못된 캐스팅
reinterpret_cast로 서로 호환되지 않는 타입 사이를 강제로 오가는 것은 이른바 strict aliasing 규칙을 위반하는 대표적인 UB입니다. 아래 예시처럼 int의 주소를 double*로 캐스팅해서 그 위치에 값을 쓰는 것은, 컴파일러 입장에서 “서로 다른 타입의 포인터는 같은 메모리를 가리키지 않는다”는 전제를 깨뜨려 최적화 과정에서 예상치 못한 결과를 낳을 수 있습니다. 타입 간 변환이 정말 필요하다면 값 자체를 안전하게 바꿔주는 static_cast를 사용하거나, 비트 패턴을 그대로 재해석해야 하는 경우에는 memcpy나 C++20의 std::bit_cast처럼 표준이 UB 없이 허용하는 방법을 사용해야 합니다.
// ❌ UB
int x = 10;
double* ptr = reinterpret_cast<double*>(&x);
*ptr = 3.14; // 타입 위반 (UB)
// ✅ 올바른 캐스팅
double d = static_cast<double>(x);
8. 수정 중인 객체 접근
같은 표현식 안에서 하나의 변수를 여러 번 수정하거나, 수정과 읽기를 순서가 정해지지 않은 채로 함께 수행하면 UB가 됩니다. 아래 i = i++;는 i에 값을 대입하는 것과 i를 후위 증가시키는 것이 한 문장 안에 뒤섞여 있는데, C++17 이전 표준에서는 이 둘 사이의 순서가 명확히 정해져 있지 않아 컴파일러마다 다른 결과를 낼 수 있었습니다. 이런 모호함을 피하는 가장 확실한 방법은 하나의 문장에서 같은 변수를 두 번 이상 수정하는 표현식을 아예 쓰지 않고, 아래처럼 증가 연산을 별도의 문장으로 분리해 순서를 명확하게 만드는 것입니다.
// ❌ UB
int i = 0;
i = i++; // 순서 미정의 (UB)
// ✅ 명확한 순서
i++;
실전 예시
지금까지 다룬 UB 유형들은 대부분 클래스 설계나 코드 패턴을 조금만 바꾸면 실질적으로 예방할 수 있습니다. 아래 세 가지 예시는 배열 범위 초과, 댕글링 포인터, 초기화 누락이라는 가장 흔한 세 가지 UB를 실제 클래스 설계로 어떻게 막을 수 있는지 보여줍니다.
예시 1: 범위 체크
배열 범위를 벗어난 접근을 막는 가장 확실한 방법은 원시 배열 인덱싱을 직접 노출하지 않고, 범위를 검사하는 래퍼 클래스로 감싸는 것입니다. 아래 SafeArray 클래스는 내부적으로 std::vector를 사용하면서 operator[]에서 인덱스가 범위를 벗어나면 조용히 UB를 일으키는 대신 std::out_of_range 예외를 던지도록 구현되어 있습니다. 이렇게 하면 잘못된 인덱스로 접근하는 버그가 있더라도 프로그램이 예측 불가능하게 동작하는 대신 명확한 예외 메시지와 함께 실패해, 문제의 원인을 훨씬 빠르게 찾을 수 있습니다.
class SafeArray {
private:
vector<int> data;
public:
SafeArray(size_t size) : data(size) {}
int& operator {
if (index >= data.size()) {
throw out_of_range("인덱스 초과");
}
return data[index];
}
};
int main() {
SafeArray arr(5);
try {
arr[10] = 0; // 예외 발생
} catch (const out_of_range& e) {
cout << e.what() << endl;
}
}
예시 2: 스마트 포인터
앞서 다룬 댕글링 포인터 문제도 원시 포인터 대신 스마트 포인터를 사용하면 근본적으로 예방할 수 있습니다. 아래 잘못된 예시처럼 delete ptr 이후에 *ptr로 값을 읽으려는 것은 이미 해제된 메모리에 접근하는 UB이지만, std::make_unique로 만든 스마트 포인터는 스코프를 벗어나는 순간 자동으로 소멸자가 호출되어 메모리를 해제하므로 개발자가 직접 delete를 호출하고 그 시점을 관리할 필요 자체가 사라집니다. 이렇게 자원의 수명을 객체의 수명에 묶어두는 RAII 원칙을 적용하면, 댕글링 포인터라는 카테고리의 버그를 애초에 코드에 등장할 수 없게 만들 수 있습니다.
// ❌ 댕글링 포인터
int* ptr = new int(10);
delete ptr;
*ptr = 20; // UB
// ✅ 스마트 포인터
auto ptr = make_unique<int>(10);
// 자동 해제, 댕글링 방지
예시 3: 초기화 강제
클래스 멤버 변수의 초기화 누락도 흔한 UB 원인인데, 생성자에서 멤버 초기화 목록을 빠뜨리면 그 멤버는 클래스 타입에 따라 쓰레기 값을 가진 채로 남을 수 있습니다. 아래 Widget 클래스는 이런 실수를 막기 위해 생성자의 멤버 초기화 목록에서 value(0)처럼 명시적으로 초깃값을 지정하고 있으며, C++11부터는 클래스 정의 시점에 int value = 0;처럼 멤버 기본값을 직접 지정하는 방법도 함께 사용할 수 있습니다. 이렇게 초기화를 생성자나 멤버 선언 단계에서 강제해 두면, 나중에 새로운 생성자를 추가하더라도 초기화를 빠뜨릴 위험이 훨씬 줄어듭니다.
class Widget {
private:
int value;
public:
// ❌ 초기화 안함
// Widget() {}
// ✅ 초기화 강제
Widget() : value(0) {}
// ✅ C++11 멤버 초기화
// int value = 0;
};
UB 탐지 도구
UB는 컴파일러가 문법적으로 잡아낼 수 없기 때문에, 코드 리뷰만으로는 놓치기 쉽고 전용 런타임 탐지 도구를 개발 과정에 포함시키는 것이 사실상 필수적입니다. 아래 도구들은 각각 서로 다른 종류의 UB를 전문적으로 탐지하도록 설계되어 있어, 목적에 맞게 조합해서 사용하는 것이 효과적입니다.
AddressSanitizer (ASan)
AddressSanitizer는 메모리 관련 UB를 전문적으로 탐지하는 도구로, 컴파일 시 -fsanitize=address 플래그를 추가하면 메모리 접근마다 유효성을 검사하는 계측 코드가 자동으로 삽입됩니다. 프로그램을 그냥 실행하기만 해도 배열 범위 초과, 힙 버퍼 오버플로, 해제 후 사용(use-after-free) 같은 문제가 발생하는 순간 정확히 어느 소스 코드 라인에서 문제가 생겼는지 상세한 리포트를 출력해 줍니다. 아래처럼 배열 범위를 넘어서는 접근이 있으면, 프로그램이 조용히 잘못된 동작을 하는 대신 ASan이 즉시 이를 감지해 명확한 에러 메시지로 알려줍니다.
# 컴파일
g++ -fsanitize=address -g program.cpp -o program
# 실행
./program
# 출력: 메모리 오류 상세 정보
int main() {
int arr[5];
arr[10] = 0; // ASan이 탐지
}
UndefinedBehaviorSanitizer (UBSan)
UBSan은 ASan이 다루는 메모리 문제와는 결이 다른, 정수 오버플로·잘못된 형변환·정렬 위반 같은 언어 차원의 UB를 탐지하는 데 특화되어 있습니다. -fsanitize=undefined 플래그로 컴파일하면 이런 연산이 발생할 때마다 런타임에 검사를 수행해, 표준상 UB로 규정된 상황이 실제로 발생하는 순간을 정확히 짚어냅니다. 아래 예시처럼 INT_MAX에 도달한 int 변수를 증가시키는 부호 있는 정수 오버플로는 겉으로는 아무 문제 없이 실행되는 것처럼 보일 수 있지만, UBSan을 활성화하면 이 시점에서 즉시 경고를 출력해 줍니다.
# 컴파일
g++ -fsanitize=undefined -g program.cpp -o program
# 실행
./program
int main() {
int x = INT_MAX;
x++; // UBSan이 탐지
}
ThreadSanitizer (TSan)
ThreadSanitizer는 앞서 다룬 데이터 레이스처럼 멀티스레드 환경에서만 드러나는 UB를 탐지하기 위한 도구로, -fsanitize=thread로 컴파일하면 스레드 간 메모리 접근을 추적해 동기화 없이 같은 메모리에 동시 접근하는 지점을 찾아냅니다. 데이터 레이스는 타이밍에 따라 발생 여부가 달라지는 특성 때문에 일반적인 테스트만으로는 재현되지 않는 경우가 많은데, TSan은 실제로 레이스가 관측되지 않아도 잠재적으로 레이스가 가능한 접근 패턴 자체를 탐지할 수 있어 훨씬 신뢰도 높은 검증이 가능합니다.
# 컴파일
g++ -fsanitize=thread -g program.cpp -o program
아래 예시처럼 동기화 장치 없는 전역 변수 counter를 두 스레드가 동시에 증가시키면, TSan은 이 두 스레드의 접근이 서로 겹치는 것을 탐지해 데이터 레이스가 발생했다는 상세한 리포트를 출력해 줍니다.
int counter = 0;
void increment() {
counter++; // TSan이 데이터 레이스 탐지
}
int main() {
thread t1(increment);
thread t2(increment);
t1.join();
t2.join();
}
Valgrind
Valgrind는 Sanitizer 계열 도구들과 달리 프로그램을 다시 컴파일하지 않고도 이미 빌드된 바이너리를 가상 머신 위에서 실행하며 메모리 오류와 누수를 검사할 수 있는 독립적인 도구입니다. --leak-check=full 옵션을 주면 프로그램이 종료될 때까지 해제되지 않은 메모리 할당을 모두 추적해, 어느 코드에서 할당된 메모리가 누수되었는지 호출 스택과 함께 보여줍니다. Sanitizer만큼 빠르지는 않지만 컴파일 옵션을 바꿀 수 없는 상황이나 이미 배포된 바이너리를 검사해야 할 때 특히 유용합니다.
valgrind --leak-check=full ./program
자주 발생하는 UB
앞서 다룬 UB 유형들을 알고 있어도, 실제 코드에서는 미묘한 변형된 형태로 반복해서 등장합니다. 아래 다섯 가지는 코드 리뷰나 디버깅 과정에서 특히 자주 마주치는 구체적인 패턴입니다.
1. 배열 범위 초과
반복문의 종료 조건에 <=를 실수로 사용하는 것은 배열 범위 초과 중에서도 가장 전형적인 실수입니다. 아래 코드는 크기가 5인 배열에 대해 i <= 5 조건으로 반복하는데, 이렇게 하면 i가 5일 때 arr[5]에 접근하게 되어 유효 인덱스 범위(0부터 4까지)를 한 칸 벗어나는 흔한 “off-by-one” 오류가 발생합니다. 이런 실수를 피하는 가장 좋은 방법은 항상 < 연산자로 배열 크기와 비교하는 습관을 들이거나, 범위 기반 for문을 사용해 인덱스 계산 자체를 언어에 맡기는 것입니다.
// ❌
int arr[5];
for (int i = 0; i <= 5; i++) { // <= 주의!
arr[i] = 0;
}
// ✅
for (int i = 0; i < 5; i++) {
arr[i] = 0;
}
2. 문자열 종료 누락
C 스타일 문자열은 항상 끝에 널 종료 문자('\0')가 있어야 한다는 암묵적인 규칙에 의존하는데, 배열 크기를 계산할 때 이 한 바이트를 빠뜨리는 실수가 자주 발생합니다. 아래 코드에서 "hello"는 다섯 글자이지만 문자열 리터럴은 항상 끝에 널 종료 문자가 자동으로 추가되므로 실제로는 6바이트가 필요한데, 배열을 5바이트로 선언하면 널 종료 문자가 들어갈 공간이 없어 배열 경계를 넘어선 어딘가에 쓰이게 되는 UB가 발생합니다. 이 문제는 배열 크기를 항상 문자열 길이보다 하나 더 크게 잡거나, std::string처럼 크기를 자동으로 관리해 주는 타입을 사용하면 완전히 피할 수 있습니다.
// ❌
char str[5] = "hello"; // '\0' 공간 없음 (UB)
// ✅
char str[6] = "hello"; // '\0' 포함
3. 삭제 후 사용
delete로 메모리를 해제한 뒤에도 그 포인터가 여전히 이전 주소를 들고 있어서, 실수로 다시 역참조하는 use-after-free 버그가 발생하기 쉽습니다. 아래 코드에서 delete ptr 이후 *ptr을 출력하려는 시도는 이미 반환된 메모리에 접근하는 것이므로 UB이며, 그 메모리가 다른 용도로 재사용되기 전까지는 우연히 이전 값이 남아 있어 문제가 없어 보일 수도 있다는 점이 더 위험합니다. 해제하기 전에 필요한 값을 미리 복사해 두거나, 해제 직후 포인터를 nullptr로 초기화해 실수로 재사용하더라도 즉시 크래시가 나서 문제를 조기에 발견할 수 있도록 만드는 것이 좋은 습관입니다.
// ❌
int* ptr = new int(10);
delete ptr;
cout << *ptr << endl; // UB
// ✅
int* ptr = new int(10);
int value = *ptr;
delete ptr;
ptr = nullptr;
cout << value << endl;
4. 타입 펀닝
타입 펀닝은 하나의 메모리 영역을 원래 타입과 다른 타입의 포인터로 재해석해서 접근하는 기법으로, 저수준 최적화나 비트 조작에서 유혹적이지만 대부분의 경우 strict aliasing 규칙을 위반하는 UB입니다. 아래 코드처럼 int의 주소를 float*로 캐스팅해서 읽으면, 컴파일러는 서로 다른 타입의 포인터가 같은 메모리를 가리키지 않는다고 가정하고 최적화하기 때문에 실제 실행 결과가 예상과 다르게 나올 수 있습니다. 이런 재해석이 정말 필요한 상황이라면 memcpy로 비트 패턴을 그대로 복사하는 방법을 쓰면 컴파일러가 이를 UB 없는 안전한 연산으로 인식해 동일한 효과를 얻을 수 있습니다.
// ❌ UB (strict aliasing 위반)
int x = 10;
float* f = reinterpret_cast<float*>(&x);
cout << *f << endl;
// ✅ memcpy 사용
int x = 10;
float f;
memcpy(&f, &x, sizeof(int));
cout << f << endl;
5. 순서 미정의
앞서 다룬 “수정 중인 객체 접근” 문제의 또 다른 변형으로, 배열 인덱스 계산에 부작용이 있는 표현식을 섞어 쓰는 경우가 있습니다. 아래 arr[i] = i++;는 i를 인덱스로 사용하는 것과 i를 증가시키는 것이 한 표현식 안에 뒤섞여 있어, 인덱스 계산에 증가 전 값을 쓸지 증가 후 값을 쓸지가 컴파일러마다 달라질 수 있는 UB입니다. 이런 모호함은 인덱스 계산과 증가 연산을 별도의 문장으로 분리해 실행 순서를 코드 상에서 명시적으로 고정하면 완전히 해소되며, 가독성 측면에서도 한 줄에 여러 부작용을 압축하는 것보다 훨씬 이해하기 쉬운 코드가 됩니다.
// ❌ UB
int i = 0;
arr[i] = i++; // i를 읽고 쓰는 순서 미정의
// ✅ 명확한 순서
int i = 0;
arr[i] = i;
i++;
UB 회피 전략
지금까지 다룬 개별 UB 유형을 하나씩 기억하기보다, 몇 가지 일반적인 습관과 도구 사용 원칙을 코딩 스타일에 녹여두는 것이 장기적으로 훨씬 효과적입니다. 아래 다섯 가지는 UB 전반을 예방하는 데 도움이 되는 실천적인 전략들입니다.
1. 초기화
모든 UB 회피 전략 중 가장 기본이 되는 것은 변수를 선언과 동시에 항상 초기화하는 습관입니다. C++11부터 도입된 유니폼 초기화 문법(int x{};)을 사용하면 기본 타입이든 클래스 타입이든 일관된 문법으로 명시적인 초기값을 지정할 수 있어, 초기화를 깜빡하는 실수 자체를 줄이는 데 도움이 됩니다.
// 모든 변수 초기화
int x = 0;
int* ptr = nullptr;
// C++11 유니폼 초기화
int x{}; // 0으로 초기화
2. 범위 체크
컨테이너에 접근할 때 operator[] 대신 범위를 검사하는 at()을 사용하거나, 아예 인덱스를 직접 다루지 않는 범위 기반 for문을 쓰면 배열 범위 초과라는 카테고리의 UB를 코드 구조 자체로 방지할 수 있습니다. at()은 약간의 범위 검사 오버헤드가 있지만 그 대가로 조용한 UB 대신 명확한 예외를 얻을 수 있어, 성능이 극도로 중요한 구간이 아니라면 우선적으로 고려할 가치가 있습니다.
// at() 사용
vector<int> v(5);
v.at(10); // 예외 발생
// 범위 기반 for
for (int x : v) {
// 안전
}
3. 스마트 포인터
원시 포인터로 new와 delete를 직접 관리하는 대신 unique_ptr나 shared_ptr 같은 스마트 포인터를 사용하면, 댕글링 포인터나 메모리 누수처럼 수명 관리와 관련된 UB를 컴파일러가 자동으로 처리해 줍니다.
// unique_ptr, shared_ptr 사용
auto ptr = make_unique<int>(10);
// 자동 해제
4. const 사용
변수를 실수로 변경할 필요가 없다면 const로 선언해 두면, 나중에 실수로 값을 바꾸려는 코드를 작성해도 런타임에 이상 동작을 겪는 대신 컴파일 타임에 즉시 에러로 잡아낼 수 있습니다.
const int x = 10;
// x = 20; // 컴파일 에러 (UB 방지)
5. Sanitizer 사용
앞서 소개한 ASan, UBSan 같은 Sanitizer 도구를 개발 및 테스트 빌드에 상시 포함시켜 두면, 배포 전에 대부분의 메모리·정수·스레드 관련 UB를 자동으로 걸러낼 수 있습니다. 프로덕션 빌드에는 성능 오버헤드 때문에 보통 포함시키지 않지만, CI 파이프라인의 테스트 빌드에는 반드시 활성화해 두는 것이 좋습니다.
# 개발 중 항상 사용
g++ -fsanitize=address,undefined -g program.cpp
컴파일러 최적화와 UB
UB가 특히 위험한 이유는 단순히 “잘못된 코드가 잘못 동작한다”는 수준을 넘어, 컴파일러의 최적화가 코드의 논리적인 안전장치까지 통째로 지워버릴 수 있기 때문입니다. 아래 예시에서 개발자는 if (ptr != nullptr)으로 안전 장치를 만들어 두었다고 생각하지만, 만약 코드의 다른 어딘가에서 ptr이 이미 역참조된 적이 있다면 컴파일러는 “역참조되는 포인터는 UB 규칙상 절대 nullptr일 수 없다”고 논리적으로 추론할 수 있습니다. 그 결과 컴파일러는 이 nullptr 검사 자체를 죽은 코드로 판단해 완전히 제거해 버릴 수 있으며, 개발자가 작성한 안전장치가 실행 파일에는 아예 존재하지 않게 되는 반직관적인 상황이 발생합니다. 이런 사례는 왜 UB를 “지금 당장 문제없어 보인다”는 이유로 방치해서는 안 되는지를 잘 보여주며, 컴파일러 버전이나 최적화 수준이 바뀌는 순간 언제든 눈에 보이지 않던 문제가 갑자기 드러날 수 있다는 것을 시사합니다.
// UB가 있으면 컴파일러가 이상한 최적화
int* ptr = nullptr;
if (ptr != nullptr) {
*ptr = 10;
}
// 컴파일러: "ptr이 역참조되므로 nullptr일 수 없다"
// if 문 제거! (최적화)
FAQ
Q1: UB는 왜 존재하나요?
A:
- 성능 최적화 여지
- 플랫폼 독립성
- 하드웨어 차이
Q2: UB를 어떻게 피하나요?
A:
- 변수 초기화
- 범위 체크
- Sanitizer 사용
- 코드 리뷰
- 정적 분석 도구
Q3: UB가 발생하면?
A:
- 크래시
- 잘못된 결과
- 보안 취약점
- 예측 불가능한 동작
Q4: 모든 UB를 찾을 수 있나요?
A: 아니요. 하지만 Sanitizer와 정적 분석으로 대부분 찾을 수 있습니다.
Q5: UB와 Implementation-Defined의 차이는?
A:
- UB: 아무 일이나 일어날 수 있음
- Implementation-Defined: 컴파일러가 정의하고 문서화
Q6: UB 학습 리소스는?
A:
- cppreference.com (UB 목록)
- “Effective C++” (Scott Meyers)
- Sanitizer 문서
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 코드 리뷰 | “체크리스트” 20가지 [실무 필수]
- C++ 템플릿 특수화 | template specialization 가이드
- C++ Default Initialization | “기본 초기화” 가이드
관련 글
이 글에서 다루는 키워드 (관련 검색어)
C++, UB, undefined-behavior, 버그, 안전성 등으로 검색하시면 이 글이 도움이 됩니다.