C++ 미정의 동작 (UB) 완벽 가이드 | "릴리스에서만 크래시" 원인과 해결
이 글의 핵심
C++ 미정의 동작 (UB) 완벽 가이드에 대해 정리한 개발 블로그 글입니다. 미정의 동작(Undefined Behavior, UB)은 C++ 표준에서 "어떤 일이 일어날지 정의하지 않은" 코드입니다. 컴파일러는 UB가 절대 일어나지 않는다고 가정하고 최적화하므로, UB가 있는 코드는 예측 불가능하게… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련…
들어가며: “디버그에서는 되는데 릴리스에서 크래시…"
"최적화를 켰더니 프로그램이 이상하게 동작해요”
미정의 동작(Undefined Behavior, UB)은 C++ 표준에서 “어떤 일이 일어날지 정의하지 않은” 코드입니다. 컴파일러는 UB가 절대 일어나지 않는다고 가정하고 최적화하므로, UB가 있는 코드는 예측 불가능하게 동작합니다.
// ❌ 미정의 동작
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[10]; // 범위 밖 접근 → UB
// 가능한 결과:
// 1. 쓰레기 값 읽기
// 2. 크래시 (Segmentation Fault)
// 3. "정상" 작동 (운 좋게 유효한 메모리)
// 4. 컴파일러가 이 코드를 완전히 제거
이 글에서 다루는 것:
- 미정의 동작의 15가지 주요 패턴
- 왜 디버그에서는 되는데 릴리스에서 크래시가 나는지
- UBSan으로 UB 탐지하기
- 컴파일러가 UB를 어떻게 최적화하는지
- 실전 UB 버그 사례
목차
1. 미정의 동작이란?
정의
미정의 동작은 C++ 표준이 “이런 코드의 동작을 정의하지 않는다”고 명시한 상황입니다. 컴파일러는 UB가 절대 일어나지 않는다고 가정하고 최적화합니다.
UB의 3가지 특징
- 예측 불가능: 같은 코드가 실행마다 다르게 동작
- 컴파일러 의존적: GCC에서는 되는데 Clang에서는 크래시
- 최적화 의존적: -O0에서는 되는데 -O3에서는 이상하게 동작
UB vs 구현 정의 (Implementation-Defined) vs 명시되지 않음 (Unspecified)
| 용어 | 의미 | 예시 |
|---|---|---|
| Undefined Behavior | 표준이 정의 안 함, 무엇이든 가능 | 배열 범위 초과, 널 포인터 역참조 |
| Implementation-Defined | 컴파일러가 정의, 문서화됨 | sizeof(int), char의 부호 |
| Unspecified | 여러 가능성 중 하나, 문서화 안 됨 | 함수 인자 평가 순서 |
2. UB의 15가지 주요 패턴
패턴 1: 배열 범위 초과
// ❌ UB
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[10]; // 범위 밖 접근
// 가능한 결과:
// - 쓰레기 값
// - 크래시
// - "정상" 작동 (다른 변수 읽기)
패턴 2: 널 포인터 역참조
// ❌ UB
int* ptr = nullptr;
int x = *ptr; // 널 포인터 역참조
// 대부분 크래시 (Segmentation Fault)
패턴 3: 댕글링 포인터
// ❌ UB
int* ptr;
{
int x = 42;
ptr = &x;
} // x 소멸
int y = *ptr; // 댕글링 포인터 역참조
패턴 4: 초기화되지 않은 변수 읽기
// ❌ UB
int x; // 초기화 안 함
std::cout << x << '\n'; // 쓰레기 값
// 디버그: 0 (운 좋게)
// 릴리스: 임의의 값 또는 최적화로 제거
패턴 5: signed integer overflow
// ❌ UB
int x = INT_MAX;
int y = x + 1; // 오버플로우 → UB
// 가능한 결과:
// - INT_MIN (wrapping, 하지만 보장 안 됨)
// - 임의의 값
// - 컴파일러가 "x + 1 > x"를 항상 true로 최적화
주의: unsigned는 UB가 아닙니다 (wrapping 보장).
// ✅ 정의된 동작
unsigned int x = UINT_MAX;
unsigned int y = x + 1; // 0 (wrapping)
패턴 6: 잘못된 캐스팅
// ❌ UB
int x = 42;
double* ptr = reinterpret_cast<double*>(&x);
double y = *ptr; // int를 double로 읽기 → UB
패턴 7: 객체 수명 외 접근
// ❌ UB
std::string* ptr;
{
std::string s = "hello";
ptr = &s;
} // s 소멸
std::cout << *ptr << '\n'; // 소멸된 객체 접근
패턴 8: 데이터 레이스 (Data Race)
// ❌ UB
int counter = 0;
void worker() {
for (int i = 0; i < 1000000; ++i) {
++counter; // 동기화 없이 공유 변수 수정
}
}
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
// counter는 2000000이 아닐 수 있음 (UB)
패턴 9: 잘못된 delete
// ❌ UB
int* arr = new int[10];
delete arr; // delete[] 아님!
// ❌ UB
int x = 42;
int* ptr = &x;
delete ptr; // 스택 변수 delete
// ❌ UB
int* ptr = new int(42);
delete ptr;
delete ptr; // 이중 해제
패턴 10: 순서 미정의 표현식
// ❌ UB
int i = 0;
int x = ++i + ++i; // 같은 변수를 두 번 수정
// ❌ UB
int arr[10];
int i = 0;
arr[i] = i++; // 읽기와 쓰기 순서 미정의
패턴 11: 잘못된 포인터 산술
// ❌ UB
int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr + 10; // 범위 밖 포인터
int x = *ptr; // 역참조 → UB
// ✅ 범위 끝 포인터는 OK (역참조만 안 하면)
int* end = arr + 5; // OK
if (ptr != end) {
int x = *ptr; // 역참조는 범위 내에서만
}
패턴 12: 잘못된 정렬 (Alignment)
// ❌ UB (일부 플랫폼)
char buffer[10];
int* ptr = reinterpret_cast<int*>(buffer + 1); // 정렬 안 맞음
int x = *ptr; // ARM에서 Bus Error
패턴 13: 가상 함수를 생성자/소멸자에서 호출
// ❌ UB
class Base {
public:
Base() {
init(); // 파생 클래스의 init 호출 안 됨
}
virtual void init() {
std::cout << "Base::init\n";
}
};
class Derived : public Base {
public:
void init() override {
std::cout << "Derived::init\n";
}
};
Derived d; // "Base::init" 출력 (예상: "Derived::init")
패턴 14: 문자열 리터럴 수정
// ❌ UB
char* str = "hello";
str[0] = 'H'; // 문자열 리터럴은 읽기 전용 → UB
// ✅ 올바른 코드
char str[] = "hello"; // 배열로 복사
str[0] = 'H'; // OK
패턴 15: 잘못된 타입 punning
// ❌ UB
int x = 42;
float y = *reinterpret_cast<float*>(&x); // strict aliasing 위반
// ✅ 올바른 방법
float y;
std::memcpy(&y, &x, sizeof(float));
3. 디버그 vs 릴리스 동작 차이
왜 디버그에서는 되는가?
디버그 빌드 (-O0):
- 메모리를 0으로 초기화 (쓰레기 값 방지)
- 경계 검사 활성화 (iterator debugging)
- 최적화 안 함 (코드 그대로 실행)
- 스택 가드 (버퍼 오버런 탐지)
// 디버그 빌드
int x; // 0으로 초기화됨 (운 좋게)
if (x == 0) { // true
// ...
}
왜 릴리스에서 크래시가 나는가?
릴리스 빌드 (-O3):
- 메모리를 초기화하지 않음 (쓰레기 값)
- 경계 검사 없음 (속도 우선)
- 공격적 최적화 (UB 가정)
- 인라인·루프 언롤링 (코드 변형)
// 릴리스 빌드
int x; // 쓰레기 값 (예: 0x12345678)
if (x == 0) { // false
// ...
}
// 또는 컴파일러가 "x는 초기화되지 않았으므로 이 코드는 도달 불가"로 판단해 제거
예제: 컴파일러 최적화와 UB
// ❌ UB 코드
int* ptr = nullptr;
if (ptr != nullptr) {
*ptr = 42;
}
// 디버그: if문이 false이므로 역참조 안 함
// 릴리스: 컴파일러가 "ptr이 nullptr이면 역참조는 UB이므로,
// 이 코드는 ptr != nullptr일 때만 실행된다"고 가정
// → if문 제거하고 무조건 역참조 → 크래시!
4. UBSan으로 UB 탐지
컴파일 (GCC/Clang)
# UBSan 활성화
g++ -g -fsanitize=undefined -std=c++17 -o myapp main.cpp
# 실행
./myapp
탐지 가능한 UB
- 배열 범위 초과
- 널 포인터 역참조
- signed integer overflow
- 잘못된 캐스팅
- 정렬 오류
- 초기화되지 않은 변수 (일부)
출력 예시
// 테스트 코드
int main() {
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[10]; // UB
return 0;
}
UBSan 출력:
main.cpp:3:13: runtime error: index 10 out of bounds for type 'int [5]'
main.cpp:3:13: runtime error: load of address 0x7ffc1234 with insufficient space
for an object of type 'int'
0x7ffc1234: note: pointer points here
01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00
^
UBSan 옵션
# 모든 UB 체크
g++ -fsanitize=undefined main.cpp
# 특정 UB만 체크
g++ -fsanitize=bounds,null,signed-integer-overflow main.cpp
# 에러 발생 시 즉시 중단
export UBSAN_OPTIONS=halt_on_error=1
# 로그 파일로 저장
export UBSAN_OPTIONS=log_path=ubsan.log
5. 컴파일러 최적화와 UB
예제 1: 널 포인터 체크 제거
// ❌ UB 코드
void process(int* ptr) {
*ptr = 42; // ptr이 nullptr이면 UB
if (ptr == nullptr) { // 컴파일러: "이미 역참조했으므로 nullptr일 수 없음"
return; // → 이 코드 제거
}
*ptr = 99;
}
// 릴리스 빌드: if문이 제거되어 항상 *ptr = 99 실행
예제 2: signed overflow 최적화
// ❌ UB 코드
bool isPositive(int x) {
return x + 1 > x; // signed overflow는 UB
}
// 컴파일러: "x + 1은 항상 x보다 크다 (overflow는 UB이므로 일어나지 않음)"
// → 함수를 "return true;"로 최적화
int x = INT_MAX;
std::cout << isPositive(x) << '\n'; // 1 (예상: 0)
예제 3: 무한 루프 최적화
// ❌ UB 코드
int main() {
int i = 0;
while (i >= 0) { // signed overflow는 UB
++i;
}
return 0;
}
// 컴파일러: "i는 항상 >= 0이다 (overflow는 UB)"
// → 무한 루프로 최적화, return 0 제거
6. 실전 UB 버그 사례
사례 1: 게임 서버 간헐적 크래시
증상: 플레이어가 많을 때 서버가 간헐적으로 크래시합니다.
// ❌ 버그 코드
class Player {
int health;
public:
void takeDamage(int damage) {
health -= damage;
if (health < 0) { // signed underflow 가능
health = 0;
}
}
bool isAlive() const {
return health > 0;
}
};
// 문제: health가 INT_MIN 근처면 health - damage가 overflow → UB
UBSan 출력:
player.cpp:6:9: runtime error: signed integer overflow:
-2147483648 - 100 cannot be represented in type 'int'
해결:
// ✅ 수정된 코드
class Player {
int health;
public:
void takeDamage(int damage) {
// 오버플로우 방지
if (damage > health) {
health = 0;
} else {
health -= damage;
}
}
};
사례 2: 이미지 처리 버그
증상: 특정 이미지에서만 크래시가 발생합니다.
// ❌ 버그 코드
void applyFilter(Image& img) {
for (int y = 0; y < img.height; ++y) {
for (int x = 0; x < img.width; ++x) {
// 3x3 커널 적용
for (int dy = -1; dy <= 1; ++dy) {
for (int dx = -1; dx <= 1; ++dx) {
int ny = y + dy;
int nx = x + dx;
Color c = img.getPixel(ny, nx); // 범위 체크 없음!
}
}
}
}
}
// y=0, dy=-1 → ny=-1 → 범위 밖 접근 → UB
해결:
// ✅ 수정된 코드
void applyFilter(Image& img) {
for (int y = 1; y < img.height - 1; ++y) { // 경계 제외
for (int x = 1; x < img.width - 1; ++x) {
for (int dy = -1; dy <= 1; ++dy) {
for (int dx = -1; dx <= 1; ++dx) {
int ny = y + dy;
int nx = x + dx;
Color c = img.getPixel(ny, nx); // 안전
}
}
}
}
}
사례 3: 금융 계산 오버플로우
증상: 큰 금액 계산 시 음수가 나옵니다.
// ❌ 버그 코드
int calculateTotal(const std::vector<int>& prices) {
int total = 0;
for (int price : prices) {
total += price; // overflow 가능 → UB
}
return total;
}
// prices = {1000000000, 1000000000, 1000000000}
// total = -1294967296 (overflow)
해결:
// ✅ 수정된 코드 1: 더 큰 타입 사용
long long calculateTotal(const std::vector<int>& prices) {
long long total = 0;
for (int price : prices) {
total += price; // long long은 범위가 넓음
}
return total;
}
// ✅ 수정된 코드 2: 오버플로우 체크
int calculateTotal(const std::vector<int>& prices) {
int total = 0;
for (int price : prices) {
if (total > INT_MAX - price) {
throw std::overflow_error("Total overflow");
}
total += price;
}
return total;
}
UB 탐지 도구 조합
권장 조합
# 1. 컴파일러 경고
g++ -Wall -Wextra -Werror main.cpp
# 2. UBSan (미정의 동작)
g++ -fsanitize=undefined main.cpp
# 3. ASan (메모리 오류)
g++ -fsanitize=address main.cpp
# 4. TSan (데이터 레이스)
g++ -fsanitize=thread main.cpp
# 5. MSan (초기화되지 않은 메모리)
clang++ -fsanitize=memory main.cpp
CI/CD 통합
# .github/workflows/sanitizers.yml
name: Sanitizers
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build with UBSan
run: |
g++ -fsanitize=undefined -g main.cpp -o myapp
./myapp
- name: Build with ASan
run: |
g++ -fsanitize=address -g main.cpp -o myapp
./myapp
정리
UB 방지 체크리스트
- 모든 포인터를 초기화했는가?
- 배열 인덱스가 범위 내인가?
- signed integer overflow 가능성이 있는가?
- 멀티스레드에서 동기화했는가?
- new/delete 짝이 맞는가? (new[] → delete[])
- 댕글링 포인터가 없는가?
- 초기화되지 않은 변수를 읽지 않는가?
UB 탐지 도구 비교
| 도구 | 탐지 범위 | 속도 오버헤드 | 재컴파일 |
|---|---|---|---|
| UBSan | 대부분의 UB | 낮음 (2배) | 필요 |
| ASan | 메모리 오류 | 낮음 (2배) | 필요 |
| TSan | 데이터 레이스 | 중간 (5~15배) | 필요 |
| MSan | 초기화 안 된 메모리 | 중간 (3배) | 필요 |
| Valgrind | 메모리 오류 | 높음 (10~50배) | 불필요 |
핵심 규칙
- UB는 절대 “운 좋게 동작”하지 않습니다 (언젠가 크래시)
- 디버그에서 되면 릴리스에서도 된다는 보장 없습니다
- Sanitizer를 CI/CD에 통합하세요
- 컴파일러 경고를 에러로 취급하세요 (-Werror)
- 스마트 포인터와 RAII를 사용하세요
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Undefined Behavior | “미정의 동작” 완벽 가이드
- C++ Segmentation Fault | core dump 디버깅 가이드
- C++ Sanitizer 완벽 가이드 | ASan·UBSan·TSan 실전
- C++ 데이터 레이스 | mutex·atomic으로 동기화
마치며
미정의 동작은 C++에서 가장 위험한 버그입니다. “디버그에서는 되는데 릴리스에서 크래시”는 대부분 UB가 원인입니다.
핵심 원칙:
- UB는 “운 좋게 동작”이 아닙니다 (시한폭탄)
- Sanitizer를 항상 사용하세요 (UBSan, ASan, TSan)
- 컴파일러 경고를 무시하지 마세요 (-Wall -Wextra -Werror)
- 스마트 포인터와 RAII로 UB를 원천 차단하세요
프로덕션 배포 전에 모든 Sanitizer로 테스트하고, 릴리스 빌드로 충분히 검증하세요. UB는 고객 환경에서 발견되면 재현이 매우 어렵습니다.
다음 단계: UB를 방지했다면, C++ RAII 패턴과 C++ 스마트 포인터로 더 안전한 코드를 작성해 보세요.
관련 글
- C++ 스택 오버플로우 에러 |
- C++ Segmentation Fault |
- C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
- C++ 반복자 무효화 에러 |
- C++ 템플릿 에러 메시지 해석 |