Menu

C++ 미정의 동작: 무엇이며 어떻게 피하는가

미정의 동작(UB)은 C++ 표준이 아무런 규칙도 두지 않은 코드입니다. 충돌하거나, 데이터를 손상시키거나, 정상으로 보일 수도 있습니다. 흔한 원인, "잘 돌아갔다"가 아무것도 증명하지 못하는 이유, 그리고 UB를 잡아내는 도구를 알아보세요.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

"미정의 동작"이 실제로 의미하는 것

앞 페이지에서는 프로그램이 정의하고 의도적으로 던지는 오류를 try/catch가 어떻게 처리하는지 보여 주었습니다. 미정의 동작은 그 반대입니다. C++ 표준이 어떤 의미도 부여하기를 거부하는 연산들의 집합입니다. 잡을 예외도, 오류 코드도, 충돌하리라는 보장도 없습니다. 컴파일러는 UB가 결코 일어나지 않는다고 가정해도 되며, 일어났을 때는 마음대로 해도 됩니다.

바로 그 자유가 UB를 이토록 위험하게 만듭니다. 동일한 버그 있는 한 줄이 여러분의 노트북에서는 "맞는" 답을 출력하고, 서버에서는 쓰레기 값을 반환하며, -O2에서는 최적화기에 의해 통째로 삭제될 수 있습니다. UB는 "우리가 문서화하지 않은 동작"이 아니라 "언어가 아무것도 약속하지 않는 동작"입니다. 여러분의 일은 애초에 그것을 결코 쓰지 않는 것입니다.

int arr[3] = {1, 2, 3};
int x = arr[5];   // 미정의 동작: 배열의 끝을 넘어 읽음

여기에 컴파일 오류는 없으며, 많은 실행에서 슬그머니 엉뚱한 정수를 건네줄 것입니다. 그 겉보기의 성공이 바로 함정입니다.

범위 밖 읽기 또는 쓰기

UB의 가장 흔한 형태는 여러분이 소유하지 않은 메모리를 건드리는 것입니다. 내장 배열과 std::vector::operator[]는 경계 검사를 전혀 하지 않습니다. 끝을 넘어선 인덱스(또는 음수)는 읽든 쓰든 즉시 UB입니다.

조심해야 할 버그는 <를 의도한 자리에 <=를 쓰는 것입니다. i == v.size()일 때 마지막 요소의 한 칸 뒤를 인덱싱하게 되고, 이는 UB입니다. 인덱스가 필요 없을 때는 범위 기반 for(앞에서 다룸)를 선호하세요. 끝을 넘어 달릴 수 없기 때문입니다. 정말 손으로 인덱싱하면서 안전망을 원할 때는, v.at(i)가 메모리를 조용히 손상시키는 대신 std::out_of_range를 던집니다.

버그를 쫓는 동안에는 at()을 쓰고, 인덱스가 유효함을 증명한 뒤에는 핫 루프에서 []로 돌아가세요.

댕글링 포인터와 use-after-free

가리키는 객체보다 오래 살아남는 포인터나 참조는 댕글링입니다. 그것을 사용하는 것은 UB입니다. 메모리는 재사용되었거나, 해제되었거나, 애초에 존재하지 않았을 수 있습니다. 이것은 스마트 포인터(앞 장)가 피하도록 도와주는 함정이지만, 원시 포인터는 여전히 여러분을 그 안으로 빠뜨립니다.

가장 날카로운 형태는 지역 변수의 주소를 반환하는 것입니다. 함수가 반환하면 지역 변수는 죽으므로, 호출자는 아무것도 가리키지 않는 포인터를 들고 남게 됩니다.

int* makeNumber() {
    int n = 42;
    return &n;   // 지역 변수의 주소를 반환 - return 이후에는 사라짐
}
// 그 결과를 역참조하는 것은 미정의 동작.

같은 일이 delete 이후나, vector가 재할당하여 그것을 가리키는 반복자나 포인터를 무효화할 때에도 일어납니다.

int* p = new int(5);
delete p;
cout << *p;   // use-after-free: 미정의 동작

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // 재할당할 수 있음 - 'first' 는 이제 댕글링
cout << *first;   // 미정의 동작

방어책은 여러분이 이미 아는 것들입니다. 어떤 포인터든 필요로 하는 동안에는 객체를 살려 두고, 소유권을 가진 원시 포인터보다 참조와 스마트 포인터를 선호하며, 컨테이너 크기를 바꿀 수 있는 연산 뒤에는 포인터/반복자를 다시 가져오세요.

초기화되지 않은 변수와 부호 있는 오버플로

값을 주기 전에 변수를 읽는 것은 내장 타입에 대해 UB입니다. 기본값 0 같은 것은 없습니다. 변수는 그 메모리에 이미 들어 있던 비트를 그대로 담으며, 최적화기는 여러분이 그것을 초기화되지 않은 채로 결코 읽지 않는다고 가정할 수 있습니다.

만약 sum이 그냥 int sum;으로 선언되었다면, sum += i마다 먼저 불확정 값을 읽게 됩니다. 이는 UB이며, 흔히 동작하는 것처럼 보이기 때문에 악명 높게 까다로운 버그입니다. 초기화를 습관으로 만드세요. int x = 0; 또는 int x{};입니다.

또 하나의 조용한 범인은 부호 있는 정수 오버플로입니다. 부호 있는 int를 최댓값 너머로 밀어내는 것은 UB입니다(부호 없는 타입은 예측 가능하게 순환하지만, 부호 있는 타입은 그렇지 않습니다).

int big = 2147483647;   // 32비트 int 의 INT_MAX
int oops = big + 1;     // 부호 있는 오버플로: 미정의 동작

"음수로 순환한다"는 데 의존하지 마세요. 컴파일러는 오버플로가 일어날 수 없다고 가정하고 그것을 전제로 최적화해도 됩니다. 정의된 순환이 필요하다면 부호 없는 타입을 쓰거나, 더하기 전에 경계를 검사하세요.

새니타이저와 경고로 UB 잡기

UB에 대해서는 테스트로 확신에 이를 수 없습니다. 성공한 실행은 아무것도 보장하지 않기 때문입니다. 효과가 있는 것은 컴파일러의 새니타이저(GCC와 Clang에서 사용 가능)로 UB를 런타임에 시끄럽게 만드는 것입니다.

// AddressSanitizer: 범위 밖, use-after-free, 누수
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer: 부호 있는 오버플로, 널 역참조, 잘못된 캐스트
g++ -fsanitize=undefined -g main.cpp -o app && ./app

기존 테스트를 이 플래그들 아래에서 실행하면, "잘 돌아갔던" 범위 밖 읽기, use-after-free, 부호 있는 오버플로가 파일과 줄을 짚어 주는 정확한 보고서로 바뀝니다. -Wall -Wextra와 결합하면, 실행하기도 전에 (초기화되지 않았을 법한 읽기 같은) 수상한 코드까지 컴파일러가 지적해 줍니다.

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

어떤 새니타이저 보고서든 무시할 경고가 아니라 반드시 고쳐야 할 버그로 다루세요. 그 줄에 대해 표준이 아무것도 약속하지 않는다고 알려 주는 것입니다.

마무리

미정의 동작은 C++에서 안전 난간이 사라지는 부분입니다. 범위 밖 접근, 댕글링 포인터, use-after-free, 초기화되지 않은 읽기, 부호 있는 오버플로는 모두 아무런 정의된 의미가 없는 코드를 만들어 내며, "잘 돌아갔다"는 결코 올바름의 증거가 아닙니다. 안전하게 지내는 방법은 방어적으로 작성하는 것(모든 변수를 초기화하고, 컨테이너 경계를 지키며, 힙 메모리의 소유를 스마트 포인터에게 맡기기), 그리고 -fsanitize=address, -fsanitize=undefined, -Wall -Wextra로 검증하여 조용한 UB가 시끄럽고 고칠 수 있는 보고서가 되게 하는 것입니다.

이로써 오류 및 디버깅 장을 마칩니다. 예외, try/catch, 그리고 UB에 대한 건강한 두려움을 갖춘 여러분은 이제 조용히 우연하게가 아니라 시끄럽게 의도적으로 실패하는 C++를 작성할 도구를 갖추었습니다.

자주 묻는 질문

C++에서 미정의 동작이란 무엇인가요?

미정의 동작(UB)은 C++ 표준이 명시적으로 아무런 정의된 결과도 남기지 않은 모든 연산입니다. 예를 들어 배열의 끝을 넘어 읽거나 댕글링 포인터를 역참조하는 것입니다. 컴파일러는 무엇이든 할 수 있습니다. 충돌하거나, 쓰레기 값을 반환하거나, 코드를 최적화로 없애 버리거나, 오늘은 동작하는 것처럼 보이다가 재컴파일 후 망가질 수도 있습니다. 이는 언어의 기능이 아니라 여러분 프로그램의 버그입니다.

미정의 동작이 있는데도 제 C++ 프로그램이 동작하는 이유는 무엇인가요?

"잘 돌아갔다"는 UB에 대해 아무것도 증명하지 못합니다. 표준은 어느 쪽으로도 보장을 주지 않으므로, UB 버그는 오늘 여러분의 머신에서 여러분의 컴파일러로는 기대한 결과를 내다가 다른 최적화 수준, 플랫폼, 컴파일러 버전에서 충돌할 수 있습니다. 성공한 실행을 UB가 무해하다는 증거로 절대 받아들이지 마세요. 새니타이저를 사용해 실제로 잡아내야 합니다.

C++에서 미정의 동작은 어떻게 잡나요?

새니타이저를 켜고 컴파일하세요. -fsanitize=address(AddressSanitizer)는 범위 밖 읽기/쓰기와 use-after-free를 찾아내고, -fsanitize=undefined(UndefinedBehaviorSanitizer)는 부호 있는 오버플로, 널 역참조, 잘못된 캐스트를 지적합니다. 경고를 켜고(-Wall -Wextra) 이 플래그들 아래에서 테스트를 실행하세요. 이들은 조용한 UB를 명확한 런타임 보고서로 바꿔 줍니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기