C/C++의 정의되지 않은 동작(UB)이 무엇이며 왜 존재하는지, 대표적인 사례와 그로 인한 위험, 그리고 경고·경계 검사·새니타이저·정적 분석·안전 플래그·최적화 설정·대안 언어 등 실용적 대응 방법을 다룬다.
2024년 2월 3일 
사진: Jonathan Kemper, 출처: Unsplash
C나 C++로 작업할 때는 정의되지 않은 동작(undefined behavior, 이하 UB)에 대해 알고 있어야 한다. 즉, UB가 무엇이며 어떤 영향을 미치고 어떻게 피할 수 있는지에 대한 인식이 필요하다. 간단히 하기 위해 여기서는 C만을 다루지만, 별도로 언급한 부분을 제외하면 이 글의 내용은 C++에도 그대로 적용된다.
일반적으로, Python 같은 언어보다 C로 프로그래밍하는 게 더 어렵다. 어떤 면에서는 C가 어셈블리에 더 가까운 저수준 언어이기 때문이다. C는 하부 머신이 제공하는 것만을 그대로 제공한다.
예를 들어, Python의 정수는 수학적 정수처럼 동작한다. 경계가 없고, 아무리 큰 수를 더해도 항상 올바른 결과를 준다. (물론 컴퓨터 메모리가 바닥나면 예외다. 어떤 언어도 무한한 자원을 만들어낼 수는 없다. 하지만 Python은 올바른 결과를 주거나, 아니면 크래시가 난다는 것을 보장한다. 절대 ‘틀린 결과’를 내진 않는다.)
C의 정수는 CPU 레지스터에 들어가는 만큼이다. + 연산자는 단일 CPU add 명령을 수행한다. 그 결과가 머신 워드를 넘치면, 올바르지 않은 값이 나온다.
cint successor(int a) { // 맞을 수도, 틀릴 수도 있다 return a + 1; }
(C의 정수와 CPU 레지스터의 연결 고리는 설명을 간명하게 하기 위한 단순화다. 예를 들어 32비트 플랫폼을 대상으로 하는 컴파일러가 런타임 라이브러리 지원으로 64비트 연산을 제공할 수도 있다. 반대로 64비트 플랫폼에서도 int는 역사적 이유로 대개 32비트다. 오랫동안 32비트 플랫폼을 썼고, 그 사이에 int 크기에 대한 가정이 너무 많은 코드에 굳어버렸기 때문이다. 자세한 내용은 위키백과를 참고하라. 여기서의 요지는 C가 기본 연산이 소수의 고정된 CPU 명령으로 컴파일되도록 설계되었다는 점이며, 그 수는 보통 1이다.)
다른 한편, 직관에 반하게도, C는 단지 ‘고수준 어셈블리’가 아니며, 항상 하부 머신이 주는 것을 그대로 주지는 않는다.
만약 머신 산술을 알고 있다면, 다음과 같이 쓰고 싶을지도 모른다:
cvoid error(const char* msg); int successor(int a) { if (a + 1 < a) error("Integer overflow!"); return a + 1; }
논리는 간단하다. a가 이미 가능한 최대 int 값이라면, 1을 더하면(모든 최신 CPU에서 성립하는 2의 보수 가정하에) 최소값으로 랩어라운드된다.
이를 Compiler Explorer에 붙여넣고, x86-64 gcc 13.2에서 -O3로 컴파일해보면 다음이 나온다:
successor:
lea eax, [rdi+1]
ret
그냥 무조건 더한다. 검사는 조용히 제거되었다. 무슨 일일까? C에서 부호 있는 정수 오버플로는 ‘정의되지 않은 동작’이다. C23 초안은 이를 “이 문서가 어떤 요구 사항도 부과하지 않는 동작”이라고 기술한다. 그러면 컴파일러는 다음과 같이 추론할 수 있다.
a + 1 < a는 거짓이어야 한다.이 논리는 언어 표준을 일종의 계약으로 본다. 애플리케이션 프로그래머인 당신이 유효한 연산만 수행함으로써 당신 몫을 지키면, 컴파일러는(버그가 없다면, 그리고 공정하게 말해 현대 컴파일러는 매우 잘 테스트되어 신뢰성이 높다) 올바른 기계어 코드를 생성함으로써 자신의 몫을 지킨다. 하지만 당신이 UB를 저지르면 계약은 깨지고, 컴파일러는 더 이상 어떤 것에도 책임을 지지 않는다. 그게 바로 정의되지 않은 동작의 의미다.
어떤 의미에서든 모든 프로그래밍 언어는 UB의 여지가 있다. Python에서 subprocess.run('foo')를 호출했는데 foo가 하드디스크를 포맷해 버린다면, Python이 그 결과에 책임을 지지 않는 건 너무나 당연하다. 하지만 보통 그렇게 완전히 무제한의 결과는 외부 코드나 운영체제 호출, 혹은 최소한 스레드 간 레이스의 어두운 구석으로 뛰어들 때에나 기대한다. C는 많은 평범하고 핵심적인 언어 구성요소의 ‘잘못된 사용’만으로도 UB가 된다는 점에서 특이하다.
C23 초안의 부록 J.2에는 218가지 UB가 나열되어 있고, 그중 1번은 “제약부가 아닌 곳에 나타난 ‘shall/shall not’ 요구사항을 위반한 경우”이므로, 실질적 개수는 더 많다. 하지만 대부분은 다소 마이너하다. 예컨대 #218은 “wctrans가 반환한 설명과 다른 LC_CTYPE 범주를 사용하여 towctrans를 호출”하는 경우인데, 버그의 원인으로서는 매우 상황 의존적이다.
다음은 실전에서 버그의 원인이 되기 쉬운 것들이다. 다만 이 목록이 결코 완전하지 않다는 점을 강조한다.
이게 가장 크고, C 프로그래밍의 숙적 같은 존재다. 세부 범주가 많다(널 포인터, 배열 인덱스 초과, 이중 해제, 해제 후 사용 등). 하지만 요지는 하나다. ‘읽기는 유효한 데이터로 가리키는 것에서만, 쓰기는 유효하고 쓰기 가능한 메모리를 가리키는 것에서만’ 해야 한다. 경계 사례(말 그대로): 배열의 끝 바로 뒤를 가리키는 포인터를 만드는 것은 완전히 유효하다.
cint a[10]; int* end = a + 10;
단, 이를 역참조하지만 않으면 된다. 배열의 끝을 표시하는 포인터로 자주 쓰이기 때문에 명시적으로 허용된다.
직관적으로 다음 코드는 올바른 것처럼 보일 수 있다. n은 초기화되지 않았으므로 그 값을 신뢰할 수 없다—아마 해당 레지스터나 스택 위치에 있던 ‘쓰레기’ 값을 담고 있을 것이다—하지만 C가 그저 이식 가능한 고수준 어셈블리라면, 어떤 값이든 0과의 비트 AND는 0이 되어야 한다.
cint zero(void) { int n; return n & 0; }
실제로는, 언어 규칙상 UB다. 0을 반환할 수도 있고, 비유적으로 ‘콧구멍에서 악마가 나오는’ 일이 벌어질 수도 있고, 현재 버전의 컴파일러에서는 전자였다가 다음 버전에서는 후자가 될 수도 있다.
앞서 보았듯이, 부호 있는 정수 산술에서의 오버플로는 단지 머신 워드의 랩어라운드가 아니라 UB다. 1을 부호 비트로 왼쪽 시프트해서 밀어 넣는 것도 UB다. 이를 2의 거듭제곱을 곱해 오버플로를 만든다고 생각하면 된다.
부호 없는 정수의 오버플로는 정의상 머신 워드 랩어라운드를 일으킨다. 그 이유 중 하나는, 해시 함수처럼 실제로 랩어라운드를 원하는 경우가 대개 부호 없는 정수가 더 적절한 맥락이기 때문이다.
당연히, 정수 나눗셈에서 0으로 나누는 것은 부호 유무와 관계없이 UB다.
경계 사례: 최소 부호 있는 정수 값을 -1로 나누는 것은 오버플로를 일으킨다.
부동소수 오버플로는 반드시 UB는 아니다. 일반적으로 부동소수 산술은 별도의 글이 필요할 정도로 자체적인 주제다.
부호 비트 문제와 별개로, 부호 없는 수에 대해서도, 수의 ‘비트 폭보다 크거나 같은’ 양만큼 시프트하는 것은 UB다.
포인터로 발이 걸리는 좀 더 미묘한 방법이 있다. A 타입의 객체가 있고 그 주소를 B 타입 포인터로 캐스팅한 뒤, 후자를 역참조하는 것은 UB다. (캐스팅 자체는 괜찮다. 금지된 것은 역참조다.) 이 규칙을 흔히 ‘엄격 별칭(strict aliasing)’이라고 부르며, 더 자세한 논의는 여기를 참고하라. 주제는 깊지만 중요한 요점 몇 가지는 다음과 같다.
char*는 특별 예외를 가진다.memcpy로 해야 한다.UB가 나타난 코드는 표준이 그렇게 규정했기 때문에 무엇이든 할 수 있고, 컴파일러는 그 자유를 활용한다. 그런데 왜 표준이 그렇게 규정하고, 왜 컴파일러는 그것을 활용할까?
가끔 UB가 존재하는 이유를 C가 70년대에 만들어졌고 임베디드 시스템에도 적합하도록 설계되었기 때문이라고들 한다. 즉, 이미 서로 다른 동작을 하는 구현들이 있었고, 표준(기존 관행을 성문화하는 것이 주목적이지 새로운 관행을 발명하는 것이 아님)이 그것을 모두 포용할 필요가 있었다는 설명이다.
분명 C는 역사적으로 매우 다양한 플랫폼을 지원해야 했다.
예컨대 PDP-11/20(UNIX가 처음 개발된 미니컴퓨터), Intel 8088(초기 IBM PC에 사용), Motorola 68000(초기 Mac과 대부분의 초기 워크스테이션에 사용)에는 MMU가 없었다. 널 포인터 역참조는 여전히 ‘유효하지 않은’ 동작(지원되지 않고 쓸모 있는 결과를 내기 어려운 뜻)이었지만 하드웨어가 이를 트랩할 수 없었기 때문에, C 표준이 그런 트랩을 필수로 요구할 수는 없었다. (소프트웨어로 하려면 코드 곳곳에 분기 조건을 뿌려야 했고, 많은 애플리케이션에 용납하기 어려울 만큼 비효율적이었을 것이다.)

DEC - PDP-11 - Ken Thompson and Dennis Ritchie, 1970년경, Gwen Bell 유물 및 도서 컬렉션, Lot X7413.2015, Catalog 102685442, Computer History Museum
하지만 그건 널 포인터 역참조의 결과를 ‘구현 정의(implementation-defined)’로 만들 근거는 된다. 즉, “준수하는 구현은 자신의 동작 선택을 문서로 명시해야 한다”는 뜻이다. MMU가 있는 칩의 구현은 널 포인터 역참조를 트랩하고, 그 사실을 문서화할 수 있다. 다른 구현은 해당 주소의 메모리에 있는 무엇이든 반환하고, 그 사실을 문서화할 수 있다. 다양한 플랫폼을 지원해야 한다는 요구는, 서로 다른 구현이 서로 다르게 동작하는 것을 정당화한다. 그러나 그것이 ‘무엇이든 가능’한 UB를 정당화하지는 못한다.
부호 있는 정수 오버플로도 마찬가지다. VAX에는 오버플로 시 정수 산술이 자동으로 트랩을 발생시키는 프로세서 상태 비트가 있다. 이 아키텍처에서 컴파일러는 적어도 선택적으로 이 모드를 켜고 싶어할 것이므로, 표준이 랩어라운드를 명시할 수는 없다. 하지만 ‘구현 정의’라고 할 수는 있다. 그러면 VAX 구현은 오버플로에서 트랩을 발생시키고, 그 사실을 문서화할 수 있다. x86, ARM, RISC-V 등은 랩어라운드를 택하고, 그 사실을 문서화할 수 있다. (또는 선택적으로 트랩을 택할 수도 있다. 뒤에서 보겠듯, 하드웨어가 자동 트랩을 제공하지 않아도 컴파일러가 문서화된 ‘오버플로 시 트랩’을 제공하는 데 장애가 되지 않는다.)
C 표준과 컴파일러의 해석을 비판하는 논지는, UB가 전혀 없는 C의 변종을 바란다는 형태를 취하곤 한다. 안타깝게도 이는 실현 불가능하다. 다음을 보자:
cint foo_or_bar(int which) { // 두 함수 모두 호출되어도 괜찮다고 가정 int x = foo(); int y = bar(); return *(&x + which); }
겉보기에 C를 이식 가능한 고수준 어셈블리로 생각하고, 지역 변수가 스택에 할당된다는 모델을 따른다면 위 코드는 동작해야 한다. 실제로 -O0로 컴파일하면 생성된 코드는 그럴듯해 보이기도 한다.
물론 최적화를 켜는 순간 무너진다. 컴파일러가 y를 스택에 spill하지 않고 레지스터에 유지했기 때문이다. 그래도 되는가? 규칙 그대로라면 답은 간단하다. 원래 가리키던 객체의 경계를 벗어난 포인터를 역참조하면 UB이므로, 컴파일러는 당신이 그런 일을 하지 않는다고 가정할 권리가 있고, 그 가정에 기반하여 변환을 수행할 수 있다. 다만 정책의 관점에서, 애초에 규칙이 그렇게 쓰여야 하느냐는 질문은 해볼 수 있다.
그렇다! 값을 레지스터에 유지하는 것은 실제로 가장 기본적이고 중요한 최적화다. 이를 잘하지 못한 것이 초기의 C가 어셈블리에 비해 성능이 뒤처진 주된 이유였다. 좋은 레지스터 할당은, 성능이 중요한 코드에서도 어셈블리를 직접 쓸 필요가 거의 없도록 만든 결정적 진전이었다. 레지스터 할당을 생략하는 것은 성능이 중요하지 않을 때에만 용인될 수 있으며, 그런 경우라면 아마 C보다 더 고수준이고 안전하며 편한 언어를 쓰는 편이 좋다.
일반적으로, 포인터 산술을 허용하고 주류 하드웨어에서 실용적으로 쓰이려는 어떤 언어라도, “원래 가리키던 객체의 경계를 벗어난 포인터의 역참조는 UB”라는 C와 동등한 규칙을 반드시 가져야 한다. 이 규칙은 최적화를 위해 필요하고, 만약 성능이 중요하지 않다면 포인터 산술을 허용할 실용적 이유가 없다. 유일한 예외는 어셈블리뿐인데, 거기에선 모든 것이 ‘정의’되지만 그 대가로 레지스터를 수동으로 할당해야 한다.
포인터가 UB가 허용하는 최적화의 가장 큰 원천이지만, 정수 산술과 비트 논리에도 꽤 많다. 여기에 모두 다루기엔 너무 많다. 더 깊이 파고들고 싶다면, 컴파일러 작업 경험이 있는 사람이 쓴 이 훌륭한 글을 추천한다.
강조하자면, “정의되지 않은 동작은 최적화를 위해 존재한다.” 어떤 것들이 ‘구현 정의’가 아니라 ‘정의되지 않음’이 되는 이유는, 우리가 빠른 코드를 원하기 때문에 컴파일러가 최적화할 수 있도록 하기 위해서다.
C23 초안 부록 J.2에 나열된 218가지 UB 가운데에는 다음도 있다.
이런 것들은 어떤 최적화에도 기여하지 않는다. 컴파일 시점에 쉽게 인지할 수 있고, 모든 컴파일러가 그렇게 한다. 이들을 ‘진단이 필요한 오류’로 만드는 편이 모든 목적을 더 잘 달성한다. 아마 표준 위원회가 ‘왜 안 되지?’라는 마음가짐에 빠졌던 듯하다. 이해할 만하지만, 필자 생각에는 언어의 미래 버전에서는 이를 되돌려서, “이 UB가 최적화를 가능하게 하는가?”라는 질문을 필터로 적용하는 편이 낫다.
어셈블리어로 웹 포럼 엔진을 작성한 사람이 있다. 대단한 업적이다. 우리는 어려운 일을 일부러 택하기도 한다. 쉬울 거라서가 아니라, 어려울 줄 알기 때문이다. 그로 인해 세상의 영광이 커진다.
AsmBB 자체보다는 Hacker News 토론이 이 맥락에서 시사적이다. 다음과 같은 댓글이 달렸다.
전통적인 C/C++ 스택보다 어셈블리 + 리눅스 커널 ABI가 더 안전하다고 생각합니다. ‘정의되지 않은 동작’으로 가득 차 있지 않기 때문이죠. 부호 있는 산술은 기대한 대로 오버플로/언더플로합니다. mmap + MAP_ANONYMOUS로 메모리를 할당하면 기대한 대로 0으로 초기화됩니다. 주소 공간의 매핑되지 않은 메모리(주소 0 포함)에 접근하면 기대한 대로 SIGSEGV가 납니다. 어셈블러는 C 컴파일러보다 가정이 훨씬 적고, 반쯤 영리하게 굴지도 않기 때문에, 기대를 은밀히 배반하기보다는 오류 시 더 잘 ‘크래시’합니다.
그리고
10000truths가 https://news.ycombinator.com/item?id=38985198에서 지적했듯, 어셈블리에서는 정의되지 않은 동작을 다룰 필요가 없어서 많은 도움이 됩니다. 구현마다 다른 동작이 있을 수는 있지만, ‘콧구멍 악마’는 없고, 특히 두 개의 알 수 없는 정수를 더해도 구현 정의 동작조차 마주하지 않습니다. 그리고 이론적으로 C 프로그램이 스택 오버플로를 절대로 일으키지 않는다고 보장할 수 없고, 실제로 Arduino에서 스택이 힙과 충돌한 일을 겪었습니다. 부호 관련 버그(흔한 보안 취약점이며 qmail에도 있었다)는 어셈블리에서 C보다 피하기 쉬울 가능성도 있습니다. 최근 컴파일러가 그 점을 돕고는 있지만요.
물론 문자 그대로 어셈블리가 더 안전한 것은 아니다. 다른 댓글이 지적하듯이:
… 이 프로젝트는 내가 참여한 CTF의 일부였고, 해당 프로젝트의 최신 버전을 그대로 호스팅했습니다. 대회 기간 동안 최소 8개의 취약점이 발견되었습니다.
이는 asmBB 저자에 대한 비판이 아니다. 복잡하고 보안이 중요한 코드를 어셈블리로 취약점 없이 작성하는 것은 인간적으로 거의 불가능하다. 단순 실수를 저지를 기회는 너무 많고, 설계와 구현을 명확히 조망할 기회는 너무 적다. 암호 알고리즘의 핵심 루프에는 타당할 수 있지만, 전체 프로그램이나 프레임워크에는 아니다. 우리가 애초에 컴파일 언어를 쓰는 이유 중 하나다.
그럼에도, 과장을 감안하더라도 첫 두 댓글은 UB가 얼마나 불편하고 불안한 존재인지 잘 보여준다.
겉보기에는 멀쩡한 코드, 심지어 UB를 일부러 피하려는 것처럼 보이는 코드에도 숨어 있을 수 있다.
이를 체계적이고 신뢰할 수 있는 방식으로 예방하거나 사후에 추적하는 방법이 없다.
컴파일러를 마치 ‘적’처럼 느끼게 만들 수 있다. 당신이 쓴 것의 허점을 적극적으로 찾아내는 ‘원숭이의 손(소원을 비틀어 들어주는 요물)’처럼 말이다.
더 나은 컴파일러가 상황을 악화시킬 수 있다. 기술적으로는 항상 잘못된 코드였지만, 어제까지 잘 작동하던 것이 오늘은 폭발하는데, 새 컴파일러가 더 영리한 데이터 흐름 분석을 하기 때문이다.
보안 결함을 만든다. 많은 맥락에서 이는 가장 우려스러운 종류의 버그다.
그리고 끝없는 논쟁을 만든다.
이 논쟁은 끝나지 않는다. 양쪽 모두 유효한 주장을 하기 때문이다.
UB를 무시할 수도, 근절할 수도 없는 이 상황에서 우리는 무엇을 할 수 있을까?
기본값보다 더 많은 경고를 켜는 것이 보통 좋다. 컴파일러마다 이를 위한 플래그가 많지만, 대표적(완전하지 않음)이고 유용한 조합은 다음과 같다.
-Wall -Wextra -Wpedantic -Wconversion -Wdeprecated/Wall /external:anglebrackets /external:W0(GCC 줄이 이상해 보일 수 있다. 왜 -Wall이 말 그대로 모든 경고를 켜지 않을까? 일부 프로젝트가 내부 빌드에 -Werror를 사용하고(그 자체로는 합리적) 소스 코드를 사용자에게 배포하면서 내부 빌드 스크립트를 수정 없이 제공한 문제가 있었다. 이때 컴파일러가 새로운 경고를 추가하면, 사용자는 빌드가 실패하는데 이를 바로잡을 위치에 있지 않다. 따라서 소스를 배포한다면, -Werror가 없는 빌드 스크립트를 제공해야 한다. 한편 이 문제의 임시 방편으로 새로운 경고가 모두 -Wall에 추가되지는 않았고, 정말 모든 경고를 원한다면 플래그 조합이 필요하다.)
상속받은 프로젝트에서 빌드할 때마다 수백 개의 경고가 스크롤되는 상황이라면 어떻게 할까? 그 시점에서는 거짓 경고 속에 진짜도 섞여 있을 수 있지만, 어느 것이 어느 것인지 알 수 없으므로 총합적으로 전혀 도움이 되지 않는다. 새로 추가한 코드에서 발생한 진짜 경고들조차 소음에 묻힌다.
따라서 구체적 조언을 하자면:
“프로젝트는 항상 경고 없이 깨끗하게 빌드되어야 한다.”(이를 -Werror로 강제할지, PR 전 경고를 정리하도록 규정할지 등은 구현 세부다. 워크플로에 맞는 방식을 쓰면 된다.)
그 원칙하에서, 가능한 많은 유용한 경고를 활성화하라. 하지만 특정 경고가 전체 빌드마다 200번씩 나고, 앞의 20건을 조사해 모두 거짓임을 확인했고, 시간이 없다면, 그 하나를 꺼도 괜찮다. 지저분함을 치워서, 더 유용할 경고를 보이게 하라.
“테스트 때는 배열 경계 검사를 켜고, 성능 때문에 프로덕션에서는 꺼두는” 옵션이 손쉽게 제공되면 좋겠지만, 유감스럽게도 C는 배열을 포인터로 환원하는 설계가—당시에는 우아해 보였고(필자도 80년대, 일반적인 코드가 보안을 크게 걱정하기 전에는 우아하다고 생각했다. 그러니 누구를 탓하자는 게 아니다)—결국 언어에 경계 검사를 사후 적용하기를 근본적으로 어렵게 만들었다.
이 점에서 C++는 꽤 다르다. 대개 생포인터 대신 템플릿 컨테이너를 쓰기 때문이다.
좋은 소식: 이로 인해 경계 검사가 가능해진다.
나쁜 소식: std::vector는 컴파일 타임 스위치가 아니라 연산자 이름에 따라 달라진다. v.at(i)는 경계 검사를 하지만 v[i]는 하지 않는다. 많은 C++ 프로젝트가 프로덕션에서 경계 검사를 유지하겠다고 쉽게 약속하지 못한다. 성능 비용을 측정할 수 있기 훨씬 전에 약속해야 하기 때문이다.
어느 정도의 좋은 소식: 애당초 std::vector를 쓰지 않는 사람들도 있다. LLVM의 SmallVector처럼 다른 할당 전략을 쓰는 자체 컨테이너를 쓰는 프로젝트가 많다. 이런 경우 #ifdef DEBUG 또는 #ifndef NDEBUG에 따라 경계 검사를 구현할 것을 추천한다.
항상 그렇듯, 디버그 빌드에서 경계나 기타 안전 검사를 사용할 때, 릴리스 빌드에서 끄기 전에 반드시 측정하라. 때로는 즐거운 놀라움이 있다. 성능 비용이 생각보다 작아서, 프로덕션에서 안전을 위해 지불할 만한 수준일 때가 있다.
새니타이저는 “테스트를 돌려도 눈에 띄는 오류가 없으니 괜찮아 보인다”에서 “앗, 눈에 띄는 효과가 없어서 그동안 드러나지 않았던 UB가 실제로 있었다”로 가기 위해 설계된 디버깅 도구다. (반대로 디버거는 “크래시가 난다”에서 “어디서 났고 당시 변수 값이 무엇이었는지 더 잘 보인다”로 가기 위한 도구다.) 즉, 런타임 동작을 바꾸려는 것이 아니라, 테스트 중에 버그를 더 잘 보이게 하려고 코드를 계측하거나 변형하는 도구다.
아마 가장 잘 알려진 것은 메모리 버그 추적에 특화된 Valgrind일 것이다. 가능하다면 코드 전체를 밑에서 실행해 보길 권한다. 겉으로는 잘 동작해 보이는 코드에서조차 ‘해제 후 사용’ 같은 것을 찾아내는 일이 놀랄 만큼 잦다. 그런 잠복 버그는 당장은 무해할 수 있지만 시한폭탄이다. 구조체에 필드를 하나 추가했을 때 드러날 수도 있다(간헐적 크래시를 디버깅하면서, 방금 추가한 코드를 들여다보고 욕을 하게 될 것이다. ‘그 코드엔 문제가 없기 때문’이다. 단지 이미 숨어 있던 문제가 드러난 것뿐이다). 아니면 더 나쁘게, 공격자가 정교하게 만든 데이터 패킷으로 이를 악용할 수도 있다.
일부 컴파일러에는 새니타이저 기능이 있다.
이 목록은 완전하지 않다.
이름 그대로, 코드를 실행해 보지 않고, 분석하고 추론하여 ‘무엇이 일어날 수 있는지’를 찾는다. 즉, 새니타이저가 assert에 해당한다면, 정적 분석기는 컴파일러 경고의 더 크고 정교한, 전용 도구 버전이다. 경험상 정적 분석기는 여러 이유로 사용 빈도가 낮다.
-D 매크로 등)가 필요하므로, 빌드 절차만큼이나 설정 비용이 크다.그럼에도 위의 관찰은 일반론일 뿐이며, 당신의 프로젝트와 선택한 정적 분석기에는 해당하지 않을 수도 있다. 시간이 허락한다면 직접 시도해 보고 맞는지 확인하라.
예외적으로 Klee는 ‘규칙을 증명하는 예외’다. 왜 현재의 기술 수준이 그런지 보여주기 때문이다. 휴리스틱(수상해 보이는 것을 표시하는 최선의 추측) 대신, Klee는 논리적 연역으로 코드가 반드시 무엇을 하는지 추론한다. 그래서 거짓 양성이 없다. 도구가 수학적으로 ‘반드시 버그임’을 증명한 것만 보고한다. 훌륭하지만, 논리적 연역은 지수적 탐색 공간과 마주치며, 실용적 결론은 Klee가 작은 프로그램에서는 인상적 성과를 내지만, 큰 프로그램에는 너무 느려서 실용적이지 않다는 것이다. 실용적 정적 분석기들이 불완전한 휴리스틱에 의존하는 이유가 여기에 있다.
때로는 컴파일러 플래그로 특정 종류의 UB를 끌 수 있다. 이에 대한 명칭이 아직 없는 듯하여, 여기서는 ‘안전 플래그’라는 이름을 붙이겠다.
성능 비용이 조금 들 수도 있고, 전혀 없을 수도 있다. 성능을 이유로 배제하기 전에 측정하라. 놀랍게도, 느려질 것이라 생각한 것들이 측정해 보면 차이를 만들지 않는 경우가 종종 있다. (물론 안전 플래그를 반드시 쓰라는 뜻은 아니다. 다만 사용하지 않기로 했다면, ‘느려질 것이라는 가정’이 이유가 되어서는 안 된다는 뜻이다.)
일반적으로 이들은 이식성이 없다. 엄밀히 말하면, 이를 사용하면 표준 C가 아니라 약간 비표준인 C 방언으로 프로그래밍하게 된다. 이것이 철학적으로 중요한지는 의견 문제다. 실용적으로 중요한지는 당신이 필요로 하는 컴파일러 등에 달려 있다. 프로젝트에서 이를 사용하기로 했다면, 왜 그렇게 했는지(일반적 예방인지, 특정 버그를 고쳤기 때문인지 등)를 문서화하길 권한다. 그 정보는 미래의 유지보수자(몇 년 뒤의 당신 포함)가 사후에 복구하기 어려울 수 있다.
-fwrapv는 GCC에 부호 있는 정수 오버플로를 ‘잘 정의된 랩어라운드’로 취급하라고 지시한다(기본적으로 Java와 동일).
이 경우 어떤 컴파일러를 쓰느냐가 중요하다. Clang은 일반적으로 GCC와의 호환을 지향하지만, Visual C++는 어떤가? 아는 한 이와 동등한 옵션이 없다. 이 문제는 글을 쓰는 시점 기준 8년 전에 제기되었고, 달라졌다는 징후를 알지 못한다.
-ftrapv는 위의 변형이다. 부호 있는 정수 오버플로가 UB도, 조용한 랩어라운드도 아니라, 결정적으로 프로그램을 크래시시킨다.
나쁘게 들릴 수 있다. 크래시는 나쁜 것 아닌가? 사실 좋다. 버그를 ‘보이게’ 만들어 고칠 수 있게 하고, 그 사이 더 심각한 해를 막는다. 특히 랩어라운드 오버플로(정수 계산을 틀리게 할 수 있음)는 배열 경계 검사 부재(틀린 정수를 보안 결함으로 바꿀 수 있음)와 아주 나쁘게 결합한다. 다시 말해, -ftrapv는 ‘잠재적 보안 결함을 단순 서비스 거부로 바꿔준다’. 대부분의 실용적 맥락에서 큰 개선이다.
또한 -fwrapv보다 이식성 측면이 낫다. GCC에서 이를 사용하면, Visual C++도 같이 써야 하는 경우에 ‘도움’이 된다. 전자에서의 크래시가, 후자에서 예측 불가능한 방식으로 영향을 줄 수 있었던 버그를 걸러내는 데 도움이 된다.
성능 비용은 -fwrapv보다 크다. UB 최적화를 잃을 뿐 아니라, 컴파일러가 오버플로를 검사하고 트랩을 넣는 코드를 명시적으로 추가해야 하기 때문이다. 그래도 측정해 보아 비용이 문제가 아니라면, 호스트 환경의 소프트웨어(즉 범용 컴퓨터에서 실행되는 소프트웨어)에는 이 플래그를 적극 추천한다. 비용이 문제라면 디버그 빌드에만이라도 추천한다.
큰 주의점은 임베디드 시스템이다. 위 논의는 서비스 거부가 원격 코드 실행만큼 나쁘지는 않다는 가정에 선다. 하지만 어떤 임베디드 시스템에서는 크래시(혹은 최소한 예기치 않은 리셋) 자체가 안전 문제일 수 있다. 그런 도메인이라면, 크래시의 결과와 조용한 오답의 결과를 신중히 저울질해야 한다.
-fno-strict-aliasing은 GCC에 엄격 별칭 최적화를 끄라고 지시한다.
다시 Visual C++에는 이 플래그가 없다. 하지만 이 경우에는 항상 이 플래그가 설정된 것처럼, 즉 엄격 별칭 기반 최적화를 하지 않는 것처럼 동작한다는 합의가 있는 듯하다. 다만 이를 보장하는 Microsoft 문서를 찾지 못했고, 다음 버전에서 이런 최적화를 시작할 가능성이 항상 있다. 이 시점에서는 가능성이 낮다고 보지만, 이는 정수 오버플로에서도 결국 일어났던 일이다.
-fno-delete-null-pointer-checks는 ‘널 포인터 역참조는 일어날 수 없으니, 이 널 체크를 이동/삭제해도 된다’류의 최적화를 비활성화한다.
역시 Visual C++에는 이 플래그가 없다. 다만 몇몇 증거는 항상 설정된 것처럼 동작한다고 시사하지만, 이에 대한 문서화된 보장은 알지 못한다.
명확히 하자. 법리적으로는 최적화 레벨과 무관하게 아무 변화도 없다. UB는 어떤 결과든 가질 수 있으며, 실제로 컴파일러는 -O0에서도 상수 식을 알아보고 치환하는 등, UB에 걸릴 수 있는 일을 습관적으로 하기도 한다.
그리고 앞서 언급했듯, 대개 최적화를 끄고 싶지 않을 것이다. 빠른 코드가 필요하기 때문이다! 그게 중요하지 않았다면 애초에 C를 쓰지 않았을 것이다.
하지만.
가령 당신이 고대 Aztec C의 오래된 버전으로 68000에서 컴파일되던 임베디드 시스템의 거대한 코드 더미를 넘겨받았고, 이를 현대의 RISC-V 마이크로컨트롤러에서 GCC로 동작시키는 일이 과제라고 하자. 코드는 지저분하고, C를 고수준 어셈블리처럼 생각하던 시기의 산물이며, 아마 UB로 가득할 것이다. 대대적 개편은커녕 리라이트에 쓸 예산과 일정은 주어지지 않았다.
당신에게 주어진 것은 ‘초과’ 성능이다. 가정에 따르면, 코드는 최적화를 많이 하지 않는 컴파일러로 8MHz 68000에서 충분히 빨랐다. 현대의 마이크로컨트롤러는 수십 배에서 수백 배 빠르다. 지금은 더 큰 데이터 볼륨을 처리해야 할지라도, 최적화를 끄더라도 아마 충분히 빠를 것이다.
이런 상황이라면, “최선의 가용 해법의 일부”(‘일부’와 ‘가용’ 둘 다 중요하다)는 -O0로 컴파일하는 것일 수 있다.
대부분의 경우 실현 가능한 선택지는 아니다. 그런 선택이 가능했던 프로젝트는 대체로 이미 바꾸었기 때문이다. 성능·저수준 제어·대규모 기존 코드와의 밀착 작업 같은 강한 요구가 없다면, 더 고수준 언어를 쓰는 것이 흔히 더 쉽다는 사실은 오래전부터 알려져 있었다. (Java의 폭발적인 성장에는, 비즈니스 애플리케이션에 C++를 쓰던 시장을 정확한 시점에 포착했기 때문이라는 점이 크게 작용했다. 그 시장은 사실 저수준 언어를 원하지 않았다.) 기존 프로젝트를 다른 언어로 갈아엎는 일은 비싸고 위험하다. 세상에는 유용한 일을 하는 C/C++ 코드가 넘쳐나고, 가까운 미래에도 그럴 것이다.
그럼에도 자동 메모리 관리가 처음 생각보다 감당 가능할 때가 있고, 감당할 수 없다면 그게 필요 없는 언어들도 있다. (이 글을 쓰는 시점에서 가장 널리 쓰이고 이해되며 검증된 것은, 수치 계산에는 Fortran, 일반 코드에는 Ada와 Rust, 신뢰할 수 없는 파일 포맷을 안전하게 다루는 도메인 특화 언어로는 Wuffs다.) 이런 언어를 고려해야 할 때가 있으므로, 완결성을 위해 여기 적어 둔다.