PL 관점에서 정의되지 않은 동작(UB)을 재평가하고, Rust 예제를 통해 UB가 책임감 있게 사용될 때 프로그램의 의도를 컴파일러에 전달해 최적화를 가능하게 하는 유용한 도구가 될 수 있음을 주장한다. 또한 C/C++의 과도한 UB 활용을 비판하고, Miri 같은 도구와 Stacked Borrows 모델을 논의하며, ‘UB를 피하기’ 대신 ‘정의된 동작을 보장하기’라는 관점 전환을 제안한다.
이 글은 내가 SIGPLAN 블로그에 쓴 글의 교차 게시물입니다.
“Undefined Behavior(정의되지 않은 동작)”은 종종 나쁜 평판을 얻는다. 사람들은 이를 컴파일러 제작자가 코드를 망가뜨릴 구실로 쓰는 것, 혹은 게으른 언어 설계자가 명세를 끝까지 완성하지 않고 모든 동작을 제대로 정의하지 않기 위한 핑계로 본다. 하지만 실제로 Undefined Behavior(이하 UB)란 무엇이며, 그 평판만큼 나쁜 것일까? 이 글에서는 PL(프로그래밍 언어) 관점에서 이 주제를 살펴보고, UB가 언어 설계자의 도구 상자에서 가치 있는 도구이며, 책임감 있게 사용하면 더 많은 최적화를 가능하게 하기 위해 프로그래머의 코드에 담긴 통찰을 컴파일러에 더 많이 전달할 수 있음을 주장한다. 또한 내가 Rust에 더 많은 UB를 추가하는 데 상당한 시간을 쓴 이유도 설명할 것이다.
PL의 좋은 전통대로, UB의 장점을 보여 주기 위해 인공적인 예시를 고려해 보자. 배열의 가운데 원소를 반환하는 함수를 구현한다고 하자. Rust를 사용한다면 대략 다음과 같이 쓸 것이다:
fn mid(data: &[i32]) -> Option<i32> {
if data.is_empty() { return None; }
return Some(data[data.len()/2]);
}
인수의 타입은 &[i32]인데, 이는 “슬라이스”라고 불리며 어떤 배열을 가리키는 포인터와 그 배열의 길이 정보를 담고 있다. mid 자체는 배열이 비어 있는 경우를 제대로 알리기 위해 Option(Haskell의 Maybe에 해당)으로 감싼 정수를 반환한다. 비어 있지 않은 경우엔 data의 가운데 인덱스를 계산해서 그 원소를 반환한다.
이제 이 함수가 다음 논문의 벤치마크에서 타이트한 루프 안에서 호출된다고 상상해 보자. 성능이 정말 중요하다. 이 함수에서 기대할 수 있는 성능 개선이 있을까? mid가 이미 해당 작업에 필요한 최소한의 일을 하는 것처럼 보일 수 있지만, data[_] 배열 접근에는 숨은 비용이 있다. 컴파일러는 우리가 data가 가리키는 배열의 크기를 넘어선 데이터를 접근하지 않도록, 여기서 경계(바운드) 검사를 삽입해야 한다. 하지만 프로그래머인 우리는 data.len()/2가 항상 data.len()보다 작다는 것을 알고 있으니 그 경계 검사는 전혀 필요 없다! 컴파일러에 이를 알려서 바운드 체크가 발생하지 않음을 확실히 할 수 있는 방법이 있다면 얼마나 좋을까?
Rust에서 이를 달성하는 한 가지 방법은 다음과 같다:
fn mid(data: &[i32]) -> Option<i32> {
if data.is_empty() { return None; }
match data.get(data.len()/2) {
Some(&x) => return Some(x),
None => unsafe { unreachable_unchecked() }
}
}
이제 배열 접근에 get 연산을 사용한다. 이는 범위를 벗어난 접근인 경우 None을 반환하는 Option을 돌려준다. 그리고 None을 받는 경우, 특별한 함수 unreachable_unchecked를 호출하는데, 이는 이 코드 조각이 도달 불가능하다는 구속력 있는 약속을 컴파일러에 한다. 여기서의 키워드 unsafe는 우리가 하는 일이 언어의 타입 안전 보장 범주에 포함되지 않음을 나타낸다. 즉, 우리가 한 약속이 실제로 성립하는지 컴파일러가 검사하지 않고, 그저 우리를 신뢰한다는 뜻이다.(“unchecked”라는 표현은 Rust 관용으로, 이 함수는 런타임 검사를 삽입해 이 코드에 도달하면 프로그램을 안전하게 중단시키는 unreachable의 “검사하지 않는” 버전이다. 좀 더 정확히는 Rust 패닉을 트리거한다.)
일부 인라이닝 후, 관련 부분은 다음과 같이 보인다:
let idx = data.len()/2;
if idx < data.len() { // 자동으로 삽입된 바운드 체크
... // 배열의 `idx` 원소에 접근
} else {
unreachable_unchecked()
}
우리가 else 분기가 도달 불가능하다고 컴파일러에 알려주었으므로, 이 분기문을 최적화로 쉽게 없앨 수 있고, 결국 배열의 idx 원소에 대한 직접 접근만 남는다. 문제 해결! (사실 Rust에는 get의 대안으로 호출자가 인덱스가 범위 내임을 약속해야 하는 get_unchecked가 있다. 따라서 Rust 프로그래머는 mid를 효율적으로 구현하기 위해 data.get_unchecked(data.len()/2)라고만 쓰면 된다.)
일부 독자들은 초기 예시에서 원하는 최적화를 달성한 방식이 못마땅하며, 컴파일러가 이 정도는 자동으로 똑똑하게 해줘야 한다고 주장할 수도 있다. 이 점은 뒤에서 다시 다루겠다. 일단 지금 시점에서 Rust의 최신 안정 버전은 이 최적화를 수행하지 않는다는 점만 기억해 두자(panic_bounds_check 호출이 그 증거다).
잠깐만, 이 글은 정의되지 않은 동작(UB)에 관한 게 아니었나? 방금 예시 설명에서는 그 용어가 등장조차 하지 않았다! 사실 나는 약간 요령을 부려, UB를 좀 더 건설적으로 사고하는 데 도움이 된다고 생각하는 다른 용어를 사용했다. 통상적인 용어를 쓰자면, 특별한 함수 unreachable_unchecked를 호출하는 것은 즉각적으로 정의되지 않은 동작을 유발한다고 말해야 했다. 최신 C 표준(그리고 C++)의 정의에 따르면, 표준은 UB를 보이는 프로그램에 대해 “어떠한 요구 사항도 부과하지 않는다”. 따라서 컴파일러는 기본적으로 else 분기를 원하는 어떤 코드로든 대체할 수 있는데, 악명 높은 예로는 “콧구멍에서 악마가 날아나오게” 만들 수도 있고, 그냥 then 분기를 실행하도록 해도 된다.
이런 식의 설명도 동일한 결론으로 이어지지만, 컴파일러 개발자들을 불필요하게 적대적으로 그려 버린다. 마치 컴파일러가 복잡한 분석을 사용해 UB를 탐지하고, UB를 찾으면 표준 뒤에 숨으며 망가진 코드를 내보낼 구실로 쓰는 것처럼 들린다. 실제로는 그렇지 않다. 앞선 예시에서 보았듯, 컴파일러는 이 코드에 UB가 있는지 전혀 모른다 — 그저 UB가 없다는 추가 가정 하에서 올바른 최적화를 수행할 뿐이다.
또 다른 반응으로, unreachable_unchecked는 “전형적인” UB 예시가 아니라고 할 수도 있다. 많은 사람들은 UB를 C나 C++과 연관 지을 텐데, 이 언어들에는 unreachable_unchecked가 아예 없다(다만 많은 컴파일러가 GCC의 __builtin_unreachable처럼 동일한 효과의 내부 함수를 제공한다). 그래서 내가 이상한 예시를 고른 것처럼 보일 수도 있다. 차라리 부호 있는 정수 오버플로가 UB라는 얘기를 해야 하지 않겠냐고 말이다.
지금이야말로 나는 C/C++의 모든 UB를 옹호할 생각은 _없다_고 인정할 시점이다. 개념으로서의 UB는 훌륭한 아이디어이고, unreachable_unchecked는 프로그래머가 컴파일러에 추가 정보를 전달하는 데 UB가 어떻게 쓰일 수 있는지를 보여 주는 가장 “순수한” 형태라고 생각한다. 하지만 C와 C++이 UB를 과하게 사용하고 있다는 점도 사실이라고 본다. 물론 뒤늦은 지혜로 말하기는 쉽다. 초기 C 컴파일러는 극도로 단순했고, 오늘날처럼 최적화를 위해 UB를 활용하는 관행은 시간이 지나며 형성되었다. 표준에서 UB를 현대적으로 해석한 결과가 무엇인지가 분명해지기까지 시간이 걸렸고, C와 C++은 매우 성공적인 언어였기에 방대한 기존 코드베이스가 있다. 이는 과거의 결정을 수정하기를 무척 어렵게 만든다. 이 글은 개념으로서의 UB를 방어하고 장려하는 이야기이지, C/C++의 UB를 방어하려는 글이 아니다.
부호 있는 정수 오버플로 얘기가 나온 김에, 이는 UB를 _어떻게 사용하지 말아야 하는지_를 보여 주는 좋은 예라고 생각한다. 무해해 보이는 +가 “이 덧셈은 절대 오버플로하지 않는다”는 프로그래머의 약속으로 바뀌지만, 프로그래머가 프로그램의 모든 덧셈에 대해 일일이 오버플로가 없음을 정신적 증명으로 확인하진 않을 것이다. 대신 +는 오버플로 검사를 수행하거나, 잘 정의된 랩어라운드 동작을 할 수 있고, 언어는 오버플로가 UB인 unchecked_add 함수를 제공할 수 있다. 이렇게 하면 프로그래머가 추가적인 “오버플로 없음” 약속을 상황에 따라 선택적으로 제공할 수 있고, 컴파일러가 이런 가정을 할 수 있을 때 성능상 정말 이득이 되는 상황(예: 이 예시)에서 쓸 수 있다. 요컨대 나는 이것을 언어(와 라이브러리) 설계 문제로 본다. UB는 날카로운 칼이다. 잘 쓰면 일을 더 잘 해내지만, 충분한 주의 없이 쓰면 크게 다칠 수도 있다.
물론 UB를 길들이는 방법이 언어와 라이브러리 설계만 있는 것은 아니다. 좋은 도구도 큰 차이를 만든다. 프로그래머가 “UB 검사 모드”로 프로그램을 쉽게 실행할 수 있다면, 적어도 특정 입력에 대해 UB가 없음을 보장하는 테스트를 작성할 수 있다. (민망하지만 자랑 하나: 나는 Rust에 대해 바로 이런 기능을 제공하는 Miri를 개발하고 있다.) 라이브러리 저자는 테스트 스위트를 이런 도구로 돌릴 수 있고, 이 도구는 무엇이 UB이고 무엇이 아닌지 탐구적으로 배우는 데에도 쓰일 수 있다. 나는 이것이 절대적으로 중요하다고 생각하며, 언어 설계자는 UB를 UB 검사 도구가 더 실현 가능하도록 설계해야 한다고 본다. 지금까지 본 UB의 예시들(unreachable_unchecked, get_unchecked, unchecked_add)에 대해서는 이는 자명하다.
그렇다고 해서 모든 UB가 이렇게 가르치기 쉽고 테스트하기 쉬운 것은 아니다. C와 C++의 수십 년에 걸친 UB 경험에서 배운 이점을 누리는 Rust조차 이보다 훨씬 더 미묘한 UB를 갖고 있다. 아마 가장 눈에 띄는 예시는 가변 참조의 잘못된 별칭(aliasing)과 관련된 UB일 것이다. (덜 극단적인 예로는 초기화되지 않은 메모리 사용으로 인한 UB, 데이터 경합(data race)으로 인한 UB 등을 들 수 있다.)
Rust의 타입 시스템은 가변 참조가 프로그램에서 현재 사용 중인 다른 어떤 참조와도 별칭을 이루지 않도록 보장한다. 즉, 가변 참조는 다른 어떤 참조와도 동일한 메모리를 가리키지 않는다. 이는 컴파일러 입장에서 탐나는 보장이다. 메모리 접근 순서를 재배치하는 것은 종종 유익하지만, 변환이 허용되는지를 알아내는 것은 매우 어려울 수 있다. 두 접근이 별칭 관계라면 원래 순서를 보존해야 하기 때문이다.
그러나 Rust의 unsafe 코드는 가변 참조가 별칭을 이루도록 쉽게 만들 수 있다. 그럼 우리는 무엇을 할 수 있을까? 프로그래머에게 그렇게 하지 않겠다고 약속하게 만들면 된다! 이는 “프로그래머가 unreachable_unchecked가 결코 호출되지 않는다고 약속한다”는 말과 매우 비슷하다. 그러니 UB 안경을 쓰고 보면, 별칭을 이루는 가변 참조가 존재하는 것은 정의되지 않은 동작이라고 말할 수 있다.
문제는 물론, 이것이 정확히 무엇을 의미하는지를 정의하는 세부 사항에 있다. Stacked Borrows는(이는 내 박사 학위 논문의 일부이며, 블로그 연재로도 설명되어 있다: v1.0, v2.0, v2.1) 운영적 의미론을 제시하여 프로그래머가 해야 하는 약속을 정확히 정의함으로써 그 모든 세부 사항을 파고든다. 그리고 그 의미론은 사소하지 않다! Stacked Borrows에 따르면, 다음 코드는 UB다:
let x = &mut 42; // 안전하게 참조 생성
let xptr = x as *mut i32; // 그 참조를 생(raw) 포인터로 변환
let x1 = unsafe { &mut *xptr }; // 포인터를 다시 참조로 변환...
let x2 = unsafe { &mut *xptr }; // ...두 번 변환하여 유일성이 깨짐
*x1 = 0; // 정의되지 않은 동작!
이 코드가 UB인 이유는 x2를 생성하는 순간, 이것이 xptr로부터 생성된 유일한 참조라는 약속을 하게 되기 때문이다. 따라서 그 전에 생성된 x1은 x2가 생성될 때 무효화된다. 이는 이후 x1을 사용하는 것이 정의되지 않은 동작임을 의미한다.
그래서 이런 질문이 생긴다. 정말로 모든 unsafe Rust 코드 작성자가 Stacked Borrows를 내재화하여, 자신들의 코드가 이 특별한 규칙 집합을 충실히 준수한다고 Rust 컴파일러에 약속하리라 기대할 수 있을까? &mut expr을 “모든 별칭이 신중히 점검되었고 이 참조는 확실히 유일하다”는 약속으로 해석하는 것이 좋은 생각일까? 다른 UB와 마찬가지로, 우리는 도구를 제공함으로써 프로그래머를 도울 수 있다. Miri에는 Stacked Borrows의 구현이 들어 있어 실제 Rust 코드가 Stacked Borrows와 호환되는지(혹은 합리적으로 호환되게 만들 수 있는지) 평가하는 데 도움이 되고, Rust 프로그래머에게는 최소한 별칭 위반을 테스트하고 의미론을 대화식으로 실험하여 더 나은 이해를 얻을 수 있는 방법을 제공한다. 나는 이것이 전반적으로 꽤 좋은 위치라고 생각하지만, 일부는 Stacked Borrows가 지나치다며 Rust가 C와 C++이 처한 상황과 비슷한 지경에 빠질 것이라고 주장한다 — 너무 적은 프로그래머만이 UB 없는 코드를 작성하는 방법을 알고, 사람들이 의존하는 코드의 상당 부분이 UB를 보일 것이라는 우려다.
Stacked Borrows는 Rust 명세의 일부가 아니며, Rust의 별칭 관련 UB에 대한 최종 결론도 아니다. 따라서 앞으로 이 모델의 개정을 통해 프로그래머의 직관과 더 잘 맞도록 만들 여지가 아직 있다. 위의 코드는 x2가 실제로 메모리 접근에 사용되지 않기 때문에 허용될 수도 있다. 아니면 &mut expr이 이런 약속을 하는 것은 unsafe 블록 바깥에서 사용될 때로만 제한해야 할지도 모른다 — 하지만 그렇다면 unsafe를 추가하는 것이 정말로 프로그램의 의미를 바꿔야 할까? 늘 그렇듯, 언어 설계는 트레이드오프의 게임이다.
나는 컴파일러가 정확성을 검사할 수 없는 코드를 프로그래머가 작성할 수 있게 해 주는 도구로서 Undefined Behavior를 소개했고, 책임감 있게 사용한다면 언어 설계자의 도구 상자에서 유용한 구성 요소라고 주장했다.
앞서 암시했듯, “명백한” 대안은 컴파일러를 더 똑똑하게 만드는 것이다. 하지만 실제 프로그램은 대개 내 단순한 예시보다 훨씬 더 복잡하다(그 예시조차 Rust의 LLVM 백엔드를 앞선다). 그리고 어떤 최적화를 정당화하기 위해 필요한 추론은 임의로 복잡해질 수 있다. 언어 설계자는 최적화기의 한계를 인정하고, 최적화기를 돕는 데 필요한 도구를 프로그래머에게 제공해야 한다. 사실, Rust가 영리한 타입 검사기와 타입 검사기가 충분히 영리하지 않은 경우를 위한 unsafe 코드 사용 아이디어를 결합했다는 점은 Rust의 성공에 결정적이라고 생각한다. unsafe는 버그가 아니다. 실전에 맞게 시스템 프로그래밍을 더 안전하게 만들려면 Rust에 반드시 필요한 기능이다. 우리가 모두 알고 사랑하는 많은 언어들도 유사한 “신뢰된” 연산이나 애노테이션을 제공한다는 점도 언급할 가치가 있다. 예를 들어 OCaml의 Obj.magic이나 GHC의 rewrite rule이 그러하다. Rust가 다른 점은 생태계에서 unsafe 코드의 보편성(그리고 그런 코드를 안전한 API로 캡슐화하는 것의 중요성)을 강조한다는 것이다.
마지막으로, “Undefined Behavior”는 브랜드를 바꿀 필요가 있을지도 모른다고 제안하고 싶다. 이 용어는 부정적인 경우에 초점을 맞춘다. 하지만 프로그래머든 컴파일러 작성자든 우리가 실제로 신경 쓰는 것은 프로그램에 UB가 없다는 점이다. 이중 부정을 제거할 수 없을까? “UB를 피한다” 대신 “잘 정의된 동작을 보장한다(ensuring Well-Defined Behavior)”고 이야기해야 하지 않을까.
요약하자면: 대부분의 경우, 잘 정의된 동작을 보장하는 책임은 타입 시스템에 있지만, 언어 설계자로서 우리는 그 책임을 프로그래머와 나누는 아이디어를 배제해서는 안 된다.
이 글의 초기 초안에 피드백을 준 Anish Athalye와 Adrian Sampson에게 감사드립니다.