Rust 전역에 영향을 주는 사운드니스 규칙을 정하는 마커 트레이트(예: Send, 가상의 Move, Leak)를 통해 어떤 타입을 허용할지를 논의하고, 이를 뒤늦게 도입하려 할 때 발생하는 하위 호환성과 에디션 메커니즘의 어려움을 검토한다.
Rust에서는 무엇이 사운드하고 무엇이 사운드하지 않은지에 대한 특정 API 결정이 모든 Rust 코드에 영향을 미칩니다. 즉, 어떤 안전 요건을 가진 타입을 허용할지 말지 결정되면, 모든 사용자는 그 결정에 구속됩니다. 규칙이 다른 다른 API를 쓸 수 있는 게 아닙니다. 모든 API가 이 규칙을 따라야 합니다.
이 규칙은 특정 “마커” 트레이트를 통해 결정됩니다. 안전한 API가 어떤 타입의 값에 대해 일부 타입이 지원하지 않는 동작을 할 수 있다면, 사용자가 그 동작을 지원하지 않는 타입의 값을 해당 API에 넘기지 못하도록 그 API는 반드시 그 마커 트레이트로 바운드되어야 합니다. 반대로, Rust가 아무 마커 트레이트 바운드 없이 모든 타입에 대해 그 동작을 허용한다면, 그 동작을 지원하지 않는 타입은 존재할 수 없습니다.
제가 무슨 말을 하는지 보여 주기 위해 세 가지 예시를 들겠습니다. 이들 각각은 Rust가 어느 시점에 고려했지만, 실제로 Rust에 존재하는 것은 첫 번째뿐입니다.
SendRust가 스레드 간에 전송할 수 없는 타입을 지원하길 원한다고 해 봅시다. 왜 이것이 필요한지에 대한 예시는 다음과 같습니다.
Rc)Args, MutexGuard)이를 지원하려면 스레드 간 전송이 가능한 타입의 집합을 뜻하는 Send라는 마커 트레이트를 포함해야 합니다. 값을 다른 스레드로 보낼 수도 있는 모든 API는 다음과 같이 Send 바운드를 포함해야 합니다.
thread::spawnrayon::jointokio::spawn물론 Rust는 스레드 간 전송할 수 없는 타입을 지원하기로 선택했기 때문에 Send 바운드가 있습니다. 하지만 대안적으로 Rust가 모든 타입이 스레드 간 전송을 지원해야 한다고 선택할 수도 있었고, 사실상 모든 내부 가변성은 동기화되어야 했을 것입니다. 실제로 Send 트레이트는 표준 라이브러리가 전적으로 강제하는 결정입니다. 누군가 rustc의 변경 없이도 이 요구를 강제하는 “대체 libcore”를 배포할 수 있습니다. 다만 세상에 존재하는 어떤 Rust 코드와도 호환되지는 않겠지만요.
MoveRust가 어떤 타입의 주소가 한 번 관찰되고 나면, 소멸자를 실행하지 않고는 무효화(이동)될 수 없는 타입을 지원하길 원한다고 해 봅시다. 이것은 “이동 불가능한 타입”에 대한 다소 특이하고 구체적인 정의지만, 스택 없는 코루틴과 인트루시브 자료구조에 정확히 들어맞습니다.
이를 지원하려면 자유롭게 이동할 수 있는 타입의 집합을 뜻하는 Move라는 마커 트레이트를 포함해야 합니다. Send와 달리 Move는 일부 언어 차원의 지원이 필요합니다. 이를 구현하는 가장 단순한 방법은, 어떤 것의 주소를 취하는 연산이 Move를 구현하지 않는 타입에 대해서는 그 값의 소유권을 가져간다고 정의하는 것입니다(그래서 let x = &mut y;가 y의 소유권을 가져가며, 사실상 이후에 그것을 다시 이동할 수 없게 만듭니다). 그리고 Box에서 값을 꺼내 이동할 수 있게 해 주는 마법 같은 동작도 Move에 의해 바운드되어야 합니다.
또한, 참조 뒤에서 값을 이동시키는 다음과 같은 API에도 Move 바운드를 걸어야 합니다.
mem::swapmem::replace현재 Rust에는 Move 트레이트가 없습니다. 대신 포인터 타입을 감싸는 Pin 래퍼를 통해 동일한 보장을 제공합니다. Move 트레이트가 아마 더 사용하기 쉬운 API였겠지만(이유는 곧 설명하겠습니다), 하위 호환적으로 도입하기 어려웠기 때문에, 그 대신 Pin API가 추가되었고 이러한 의미론을 필요로 하는 새로운 인터페이스에서만 사용되었습니다.
LeakRust가 소멸자를 실행하지 않고는 스코프를 벗어날 수 없는 타입을 지원하길 원한다고 해 봅시다. 이것은 “선형 타입”에 대한 두 가지 정의 중 하나입니다. 다른 정의보다 덜 표현력이 있지만(다른 정의는 소멸자 실행을 금지하고, 타입이 최종적으로 구조 분해되도록 요구합니다), 언어에 추가하기는 더 쉽습니다(제네릭과 더 잘 맞기 때문) 그리고 선형 타입의 가장 설득력 있는 사용 사례 대부분을 지원합니다.
이를 지원하려면 소멸자를 실행하지 않고 스코프를 벗어날 수 있는 타입의 집합을 뜻하는 Leak라는 마커 트레이트를 포함해야 합니다. 이는 Send와 같고 Move와 달리, 언어 차원의 지원이 전혀 필요 없습니다. Rust의 코어 언어만으로는 값을 “새는(leak)” 방식으로 버리는 것이 불가능하고, 표준 라이브러리 API를 사용해야 하기 때문입니다.
다음과 같은 API에는 Leak 바운드가 필요합니다.
mem::forget)ManuallyDrop::new)Rc::new, Arc::new)물론 Rust에는 Leak 트레이트가 없지만, 거의 추가될 뻔했습니다. 이 논의는 2015년 초에 정점에 달했습니다. Rust가 사용하던 스코프드 스레드 API의 안전성이 그 가드 타입이 절대 누수되지 않는다는 가정에 의존한다는 이유로 사운드하지 않음이 밝혀졌기 때문입니다. 결국(논란이 있던 시점으로부터 몇 달 뒤 1.0 릴리스를 예정해 두었기 때문에 다소 성급하게) Rust는 누수될 수 없는 타입을 지원하지 않기로 결정했고, Leak 트레이트는 추가되지 않았습니다.
최근 Rust에서 선형 타입을 지원하려는 관심이 다시 높아졌습니다. 특히 제가 스코프된 태스크 삼중고라고 부른 문제가, 소멸자를 확실히 실행할 수 없다는 사실 때문에만 성립하기 때문입니다. 이동 불가능한 타입과 달리, Pin처럼 고립된 API 추가만으로 소멸자 실행을 보장할 수 있는 방법은 없습니다. (객체의 소유권을 결코 넘기지 않고 일종의 클로저 전달 스타일을 사용하면 소멸자 실행을 보장할 수는 있지만, 이는 “스코프드 태스크” 사용 사례에는 충분하지 않습니다.) 그래서 일부 사용자는 Rust에 Leak 트레이트를 추가하길 원합니다.
이런 Leak 같은 마커 트레이트를 Rust에 추가하는 방법은 두 가지가 있습니다.
Send와 Sync처럼 새로운 자동 트레이트를 추가한다.Sized처럼 새로운 “?Trait”를 추가한다.각 방법은 하위 호환성과 관련된 특정한 도전을 제시합니다.
겉보기에는 자동 트레이트를 추가하는 것이 하위 호환적인 변경처럼 보일 수 있습니다. 누수될 수 있는 타입임을 뜻하는 새 트레이트 Leak를 추가합니다. 이 트레이트를 구현하지 않는 타입은 소멸자 없이 스코프를 벗어날 수 없습니다. 오늘날 Rust의 모든 타입은 필연적으로 누수될 수 있습니다(이는 Leak 트레이트가 없기로 한 결정의 결과입니다). 그러니 모든 타입이 Leak를 구현해도 아무 문제가 없습니다. 이것이 자동 트레이트의 의미론이니, 잘 작동할 것처럼 들립니다.
문제는 mem::forget 같은 누수를 일으킬 수 있는 API에 바운드를 추가하려 할 때 발생합니다. Leak를 구현하지 않는 타입을 누수시킬 수 없게 만들려면 mem::forget에 바운드를 추가해야 합니다. 하지만 이는 두 가지 방식으로 하위 호환적이지 않습니다.
첫째, 제네릭과 맞지 않습니다. 다음 코드는 오늘은 합법이지만, mem::forget에 Leak 바운드를 추가하면 깨집니다.
pub fn forget_generic<T>(value: T) {
mem::forget(value);
}
이 함수의 타입 매개변수에 Leak 바운드가 없기 때문입니다. mem::forget(또는 값을 잊을 수 있는 다른 어떤 API)의 API에 그와 같은 바운드를 추가하는 것은 중단적인 변경이 됩니다.
둘째, 트레이트 객체 타입은 + Leak를 명시하지 않는 한 Leak를 구현하지 않습니다. 트레이트 객체 타입은 자동 트레이트를 통해 구현을 상속하지 않습니다. 왜냐하면 트레이트 객체의 실제 타입을 알 수 없기 때문입니다. 그래서 dyn Future 같은 트레이트 객체는 Leak를 구현하지 않습니다. 예를 들어:
pub fn forget_trait_object(object: Box<dyn Display>) {
mem::forget(object);
}
자동 트레이트 추가가 하위 호환적이지 않다면, ?Trait 해법으로 돌아오게 됩니다. 하지만 여기에도 문제가 있습니다.
가장 엄밀한 의미에서, ?Leak 같은 새 트레이트를 추가하는 것은 하위 호환적입니다. mem::forget 같은 API에 새 바운드를 추가하는 대신, 다른 API들의 바운드를 완화(relax)하는 것이기 때문입니다. 그래서 위의 모든 코드는 괜찮습니다. 선형 타입을 받는 제네릭 함수를 만들고 싶다면 ?Leak 바운드를 작성해야 하니까요.
이 접근이 Leak 같은 것에 대해 가지는 첫 번째 문제는, Rust의 대다수 제네릭 API는 자신의 값을 잊어버릴(누수시킬) 가능성이 거의 없다는 점입니다. 어쨌든 메모리 누수는 정의되지 않은 동작은 아니지만 바람직하지 않고 대부분 피합니다. 이는 Sized와 꽤 다릅니다. 값으로 전달하려면 Sized가 필요하기 때문에 Rust의 대다수 제네릭 API는 Sized를 요구하고, 따라서 ?Sized 바운드는 비교적 드뭅니다. 반대로 ?Leak를 추가하면, 대다수의 제네릭이 T: ?Leak 바운드를 갖게 되어 생태계 전반에 영구적인 흉터를 남기게 됩니다.
두 번째 문제는 더 큽니다. ?Traits와 연관 타입의 상호작용 때문에, 연관 타입에 ?Trait 바운드를 추가하는 것은 중단적인 변경입니다. 즉, 표준 라이브러리의 어떤 안정된 연관 타입도 ?Leak 바운드를 획득할 수 없습니다.
이 예시를 보세요.
pub fn forget_iterator(iter: impl Iterator) {
iter.for_each(mem::forget);
}
Iterator::Item 연관 타입을 전혀 언급하지 않았는데도, 이 코드는 반복자의 모든 원소를 잊어버립니다. 그러므로 Iterator::Item은 항상 Leak를 구현해야 합니다. 컴파일러는 모든 반복자의 원소가 Leak를 구현한다고 가정할 수 있으며, 이 가정을 무효화하는 것은 중단적인 변경입니다.
그 함의는 광범위합니다. 만약 Leak가 ?Trait로 추가된다면, 선형 타입에 대해 다음과 같은 것들이 모두 불가능해집니다.
특별한 연관 타입 집합으로 Fn 트레이트의 반환값이 있습니다. Rust 프로젝트는 장래의 변경 유연성을 위해 이 트레이트들의 Output 연관 타입을 참조하기 어렵게 의도적으로 만들어 두었습니다. 그럼에도 과거에 Move 트레이트를 실험하면서 특정 이슈가 발견되었습니다. 이러한 이슈가 다른 트레이트들(예: Leak)에도 나타났을지, 아니면 Move의 내장 의미론에 특화된 문제였을지는 저에게 분명하지 않습니다.
요컨대, 새로운 ?Trait—특히 Leak처럼 많은 바운드에 관계없는 것—를 도입하는 것은 매우 가파른 트레이드오프입니다. 방대한 종류의 제네릭 인터페이스에 혼란스러운 새 문법을 추가하는 대가로 극히 제한적인 새 기능만 얻을 수 있기 때문입니다. 저는 현재 Rust의 규칙이 “잘못”되었다고 인정하고 Rust에 선형 타입이 있었으면 좋겠다고 하더라도, ?Leak 같은 것을 채택하는 것은 비용 대비 효과가 나쁜 선택으로 보입니다.
마지막으로 고려해야 할 것은 에디션 메커니즘입니다. 이 메커니즘을 이용해 이러한 트레이트 중 하나를 도입하는 것이 가능할까요? 아마도요.
각 에디션은 사실상 Rust의 “방언”을 형성하며, 모두 같은 컴파일러로 지원됩니다. 그래서 언뜻 보기에는 한 방언에서는 모든 타입을 잊을 수 있고, 다른 방언에서는 Leak 트레이트가 존재한다고 해도 그럴듯해 보입니다. 문제는 에디션에 대한 강제 요건이, 한 에디션의 크레이트가 다른 에디션의 크레이트에 의존할 수 있어야 한다는 점입니다. 즉, 에디션 업그레이드는 원활하고 자발적이어야 합니다.
Rust 프로젝트가 2024 에디션에서 Leak 트레이트를 추가하기로 했다고 가정해 봅시다. 2021 에디션의 모든 코드는 여전히 작동해야 합니다—위에서 보여 준 예시 같은 코드도 포함해서요. 물론 cargo fix 같은 도구로 곳곳에 Leak 바운드를 추가하고, 사용자가 2024 에디션으로 옮겨 가면서 이를 스스로 완화(relax)하도록 기대할 수는 있겠지만, 2021 에디션의 코드는 수정 없이도 작동해야 합니다.
가능한 한 가지 방법은 트레이트 정합성(coherence)을 에디션에 의존하도록 만드는 것입니다. 즉, 2024 이전 에디션에서는, 경계 없는 제네릭이나 Leak를 언급하지 않는 트레이트 객체 타입처럼 절대로 그래서는 안 되는 타입들까지 포함하여, 모든 타입이 Leak 바운드를 만족하도록 하는 것입니다. 2024 이전 에디션에서 모든 타입이 Leak를 구현한다면, 추가된 바운드는 결코 실패하지 않을 것입니다.
그러나—이 섹션의 크고 거대한 주의점은—이는 컴파일러가 일종의 절대 깨질 수 없는 방화벽을 구현해, Leak를 구현하지 않는 타입이 2024 이전 코드와 절대로 엮이지 않도록 해야 한다는 점에 의존합니다. 이는 다음을 의미합니다.
Leak를 구현하는 타입으로 인스턴스화되는지를 검사해야 합니다.Leak를 구현하는지를 검사해야 합니다. 표준 라이브러리도 2024년 이후의 코드로 간주될 것이므로, 2024 이전 에디션에서는 std API를 호출할 때마다 타입의 Leak 여부를 점검해야 합니다.이것이 구현 가능할지도 모르겠습니다만, 최소한 2024 이전 에디션 코드에서는 컴파일 시간에 꽤 나쁜 영향을 줄 것이고, 초기에는 매우 어려운 전환을 초래할 듯합니다. 그러나 장기적으로는 적어도 Leak를 “올바른” 상태—오래된 연관 타입과도 올바르게 사용할 수 있는 자동 트레이트, ?Trait가 아닌 것—로 남겨둘 수 있을 것입니다.
제가 2015년으로 돌아갈 수 있다면, 아마 Move와 Leak를 모두 Rust에 추가했을 것입니다. 당시 팀이 Leak를 추가하지 않기로 결정하면서 강조한 몇 가지 단점이 있긴 했습니다. 이 바운드를 충족해야 하는 트레이트 객체 타입은 정의에 + Move나 + Leak를 추가해야 했습니다. 전역적으로 큰 의미를 갖는 자동 트레이트가 이미 둘(Send와 Sync)이나 된다는 점도 충분히 부담스럽다고 여겨졌습니다.
하지만 솔직히 말하면(당시 저는 거기에 있지 않았습니다), Leak를 제외하기로 한 결정에는 최소한 부분적으로는 신속성의 문제가 있었다고 생각합니다. 제 이해로는 Mozilla의 Rust 팀은 경영진으로부터 자신들이 설정한 마감일에 맞춰 1.0을 출시하라는 엄청난 압박을 받았고, 이는 그 마감일을 지키는 데 영향을 줄 수 있는 규칙 변경을 막판에 하지 않기로 한 결정에 영향을 주었을 것입니다.
Leak가 있는 언어에서는 스코프드 태스크 삼중고가 존재하지 않았을 것이고, 더 단순한 스코프드 스레드 API가 안전했으며, GC 통합이 더 쉬웠을 수도 있고, 많은 시스템 API를 안전하게 감싸는 것도 더 쉬웠을 것이라는 인상을 받았습니다(세부 사항은 모릅니다).
Move가 있는 언어에서는 Pin 타입이 필요 없었을 것이고, 따라서 사용자들이 그것을 다루느라 겪는 성가심도 없었을 것이며, 이른바 “핀 프로젝션”도 매크로로 해결해야 하는 문제가 되지 않았고, 자기 참조 제너레이터를 만드는 것도 Iterator 트레이트에 대해 복잡함을 만들지 않았을 것입니다.
하지만 지금 이런 변경을 하는 것은 훨씬 더 가시덩굴 같은 문제입니다. 저는 에디션 기반 기법이 새롭고 전역적으로 중요한 마커 트레이트를 추가하기 위한 유일하게 실행 가능한 해법이라고 생각합니다(여기서 논의하지 않은 DynSized 같은 몇 가지 특수한 예외는 제외하고요). 그리고 그것조차 작동하지 않을 온갖 이유가 있다고 생각합니다—구현하기 너무 어렵고, 구현에는 수많은 사운드니스 구멍이 생기며, 전환이 너무 파괴적이고, 제가 중요한 무언가를 놓쳐서 사실상 완전히 불가능할 수도 있습니다.
그래서 저는 이동 불가능한 타입을 위해 Pin 해법을 찾아냈고, 그 위에 자기 참조 futures와 async/await 문법을, 기존 사용자들에게 큰 혼란 없이 합리적인 시간 안에 배송할 수 있었던 점이 매우 기쁩니다. Leak와 선형 타입에 관해서는, 저는 그저 낙담할 뿐입니다.