본문으로 건너뛰기 C++ 미정의 동작 (UB) 완벽 가이드 | '릴리스에서만 크래시' 원인과 해결

C++ 미정의 동작 (UB) 완벽 가이드 | '릴리스에서만 크래시' 원인과 해결

C++ 미정의 동작 (UB) 완벽 가이드 | '릴리스에서만 크래시' 원인과 해결

이 글의 핵심

미정의 동작(Undefined Behavior, UB)은 C++ 표준에서 \"어떤 일이 일어날지 정의하지 않은\" 코드입니다. 컴파일러는 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가지 특징

  1. 예측 불가능: 같은 코드가 실행마다 다르게 동작
  2. 컴파일러 의존적: GCC에서는 되는데 Clang에서는 크래시
  3. 최적화 의존적: -O0에서는 되는데 -O3에서는 이상하게 동작

UB vs 구현 정의 (Implementation-Defined) vs 명시되지 않음 (Unspecified)

용어의미예시
Undefined Behavior표준이 정의 안 함, 무엇이든 가능배열 범위 초과, 널 포인터 역참조
Implementation-Defined컴파일러가 정의, 문서화됨sizeof(int), char의 부호
Unspecified여러 가능성 중 하나, 문서화 안 됨함수 인자 평가 순서

2. UB의 15가지 주요 패턴

패턴 1: 배열 범위 초과

C/C++ 예제 코드입니다.

// ❌ UB
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[10];  // 범위 밖 접근

// 가능한 결과:
// - 쓰레기 값
// - 크래시
// - "정상" 작동 (다른 변수 읽기)

패턴 2: 널 포인터 역참조

// ❌ UB
int* ptr = nullptr;
int x = *ptr;  // 널 포인터 역참조

// 대부분 크래시 (Segmentation Fault)

패턴 3: 댕글링 포인터

C/C++ 예제 코드입니다.

// ❌ UB
int* ptr;
{
    int x = 42;
    ptr = &x;
}  // x 소멸
int y = *ptr;  // 댕글링 포인터 역참조

패턴 4: 초기화되지 않은 변수 읽기

C/C++ 예제 코드입니다.

// ❌ UB
int x;  // 초기화 안 함
std::cout << x << '\n';  // 쓰레기 값

// 디버그: 0 (운 좋게)
// 릴리스: 임의의 값 또는 최적화로 제거

패턴 5: signed integer overflow

C/C++ 예제 코드입니다.

// ❌ 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: 객체 수명 외 접근

C/C++ 예제 코드입니다.

// ❌ 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

C/C++ 예제 코드입니다.

// ❌ 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: 순서 미정의 표현식

C/C++ 예제 코드입니다.

// ❌ 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: 문자열 리터럴 수정

C/C++ 예제 코드입니다.

// ❌ UB
char* str = "hello";
str[0] = 'H';  // 문자열 리터럴은 읽기 전용 → UB

// ✅ 올바른 코드
char str[] = "hello";  // 배열로 복사
str[0] = 'H';  // OK

패턴 15: 잘못된 타입 punning

C/C++ 예제 코드입니다.

// ❌ 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
  • 잘못된 캐스팅
  • 정렬 오류
  • 초기화되지 않은 변수 (일부)

출력 예시

main 함수의 구현 예제입니다.

// 테스트 코드
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 최적화

isPositive 함수의 구현 예제입니다.

// ❌ 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배)불필요

핵심 규칙

  1. UB는 절대 “운 좋게 동작”하지 않습니다 (언젠가 크래시)
  2. 디버그에서 되면 릴리스에서도 된다는 보장 없습니다
  3. Sanitizer를 CI/CD에 통합하세요
  4. 컴파일러 경고를 에러로 취급하세요 (-Werror)
  5. 스마트 포인터와 RAII를 사용하세요

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


마치며

미정의 동작은 C++에서 가장 위험한 버그입니다. “디버그에서는 되는데 릴리스에서 크래시”는 대부분 UB가 원인입니다.

핵심 원칙:

  1. UB는 “운 좋게 동작”이 아닙니다 (시한폭탄)
  2. Sanitizer를 항상 사용하세요 (UBSan, ASan, TSan)
  3. 컴파일러 경고를 무시하지 마세요 (-Wall -Wextra -Werror)
  4. 스마트 포인터와 RAII로 UB를 원천 차단하세요

프로덕션 배포 전에 모든 Sanitizer로 테스트하며, 릴리스 빌드로 충분히 검증하세요. UB는 고객 환경에서 발견되면 재현이 어렵습니다.

다음 단계: UB를 방지했다면, C++ RAII 패턴C++ 스마트 포인터로 더 안전한 코드를 작성해 보세요.


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 미정의 동작 (UB) 완벽 가이드에 대해 정리한 개발 블로그 글입니다. 미정의 동작(Undefined Behavior, UB)은 C++ 표준에서 .

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, UndefinedBehavior, 미정의동작, 크래시, 디버깅, 릴리스빌드, UB 등으로 검색하시면 이 글이 도움이 됩니다.