Skip to content
Historical revision
정의되지 않은 동작2026-06-23 09:15 UTC
rev_c47bafd6a3ea494b95da86b389261aae

정의되지 않은 동작

조금이라도 규모가 있는(nontrivial) C/C++ 코드는 거의 반드시 정의되지 않은 동작(Undefined Behavior, UB)을 품고 있다.
여기서 nontrivial이란 몇 줄짜리 예제를 넘어 실제 기능을 수행하는 코드, 즉 포인터, 정수 연산, 타입 캐스팅, 동시성 등이 얽히기 시작하는 코드를 말한다. 이 문서는 C/C++에서 UB가 왜 개인의 부주의만으로 설명되지 않는지를 정리한다.

30년 가까이 C/C++를 매일 써 온 전문가조차 UB를 끊임없이 만든다는 사실은, 이것이 개인 실력의 문제가 아니라 언어와 생태계가 부과하는 기본 비용에 가깝다는 것을 보여준다.[1]

기본적으로 멋들어지게 문장을 완성하면 써내려가면 아래처럼 정의 내릴 수 있다.

UB는 컴파일러가 악의적으로 최적화해서 터지는 문제가 아니다.
UB는 컴파일러가 "그런 상황은 발생하지 않는다"고 가정할 수 있게 만드는 권리다.

UB가 있는 코드는 컴파일러, ABI, 하드웨어, 런타임이 모두 "정상 C 코드라면 이런 일은 없다"는 전제 위에서 동작한다.
사람이 읽으면 의도가 명백해 보여도, 컴파일러 단계 사이나 모듈 사이에는 그 의도를 표현할 수단이 없을 수 있기 때문에 발생한다.
즉 컴퓨터와 사람의 의사 소통이 다르기때문이다.


0. UB와 비슷한 말들

C/C++ 표준 문서를 읽을 때는 비슷해 보이는 표현을 구분해야 한다. C계열 언어의 고질병이다.

구분

컴파일러/구현의 의무

예시

정의된 동작

표준이 결과를 정한다

표준대로 해야 한다

unsigned 정수 연산의 modulo 2^n wraparound

구현 정의 동작

구현이 결과를 정하고 문서화해야 한다

문서화 의무가 있다

char가 signed인지 unsigned인지

미지정 동작

가능한 결과는 있지만 어느 것을 고를지는 지정하지 않는다

가능한 범위 안에 있어야 한다

일부 평가 순서 문제

정의되지 않은 동작

표준이 아무 요구도 하지 않는다

아무 보장이 없다

signed overflow, null 역참조, 범위 밖 배열 접근

ill-formed

프로그램 자체가 규칙을 어긴다

보통 진단해야 한다

문법 오류, 일부 타입 오류

C++ working draft도 undefined behavior를 "no requirements"로 다루며, 가능한 결과는 무시, 예측 불가능한 실행, 진단 후 종료 등 넓게 열려 있다고 설명한다.[7] cppreference도 C/C++ UB 예시로 범위 밖 메모리 접근, signed integer overflow, null pointer dereference, strict aliasing 위반 등을 든다.[6][8]

구분:

  • implementation-defined는 문서화된 구현 선택이다. 위험은 남는다.

  • unspecified는 몇 가지 가능한 선택 중 하나다.

  • UB는 선택지가 정해져 있지 않다.

UB는 "랜덤한 결과가 나온다"보다 범위가 넓다고 볼 수 있다.
매번 이상하게 터진다는 뜻도 아니다.
평소에는 동작하다가, 컴파일러 버전이나 최적화 옵션을 바꾼 뒤 해당 경로가 불가능한 것으로 취급될 수 있다.

즉 예측할 수 없기때문에 '정의되지 않은 동작'인 것이다.


1. UB는 최적화 옵션의 문제가 아니다

-O0(Optimization Level 0, 컴파일러의 최적화 끄기 옵션)를 준다고 해서 UB가 정의된 동작으로 바뀌지는 않는다. 흔히 개발자들은 "최적화를 완전히 끄면 컴파일러가 소스 코드를 있는 그대로 1:1 기계어로 번역할 테니, 예측할 수 없는 이상한 동작(UB)은 피할 수 있을 것"이라고 착각한다.[1]

하지만 UB는 "최적화기가 허술한 코드를 악용해서 생긴다"는 뜻이 아니다. 컴파일러 입장에서는 단지 "이 코드는 표준에 맞는(valid) 코드다"라고 전제하고 코드 파싱과 생성 단계를 넘어갈 뿐이다. 애초에 언어 표준상 '발생할 수 없다'고 간주되는 상황은, 아무리 -O0로 최적화를 껐다 하더라도 컴파일러의 프론트엔드나 코드 생성 단계에서 방어 로직 자체가 생략되거나 의도와 다르게 번역될 수 있다.

이 과정은 "전화 게임(telephone)"에 비유할 수 있다. 소스 코드의 의도는 컴파일러 중간 표현(IR), 최적화 단계, ABI, 하드웨어 명령을 거치며 보존되지 않을 수 있다. 지금 -O0 환경에서 우연히 의도대로 동작하더라도, 미래의 컴파일러 버전이나 다른 아키텍처에서 같은 결과가 나온다는 보장은 전혀 없다.

이 관점은 LLVM과 John Regehr의 고전적 정리와도 일치한다. 컴파일러는 "UB는 절대 일어나지 않는다"를 전제로 케이스 분석과 dead code elimination을 수행하므로, UB 한 줄이 코드 경로 전체를 사라지게 만들 수 있다.[3][4]

컴파일러가 UB를 "발생하지 않는다"고 가정하는 효과는 UB 문장 이후로 한정되지 않는다. UB가 일어나는 지점을 기준으로, 그 앞이나 주변에 있던 방어 코드까지 거꾸로 제거될 수 있다.
흔히 시간 여행 최적화(time-traveling optimization)라고 부른다.

C
int *p = get_pointer();
int value = *p;          /* (1) 역참조 성공 = 이후 p는 non-null이라고 가정 가능 */
if (p == NULL) {         /* (2) "역참조가 성공했으니 p는 NULL일 수 없다" */
        return -1;       /* (3) 분기 전체가 dead code로 제거됨 */
}
return value;            /* value가 실제로 쓰이므로 (1)의 로드는 제거되지 않는다 */

p가 null인 채로 호출되면
(1)에서 이미 UB가 성립한다. 컴파일러는 valid program에서는 (1)이 성공했다고 보고, 그 사실로부터 p가 non-null임을 역으로 확정한 뒤 (2)의 검사를 제거할 수 있다.
"UB면 이상한 값이 나온다"가 아니라 "UB가 포함된 경로는 방어 로직째로 증발한다"가 더 정확한 모델이다.
LLVM 글의 null check 제거 사례가 정확히 이 형태이며, Linux 커널에서 실제 취약점으로 이어진 적도 있다.[4]

예를 들어 signed overflow가 UB라는 사실은 단순히 "값이 이상해질 수 있음"이 아니다.

C
int greater_after_increment(int x)
{
        return x + 1 > x;
}

사람은 x == INT_MAX일 때 overflow가 나서 이상해질 수 있다고 생각한다. C/C++의 signed overflow는 UB다. 컴파일러는 valid program에서는 x + 1이 overflow하지 않는다고 가정할 수 있고, 이 함수는 항상 true를 반환한다고 최적화될 수 있다.

C
int greater_after_increment(int x)
{
        return 1;
}

이것은 컴파일러가 악의적인 게 아니다. 언어 표준이 valid C/C++ 프로그램에서 signed overflow가 발생하지 않는다고 놓고 최적화할 수 있게 만든 것이다. LLVM 글은 이런 UB가 loop optimization, dead code elimination, null check 제거 등에 직접 연결된다고 설명한다.[4]


2. 주요 UB 사례

아래 사례들은 주로 자주 발생하는 메모리 안전성 3종 세트(double-free, use-after-free, 범위 초과)보다 훨씬 평범한 코드 주변에 숨어 있다.
전수 목록은 아니다.
전수 목록을 만들면 문서가 아니라 퇴마 의식이 될 것이다.

개인적인 메모: C 프로그래머는 심신의 안정을 위해 종교를 가지는 것이 좋다 생각한다.

2.1 정렬되지 않은 포인터 접근 (C23 6.3.2.3)

C
int foo(const int *p)
{
        return *p;
}

pint의 정렬 조건(대개 sizeof(int)의 배수 주소)을 만족하지 않으면 역참조는 UB이며 결과는 플랫폼마다 다르다.

  • Linux Alpha: 일부 로드에서 커널이 트랩을 받아 의도한 동작을 소프트웨어로 에뮬레이션. 다른 로드에서는 SIGBUS로 크래시.

  • SPARC: SIGBUS.

  • x86/amd64: 대부분의 경우 문제없이 동작하고, atomic read처럼 보일 때도 있다. 캐시 일관성에 관대한 아키텍처이기 때문.

개인메모: "내 머신에서는 잘 도는데?"라는 말은 C 표준 앞에서 가장 무의미한 문장이다. x86에서 얌전히 돌아갔다고 한들, 그것은 표준이 당신의 코드를 보증한 것이 아니라 단지 그날의 하드웨어가 생리하지 않았을뿐이다. 컴퓨터는 언제나 생리하는 여성처럼 대하는 게 중요하다.

컴파일러는 정렬되지 않은 포인터를 위해 안전한 기계어를 생성해 줄 의무가 전혀 없다.
언제든 최적화를 핑계로 다른 로드 명령을 생성할 수 있으며, 그 순간 하드웨어가 베풀던 얄팍한 자비는 사라지고 프로그램은 즉시 크래시로 응답할 것이다.

원자성도 비슷하다.

정렬되지 않은 std::atomic<int>에 대한 store/load가 atomic이냐고 묻는 순간 이미 길을 잘못 들었다.
UB라서 질문이 성립하지 않는다. 실무적으로는 atomicity가 깨질 수 있다. 객체가 페이지 경계를 걸치는 경우를 떠올리면 더 분명해진다.

더 엄격하게는, 캐스팅 자체가 문제가 될수도 있다.

C
bool parse_packet(const uint8_t *bytes)
{
        const int *magic_intp = (const int *)bytes;   /* ✗ 여기서 이미 UB */
        int magic_raw = foo(magic_intp);              /* SPARC에서 크래시 가능 */
        int magic = ntohl(magic_raw);                 /* 이 줄 자체는 문제없음 */
        /* ... */
}

여기서 더 불편한 부분은 foo()의 역참조만 문제가 아니라는 점이다. 잘못 정렬된 객체 타입 포인터를 만드는 캐스팅 단계부터 이미 UB다
"읽기 전까지는 캐스팅만 해도 괜찮다"라고 생각이 여기서 깨지는 지점이 있다.

7 A pointer to an object type may be converted to a pointer to a different object type. If the resulting pointer is not correctly aligned59) for the referenced type, the behavior is undefined.

(C23 6.3.2.3p7: 변환 결과 포인터가 대상 타입에 맞게 정렬되지 않으면 그 변환 자체가 UB).

대부분의 C/C++ 프로그래머들은 포인터를 캐스팅하는 것 자체는 안전하고, 그 포인터로 실제 메모리 값을 읽거나 쓸 때(*p) 문제가 터진다고 멘탈 모델을 형성하지만,

표준 문구는 "타입 변환(converted)의 결과 포인터가 정렬되지 않았다면 그 즉시 UB"라고 명시하고 있다 즉, 메모리에 접근하기도 전에 캐스팅을 하는 행위 그 자체로 이미 컴파일러가 사고치게 두는 것이다.

컴파일러가 int *의 하위 비트에 가비지 컬렉션이나 보안 태깅 같은 의미를 부여하는 것도 표준상 허용된다.

2.2 isxdigit(char) 문제 (C23 7.4, 6.2.5 p20)

C
bool bar(char ch)
{
        return isxdigit(ch);   /* ✗ char가 signed인 플랫폼에서 UB 가능 */
}

isxdigit 계열 함수는 unsigned char로 표현 가능한 값 또는 EOF를 받도록 정의된다. EOFint이며 unsigned char로 표현되지 않는 음수다. 따라서 인자 타입은 int다.

char가 signed인 플랫폼(구현 정의, 6.2.5 p20)에서 0x80 이상의 바이트를 넘기면 음수 int로 변환되고, 다음과 같은 valid한 구현에서 엉뚱한 메모리를 읽게 된다.

C
int isxdigit(int c)
{
        if (c == EOF) {
                return false;
        }
        return some_array[c];   /* c가 음수면 배열 밖 읽기 */
}

이 메모리가 I/O mapped 영역이면 단순한 random 값/크래시로 끝나지 않을 수 있다.
임베디드 환경이라면 모터가 돌 수도 있다. isxdigit() 한 번 잘못 불렀는데 기계가 움직이면, 그날은 집에 가서 C 책을 베개 밑에 넣고 자도 된다.

방어 형태는 아래와 같다.

C
  /* 좋은 예시 */
bool bar(char ch)
{
        return isxdigit((unsigned char)ch) != 0;   /* ✓ */
}

2.3 float를 int로 변환 (C23 6.3.1.4)

C
int milliseconds(float seconds)
{
        int tmp = (int)(seconds * 1000.0);   /* ✗ 범위 초과 시 UB */
        return tmp + 1;                       /* ✗ signed overflow도 별도 UB */
}

If the value of the integral part cannot be represented by the integer type, the behavior is undefined.

유한한 부동소수 값을 정수형으로 변환할 때 정수부가 대상 타입으로 표현 불가능하면 UB다(6.3.1.4). 생략에 의해 NaN/Infinity 같은 비유한 값도 UB다.

어려운점은 안전하게 비교하는 일조차 간단하지 않다는 데 있다. INT_MAXfloat로 캐스팅하면 정확히 표현된다는 보장이 없어 비교가 부정확해질 수 있다.
방어코드는 아래와 같다.

C
int milliseconds(float seconds)
{
        const float ftmp = seconds * 1000.0f;

        if (!isfinite(ftmp)) {
                return 0;   /* 또는 다른 에러 보고 */
        }
        if ((float)(INT_MIN + 1000) > ftmp) {
                return 0;
        }
        if ((float)(INT_MAX - 1000) < ftmp) {
                return 0;
        }

        /* 이제 변환해도 안전 */
        const int tmp = (int)ftmp;
        if (INT_MAX == tmp) {
                return 0;
        }

        /* 이제 더해도 안전 */
        return tmp + 1;
}

"초를 밀리초로 바꾸려 했을 뿐인데" 이만큼이 필요하다.
오픈소스만 봐도 그냥 곱하고 캐스팅하는 코드가 널려 있다.

개인메모:이쯤 되면 C는 계산보다 사고치는 경계 확인을 먼저 가르치는 언어다.

2.4 주소 0과 널 포인터 (C23 6.3.2.3, 3.4.3)

C 표준의 NULL(정수 상수 0 또는 nullptr에서 변환된 null pointer constant)은 하드웨어 주소 0과 동일하다는 보장이 없다.
표준은 C 추상 기계만 이야기하기 때문이다.
보장되는 것은 NULL을 0과 비교하면 같게 보인다는 사실뿐이며, 그 0이 플랫폼 고유의 NULL(예: 0xffff)로 변환된 결과일 수도 있다.

여기서 자주 발생하는 함정이 2개 있다.

C
memset(&ptr, 0, sizeof(ptr));   /* ✗ ptr이 NULL이 된다는 보장 없음 */

구조체를 0으로 밀어 멤버 포인터가 모두 NULL이 된다고 가정할 수 없다.
대부분의 현대 플랫폼에서는 동작한다.
다만, 표준 차원의 보장은 아니다.

C
void (*func_ptr)() = NULL;
func_ptr();   /* ✗ null 역참조, 3.4.3의 UB 예시 */

null 포인터 역참조는 그 값이 무엇이든 UB다(3.4.3). 6.3.2.3은 NULL이 "임의의 객체나 함수"와 같지 않다고 명시하므로, 실제 주소 0에 객체나 벡터 테이블이 있더라도 표준 C 관점에서는 그 위치를 일반 객체처럼 다룰 수 없다.
"all zeroes 비트 패턴으로 call 명령을 내면 되지 않나"라는 생각도,
16비트 x86에서 0000:0000인지 CS:0000인지처럼 "all zeroes"의 정의 자체가 모호해지면서 무너지게 되어있다.

OS 커널/임베디드 쪽에서는 이게 농담이 아니다.

개인메모:일반 앱 개발자는 주소 0을 보통 버그로 만나지만, 커널과 임베디드 쪽 사람들은 주소 0을 동네 주민처럼 만난다. 가끔 동네 술집 들어가서 술 한잔 걸치기도 하겠지.

2.5 가변 인자와 타입 불일치

C
execl("/bin/sh", "sh", "-c", "date", NULL);   /* ✗ */
execl("/bin/sh", "sh", "-c", "date", 0);      /* ✗ */
execl("/bin/sh", "sh", "-c", "date", (char *)NULL);   /* ✓ */

가변 인자 종료 표식은 포인터 타입이어야 하는데, NULL 매크로가 정수 0으로 해석될 수 있어 캐스팅이 필요하다.

여기엔 ABI 레벨의 뉘앙스가 있다. NULL((void *)0)로 정의된 구현(상당수 POSIX)에서는 execl(..., NULL)도 포인터를 넘기므로 실무상 동작한다. 진짜로 깨지는 경우는 NULL이 정수 0으로 정의되고 int가 32비트, 포인터가 64비트인 LP64 환경이다.
가변 인자에는 타입 정보가 없어 호출 측은 32비트 int를 넘기지만, execl은 내부에서 va_arg(ap, char *)로 64비트 포인터를 꺼낸다. 폭이 어긋나 상위 비트에 가비지가 섞이면 종료 표식이 NULL로 인식되지 않을 수 있다.
(char *)NULL로 캐스팅하면 정의에 무관하게 포인터 폭으로 전달된다.

C
uint64_t blah = 123;
printf("%ld\n", blah);   /* ✗ format과 인자 타입 불일치 = UB */

printf("%" PRIu64 "\n", blah);   /* ✓ */

uint64_tPRIu64 같은 매크로로 출력해야 한다. uid_t처럼 부호가 구현 정의인 타입은 uintmax_t로 캐스팅 후 PRIuMAX로 출력하는 식으로 우회한다.

2.6 0으로 나누기

0으로 나누기가 UB라는 사실은 다들 안다.
그런데 생각보다 자주 나온다. 분모가 신뢰할 수 없는 입력에서 오는 경우가 생각보다 많고, divide-by-zero는 공격 표면이 될 수 있다.

참고로 C23 표준은 "undefined"라는 단어를 283번 쓴다. 생략에 의해 정의되지 않는 것들은 여기 포함되지도 않는다.

개인메모:Undefined가 283번이면 그건 경고가 아니라 어쩌면 철학이라고 생각한다.

2.7 strict aliasing

C/C++에서는 어떤 객체를 아무 타입 포인터로나 읽을 수 없다. 서로 alias할 수 없는 타입이라고 판단되면 컴파일러는 두 포인터가 같은 메모리를 가리키지 않는다고 가정할 수 있다.

C
int f(int *i, float *f)
{
        *i = 1;
        *f = 0.0f;
        return *i;
}

if가 실제로 같은 주소를 가리키도록 억지로 만들면, 사람이 보기에는 *f = 0.0f*i의 비트를 덮을 수 있다. strict aliasing 규칙상 int *float *는 일반적으로 같은 객체를 가리킨다고 볼 수 없다. 컴파일러는 return *ireturn 1로 볼 수 있다.

바이트 복사는 memcpy로 우회하는 편이 안전하다.

C
float read_float_from_bytes(const unsigned char bytes[sizeof(float)])
{
        float value;
        memcpy(&value, bytes, sizeof(value));
        return value;
}

char, unsigned char, std::byte 계열은 객체 표현을 읽는 특별한 통로로 다뤄진다. 임의 타입 포인터로 재해석하는 코드는 별도 규칙을 따라야 한다.

2.8 객체 수명과 placement new

C++에서는 메모리 주소가 있다고 해서 그 위치에 원하는 타입의 객체가 살아 있는 것은 아니다. 객체의 storagelifetime은 다르다.

C++
alignas(std::string) unsigned char buffer[sizeof(std::string)];

auto *s = reinterpret_cast<std::string *>(buffer);
// *s를 여기서 사용하면 안 된다. 아직 std::string 객체가 시작되지 않았다.

객체를 실제로 만들려면 placement new 같은 수명 시작 작업이 필요하다.

C++
auto *s = new (buffer) std::string("hello");
std::cout << *s << "\n";
s->~basic_string();

C++의 object lifetime 규칙은 allocator, arena, serialization, ECS, custom container를 만들 때 자주 문제가 된다. "메모리만 확보했다"와 "그 타입의 객체가 살아 있다"는 같은 말이 아니다.

2.9 data race

C++에서 data race는 UB다. 두 스레드가 같은 메모리에 접근하고, 하나 이상이 쓰기이며, 적절한 synchronization이 없으면 프로그램 전체가 정의되지 않는다.

C++
int counter = 0;

void worker()
{
        counter++;   // 여러 스레드가 동시에 실행하면 data race
}

이럴 때는 원자 타입을 쓴다.

C++
std::atomic<int> counter = 0;

void worker()
{
        counter.fetch_add(1, std::memory_order_relaxed);
}

data race가 UB인 이유는 단순히 값이 틀릴 수 있어서가 아니다. 컴파일러와 CPU가 메모리 접근을 재배치하고 레지스터에 값을 유지할 수 있기 때문이다. 동시성 data race는 단순한 값 오류가 아니라 언어 모델 밖의 상태에 가깝다.

2.10 shift UB

시프트도 흔한 UB 지점이다.

C
int x = 1 << 31;       /* int가 32비트라면 UB 가능 */
int y = 1 << -1;       /* UB */
int z = 1 << 32;       /* 32비트 int라면 UB */

시프트 개수가 음수이거나 타입 폭 이상이면 UB다. signed 타입에서 표현 불가능한 결과를 만드는 left shift도 문제가 된다. 비트 연산 코드는 uint32_t, uint64_t 같은 명시적 unsigned 타입으로 작성하고, shift count를 먼저 검증하는 편이 낫다.


3. 보너스: integer promotion (UB는 아니지만 함정)

UB는 아니지만, integer promotion 규칙은 코드를 빠르게 훑으며 판단하기 어렵다. 이 규칙은 작은 타입으로 보이는 연산을 실제로는 int 연산으로 바꾼다.

C
unsigned char a = 0xff;
unsigned char b = 1;
unsigned char zero = 0;
bool overflowed = (a + b) == zero;
/* overflowed는 0(false). a, b가 int로 승격되어 0x100이 되므로 zero와 다름 */
C
unsigned char a = 0x80;
uint64_t b = a << 24;
/* b는 0x80000000(2147483648)이 아니라 0xffffffff80000000(18446744071562067968) */
/* 모든 변수가 unsigned여도 그렇다. a가 int로 승격된 뒤 시프트되어 부호 확장 */

모든 변수가 unsigned여도 promotion은 int에서 일어나고, 그 결과의 부호 확장이 최종 값을 바꾼다. 사람이 skim 속도로 promotion 규칙을 적용하는 것은 사실상 불가능하다.

엄밀히 말하면 위의 a << 24는 promotion 함정인 동시에 그 자체가 UB이기도 하다. int로 승격된 0x80을 24비트 시프트하면 결과가 2^31이 되어 signed int로 표현할 수 없고, 이런 signed left shift는 UB다.

이 지점에서 C와 C++의 최신 규정이 갈리는 것을 체크하였다.
C++20은 signed 정수를 2의 보수로 고정하면서 signed left shift를 modulo 연산으로 정의해, 이 시프트가 정의된 동작이 되었다.
반면 C23도 signed 정수 표현을 2의 보수로 못박았지만, signed left shift의 overflow는 여전히 UB로 남겨 두었다.
표현 방식이 정해졌다고 산술/시프트 overflow까지 정의되는 것은 아니다.
따라서 같은 0x80 << 24라도 C23에서는 UB, C++20 이상에서는 정의된 동작이다. 어느 쪽이든 비트 연산은 uint32_t, uint64_t 같은 명시적 unsigned 타입으로 작성하는 편이 안전하다.


4. 다른 언어들은 이 비용을 어떻게 처리하는가

C/C++의 UB는 "프로그래머가 멍청해서 생기는 실수"만으로 설명되지 않는다. 전문가도 30년간 계속 만든다면, 개인 실력보다 언어와 생태계의 기본 비용을 봐야한다고 생각한다.

다른 언어들은 이 비용을 주로 네 방향 중 하나로 처리한다.

전략

대표 언어

의미

컴파일 타임에 막는다

Rust, SPARK, Kotlin/Swift null safety

위험한 상태를 타입 시스템이나 정적 검증으로 거절한다

런타임에 예외/panic으로 터뜨린다

Java, C#, Go, Swift, Python

실패를 정의된 실패로 만든다

위험 영역을 unsafe로 격리한다

Rust, C# unsafe, Go unsafe, Swift

위험한 연산을 문법적으로 표시한다

동작을 명시적으로 정의한다

Java integer overflow, C# checked/unchecked

느리거나 이상하더라도 결과를 정한다

4.1 Rust

타입 시스템과 borrow checker로 C/C++의 많은 UB 영역을 컴파일 타임에 차단한다. 배열 범위 초과는 UB가 아니라 panic이고, use-after-free/double-free/data race는 safe Rust에서 대부분 컴파일 단계에 막힌다.

Rust
let value = vec![1, 2, 3];
let x = value[10];   // UB가 아니라 panic

Rust에도 UB는 있지만 unsafe 블록으로 책임이 격리된다.

Rust
unsafe {
    let p = 1 as *const i32;
    let x = *p;   // UB 가능
}

unsafe 블록으로 격리 한다는 건.

C/C++는 평범한 길에도 지뢰가 섞여 있지만, Rust는 적어도 safe 영역에서는 다리 잘릴 각오하고 들어오라고 경고라도 해준다.
물론 unsafe 문을 열면 다시 본인이 책임져야 한다.

4.2 Zig

Zig는 C보다 더 명시적인 저수준 언어다. overflow, alignment, optional, error handling을 언어 차원에서 더 드러낸다. debug/safe 빌드에서는 integer overflow를 trap할 수 있고, release-fast에서는 성능을 위해 일부 체크가 빠진다. @intCast, @alignCast, @ptrCast처럼 위험한 변환도 코드에 드러난다. pointer, alignment, unsafe operation이 남아 있으므로 UB가 사라진 언어는 아니다. 그리고 솔직히 내가 잘 모르기도 한다.

4.3 Go

정의된 동작을 선호한다. 배열/slice 범위 초과와 nil 역참조는 보통 panic이고, GC가 있어 일반 코드에서는 use-after-free/double-free가 없다. unsafe 패키지로 C식 위험을 다시 열 수 있다.

Go
arr := []int{1, 2, 3}
fmt.Println(arr[10])   // UB가 아니라 panic

4.4 Java / C#

대부분의 C식 UB를 런타임 예외로 바꾼다.

C#
int[] arr = { 1, 2, 3 };
Console.WriteLine(arr[10]);   // IndexOutOfRangeException
Java
int[] arr = {1, 2, 3};
System.out.println(arr[10]);   // ArrayIndexOutOfBoundsException

범위 초과는 예외, null 참조는 예외, 메모리 해제는 GC, 타입 불일치는 런타임/컴파일 타임 검사로 처리된다. C#은 unsafe 블록으로 포인터를 쓸 수 있지만 위험 영역이 문법적으로 표시된다.
Java도 Unsafe, JNI, Panama 경로가 있으나 일반 코드와 분리된다.

C#
unsafe {
    int* p = stackalloc int[10];
}

4.5 Swift / Kotlin

nullability를 타입 시스템에 넣어 null pointer 문제를 크게 줄인다.

Kotlin
var name: String = "abc"
name = null            // compile error
var maybeName: String? = null
Code
var name: String = "abc"
var maybeName: String? = nil

배열 범위 초과는 trap/exception 계열로 처리되어, UB로 조용히 붕괴하는 대신 실패를 명시적으로 드러낸다.

4.6 Python / JavaScript

C식 UB가 거의 없다. 대신 런타임 오류나 특이한 정의된 동작이 많다.

Python
arr = [1, 2, 3]
arr[10]   # IndexError
JavaScript
const arr = [1, 2, 3];
console.log(arr[10]);   // undefined

JavaScript는 UB 대신 undefined, NaN, implicit coercion 같은 다른 종류의 혼란이 있다. 컴파일러가 "이 상황은 불가능"이라고 가정해 프로그램 전체 의미를 무너뜨리는 방식은 아니다.

4.7 Ada / SPARK

범위 타입, 런타임 체크, 강한 타입 시스템을 제공한다.

Code
subtype Percent is Integer range 0 .. 100;

SPARK는 Ada의 검증 가능한 부분집합으로, 수학적 증명과 정적 분석으로 overflow/범위 초과/data race를 줄이는 데 초점을 둔다.

방산/항공/철도 같은 safety-critical 분야에서 C보다 자연스러운 선택이 되는 경우가 있다고 하지만 굳이 Ada를 써야할까?

4.8 명시적으로 정의된 동작: C# checked / Java overflow

C#은 정수 overflow에 대해 checked(예외)와 unchecked(wraparound)를 선택할 수 있고, 선택한 동작이 정의되어 있다.

C#
checked {
    int x = int.MaxValue;
    x = x + 1;   // OverflowException
}

Java의 int overflow도 UB가 아니라 2의 보수 wraparound로 정의된다.

Java
int x = Integer.MAX_VALUE;
System.out.println(x + 1);   // Integer.MIN_VALUE

버그를 만들 수는 있지만, 컴파일러가 "overflow는 없다"고 가정해 코드를 재작성하는 종류의 UB는 아니다.

4.9 비교표

언어

기본 전략

UB 위험

C

개발자 책임, 많은 UB

매우 높음

C++

RAII/타입 도구는 있으나 UB 많음

매우 높음

Rust

safe/unsafe 분리, borrow checker

safe Rust는 낮음

Zig

위험 연산 명시, 빌드 모드별 체크

중간

Go

panic/GC/정의된 동작 우선

낮음, unsafe 제외

Java

런타임 예외/GC/정의된 overflow

낮음

C#

런타임 예외/GC/checked 지원

낮음, unsafe 제외

Swift/Kotlin

null safety, runtime trap

낮음

Python

런타임 오류

낮음

JavaScript

정의된 이상동작 많음, UB는 적음

낮음

Ada/SPARK

범위 타입/검증/계약

매우 낮게 설계 가능

4.10 Rice 정리와 "만능 검사기" 문제

UB 검사와 관련된 질문을 하면 보통 이런 생각을 한다.

그럼 컴파일러나 정적 분석기가 모든 UB를 미리 찾아주면 되는 거 아닌가?

Rice 정리는 계산 가능한 함수의 비자명한 의미적 성질을 일반적으로 판정하는 알고리즘은 없다고 말한다.[13] 임의의 프로그램을 받아서 "이 프로그램이 어떤 의미 있는 성질을 갖는가"를 항상 정확히 판정하는 만능 검사기는 없다.

UB 탐지는 프로그램의 의미에 관한 질문을 포함한다.

  • 이 포인터가 실행 시점에 항상 유효한가?

  • 이 정수 덧셈은 어떤 입력에서도 overflow하지 않는가?

  • 이 배열 접근은 모든 경로에서 범위 안인가?

  • 이 두 스레드는 실제 실행에서 data race를 만들지 않는가?

  • 이 함수는 특정 입력에서 반드시 종료하는가?

정적 분석은 이런 질문에 실행 전 답을 요구한다. 일반적인 경우에는 정지 문제와 같은 undecidability 벽에 걸린다. Turing의 1936년 논문은 이 한계를 보인 고전적 출발점이고, Rice 정리는 그 한계를 프로그램의 의미적 성질 전반으로 넓혀 본다.[14]

정적 분석기의 선택지:

선택

결과

false positive를 허용한다

실제로는 안전한 코드도 경고한다

false negative를 허용한다

일부 UB를 놓친다

분석 대상을 제한한다

특정 언어 부분집합, 특정 코딩 규칙 안에서만 강해진다

annotation을 요구한다

개발자가 precondition, invariant, ownership 정보를 적어야 한다

만능 UB 탐지기는 일반 알고리즘으로 기대할 수 없다. 그런 도구는 사실상 "모든 버그를 찾아주는 컴파일러"에 가깝다. 그쯤 되면 컴파일러가 아니라 점집이다.

이론적 한계가 실무 도구를 무력화한다는 뜻은 아니다.
일반해가 없으므로 좁은 문제로 잘라서 푼다.

  • sanitizer는 실행된 경로에서 UB를 잡는다.

  • static analyzer는 보수적으로 의심 지점을 찾는다.

  • type system은 애초에 위험한 표현을 못 쓰게 한다.

  • fuzzing은 사람이 생각 못 한 입력을 밀어 넣는다.

  • MISRA/CERT 같은 규칙은 위험한 C 부분집합을 못 쓰게 만든다.

Rice 정리는 "왜 도구를 여러 개 써도 마지막에 사람이 봐야 하는가"를 설명한다. C/C++ UB 검사는 수학적 한계와 비용이 겹친다. 이것이 이 문제가 유난히 피곤한 이유다.


5. 실무 대응

C/C++는 여전히 성능, ABI, OS, 임베디드, 게임 엔진, 드라이버, 레거시 코드에서 쓰인다. 2026년 기준으로는 "잘 조심해서 쓰면 됨"보다 "도구를 켜고, 규칙을 정하고, 위험 구역을 표시해야 함"에 가깝다.

  • sanitizer (ASan, UBSan, TSan)

  • static analyzer

  • fuzzer

  • 컴파일러 경고 최대화

  • MISRA / CERT 규칙

  • 코드 리뷰

  • LLM 보조 UB 감사

  • unsafe 경계 문서화

새 C/C++ 코드라면 최소한 sanitizer 빌드를 하나 둔다.

Bash
clang -Wall -Wextra -Wpedantic -Wconversion -Wshadow -fsanitize=undefined,address -fno-omit-frame-pointer main.c
Bash
gcc -Wall -Wextra -Wpedantic -Wconversion -Wshadow -fsanitize=undefined,address -fno-omit-frame-pointer main.c

UBSan은 실행 중 여러 UB를 잡기 위한 도구다. Clang 문서는 UBSan이 컴파일 타임 instrumentation을 삽입해 out-of-bounds, 잘못된 shift, null/misaligned pointer dereference, signed integer overflow 등을 런타임에 포착한다고 설명한다.[9]

sanitizer는 증명기가 아니다. CCTV를 달았다고 도둑이 존재하지 않는 것은 아니다. 찍힌 도둑을 더 잘 볼 수 있을 뿐이다.

  • 실행된 경로만 잡는다.

  • 모든 UB를 잡지 못한다.

  • 최적화/빌드 옵션에 따라 보이는 문제가 달라질 수 있다.

  • 커널/임베디드/실시간 환경에서는 런타임 사용이 제한될 수 있다.

  • sanitizer를 켰다고 프로덕션 성능/동작이 같다고 보면 안 된다.

도구 조합 예:

단계

도구

개발 중

UBSan, ASan, TSan

CI

sanitizer 빌드, 경고를 에러로 처리, fuzz test

릴리스 전

static analyzer, clang-tidy, cppcheck, Coverity/PVS-Studio 등

규제/임베디드

MISRA C/C++, CERT C/C++, SPARK/Ada 검토

코드 리뷰

unsafe/포인터/캐스팅/수명/동시성 경계 집중 검토

성숙하고 엄격하게 작성되기로 유명한 코드베이스(예: OpenBSD)에 LLM 감사를 붙여도 UB 후보가 쏟아지고, 실제 out-of-bounds write가 발견되어 패치된 사례가 있다. LLM 감사도 한계가 있다.
찾아낸 항목을 확인하려면 결국 전문가 인간이 필요하다.
이 작업은 청소에 가깝지만, 막상 빗자루를 잡으려면 표준 문서와 어셈블리를 같이 봐야 한다. 솔직히 이걸 전부 할 수 있을까?[1]

요약:

C/C++는 실패를 "정의하지 않음"으로 밀어내고, 현대 언어들은 실패를 "컴파일 오류, 런타임 예외, unsafe 경계, 명시적 wraparound" 중 하나로 끌어올린다.


UB는 "내 머신에서 됨"과 "표준상 valid함" 사이의 거리정도로 표현할 수 있다. 하지만 영


같이 보기

  • cpp_idioms

  • 프로그래밍 이디엄

  • 데이터 지향 프로그래밍

  • 함수형_프로그래밍

  • C++ 메모리 안전성 논쟁

  • x86_메모리_관리의_진화


참고문헌

[1] Everything in C is undefined behavior. Thomas Habets, 2026-05-19. 다양한 UB 사례와 OpenBSD LLM 감사 일화를 다룬 글.

[2] C23 표준에서 인용된 절들. 본문 사례의 근거 조항.

  • 6.3.2.3 (pointers, null pointer) - 정렬되지 않은 포인터, NULL과 객체/함수의 비교

  • 6.3.1.4 (real floating to integer) - float-to-int 변환 범위 초과 UB

  • 7.4 (character handling), 6.2.5 p20 (char signedness 구현 정의) - isxdigit(char) 문제

  • 3.4.3 (undefined behavior 정의와 null 역참조 예시)

[3] A Guide to Undefined Behavior in C and C++. John Regehr. UB 입문의 고전.

[4] What Every C Programmer Should Know About Undefined Behavior. Chris Lattner, LLVM Project Blog (3부작).

  • Part 1 - UB가 성능 최적화를 가능하게 하는 원리

  • Part 2 - null check 제거 등 놀라운 효과, Linux/OpenSSL/glibc 피해 사례

[5] UB의 의미론과 최적화 동기. 보충 관점.

[6] cppreference.

[7] C++ working draft.

[8] cppreference C++.

[9] Clang UndefinedBehaviorSanitizer.

[10] SEI CERT C Coding Standard.

[11] C++ Core Guidelines.

[12] What every compiler writer should know about programmers.

[13] Rice's theorem.

[14] Turing and undecidability.


분류: 정의되지 않은 동작 | C++ 메모리 안전성 논쟁 | 프로그래밍 이디엄 | WIKI

편집 역사 — 정의되지 않은 동작