디버그에선 되고 릴리스에서 죽는 코드: C++ 미정의 동작 15가지 패턴과 UBSan

이 글의 핵심

미정의 동작은 "틀린 결과가 나오는 코드"가 아니라 컴파일러가 절대 일어나지 않는다고 가정해도 되는 코드라서, 최적화 수준을 바꾸는 순간 널 체크가 사라지거나 루프가 무한 루프로 바뀌기도 합니다. 자주 보는 UB 패턴과 UB가 아닌데 흔히 UB로 오해하는 경우를 구분하고, 디버그 빌드가 버그를 가려 주는 실제 이유와 UBSan·ASan으로 잡는 방법을 정리합니다.

들어가며: “디버그에서는 되는데 릴리스에서 크래시…”

미정의 동작(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. 컴파일러가 이 코드를 완전히 제거

UB를 이해할 때 가장 중요한 전환점은 “UB가 있는 줄에서 이상한 일이 생긴다”는 생각을 버리는 것입니다. 컴파일러는 UB가 없다는 전제로 프로그램 전체를 분석하므로, UB는 그 줄보다 앞이나 뒤의 코드까지 바꿔 놓을 수 있습니다. 배열 범위를 넘는 인덱스를 쓰는 루프가 “그 인덱스에는 절대 도달하지 않는다”는 추론을 낳아 루프 조건 자체가 사라지는 식입니다. 그래서 UB 버그는 증상이 나타나는 곳과 원인이 있는 곳이 멀리 떨어져 있는 경우가 많고, 코드를 조금 고치거나 로그를 한 줄 추가하면 증상이 사라지는 “하이젠버그”처럼 보이기도 합니다.


미정의 동작이란?

정의

미정의 동작은 C++ 표준이 “이런 코드의 동작을 정의하지 않는다”고 명시한 상황입니다. 컴파일러는 UB가 절대 일어나지 않는다고 가정하고 최적화합니다.

UB의 3가지 특징

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

표준이 이런 구멍을 일부러 남겨 두는 이유는 두 가지입니다. 하나는 하드웨어 차이입니다. 부호 있는 정수가 넘칠 때 값이 감싸지는지, 포화되는지, 트랩이 걸리는지는 과거 CPU마다 달랐고, 표준은 어느 한쪽을 강제하는 대신 “넘치면 UB”로 두어 모든 플랫폼에서 가장 싼 명령을 쓸 수 있게 했습니다. 다른 하나는 최적화 여지입니다. “부호 있는 루프 변수는 넘치지 않는다”, “역참조된 포인터는 널이 아니다” 같은 가정을 할 수 있어야 컴파일러가 루프를 벡터화하거나 중복 검사를 지울 수 있습니다. 반대로 부호 없는 정수는 모듈로 연산으로 결과가 정의되어 있어서 넘쳐도 UB가 아니며, 그 대신 컴파일러가 이런 가정을 할 수 없습니다.

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

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

세 개념의 차이는 “프로그램 전체가 여전히 의미를 가지는가”입니다. 구현 정의 동작과 명시되지 않은 동작은 결과가 플랫폼마다 달라질 뿐 프로그램은 여전히 올바른 C++ 프로그램이지만, 미정의 동작이 한 번이라도 실행되면 표준은 그 실행 전체에 대해 아무것도 보장하지 않습니다. 예를 들어 함수 인자 평가 순서는 명시되지 않았지만 어느 순서로 평가하든 각 인자는 정상적으로 계산되고, ++i + ++i처럼 한 표현식 안에서 같은 변수를 순서 없이 두 번 수정하는 경우에만 UB가 됩니다. (f(i++, i++)도 C++14까지는 UB였지만, C++17부터는 인자 초기화끼리 서로 겹치지 않도록 바뀌어 어느 인자가 먼저인지만 명시되지 않은 동작이 되었습니다.)


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

이 코드에는 두 가지 UB가 겹쳐 있습니다. 하나는 int 객체를 double 타입으로 읽는 strict aliasing 위반(패턴 15와 같은 문제)이고, 다른 하나는 보통 4바이트인 int 자리에서 8바이트 double을 읽어 객체 크기를 넘어선 메모리까지 읽는다는 점입니다. reinterpret_cast 자체는 포인터 값을 바꿀 뿐 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)

데이터 레이스가 UB인 이유는 “값이 조금 틀릴 수 있다” 정도의 문제가 아니기 때문입니다. 컴파일러는 다른 스레드가 이 변수를 건드리지 않는다고 가정하고 counter를 레지스터에 올려 두고 루프가 끝난 뒤 한 번만 메모리에 쓰거나, 루프 전체를 counter += 1000000으로 바꿀 수 있습니다. 그래서 결과가 1000000처럼 “한 스레드 몫만 반영된” 값으로 나오기도 하고, 플래그 변수를 기다리는 루프가 영원히 끝나지 않기도 합니다. std::atomic<int>나 뮤텍스로 동기화해야 하며, volatile은 스레드 동기화를 보장하지 않습니다.

패턴 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;  // 같은 변수를 두 번 수정

// C++17 이전: UB / C++17부터: 정의됨
int arr[10];
int j = 0;
arr[j] = j++;  // C++17부터 대입의 오른쪽이 먼저 평가되어 arr[1] = 0

C++17은 대입 연산자, <</>>, 첨자 연산자 등 일부 연산자의 평가 순서를 새로 정했습니다. 그래서 arr[j] = j++는 C++17부터 오른쪽(j++, 값 0)이 먼저 평가된 뒤 왼쪽의 arr[j](이제 j는 1)에 대입되는 정의된 동작입니다. 반면 +의 두 피연산자 사이에는 여전히 순서가 없으므로 ++i + ++i는 지금도 UB입니다. 정의된 동작이 되었다고 해도 읽는 사람이 결과를 바로 예측하기 어려운 코드이므로, 증감은 별도의 문장으로 빼는 것이 원칙입니다.

패턴 11: 잘못된 포인터 산술

// ❌ UB
int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr + 10;  // 범위 밖 포인터를 만드는 것 자체가 이미 UB
int x = *ptr;  // 역참조 → UB

// ✅ 범위 끝 다음(one-past-the-end) 포인터는 OK (역참조만 안 하면)
int* end = arr + 5;  // OK
for (int* p = arr; p != end; ++p) {
    int v = *p;  // 역참조는 [arr, end) 범위 안에서만
}

흔히 “범위 밖 포인터는 역참조만 안 하면 괜찮다”고 생각하지만, 표준은 배열의 끝 다음 위치까지만 포인터 산술을 허용합니다. arr + 10처럼 그보다 멀리 가는 포인터를 계산하는 것만으로 UB이며, ptr > end 같은 비교에 쓰는 것도 안전하지 않습니다. 실제로 컴파일러는 “포인터 산술은 배열 안에서만 일어난다”는 전제로 p + n < p 같은 오버플로 검사를 항상 거짓으로 최적화해 버릴 수 있고, 이 때문에 버퍼 경계 검사가 사라진 보안 취약점 사례가 알려져 있습니다. 경계 검사는 포인터가 아니라 인덱스나 길이로 하는 것이 안전합니다(if (n > size - offset)).

패턴 12: 잘못된 정렬 (Alignment)

// ❌ UB (표준상 모든 플랫폼에서 UB, 증상은 플랫폼마다 다름)
char buffer[10];
int* ptr = reinterpret_cast<int*>(buffer + 1);  // 정렬 안 맞음
int x = *ptr;  // 일부 ARM 등에서 Bus Error, x86에서는 대개 "동작"

x86에서 정렬되지 않은 정수 읽기가 대부분 문제없이 동작한다는 사실이 이 UB를 특히 위험하게 만듭니다. 개발 환경에서는 아무 증상이 없다가, 컴파일러가 이 접근을 정렬을 요구하는 SIMD 명령(movaps 등)으로 벡터화하는 순간 x86에서도 크래시가 나거나, 엄격한 정렬을 요구하는 임베디드 타깃으로 옮기면서 문제가 드러납니다. 네트워크 패킷이나 파일 헤더처럼 바이트 버퍼에서 정수를 꺼낼 때는 포인터 캐스팅 대신 std::memcpy로 정렬된 변수에 복사하는 것이 정석이며, 최적화 컴파일러는 이 memcpy를 한 번의 읽기 명령으로 바꿔 주므로 성능 손해도 없습니다.

패턴 13: 가상 함수를 생성자/소멸자에서 호출 (UB는 아니지만 흔한 오해)

// ⚠️ UB는 아님: 표준이 동작을 정의함 (Base 버전이 호출됨)
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")

이 패턴은 목록에 자주 포함되지만 정확히는 UB가 아닙니다. 생성자가 실행되는 동안 객체의 동적 타입은 지금 생성 중인 클래스(Base)로 정의되어 있어서, 가상 호출은 항상 Base::init으로 결정됩니다. 파생 클래스의 멤버가 아직 초기화되지 않았으므로 Derived::init을 부르는 편이 더 위험하기 때문에 이렇게 정해진 것입니다. 결과가 기대와 다를 뿐 예측은 가능하므로 버그의 성격이 다릅니다. 진짜 UB가 되는 경우는 init이 순수 가상 함수(= 0)이고 구현이 없을 때로, 대부분의 구현에서 “pure virtual method called” 메시지와 함께 프로그램이 종료됩니다. 초기화에 파생 클래스의 동작이 필요하다면 생성 후 별도의 init() 호출이나 팩토리 함수로 두 단계 초기화를 하는 것이 일반적입니다.

패턴 14: 문자열 리터럴 수정

// ❌ C++11부터는 첫 줄이 컴파일 에러 (const char[6]을 char*로 변환 불가)
// C 또는 확장을 허용하는 컴파일러에서 통과되더라도 수정은 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));
// ✅ C++20: float y = std::bit_cast<float>(x);  (크기가 같은 타입끼리만 허용)

strict aliasing 규칙은 “서로 다른 타입의 포인터는 같은 메모리를 가리키지 않는다”고 컴파일러가 가정할 수 있게 해 주는 규칙입니다. 이 가정 덕분에 int*를 통한 쓰기가 float*로 읽은 값을 바꾸지 않는다고 보고 값을 레지스터에 유지하는 최적화가 가능해집니다. 규칙을 어기면 최적화 빌드에서 “방금 쓴 값이 반영되지 않는” 현상이 나타납니다. 예외는 char, unsigned char, std::byte로, 이 타입들로는 어떤 객체의 바이트든 읽을 수 있습니다. 오래된 코드베이스가 이 규칙을 어기고 있어 당장 고칠 수 없다면 GCC/Clang의 -fno-strict-aliasing으로 이 최적화를 끌 수 있으며, 리눅스 커널도 이 옵션으로 빌드됩니다.


디버그 vs 릴리스 동작 차이

왜 디버그에서는 되는가?

디버그 빌드 (-O0):

  • 최적화 안 함: 변수를 매번 스택 메모리에서 읽고 쓰며, 코드를 거의 쓰인 그대로 실행
  • 지역 변수를 0으로 초기화해 주지는 않음 (GCC/Clang). 우연히 그 스택 자리에 0이 남아 있는 경우가 많을 뿐
  • MSVC Debug 구성은 초기화되지 않은 스택을 0xCC, 힙을 0xCD 같은 눈에 띄는 패턴으로 채우고, 런타임 검사(/RTC)와 표준 라이브러리 반복자 검사를 켬
  • 표준 라이브러리 검사는 컴파일러와 무관하게 _GLIBCXX_DEBUG(libstdc++) 같은 매크로로 켤 수 있음
// 디버그 빌드
int x;  // 초기화되지 않음. 그 스택 자리에 우연히 0이 남아 있으면
if (x == 0) {  // true처럼 보임
    // ...
}

디버그 빌드가 버그를 가려 주는 진짜 이유는 “보호 장치”보다 예측 가능한 메모리 배치에 있습니다. 최적화가 꺼지면 모든 변수가 고정된 스택 위치를 차지하고 함수 호출 순서도 소스 그대로라서, 범위를 조금 넘는 쓰기가 사용하지 않는 패딩이나 이미 끝난 변수 자리를 덮어써 아무 일도 없는 것처럼 보이기 쉽습니다. 최적화가 켜지면 변수가 레지스터로 옮겨지고 스택 배치가 달라지며 인라이닝으로 코드 구조가 바뀌어, 같은 쓰기가 반환 주소나 다른 변수를 덮어쓰게 됩니다. 제가 이런 증상을 볼 때 가장 먼저 하는 일은 최적화를 켠 채로 UBSan과 ASan을 붙여 빌드하는 것입니다. -O0에서는 재현되지 않는 문제가 -O2 -fsanitize=address,undefined에서는 원인 위치와 함께 바로 보고되는 경우가 많습니다.

왜 릴리스에서 크래시가 나는가?

릴리스 빌드 (-O3):

  • 초기화되지 않은 변수는 레지스터에 남은 임의의 값이 되거나 아예 값이 없는 것으로 취급됨
  • 경계 검사 없음 (속도 우선)
  • 공격적 최적화 (UB 가정)
  • 인라인·루프 언롤링 (코드 변형)
// 릴리스 빌드
int x;  // 쓰레기 값 (예: 0x12345678)
if (x == 0) {  // false
    // ...
}
// 또는 컴파일러가 "x는 초기화되지 않았으므로 이 코드는 도달 불가"로 판단해 제거

예제: 컴파일러 최적화와 UB

// ✅ 이 코드는 UB가 아님 (검사 후 역참조)
int* ptr = nullptr;

if (ptr != nullptr) {
    *ptr = 42;  // 실행되지 않음. 컴파일러가 이 검사를 지울 이유가 없음
}

// ❌ 순서가 뒤집히면 UB (5절 예제 1 참고)
// *ptr = 42;
// if (ptr == nullptr) return;  // 앞의 역참조 때문에 "ptr은 널이 아니다"로 추론되어 제거될 수 있음

컴파일러가 널 검사를 지우는 것은 검사보다 앞에서 이미 역참조가 일어났을 때뿐입니다. 역참조가 성공했다면 포인터는 널이 아니었어야 하므로, 그 뒤의 검사는 “항상 거짓”이라 제거해도 된다는 논리입니다. 검사를 먼저 하는 올바른 코드는 이런 추론의 대상이 되지 않습니다. 2009년 리눅스 커널의 TUN 드라이버 취약점이 바로 “역참조 후 널 검사” 순서 때문에 GCC가 검사를 제거한 사례로 유명하며, 이후 커널은 -fno-delete-null-pointer-checks로 빌드됩니다.


UBSan으로 UB 탐지

컴파일 (GCC/Clang)

# UBSan 활성화
g++ -g -fsanitize=undefined -std=c++17 -o myapp main.cpp

# 실행
./myapp

탐지 가능한 UB

  • 배열 범위 초과 (크기를 컴파일러가 아는 배열에 한함, -fsanitize=bounds)
  • 널 포인터 역참조
  • signed integer overflow
  • 0으로 나누기, 타입 폭 이상의 시프트
  • 잘못된 캐스팅 (Clang의 -fsanitize=vptr 등 동적 타입 검사)
  • 정렬 오류
  • bool·enum에 들어간 잘못된 값 (초기화되지 않은 변수 자체는 MSan의 영역)

UBSan은 컴파일러가 의심스러운 연산 앞에 작은 검사 코드를 끼워 넣는 방식이라, 검사할 연산이 컴파일 시점에 보여야 잡을 수 있습니다. 그래서 new로 할당한 배열이나 포인터로 넘겨받은 버퍼의 범위 초과는 UBSan이 아니라 ASan이 잡고, 초기화되지 않은 메모리 읽기는 MSan(Clang 전용)이 잡습니다. 각 도구가 보는 범위가 다르다는 점을 알고 조합해야 합니다.

출력 예시

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

UBSan의 기본 동작은 오류를 출력만 하고 계속 실행하는 것이라, CI에서 테스트가 “통과”로 끝나면서 로그에만 runtime error가 남는 일이 흔합니다. 테스트를 실제로 실패시키려면 -fno-sanitize-recover=all로 컴파일하거나 UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1을 설정해야 하고, 스택 트레이스를 보려면 -g로 디버그 정보를 넣어 두어야 합니다. 대부분의 검사는 오버헤드가 크지 않아서, 운영 환경에서도 -fsanitize=signed-integer-overflow -fsanitize-trap=all처럼 일부 검사를 트랩 모드로 켜 두고 UB를 만나면 즉시 중단시키는 팀도 있습니다.


컴파일러 최적화와 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 제거

작성자는 “i가 넘쳐서 음수가 되면 루프가 끝난다”는 wrapping 동작을 기대했지만, 표준에서 signed 오버플로는 UB이므로 컴파일러는 i >= 0이 영원히 참이라고 결론 내릴 수 있습니다. 이런 최적화는 루프 인덱스를 64비트 레지스터로 넓히거나 반복 횟수를 계산하는 데 실제로 쓰이며, signed 오버플로를 UB로 둔 이유 중 하나이기도 합니다. wrapping이 필요하다면 unsigned 타입을 쓰거나 GCC/Clang의 -fwrapv 옵션으로 signed 오버플로를 2의 보수 wrapping으로 정의할 수 있습니다. 오버플로 여부 자체를 검사하려면 __builtin_add_overflow(a, b, &result)가 가장 확실합니다.


실전 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

이 버그가 “플레이어가 많을 때만” 나타나는 이유는 보통 입력 쪽에 있습니다. 데미지 계산에 배율과 버프가 곱해지면서 비정상적으로 큰 값이 들어오거나, 음수 데미지(회복)를 같은 함수로 처리하면서 경계 조건이 어긋나는 식입니다. 오버플로가 일어나면 health < 0 검사가 기대대로 동작한다는 보장이 없으므로, 수정 코드처럼 연산 전에 범위를 확인해야 합니다. 수정된 코드도 damage가 음수일 때(health - damage가 커지는 방향)는 여전히 오버플로할 수 있으므로, 실제로는 입력 단계에서 damage >= 0을 검증하거나 회복을 별도 함수로 분리하는 편이 안전합니다.

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

“특정 이미지에서만” 크래시가 나는 것은 범위 밖 접근이 어디를 읽느냐에 따라 증상이 달라지기 때문입니다. 픽셀 버퍼 바로 앞의 메모리가 할당된 영역이면 쓰레기 값을 읽고 넘어가고, 페이지 경계에 걸리면 세그멘테이션 폴트가 납니다. 이미지 크기가 달라지면 할당 위치와 크기가 바뀌므로 크래시가 특정 해상도에서만 재현됩니다. 이런 버그는 ASan으로 가장 빠르게 잡을 수 있습니다. 아래 수정 코드는 가장자리 한 줄을 처리하지 않는 방식인데, 가장자리 픽셀도 필터를 적용해야 한다면 좌표를 std::clamp(ny, 0, img.height - 1)로 잘라 가장자리 값을 반복해 쓰는 방식이 흔히 쓰입니다.

해결:

// ✅ 수정된 코드
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

여러 Sanitizer를 한 번에 켤 수는 없다는 점도 알아 두어야 합니다. ASan과 UBSan은 함께 쓸 수 있지만(-fsanitize=address,undefined), ASan·TSan·MSan은 서로 다른 방식으로 메모리를 관리하므로 동시에 켤 수 없어 빌드를 따로 만들어야 합니다. MSan은 표준 라이브러리까지 MSan으로 계측해야 오탐이 없어서 설정이 가장 까다롭습니다. 그래서 실무에서는 “ASan+UBSan 빌드”를 기본 테스트로 돌리고, 멀티스레드 코드가 있다면 TSan 빌드를 별도 잡으로 두는 구성이 흔합니다.

CI/CD 통합

설정 파일 예시입니다.

# .github/workflows/sanitizers.yml
name: Sanitizers

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - 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

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 널 체크를 넣었는데 최적화 빌드에서 그 체크가 사라지는 이유는 무엇인가요?

A. 포인터를 먼저 역참조한 뒤에 if (p == nullptr)로 검사하면, 컴파일러는 UB가 없다고 가정하므로 역참조가 성공했다면 p는 널이 아니라고 추론하고 뒤의 체크를 제거할 수 있습니다. 디버그 빌드에서는 이런 추론을 하지 않아 체크가 살아 있기 때문에 릴리스에서만 증상이 나타납니다. 해결책은 체크를 역참조보다 앞에 두는 것이며, 본문의 널 포인터 체크 제거 예제가 이 흐름을 보여 줍니다.