표준 C의 ‘정의되지 않은 동작’이 어떻게 생겨났는지와 그 역사적 맥락, 포인터/별칭 규칙과 최적화, 스레드 메모리 모델, 컴파일러와 개발자 관점의 긴장, 그리고 UBSan·퍼저 같은 도구와 실천적 대응법을 다루는 글.

정의되지 않은 동작(Undefined Behavior, 이하 UB)은 보안 취약점을 포함한 많은 심각한 문제에 기여한다. 또한 필자가 보기에 UB는 잘 이해되지 않는 경우가 많고, 이를 둘러싼 논의는 종종 논쟁적으로 흐른다. 우리는 어떻게 여기까지 왔을까? 이를 다루는 최선의 방법은 무엇일까? 그것이 좋은 것일까 나쁜 것일까? 만약 나쁘다면, 없앨 수는 있을까? 이 질문들을 다루려면 역사로 조금 내려가 볼 필요가 있다.
C 프로그래머들은 동질적인 집단이 아니다. UB를 이해하기 위해 필자는 그들을 세 가지 진영으로 나눈다. 한 진영의 원칙과 관행은 다른 진영에게는 낯설거나 심지어 위협적으로 느껴질 수 있다.
“비이식성 C” 진영에서 궁극적인 목표는 보통 하나의 하드웨어를 위한 바이너리를 만들어 내는 것이다. C 컴파일러는 그 바이너리에 더 빨리 도달하도록 도와주는 도구다. 컴파일러가 제공하는 확장을 최대한 활용하는 것이 타당하다. 언어의 의미가 혼란스러울 때에는 바이너리(컴파일러의 출력)가 최종적인 진실의 근거다.
계산 모델은 어셈블리어와 매우 비슷하며, 세부사항은 대상 하드웨어와 일치한다. 포인터는 특정 방식으로 쓰이는 정수(알려진 워드 크기)일 뿐이고, 정수 연산은 안정적으로 래핑되며, 등등이다.
비이식성 방식의 C 사용은 점점 드물어지고 있지만 한때는 흔한 관행이었다. 비이식성 C의 극단적인 예로는 최근 혹평을 받은 책 ‘Mastering C Pointers: Tools for Programming Power’가 있다. 공정하게 말하자면, 그 책은 단지 다른 진영에 속해서가 아니라 다른 결함들도 갖고 있지만, 그 점이 오히려 비판의 강도를 키운 측면이 있다고 본다.
많은 경우 단일 머신이 아니라 여러 종류의 머신, 운영체제, 컴파일러, 기타 요소들을 대상으로 삼고 싶을 때가 있다. 이 머신들은 다양할 수 있다. 포인터 크기는 16/32/64비트일 수 있고, 리틀엔디언과 빅엔디언이 모두 가능하다. 어떤 형태로든 구성 메커니즘(유닉스 계열에서는 종종 autoconf)이 전처리기 심볼을 채워 넣고, #ifdef 지시문이 그 타깃에 맞는 대체 코드를 고르도록 한다.
컴파일러 전용 확장은 의심의 눈초리로 보되(다만 성능이나 디버깅 등에 도움이 된다면 #ifdef로 켜는 것이 합리적일 때가 많다), 특정 타깃 머신에 대한 가정을 두기보다는 최소한 이식성을 염두에 두고 코드를 작성하는 것이 합리적이다.
계산 모델은 비이식성 경우와 대체로 같지만 세부가 달라질 수 있다. 특히 타입 퍼닝(type punning)은 크기가 맞도록 주의만 기울이면 괜찮다. 가끔은 낭패를 보기도 하는데, 예컨대 x86은 RISC보다 미정렬 접근에 훨씬 관대하다. 어떤 경우에는 심각한 성능 문제를 일으키고, 다른 경우에는 그냥 크래시가 날 수 있다. 마찬가지로 비트폭을 넘겨 시프트하면 머신마다 다른 동작을 할 수 있다(모두 0을 채워 넣을 수도, 시프트량을 비트폭으로 모듈러 할 수도 있다). 하지만 언제나 “상식적”인 어떤 결과가 나온다고 여긴다.
준이식성 C는 한때 주류였으나, 지금은 많은 맥락에서 표준 C에 자리를 내주고 있다. 하지만 뒤에서 보겠듯 여전히(적어도 부분적으로) 버티는 곳들이 있다. 사람들이 C를 “이식 가능한 어셈블리어”라고 부를 때 대체로 준이식성 진영을 염두에 두고 하는 말이다.
이러한 상황을 마주한 표준화 위원회는 막대한 과제를 떠안았다. 모든 컴파일러가 무리 없이 구현할 수 있는 C의 버전을 만들어야 했다. 기존 C와 충분히 가까워서 기존 코드를 옮기는 데 큰 어려움이나 비용이 들지 않아야 했고, 동시에 언어를 개선할 기회, 특히 함수 매개변수 타입에 관한 느슨함을 정리할 기회이기도 했다.
그 과정에서 위원회는 모든 타깃을 포괄하는 어떤 계산 모델에 수렴해야 했다. 이는 매우 어려운 과제였다. 당시에는 특이하고 이국적으로 여겨질 타깃이 많았기 때문이다. 산술이 2의 보수(two’s complement)라는 보장조차 없었고(대안으로 1의 보수도 있었다), 워드 크기가 2의 거듭제곱이 아닐 수도 있었고, 기타 등등의 차이가 존재했다.
C는 use-after-free, double-free, 경계를 벗어난 접근 같은 메모리 오류를 피하기 위해 포인터를 올바르게 사용하는 데 매우 엄격한 주의를 요구한다. 이 중 어느 하나라도 크래시에서 미묘한 메모리 손상, 조용히 잘못된 결과에 이르기까지 다양한 증상을 유발할 수 있으며, 머신마다 결과가 다를 가능성이 매우 크다. 표준화 위원회는 이러한 동작 범위를 포착하기 위해 “정의되지 않은 동작(UB)” 개념을 만들었다. 본질적으로 이것은 “구현이 원하는 것은 무엇이든 해도 된다”는 면허다. 이는 타당하다. 성능이나 문제의 본질을 해치지 않고 이러한 동작을 더 자세히 못 박는 것은 상상하기 어렵다.
하지만 이 망치를 쥔 이상, 위원회는 이를 훨씬 더 광범위하게 적용했다. 예컨대 비트폭을 넘겨 시프트하는 동작도 UB로 간주했다. 많은 이들이 이 경우만큼은 “구현 정의(implementation-defined)”로 다루는 편이 더 낫다고 설득력 있게 주장해 왔다. 그렇게 하면 프로그래머는 적어도 같은 칩에서 같은 입력에 대해 항상 같은 결과를 얻을 것이라 기대할 수 있다(물론 엔디언처럼 칩이 바뀌면 달라질 수는 있다). 그러나 그들은 그렇게 결정하지 않았다. 대신 x << 64를 계산하는 것은 크래시를 일으키거나, 은근히 메모리를 손상시키거나, 심지어 서버에 연결해 당신 계좌에서 돈을 빼갈 수도 있다. 마지막 예는 비강의 악마(nasal demons) 식 농담이 아니다. UB는 많은 심각한 보안 취약점의 근원이며, 산술 문제(시프트 포함이지만 특히 정수 오버플로)는 그 중에서도 적지 않은 부분을 차지한다.

실제로 잠재적인 UB 목록은 매우 방대하다. 부호 있는 정수 오버플로, 초기화되지 않은 메모리 읽기, (역참조가 아니라) 경계를 벗어난 포인터 값 계산, 포인터를 통한 타입 퍼닝 등등. 여기서 완전한 목록을 제공하려는 것은 아니다(John Regehr의 가이드는 훌륭한 입문 자료다). 요점은 그물이 너무 넓어서, 사실상 존재하는 모든 프로그램이 이러저러한 형태의 UB에 걸렸다는 데 있다.
다시 말해 표준은 기본적으로 모든 기존 프로그램을 망가뜨렸다. “엄격히 적합(strictly conforming)”이라는 새 범주에서는 동작하지 않는다는 의미에서다. 아마도 위원회는 프로그래머들이 자신의 프로그램 결함을 비교적 쉽게 청소할 수 있다고 느꼈을 것이다. 마치 인자 구문을 바꾸어야 했던 것처럼 말이다. 하지만 그랬다면 그 과업을 엄청나게 과소평가한 셈이다.
이 글의 나머지 대부분은 이렇게 광범위한 UB 정의가 낳은 함의와 결과에 바쳐진다.
UB는 구현 간 변이를 포착하기 위한 것에 그치지 않는다. 그렇지 않았다면 어려웠을 최적화를 가능하게 하려는 주요 동기도 있다. 그중 가장 까다로운 영역이 엄격 별칭(strict aliasing)이다.
동기를 간단히 스케치해 보자. 많은 최적화는 “별칭 분석(alias analysis)”에 의존한다. 요지는 포인터들이 서로 별칭이 아니거나, 더 일반적으로 메모리 구간이 겹치지 않는다는 보장이다. 일반적으로 별칭 분석은 난해하고(사실상) 풀기 어렵다. 하지만 타입 규칙을 강하게 준수하는 프로그램에서는 서로 다른 타입의 두 값이 겹칠 수가 없다. 표준 C는 기본적으로 프로그램이 타입을 준수한다는 가정을 하고, 따라서 서로 다른 타입의 두 포인터는 절대 겹치지 않는다고 본다. 예전에는 많은 사람들이 이를 받아들이기 어려워했고, 그래서 “세상의 전부가 VAX는 아니다(Not all of the world is a VAX)” 같은 구호가 나오기도 했다.
그리고 이러한 규칙을 포인터 사용에 조금이라도 어긋나면 UB로 선언함으로써 “강제”한다. 이는 포인터가 단지 숫자라는 단순한 모델로는 불가능하다. 실제 C 표준을 이해하는 가장 좋은 방법은 프로그램이 이국적이고 복잡한 가상 머신에서 실행된다고 보는 것이다. 이 머신에서 포인터는 숫자이긴 하지만, 타입과 유효 범위 주석이 덧붙어 있다. 포인터를 규칙에 엄격히 따르지 않는 방식으로 사용하면 즉시 UB가 된다.
이것이 표준 C의 진정한 계산 모델이다. 상황이 더없이 기만적인 이유는 이 복잡한 가상 머신이 표준 하드웨어에서 쉽게 돌 수 있다는 데 있다. 단지 그 추가 정보를 제거하고 실행하면 되기 때문이다. 하지만 컴파일러는 훨씬 더 복잡한 일을 할 수 있고, 종종 최적화를 위해 실제로 그렇게 한다는 사실을 종종 잊기 쉽다.
실제 규칙을 이해하는 것은 쉽지 않다. 좀 더 자세히 파고든 훌륭한 최근 논의로는 Pointers Are Complicated, or: What’s in a Byte? 글이 있다. 그리고 표준을 엄밀히 따르는 프로그램조차 LLVM과 GCC가 잘못 컴파일하는 사례가 있음을 포함해 매우 자세히 다룬 연구 논문으로는 Reconciling High-level Optimizations and Low-level Code in LLVM이 있다.
C는 안정되고 성숙한 언어라는 평판이 있다. 그러나 표준 C로의 전환은 결코 안정적이지 않았다. 거의 모든 프로그램을, 그것도 식별하기 어려울 정도로 깊고 근본적인 방식으로 망가뜨렸기 때문이다. 만약 컴파일러들이 표준이 허용하는 대로 모든 UB에 대해 실제로 크래시 나는 코드를 생성했다면, 공개적인 반발이 일어났을 것이다.
하지만 초기에는 컴파일러가 거의 변하지 않았다. UB는 기존 구현체들이 표준 준수를 주장하기에 편리한 수단이었다. 프로그램들은 일상적으로 표준의 문구를 위반했지만, 컴파일하면 작동했다. 실제로는 모두가 준이식성 진영에 있었다. 표준 용어로 말하자면, 컴파일러는 “적합(conforming)”했다.
그러나 시간이 지나면서 컴파일러 작성자들은 대담해졌다. 표준이 허용하는 것은 모두 정당한 것처럼 느꼈고, 더 강한 가정을 바탕으로 더 정교한 최적화를 할 수 있게 되었다. 그 결과, 이론상 망가졌던 모든 프로그램들이 실제로도 망가지게 되었다. 당연히 준이식성 진영의 프로그래머들은 표준 진영의 컴파일러 작성자들이 지나치게 공격적이라고, 최적화를 너무 많이 한다고 비난했다. 최근의 열정적인 사례로는 유니온 기반 별칭 제거 패치를 비판한 리누스 토르발스의 격한 글이 있다.
마찬가지로 표준 진영의 사람들(아마도 그 패치의 작성자들)은 UB의 가능성을 도입하는 어떤 코드도 위험하다고 보며, 종종 오염과 불결함에 관한 표현을 끌어온다. 이런 관점에서 보면 논의가 논쟁적으로 흐르는 것이 놀랍지 않다.
스레드는 점점 중요해지고 있다. 멀티코어 CPU가 이제는 어디에나 있기 때문이다. 그러나 스레드는 근본적으로 순차적인 언어(예: C)와 복잡하게 상호작용한다. C89 당시의 지배적 접근은 스레드 프리미티브(예: pthreads)를 라이브러리로 제공하고, 그 동작을 전적으로 준이식성 개념으로 설명하는 것이었다.
따라서 엄격한 “표준 C” 언어는 준이식성 대화에 비해 훨씬 표현력이 떨어졌는데, 스레드 프로그램을 아예 표현할 수 없었기 때문이다. 다만 실무에서는 큰 문제가 되지 않았다.
C++11 메모리 모델(그리고 그에 앞서 Java 메모리 모델에서 큰 영감을 받은)의 수년간의 작업을 토대로 C11에서 상황은 상당히 개선되었다. 이들 메모리 모델을 실제로 이해하는 것은 복잡하지만, 적어도 이제 표준 방언이 잃었던 표현력의 많은 부분을 회복했다고 말할 수 있다.
오늘날 준이식성과 표준 C 사이의 표현력 격차는 무엇일까? 상당 부분은 더 높은 수준의 프로그래밍 언어에서 온 개념의 구현에 있다. 예를 들어 Boehm-Demers-Weiser 가비지 컬렉터는 준이식성 구성 요소에 크게 의존한다. 마찬가지로, 꼬리 재귀와 코루틴은 표준 C로 쉽게 표현될 수 없는 인기 있는 언어 기능이다. 따라서 많은 시스템(스크립트 언어를 내장하는 많은 시스템 포함)은 준이식성 C에 의존해야 한다. 모든 프로그램이 표준에 엄격히 적합하도록 하려는 위원회의 꿈은, 설령 언젠가 가능하더라도, 아마 한참 먼 이야기일 것이다.
UB는 엉망이다. 더 노골적인 사례 몇 가지를 제거한 우호적인 방언(friendly dialect) 제안도 있었지만, 결국 관련자들의 합의를 얻지 못해 무산되었다. 컴파일러 작성자들은 공격적인 UB가 제공하는 최적화의 자유를 매우 좋아하고, 성능에 영향을 줄 수 있는 어떤 양보에도 소극적이다. 미래의 C 개정판이 가장 나쁜 선택 중 일부를 되돌릴 가능성도 있지만, 상황이 크게 달라지지는 않을 것이라고 본다.
UB가 특히 음험한 이유 중 하나는, 프로그램이 UB를 보이는지 여부를 파악하기가 매우 어렵기 때문이다. 코드베이스에 다년간 잠복해 있다가 컴파일러 업그레이드로 촉발되는 경우가 아주 흔하다. 다행히 Undefined Behavior Sanitizer(UBSan) 같은 도구가 등장하고 있다. 물론 이런 도구는 특정 실행에서 UB가 발현되는 경우만 감지할 수 있다. 문제의 상당 부분이 특정(악의적인) 입력에서야 비로소 발동하는, 재현하기 어려운 동작이라는 점을 고려하면 퍼저와 함께 사용할 때 더욱 효과적이다. 이 도구들을 익히고 설정하는 일은 쉽지 않지만, C나 C++로 소프트웨어를 개발하면서 UB가 야기하는 문제에서 벗어나고자 한다면 치러야 하는 필수 비용이다.
하지만 한 가지 주의할 점이 있다. Undefined Behavior in 2017에서 지적하듯, 이러한 sanitizer는 한계가 있다. 실제 시스템에서 UB를 완화하는 방법에 대한 자세한 내용은 해당 링크를 따라가 보라.
초고에 대해 Thomas Lord는 이렇게 평했다.
아마도 C를 사용하는 실무 프로그래머들 중 상당수, 혹은 대부분이 잘못하고 있다고 생각합니다. 단 하나의 ‘진리의 길’이 있다는 뜻은 아니지만요 — 그렇긴 합니다.
안전하고 이식 가능한 코드를 원한다면, C는 매우 훌륭한 선택이 될 수 있습니다. 단, 잘 써야 합니다. C는 타깃 언어로 간주되어야 합니다. 저는 사람들이 보통 말하는 것보다 더 넓은 의미로 그렇게 말합니다.
C의 날카로운 모서리는 최소 두 가지 방식으로 안전하게 다룰 수 있습니다:
하나는 잘 설계된 코딩 표준을 주의 깊게 사용하는 것입니다. 대형 프로그램 작성자들은 아주 이른 시점에 핵심 아키텍처 결정을 내리고, 안전하고 제약된 스타일을 정의한 뒤, 팀이 그 스타일을 지키도록 해야 합니다.
상황에 따라, 추가로 코드 생성 도구가 적절할 수 있습니다. 그런 도구는 C 코드 조각을 다른 것들과 섞는 하이브리드일 수도 있고(예: lex와 yacc), 아예 더 높은 수준의 언어일 수도 있습니다.
여기까지는 C에 집중해 왔지만, UB가 다른 언어에 어느 정도 영향을 미치는지 묻는 것이 타당하다. 예컨대 Java는(아래에서 다룰 JNI를 제외하면) UB를 거의 완전히 피한다. Rust는 필자가 좋아하는 접근을 취한다. 안전(safe) 영역은 UB가 없지만, unsafe를 쓰면 언어가 잠재적으로 안전하지 않게(그리고 더 표현력 있게) 되며, 그 대가로 UB 가능성이 생긴다. 주의 깊은 프로그래머가 둘 다를 얻을 수 있도록 규칙을 세심하게 문서화하는 프로젝트가 진행 중이다.
대부분의 다른 언어는 필자가 ‘대략 안전한(safe-ish)’ 범주에 든다고 본다. 가장 흔한 타협은, 순차적 코드에는 UB가 거의 없거나 아예 없지만 데이터 레이스가 UB를 유발할 수 있다는 것이다. 이를 방어하기는 매우 어렵다. 한때는 데이터 레이스를 ‘무해한(benign)’ 것과 위험한 것으로 분류할 수 있다고 여겨졌지만, 연구는 전자가 존재하지 않는다는 강력한 근거를 제시한다.
아마 가장 지속적인 문제는, C가 거의 모든 런타임의 기저에 있다는 점(최하위 레벨을 C 이외의 언어로 구축하는 것도 가능하지만 꽤 이례적으로 여겨질 것이다), 그리고 FFI가 이러한 하위 레벨을 더 높은 수준의 언어와 연결하기 위해 거의 항상 필요하다는 점이다. 심지어 더 높은 수준의 언어가 엄격한 안전성을 목표로 설계된 경우에도 그렇다.
또한 “모던 C++”을 제대로 적용하면 C에서 흔한 많은 메모리 손상 문제를 피할 수 있지만, 여전히 정수 오버플로에 취약하고, 이터레이터 무효화 같은 더 미묘한 형태의 문제도 존재한다. 개인적으로는 모던 C++의 신중한 사용과 코어 가이드라인 같은 노력이 UB를 줄일 수는 있어도, 진정으로 안전한 언어가 제공하는 수준의 보장을 제공하기에는 한참 모자란다고 느낀다.
여기까지 읽었다면 이제 이 글의 진짜 목적을 밝힐 때가 됐다. 바로 맨 위의 무지개와 유니콘 아트를 담은 정의되지 않은 동작 티셔츠를 소개하려는 것이다. 친구와 동료들에게 당신이 UB의 미묘한 포인트들을 이해하고 있음을 알리는 좋은 방법이며, 동시에 알록달록하고 재미있다고 생각한다. 수익 전액은 국제앰네스티에 기부된다.
lobste.rs, Hacker News, /r/rust의 스레드를 따라가 보라.
상단 이미지는 dbeast32. 스페이스 유니콘 이미지는 아마도 cinderellapop의 작품으로 보이며, 허가를 받기 위해 작가와 연락을 시도하고 있다.