Rust 컴파일러는 모든 코드의 동작에 대한 가정들을 가지며, 이를 어기면 UB가 된다. 이 글은 루프 불변 코드 이동 같은 최적화와 도구 설계의 관점에서, 프로그램에서 실제로 사용되지 않는 값이라도 항상 타입의 유효성을 만족해야 하는 이유를 설명한다.
Rust 컴파일러는 모든 코드의 동작에 대해 몇 가지 가정을 한다. 이러한 가정을 위반하는 것은 Undefined Behavior(정의되지 않은 동작, UB)라고 부른다. Rust는 기본적으로 안전한 언어이므로, 보통 프로그래머는 이런 규칙을 걱정할 필요가 없다(컴파일러와 라이브러리가 안전한 코드가 항상 그 가정을 만족하도록 보장한다). 하지만 unsafe 코드를 작성하는 사람은 이러한 요구사항을 스스로 준수할 책임이 있다.
이러한 가정은 Rust 레퍼런스에 나열되어 있다. 그중 많은 사람에게 가장 놀라운 조항은, Rust 코드가 “[…] 비공개 필드나 지역 변수에 대해서조차 _유효하지 않은 값을 생성(produce)해서는 안 된다”는 규칙일 것이다. 레퍼런스는 이어서 “값을 생성(producing) 한다는 것은, 어떤 장소(place)에 값을 대입하거나 그곳에서 읽을 때, 함수/원시 연산(primitive operation)에 전달하거나 그로부터 반환될 때마다 일어난다”고 설명한다. 달리 말해, 예를 들어 유효하지 않은 bool을 그냥 구성(constructing) 하기만 해도, 그 bool이 프로그램에서 실제로 “사용”되는지와 상관없이 Undefined Behavior가 된다. 이 글의 목적은 왜 그 규칙이 그렇게까지 엄격한지 설명하는 것이다.
먼저 여기서 말하는 “사용”의 의미를 분명히 하자. 이 용어는 사람마다 매우 다른 의미로 쓰이곤 한다. 다음 코드는 b를 “사용”한다:
fn example(b: bool) -> i32 {
if b { 42 } else { 23 }
}
예컨대 3을 bool로 변환(transmute)해서 example을 호출하는 것이 Undefined Behavior(UB)라는 점은 그리 놀랍지 않을 것이다. if를 컴파일할 때 컴파일러는 0과 1만이 가능한 값이라고 가정한다. 그 가정이 깨지면 무슨 일이 벌어질지 장담할 수 없다. 예를 들어, 컴파일러가 점프 테이블을 사용할 수도 있다. 그 테이블에서 범위를 벗어난 인덱스가 사용되면, 말 그대로 임의의 코드가 실행될 수 있으며, 그 경우의 동작을 제한할 방법이 없다. (이것은 언어 사양에 고정된, 컴파일러가 이해하는 유효성 불변식(validity invariant) 으로, 사용자가 정의하는 안전성 불변식(safety invariant) 과는 매우 다르다. 그 구분에 대한 더 자세한 내용은 이 예전 글을 참고하라.)
덜 자명한 것은, 위와 같은 if가 실제로 실행되지 않는 경우에도 3으로 example을 호출하는 것이 왜 UB인지이다. 왜 이것이 중요한지 이해하기 위해 다음 예제를 보자:
fn example(b: bool, num: u32) -> i32 {
let mut acc = 0;
for _i in 0..num {
acc += if b { 42 } else { 23 };
}
acc
}
이제 약간 다른 버전의 Rust를 사용한다고 가정해 보자. 그 Rust에서는 3을 bool로 변환해도 그 bool을 “사용”하지만 않으면 괜찮다고 하자. 그러면 example(transmute(3u8), 0)을 호출하는 것은 실제로 허용된다. 그 경우 루프가 전혀 실행되지 않으므로, 우리는 b를 결코 “사용”하지 않기 때문이다.
하지만 이것은 루프 불변 코드 이동(loop-invariant code motion)이라는 매우 중요한 변환에 문제를 일으킨다. 이 변환을 사용하면 위의 example 함수를 다음과 같이 바꿀 수 있다:
fn example(b: bool, num: u32) -> i32 {
let mut acc = 0;
let incr = if b { 42 } else { 23 };
for _i in 0..num {
acc += incr;
}
acc
}
이제 증가량 if b { 42 } else { 23 }(이제 incr라 부른다)는 루프 실행 동안 “불변”이며, 따라서 그 계산을 루프 밖으로 옮길 수 있다. 왜 이것이 좋은 변환일까? 루프를 돌 때마다 증가량을 결정하는 대신, 단 한 번만 계산하면 된다. 그 결과 CPU가 싫어하는 많은 조건 분기들을 줄일 수 있다. 이는 또한 이후의 추가 변환을 가능하게 한다. 예컨대 컴파일러가 이것이 그냥 num*incr임을 알아차릴 수도 있다.
그런데 “사용되지 않는” 값이 유효하지 않아도 되는 가상의 Rust에서는, 이 중요한 최적화가 실제로는 올바르지 않게 된다! 그 이유를 보려면 example(transmute(3u8), 0) 호출을 다시 생각해 보자. 최적화 이전에는 그 호출이 괜찮았다. 최적화 이후에는 b가 3인 상태에서 if b를 수행하게 되므로 그 호출은 UB가 된다.
이는 루프가 실제로 실행되지 않을 때, 루프 불변 코드 이동이 죽은 코드를 살아나게 만들기 때문이다. (이를 일종의 “투기적 실행(speculative execution)”으로 생각할 수도 있지만, CPU 수준의 투기적 실행과는 전혀 관계가 없다.) 이 최적화를 고치려면, 컴파일러가 루프가 최소한 한 번은 실행됨을 증명하도록 요구할 수 있다(즉, 투기적 실행을 피할 수 있다). 그러나 일반적으로 그것은 어렵고(그리고 example의 경우에는 num이 실제로 0이 될 수 있으므로 불가능하다). 또 다른 선택지는 num > 0일 때만 incr를 계산하도록 코드를 재구성하는 것이지만, 이것도 일반적으로는 어렵다. 그래서 Rust가 택한 대안은 “사용되지 않는” 데이터라 해도 어떤 기본적인 유효성을 만족하도록 요구하는 것이다. 이렇게 하면 example의 두 버전 모두에서 example(transmute(3u8), 0)이 UB가 되고, 그 결과 이 최적화는 이 입력(사실 가능한 모든 입력)에 대해 올바르다.
이제 누군가는 여기의 예에서 b가 진정으로 “사용되지 않은” 것이 아니라, 단지 “죽은 코드에서 사용”되었을 뿐이라고 주장할 수도 있다. 그렇다면 b가 항상 유효해야 한다고 말하는 대신, 죽은 코드에서의 b 사용 여부가 프로그램이 UB인지 아닌지에 영향을 주도록 만들 수 있지 않을까? 그러나 그것은 엄청난 골칫거리다. 현재 우리는 _죽은 코드는 프로그램의 동작에 영향을 줄 수 없다_는 근본 원칙을 가지고 있다. 이 원칙은 Miri 같은 도구에 매우 중요하다. Miri는 인터프리터이므로 죽은 코드를 아예 보지 못한다. 나는 Miri 같은 도구를 가질 수 있다는 것이 엄청나게 중요하다고 생각하며, 그런 도구의 존재를 가능하게 하는 의미론을 채택할 가치가 있다고 본다. 하지만 이는 우리의 손을 상당히 묶는다. b가 “죽은 코드에서 사용”되는지를 고려할 수 없으므로, b가 어디서 어떻게 “사용”되든 상관없이 항상 유효해야 한다고 요구할 수밖에 없다. 또한 인라이닝(inlining)과 아웃라이닝(outlining)을 지원하려면 함수 경계가 관련되도록 하고 싶지 않다. 이는 결국 오늘날 Rust가 요구하는 규칙으로 이어진다: 어떤 타입의 데이터가 어디에서든 생성(produced) 될 때마다, 그 데이터는 그 타입에 대해 유효해야 한다.
이 글이 Rust에서 Undefined Behavior가 그렇게 정의된 이유를 설명하는 데 도움이 되었기를 바란다. 평소처럼 의견이나 질문이 있으면 포럼에 남겨 달라.