사용성을 높이면서도 메모리 누수까지 포함해 Rust와 같은 종류의 버그를 제거하는 메모리 안전성 접근법을 소개합니다.
이 글에서는 내가 현재 이용 가능한 대안들보다 더 실용적이고 더 사용하기 편하다고 믿는 메모리 안전성 접근법을 소개한다.
모든 것은 아주 오래전에 시작되었고, 내가 읽고 썼던 여러 내용에서 영감을 받았다.
그로부터 3년간 개발한 끝에, 나는 마침내 해법을 찾았다고 생각한다. 이 제안은 완성되었다.
게다가 나는 이 방식을 내가 곧 공개하려는 자체 프로그래밍 언어에 구현해 두었고, “흠, 왜 안 되지?”에서 “와, 이게 돌아간다”에 이르는 설계 결정과 전체 여정을 공유하고 싶다.
그러니 요약하면 이렇다. 선형 타입(정확히 한 번 drop되는 타입) + 추상 해석 + 여러 가지 기법을 조합하면 Rust가 제거하는 것과 같은 종류의 버그를(적어도 비동시성 환경에서는) 제거할 수 있을 뿐 아니라 메모리 누수까지 없앨 수 있고, 이 접근법을 확장해 동시성 환경까지도 다룰 수 있다. 그러면서도 더 사용하기 쉽고 제약은 덜하다.
재미있어 보이는가? 이제 들어가 보자.
이것은 안전하다 - 다음과 같은 버그의 전체 부류를 완전히 제거한다.
단일 소유권은 선형성을 가능하게 한다. 즉 각 값은 정확히 한 번 drop되며, 소유권 순환도 금지된다. 이를 강제하기 위해 만들어진 흐름 민감형 타입 시스템과 함께, 위 목록의 대부분이 제거된다. 버퍼 오버플로와 범위 밖 접근은 별도로 다루지만, 나머지 시스템의 메커니즘 덕분에 이 문제들 역시 쉽고 효율적으로 처리할 수 있다.
이것은 건전하다 - 이 연재를 진행하면서 이러한 주장들이 임의의 입력에 대해서도 성립함을 보일 것이다. 시스템 내부에서 보장된 안전성을 깨뜨릴 수 있는 구멍은 없다.
이것은 단순하지 않다 - 시스템 전체가 약속한 안전성 보장을 유지하려면 함께 작동하는 기본 요소들이 꽤 많다.
이것은 동시성 자체를 다루지는 않는다 - 비록 “겁 없는 동시성” 보장은 제안한 시스템의 자연스러운 확장이지만, 아직 그 접근법의 실현 가능성을 보여 줄 만큼 완전하게 구현되지는 않았다. 이 부분은 구현이 충분히 갖춰지면 이후 글에서 더 자세히 다룰 것이다.
이것은 “제로 코스트”를 주장하지 않는다. 다만 실행 시간 오버헤드는 최소화한다 - 컴파일러가 가용성을 정적으로 증명할 수 없을 때 런타임 검사(불확정 접근당 단 한 번의 분기)가 들어간다.
다음 의사코드를 보자.
var x: T = new T;
if random() > 0.5 {
drop x;
}
print(x);
이 코드가 하는 일은 값을 조건부로 소비하는 것이다.
실제 언어에서는 두 가지 방향으로 흘러갈 수 있다. C++은 이런 상황을 특별히 신경 쓰지 않고 이 코드를 기꺼이 컴파일한다.
#include <cstdlib>
#include <cstdio>
int main() {
int *i = new int(42);
if ((double)rand() / RAND_MAX > 0.5) {
delete i;
}
printf("i=%d\n", *i);
return 0;
}
그러고 나면 실행의 약 50%에서 UB를 일으키게 된다. 현대적인 C++ 개발자라면 여기서 std::unique_ptr와 std::optional을 꺼내 들 것이다. 그리고 그것들은 부분적으로는 도움이 된다. 스마트 포인터를 통한 RAII는 수동 delete를 없애고, optional은 “아마 move되었을 수도 있음”을 표현할 방법을 제공한다. 하지만 unique_ptr는 힙에 할당된 객체만 관리하며, 타입 시스템은 optional 검사를 강제하지 않는다. 비어 있는 optional에 대한 operator*는 정의되지 않은 동작이고, .value()조차 컴파일 타임 오류가 아니라 런타임 예외만 줄 뿐이다. 결국 기억해야 하는 책임은 여전히 개발자에게 있다.
하지만 Rust에서는 이 코드는 아예 컴파일되지 않는다.
fn main() {
let x = Box::new(42);
if rand::random::<f64>() > 0.5 {
drop(x);
}
println!("{}", x); // error[E0382]: borrow of moved value: `x`
}
Rust는 매우 다른 접근을 취한다. 컴파일러는 제어 흐름을 따라 move를 추적한다. if 분기에서 x가 move되었을 _수도 있다_는 점을 보고 프로그램을 아예 거부한다. Rust의 소유권 모델은 프로그램의 모든 지점에서 각 변수의 move 상태가 정적으로 알려져 있어야 한다고 요구한다. 조건부로 move된 값은 그 요구를 위반하므로 프로그램은 거부된다. 물론 값을 직접 Option<T>로 감싸고 수동으로 .take()를 호출할 수는 있지만, Rust가 그것을 대신 해 주지는 않는다. 코드를 미리 재구성할 책임은 개발자에게 있다.
그렇다면 이 둘 사이에 세 번째 길이 있다면 어떨까?
제안하는 해법은 간단하다.
var x: T = new T;
if rand() > 0.5f {
drop x;
}
// <- 이 시점에서 typeof(x)는 Option<T>
이제 값의 타입은 제어 흐름에 따라 달라진다. 컴파일러는 프로그램을 따라가며 타입을 계산하고, 제어 흐름이 갈라질 때마다 두 가능성 모두를 수용하도록 타입을 _확장_한다. 그리고 개발자가 값을 사용하고 싶을 때는 그 타입을 다시 _축소_해야 한다.
var x: T = new T;
if rand() > 0.5f {
drop x;
} // x는 `Option<T>`로 _확장_된다
if x {
// 이 분기에서 x는 확실히 사용 가능하므로 사용할 수 있다
} else {
// 여기서 x는 확실히 사용 불가능하다
} // x는 다시 `Option<T>`로 _확장_된다
이를 이해하는 한 가지 방법은 프로그램의 각 지점에서 컴파일러가 어떤 정보를 갖고 있는지 생각해 보는 것이다.
x의 가용성에 대한 정보를 잃게 만든다. 타입 시스템에서는 이것이 x의 타입을 Option<T>로 확장하는 것으로 표현된다.x의 가용성이 확정된다.C++ 방식과 비교하면, 이제 개발자가 상태 공간을 명시적으로 고려하도록 강제하므로 크래시를 피할 수 있다. 타입 검사기가 T가 필요한 자리에 Option<T>를 사용하려는 모든 시도나, 확실히 사용할 수 없는 값을 사용하려는 시도를 잡아내기 때문이다.
Rust 방식과 비교하면, 우리는 런타임 검사라는 대가를 치르고 유연성을 얻는다. 구체화가 일어나는 지점에서 단 한 번의 null/tag 비교만 하면 된다.
이것이 새로운 아이디어는 아니라는 점도 짚고 넘어갈 필요가 있다. 어쩌면 세계에서 가장 인기 있는 언어 중 하나인 TypeScript가 정확히 이런 일을 한다고 볼 수도 있다. 그러나 TypeScript는 JavaScript로 컴파일된다. JavaScript는 가비지 컬렉션과 공유 소유권을 가진 언어이기 때문에 수명, 메모리나 자원 관리 문제, 동시성을 크게 신경 쓰지 않는다. 하지만 나는 이 모든 것을 다뤄야 한다.
이것은 빙산의 일각일 뿐이며, 시스템의 아주 시작에 불과하다. 더 넓은 영역, 즉 집합 타입, 참조, 함수 호출, 동적 디스패치, 람다와 클로저를 다루기 시작하면 새로운 요구 사항을 수용하도록 시스템도 커질 것이다.
내가 원했던 것이 하나 더 있다. 바로 각 값이 정확히 한 번 drop된다는 보장이다. 이것은 _선형 타이핑_이라고 알려져 있다. 선형 타이핑을 논할 때 이 보장은 보통 “정확히 한 번 사용된다”라고 표현되지만, 무엇이 _사용_인지는 달라질 수 있다. 내 경우에는 use == drop이다.
제안한 접근법을 쓰면 이것은 놀랄 만큼 단순해진다.
T라면, 그 값은 자신의 스코프를 벗어나거나 소유자가 스코프를 벗어날 때, 혹은 수동으로 drop될 때 drop되며, 이때 타입은 None으로 전이된다.T에 대한 Option<T>라면, 컴파일러는 스코프 끝에서 런타임 검사와 조건부 drop을 삽입한다. 물론 이런 값도 수동으로 drop할 수 있다.여기서 구체화된 분기에 대한 질문이 생긴다.
var x: T = new T;
if rand() > 0.5f {
drop x;
} // x는 `Option<T>`로 _확장_된다
if x {
// 이 분기에서 x는 확실히 사용 가능하고 사용할 수 있다.
// 하지만 그 타입은 정확히 무엇인가?
} else {
// x는 None이다
} // x는 다시 `Option<T>`로 _확장_된다
두 번째 조건문에서 x가 사용 가능한 무언가로 구체화될 때, 정확히 어떤 타입을 가지게 될까?
만약 T로 구체화된다면, if 분기의 스코프가 끝날 때 그 값은 drop될 것이다. 그러면 상황이 우스워진다. 어떤 값이든 한 번만 구체화해서 사용한 뒤, 분기가 끝나자마자 즉시 drop되어 버리기 때문이다. 안전하긴 하지만 지나치게 가혹하다.
대신 우리는 새로운 타입 Some<T>를 정의한다. 이 타입의 유일한 목적은 drop되는 것을 피하거나, _소유권을 가져오지 않고도 가용성을 증명하는 역할_을 하는 것이다.
이것은 타입 검사기가 특별한 방식으로 다루는 많은 타입 중 하나이며, 구체적으로는 다음과 같다.
Option<T>를 구체화하는 경우를 제외하고는 생성할 수 없다. T로부터 생성하면 소유권이 안으로 이동하게 되는데, Some<T>는 자동 drop되지 않으므로 값이 누수된다.Option<T>에 _의존_한다. 감싼 Option<T> 안의 값이 어떤 방식으로든 drop된다면, 그 option의 구체화된 뷰는 더 이상 사용할 수 없어야 한다. 의존성과 그것이 어떻게 도움이 되는지는 이후 글에서 다룰 것이다.Option<T>를 명시적으로 drop하면 Some<T>도 무효화된다. 이 의존성은 양방향이다.T가 쓰일 수 있는 모든 자리에서 자유롭게 사용할 수 있다. 개발자는 이것을 명시적으로 drop할 수 있으며, 그러면 기반 값을 소비하고 원래의 Option<T>를 None으로 설정한다.말했듯이, 절대로 단순하지는 않다. 하지만 그렇다고 아주 복잡하기만 한 것도 아니다.
위에서 설명한 접근법은 프로그래밍 언어 이론의 여러 잘 정립된 분야에 뿌리를 두고 있다.
흐름 민감형 타이핑은 프로그램이 실행됨에 따라 변수의 타입이 바뀌는 것을 허용한다. 대부분의 타입 시스템은 흐름 _비민감형_이다. T로 선언된 변수는 전체 스코프 동안 계속 T로 남는다. TypeScript의 제어 흐름 축소처럼 흐름 민감형 시스템은 서로 다른 실행 경로를 따라 타입이 어떻게 변하는지를 추적한다. 여기에 우리가 더하는 것은 이것을 _소유권_에 적용하는 일이다. 값의 가용성은 그 타입의 일부이며, 그 가용성은 move, drop, 조건부 분기를 거치며 변한다.
정제 타입은 술어를 통해 타입을 더 좁힐 수 있게 한다. if x { ... }를 쓸 때 우리는 x의 타입을 Option<T>에서 Some<T>로, 또는 else 분기에서는 None으로 정제하고 있다. 이것은 정제 타이핑의 직접적인 적용이다. 조건문이 값이 사용 가능하다는 증명이 되고, 타입 시스템은 그 증명을 반영한다.
시스템에는 컴파일 중 값과 그 타입을 _연결_하는 부분이 여러 곳 있다. Some<T>는 그것이 정제된 Option<T>에 의존하며, 이후 글에서 보겠지만 참조는 자신이 가리키는 값에 의존한다. 이것은 특정 프로그램 값과 소유권 관계에 따라 타입의 유효성이 달라진다는 제한된 의미에서의 의존 타입과 관련이 있다. 이 시스템은 Idris나 Agda 식의 완전한 의존 타이핑을 시도하지는 않지만, 함수 경계를 넘어서고 제어 흐름을 통과하는 값-타입 의존성은 추적한다.
추상 해석은 이를 모두 묶는 통합 프레임워크를 제공한다. 컴파일러가 하는 일은 프로그램을 _가용성 도메인_에서 추상적으로 해석하는 것이다. 실제 값을 계산하는 대신, 각 변수가 확실히 사용 가능한지, 확실히 사용 불가능한지, 아니면 불확정인지를 계산한다. 분기 합류는 상태를 확장하고, 조건문은 상태를 축소한다. 이는 단순한 격자 위에서 수행되는 표준적인 추상 해석이다. T(사용 가능)와 None(사용 불가능)은 정확한 상태이고, Option<T>는 그 둘의 join이다.
가용성만이 컴파일러가 해석하는 유일한 도메인은 아니라는 점도 중요하다. 이후 글에서는 추가적인 도메인들을 소개할 것이다. 각각은 자체 격자를 가지며, 동일한 제어 흐름 구조 위에서 해석된다. 의존성 추적, 참조의 유효성, 집합 타입의 소유권도 모두 같은 추상 해석 접근법을 따른다.
이 시스템의 규칙, 불변식, 동작 방식을 진행하면서 계속 목록으로 유지할 것이다. 각 글은 여기에 내용을 더한다. 이 목록은 모든 것이 하나의 문법적 기교에서 흘러나오는 것이 아니기 때문에 즉흥적이고 혼란스러워 보일 수 있다. 여러 기본 원리가 함께 작용하고, 그 결과로 많은 작은 규칙들이 생겨난다.
처음에 경고했듯이, 이것은 단순하지 않다.
하지만 중요한 점은 이 규칙들을 어차피 안전을 위해 반드시 지켜야 한다는 것이다. C++에서는 머릿속으로 지키고, Rust에서는 borrow checker와 씨름하며 지킨다. 여기서 우리가 하는 일은 그 책임을 개발자에게서 컴파일러로 기계적으로 옮기는 것뿐이다. 각 규칙은 잘 확립된 이론에 기반하고 있으며, 우리는 그것을 개발자가 직접 신경 쓰지 않아도 되는 방식으로 적용할 뿐이다.
Option<T>로 확장된다.T가 소유 타입이면 Option<T> 자체도 소유 타입이다.Option<T>는 조건 검사에 의해 Some<T> 또는 None으로 정제될 수 있다.Some<T>는 비소유형이다 - 정제는 소유권을 이전하지 않는다.Option<T>를 정제해서 얻은 Some<T>와 원본 Option<T>는 서로 _의존_한다.이로써 시작의 끝에 도달했지만, 아직 다뤄야 할 내용은 정말로, 정말로 많이 남아 있다.
다음 글에서는(UPD: 여기에서 볼 수 있다) 이 접근법을 레코드와 배열 같은 집합 타입으로 일반화하고, 소유권을 이전하지 않고도 값을 공유할 수 있게 해 주는 참조를 도입할 것이다.