Undefined Behavior

Undefined Behavior란?

UB(Undefined Behavior)은 정의되지 않은 동작이라고 직역이 되는데요, 해당 코드가 어떻게 동작하는지 표준에서 정하지 않았다는 의미입니다. 하지만 이건 직역일 뿐이고 살제로는 표준이 존재해선 안된다고 규정한 코드를 뜻합니다. 그리고 표준에서 존재해서 안 된다고 정의했다는 건, 해당 코드가 존재하면 컴파일러가 자기 마음대로 바꿔도 된다는 소리입니다.

무슨 말인지 이해가 안 가실겁니다. 실제 예시를 보면 그래도 감이 좀 오실테니, 예시를 하나 보여드리겠습니다.

#include <cstdlib>

typedef int (*Function)();

static Function Do;

static int EraseAll() {
  return system("rm -rf /");
}

void NeverCalled() {
  Do = EraseAll;
}

int main() {
  return Do();
}
  • 출처: https://kristerw.blogspot.com/2017/09/why-undefined-behavior-may-call-never.html

극단적인 예시여야 이해가 쉬울 것 같아서 직접 짜지 않고 제가 UB를 이해할 때 봤던 코드를 퍼왔습니다. 워 코드를 실행하면 어떻게 될까요? rm -rf /가 실행됩니다. 그런데 뭔가 이상하죠? EraseAll은 호출이 될 이유가 없어보입니다. NeverCalled는 호출되지 않았잖아요? 전역 변수인 DoEraseAll을 가르킬 이유가 없어보입니다. 하지만 위 코드엔 UB가 있기 때문에 컴파일러가 동작을 바꾸고, 따라서 EraseAll이 호출됩니다.

뭐가 UB일까요? Do가 초기화되지 않은 상태에서 Do를 호출하는 게 UB입니다.

그리고 UB가 있는 건 있는 건데, 왜 저렇게 바뀔까요? Do단 한번 할당되는 전역 변수이기 때문입니다. Do는 단 한번 할당되기 때문에, Do가 가질 수 있는 값은 0 (NULL) 또는 EraseAll 뿐입니다. 그런데 컴파일러 입장에서는 Do가 초기화되지 않은 상태(0)에서 Do를 호출하는 건 UB이기에 불가능합니다. 따라서 Do가 가질 수 있는 유일한 값은 EraseAll입니다. 그렇기에 컴파일러는 DoEraseAll로 바꿔버립니다.

UB가 존재하는 이유

기본적으로는 성능 문제 떄문에 존재한다고 생각하시면 됩니다. 해당 동작이 UB가 아니면, 거의 모든 프로그램의 성능이 지나치게 나빠집니다. 컴파일러랑 관련이 깊은 얘기인만큼, 이것 또한 이해가 안 가실 확률이 높습니다. 아래에서 유명한 UB 예시를 보여드릴 건데요, 왜 해당 동작이 UB인지까지 같이 설명드리겠습니다.

UB의 종류 (C/C++)

0으로 나누기

int val = 100;
return val / 0;

0으로 나누는 동작은 UB입니다. 이 동작은 근본적으로 UB입니다. 정수를 0으로 나누는 건 수학적으로 정의되지 않았습니다.

부호 있는 정수형의 overflow

int x = INT_MAX;
printf("%d", x + 1);

부호 있는 정수형의 overflow는 UB입니다. 이게 UB인 이유는 성능 때문입니다. 이게 UB가 아니고 정의된 동작이 있답면, 컴파일러는 모든 정수형 덧셈과 뺄셈 다음에 overflow를 체크하는 기계어를 추가해야합니다. 말만 들어도 비용이 너무 크죠?

배열의 범위를 넘어서는 메모리 접근

int values[1] = {1, 0};
return values[3];

배열의 범위 바깥에 있는 원소를 가리키면 UB입니다. 이것 역시 성능 때문에 UB입니다. 어떤 배열 접근이 배열 바깥에 있는지는 컴파일 시점에 알 수 없는 경우가 많습니다. 다르게 말하면 런타임에나 알 수 있다는 소리인데, 이는 이게 UB가 아니면 모든 배열 접근에 런타임 체크를 추가해야한다는 의미입니다.

문자열 리터럴 수정

char* s = "undefined";
s[0] = 'U';

이건 올바른 동작을 정의하기 힘들다는 문제도 있고, 다른 최적화를 심각하게 방해하기 떄문에 UB입니다. 정적인 문자열은 바이너리에 저장됩니다. 그 값을 바꿨을 때 올바른 동작은 무엇일까요? 의견을 낼 수는 있어도, 모두를 설득시킬 수는 없으실 겁니다. 그리고 성능 문제도 있습니다. 정적인 문자열은 인라이닝해서 최적화할 수 있는데, 이게 UB가 아니라면 인라이닝을 할 수 없습니다. 언제 바뀔지 모르는 값은 인라이닝할 수 없으니까요.

Null 포인터 접근

int* ptr = NULL;
int val = *ptr;

이것 역시 두가지 이유로 UB입니다. 올바른 동작이 있을까요? 그리고 모든 포인터 접근에 NULL인지 확인하는 어셈블리를 추가하는 게 과연 그만큼의 가치가 있을까요?

결론

오늘은 UB에 대해서 알아봤는데요, C, C++, Rust등의 언어로 개발하실 때에는 UB를 조심하셔야합니다. 프로젝트가 커지면 찾는 게 매우 힘들어지는데, 그런 상황이 되지 않도록 미리 조심하셔야합니다.