async/await 도입 4년을 돌아보며, Rust의 async 사용자 경험을 개선하기 위한 단기·중기·장기 과제를 제안한다. AsyncIterator와 async 생성기, RTN과 코루틴 클로저부터 객체 안전 코루틴 메서드, async 소멸자, 그리고 이동성·선형 타입 규칙 변화까지를 다루고 프로젝트 의사결정 문화에 대한 성찰을 덧붙였다.
오늘로부터 정확히 4년 전, Rust의 async/await 기능이 1.39.0 버전에서 릴리스되었다. 발표 글의 포스트는 “이 작업은 오랜 기간 개발되어 왔습니다 — 예컨대 제로 코스트 futures에 대한 핵심 아이디어는 2016년에 Aaron Turon과 Alex Crichton이 처음 제안했습니다”라고 말한다. 이제는 async/await가 릴리스된 이후의 시간이, futures에 대한 첫 설계 작업과 async/await 문법 릴리스 사이의 시간보다 더 길어졌다. 그럼에도 불구하고, 그리고 async/await 문법이 명시적으로 “최소기능제품(MVP)”으로 선적되었다는 사실에도 불구하고, Rust 프로젝트는 그 이후 4년 동안 async/await에 거의 어떤 확장도 선적하지 못했다.
이 사실은 이미 여러 곳에서 지적되었고, 나는 이것이 async Rust가 부정적 평판을 얻게 된 주된(통제 가능한) 이유라고 본다(그 외 본질적 복잡성 같은 이유는 프로젝트가 통제할 수 없는 영역이다). 프로젝트 리더인 Niko Matsakis가 이 문제를 인식하고 있는 것을 보는 건 고무적이다. 여기서는 내가 생각하는, async Rust의 사용자 경험을 계속 개선하기 위해 필요한 기능들을 개관하려 한다. 나는 이 기능들을 단기(대략 향후 18개월) 안에 선적할 수 있는 것들, 좀 더 오래(최대 3년) 걸릴 것들, 그리고 마지막으로 수년의 기획과 준비가 필요한 잠재적 언어 변화라는 세 구간으로 나누어 정리했다.
이 섹션의 기능들은 모두 Rust 프로젝트가 1~2년 내에 선적할 수 있으리라 생각하는 것들이다. 이미 구현된 추상화 능력에 의존하므로 컴파일러 변경이 비교적 작고, 표면 문법의 변경도 작다. 대개 기존 문법이 암시하던 새로운 설탕(sugar)을 추가하는 수준이다. 이런 것들이야말로 상대적으로 쉽게 선적하고 합의를 모을 수 있으므로, 프로젝트가 우선순위를 두어야 한다고 생각한다.
나는 과거에도 Rust에서 생성기의 중요성에 대해 여러 차례 강조해 왔으므로 여기서 길게 반복하지는 않겠다. 또한 반복자에 대한 원래의 계획에 생성기 문법의 선적이 포함되어 있었다는 점도 이미 강조했다. 간단히 말해, 생성기의 부재는 Rust를 혼란스러운 상태로 남겨 두었고, 비동기성과 반복 사이의 관계가 불명확해졌다(자세한 내용은 링크한 블로그 글을 참조). 여기서는 특히 async 반복자와 async 생성기에 초점을 맞추고, 이를 완성하는 데 필요한 기능들을 말하고자 한다.
async 생성기는 생성기의 자연스러운 변형이다. 함수와 마찬가지로 생성기에도 async를 표시할 수 있고, 그러면 그 안에서 await 연산자를 사용할 수 있다. 내가 선호하는 문법을 쓰면 대략 다음과 같이 보일 것이다:
async gen fn sum_pairs(rx: Receiver<i32>) yields i32 {
loop {
let left = rx.next().await;
let right = rx.next().await;
yield left + right;
}
}
이 기능들의 조합은 이러한 문법에서 자연스럽게 따라 나온다. 생성기와 달리 async 생성기는 AsyncIterator로 컴파일된다.
또 하나 필요한 문법이 있다: for await 루프다. 이는 어떤 async 컨텍스트에서든 호출할 수 있고, AsyncIterator에서 항목을 소비하며, AsyncIterator가 pending을 내놓을 때 제어를 양도한다:
for await item in async_iter {
println!("{}", item);
}
내가 async Rust 작업을 하던 당시, 이 문법은 두 가지 설계 논점에서 발목이 잡혀 있었다. 하나는 Taylor Cramer가 이 기능이 좋지 않다고 보았던 점인데, 사용자들은 대신 일부 동시성을 얻기 위해 for_each_concurrent를 사용해야 한다는 의견이었다. 나는 이에 동의하지 않는다. 사용자가 항상 for_each_concurrent를 쓰고 싶어 하는 것은 아니며, async 함수에 내부 동시성을 더하는 결정은 신중히 고려되어야 한다. 그리고 그게 싫을 때 쓸 수 있는 명백한 문법(바로 for await)이 있어야 한다. 다른 하나는 “await 패턴”으로 futures를 구조분해하려는 아이디어가 있었고, 이를 어떻게든 여기서도 작동시키자는 추측이었다. 나는 이것이 신중하지 못하다고 본다. await는 표현식으로 남겨 두고, for await는 AsyncIterator를 다루는 특별한 표현식으로 두는 것이 가장 합리적인 선택이다.
이전 블로그 글의 표를 다시 가져와 async 반복에 대한 열을 추가하면 다음과 같다:
│ ASYNCHRONOUS ITERATION
─────────────────────┼───────────────────────────
│
CONTEXT │ async gen { }
│
EFFECT (iteration) │ yield
│
FORWARD (asynchrony) │ await
│
COMPLETE (iteration) │ for await
│
이를 가로막는 가장 큰 문제는 라이브러리 측에 있다. AsyncIterator 인터페이스를 어떻게 표현할 것인가? 나는 이미 문서에서 poll_next 메서드를 가진 현재 형태 그대로 AsyncIterator를 안정화하자는 선호를 밝혀 두었다. 이 문제는 여전히 논쟁거리이므로 다시 다룰 생각이지만, 이번 글에서는 넘어가겠다.
지금은 단지 이렇게 말하고 싶다. 지난 4년간 AsyncIterator를 안정화하지 못한 것(이는 우리가 async MVP를 계획할 때 결코 의도한 바가 아니었다)은 async Rust에 해로웠다. async 반복에 기반한 API들은 불안정 기능이나 사이드 라이브러리로 밀려났고, 반복적인 비동기 이벤트(아주 흔한 패턴)를 다뤄야 하는 사용자들은 혼란스럽고 지원도 부족한 상태에 놓였다. Rust 프로젝트가 사용자들을 위해 할 수 있는 가장 좋은 일은 AsyncIterator를 안정화해서 생태계가 그 위에 구축되도록 하는 것이다. 이는 내일이라도 할 수 있다.
다행히 다음 에디션에서 gen 키워드를 예약하기 위한 작업이 이미 진행 중이다. 덕분에 생성기를 구현할 수 있다. 이 기능은 이미 async 함수가 사용하는 것과 같은 상태 머신 변환을 사용하며, 유추컨대 컴파일러에 큰 변화를 요구하지 않고도 구현 가능할 것이다. 생성기에 남아 있는 큰 미해결 질문(그리고 AsyncIterator를 현재대로 안정화한다면 async 생성기에는 해당되지 않는)은 생성기를 어떻게 self-referential하게 만들 것인가 하는 점이다. 이 질문은 글 후반에 다시 다루겠다.
이러한 추가 코루틴들을 도입하는 것과는 별개로, 이를 트레이트 시스템과 통합하는 문제가 있다. 현재 안정 Rust에서는 async 트레이트 메서드를 정의할 수 없다. 다행히 이 점은 곧 바뀌고, 머지않아 릴리스될 Rust 버전부터는 async 트레이트 메서드를 쓸 수 있게 된다. 다른 코루틴들과 마찬가지로, 생성기와 async 생성기는 트레이트에서 사용하기 위해 async 함수에 이미 구현된 것 이상의 특별한 지원을 요구하지 않을 것이다. 따라서 생성기와 async 생성기가 구현되고 안정화되면, 곧바로 메서드로도 지원되어야 한다.
코루틴 메서드를 위해 남아 있는 유일한 과제는 “반환 타입 표기법(Return Type Notation, RTN)”이다. 문제는 이렇다. 트레이트에 코루틴 메서드를 추가하면 그 메서드의 반환 타입에 해당하는 익명 연관 타입이 트레이트에 추가된다. 때때로(가장 중요하게는: 워크 스틸링 실행기에서 태스크로 스폰하거나 다른 스레드로 옮길 때) 사용자는 이 익명 연관 타입에 추가 바운드를 부여할 필요가 있다. 따라서 Rust에는 이를 선언하는 문법이 필요하다. 이것이 RTN이다. 예를 들어:
trait Foo {
async fn foo(&self);
}
// 이후에:
where F: Foo + Send,
F::foo(): Send
내 견해로는, RTN을 선적하는 것이 중요하다. 나는 이를 “고칠 수 있나요?” 원칙이라 부른다. 여러분의 상위 의존성이 async 메서드를 가지고 있고, 그 반환 타입에 Send 바운드를 추가할 필요가 있다면, 여러분은 그걸 고칠 수 있는가? 아니면 라이브러리를 포크해야 하는가? where 절에 RTN 바운드를 추가할 수 없으면, 비록 여러분의 코드가 전적으로 타당하더라도(즉, 호출하려는 async 메서드가 실제로 Send이더라도) 상위 코드를 바꾸지 않고는 필요한 바운드를 표현할 수 없다. 컴파일러가 만족하도록 하려면 의존성을 포크해야만 하는 상황을 마주하는 것은 사용자에게 매우 좌절감을 준다.
다행히도, 프로젝트는 이미 이 기능에 집중하고 있으며 내년 안에 선적될 것으로 예상한다. 이 기능의 정확한 문법을 두고 약간의 논의가 있는 듯한데, 나는 기여자들에게 기능을 본질적으로 바꾸지 않는 문법 차이에 지나치게 집착하지 않기를 권한다.
Rust의 언어 설계에서 코루틴이 아직 잘 지원되지 않는 또 다른 측면은 클로저다. Niko Matsakis는 최근 두 개의 블로그 글에서 이 문제를 다루었는데, 생성기 또는 비동기 생성 클로저가 아닌, 오직 async 클로저에만 초점을 맞췄다. 첫 번째 글에서는 async 클로저를 새로운 계층의 함수 트레이트(즉 AsyncFn, AsyncFnMut, AsyncFnOnce를 추가)로 다루자는 제안을 했다. 두 번째 글에서는 대신 async 클로저를 impl Future를 반환하는 클로저(예: F: Fn() -> impl Future)로 모델링하는 아이디어를 탐구한다.
나는 두 번째 접근을 선호한다. 트레이트의 남발을 피할 수 있기 때문이다. 이는 생성기 클로저와 비동기 생성 클로저까지 고려하면 특히 분명해진다. 각 경우에 대해 별도의 함수 트레이트가 필요하다면, 3개의 함수 트레이트가 12개로 불어난다. 반면, 코루틴 클로저를 impl Trait을 반환하는 클로저로 모델링하면 새로운 트레이트가 전혀 필요 없다. 또한 이는 Rust가 일반 async 함수를 디슈가링하는 현행 방식과 정확히 같은 모델링이라는 추가 장점도 있다.
Niko가 글에서 강조했듯, 이를 위해서는 Fn 트레이트가 반환 타입에서 입력 라이프타임을 캡처할 수 있도록 조정이 필요하다. 그의 글에서 호출된 몇 가지는 Rust의 문법을 바꾸어야 하는데, 에디션 경계에 걸칠 수도 있다:
Fn 트레이트의 Output 매개변수에 라이프타임을 추가-> impl Trait의 디슈가링을 새 변수 도입이 아니라 연관 타입 프로젝션의 바운드로 바꾸기에디션 변경이 필요할 수 있으므로, 프로젝트는 이 변화의 세부사항을 즉시 검토해야 한다. 그러나 이는 매우 까다로운 문제로 보이지는 않는다.
이 기능에 하나 더 덧붙이고 싶은 것이 있다. 일단 Fn() -> impl Future 등이 가능해지면, 함수들처럼 일종의 “async 슈가”(그리고 “gen 슈가”)를 문법에 확장하는 것이 자연스럽다. 즉, 함수 트레이트에 특수한 설탕을 추가해 다음과 같은 클로저 바운드를 작성할 수 있어야 한다:
where F: async FnOnce() -> T
// 다음과 동치:
where F: FnOnce() -> impl Future<Output = T>
where F: gen FnOnce() yields T
// 다음과 동치:
where F: FnOnce() -> impl Iterator<Item = T>
where F: async gen FnOnce() yields T
// 다음과 동치:
where F: FnOnce() -> impl AsyncIterator<Item = T>
좋은 점은, 이것이 “트레이트 변환기”나 “효과 제네릭” 같은 새로운 범용 추상 개념이 아니라는 것이다. 이미 한 곳(함수 선언)에 존재하던 설탕을 다른 곳(함수 트레이트 바운드)으로 자연스럽게 확장하는 작은 설탕일 뿐이다. 그리고 이러한 함수 트레이트는 이미 매개변수와 반환 타입에 괄호와 화살표를 사용하는 특수 문법을 갖고 있다. 구현 작업도 많지 않고, 논쟁적인 새로운 기능에 대한 합의를 필요로 하지도 않는다.
앞 절의 기능들은 구현 노력도 크지 않고 설계에서 까다로운 쟁점이 많지 않아 곧 선적할 수 있다고 본 것들이다. 반면 이 절의 기능들은 더 어렵다. 이미 지금부터 연구가 진행되고 있는 것은 좋은 일이나, 가까운 1~2년 내에 선적될 것 같지는 않다.
곧 안정 기능이 될 async 트레이트 메서드이지만, 초기에는 객체 안전(object-safe)이 아니다. 이는 옳은 결정이었다고 생각하지만, 언젠가 객체 안전이 되는 것이 이상적이다. 객체 안전의 문제는 이렇다. 각 코루틴 메서드는 익명 연관 타입(그 메서드의 반환 타입)을 함의하는데, 이는 각 구현마다 크기와 레이아웃이 다르다. 트레이트 객체의 정적 타입을 지우려면 그 메서드의 익명 반환 타입도 지워야 한다. 즉, 그것 역시 어떤 의미에서는 트레이트 객체가 되어야 한다.
예시로 다음 트레이트를 생각해 보자:
trait Foo {
async fn foo(&self);
}
만약 Foo의 트레이트 객체를 만들고 싶다면 Foo::foo의 반환 타입을 지정해야 한다. 다행히 RTN은 이 문제를 Box<dyn Foo<foo() = Something>> 같은 문법으로 풀어가기 시작한다. 하지만 그 Something은 무엇인가? 구체 타입일 수는 없다. 그렇다면 그 타입을 반환하는 구현만 허용하게 되는데, 실상 특정 타입 하나로 제한하는 셈이니, 더는 의미 있는 트레이트 객체가 아니다. 그래서 그것 자체가 트레이트 객체여야 한다.
예를 들어 Box<dyn Foo<foo() = Pin<Box<dyn Future<Output = ()>>>>> 같은 형태일 수 있다. 물론 엄청나게 장황하다. 설계를 좌우하는 두 가지 문제가 있다:
Foo 구현을 받아들이고, 퓨처를 힙에 할당하는 글루(glue) 코드를 포함하는 어떤 변환기가 필요하다.그 결과, 프로젝트는 새로운 래퍼 타입을 도입하는 방안을 검토해 왔다. 이 래퍼 타입은(다른 타입이라는 사실을 통해) 퓨처 타입이 힙에 할당됨을 “명시적”으로 나타낸다. 내가 이해한 바로는, 위에 내가 쓴 형태 같은 것이 Box<Boxed<dyn Foo>> 혹은 아예 Boxed<dyn Foo>가 되는 식이다(가용한 자료만으로는 정확히 명확하지 않다).
내 의견은 조금 다르다. 힙에 할당된 트레이트 객체(Box<dyn Foo>, Rc<dyn Foo>, Arc<dyn Foo>)의 기본 동작이 그 타입이 사용하는 동일한 할당자를 써서 상태 머신을 할당하도록 하는 것이 합리적이라고 본다. 비소유 트레이트 객체(예: &mut dyn Foo)의 경우에는 글로벌 할당자로 할당하는 기본 동작에도 동의할 수 있다. 다만 여기서는(no_std 문맥에서는 특히) 그 주장의 요점을 더 잘 알겠다.
어쨌든, 사용자가 이 기본 동작을 대체 글루 메커니즘으로 재정의할 수 있게 하는 것이 중요하다는 점에는 동의한다. 이를 위해서는, 다른 일을 하는(예: alloca를 써서 스택에 동적 크기 타입을 할당하는) 자체 글루 코드를 작성할 수 있는 인터페이스가 필요하다. 나는 그저 합리적인 기본 동작이 있어야 한다고 생각할 뿐이다. 힙에 할당된 트레이트 객체의 경우에는 그 상태를 힙에 할당하는 것이 아마도 합리적일 것이다. 내 생각에는 이것이 “암묵적”인 것도 아니다. 모든 사용자가 어댑터를 쓰도록 강제하는 것이 오히려 “암묵적”이다. 다만 합리적인 기본값을 설정하는 문제일 뿐이다. 그럼에도, 이 논쟁을 모두가 만족하도록 해결하는 일과 글루 코드 인터페이스를 개발하는 일은 이 기능의 블로커가 될 것이다.
이 섹션에서 한 가지 더 언급하고 싶다. 과거 논의에서는 불안정 기능인 dyn*를 객체 안전 코루틴 메서드의 전제 조건으로 취급했다. 나는 그렇게 생각하지 않는다. dyn*이 하는 일은, 소멸자 코드까지 가상화함으로써 서로 다른 트레이트 객체 포인터 타입들이 모두 구현하는 존재 타입을 만드는 것이다. 만약 가상 코루틴 메서드에 서로 다른 할당 전략을 사용하는 트레이트 객체를 서로 다른 타입으로 받아들일 수 있다면, dyn*에 대한 의존은 전혀 없다. 개인적으로 나는 dyn*이 Rust 프로젝트가 추구할 방향으로 의문스럽다고 생각한다.
또 다른 매우 까다로운 이슈는 async 소멸자(destructor) 문제다. 때로 소멸자는 어떤 IO 작업을 수행하거나 현재 스레드를 블록해야 할 수 있다. 이때 다른 태스크가 동시에 실행될 수 있도록 제어를 양도하는 논블로킹 소멸자를 지원하는 것이 바람직하다. 불행히도, 몇 가지 문제가 있다.
첫 번째 문제는, async 소멸자를 실행하는 것이 다른 소멸자보다도 더 “최선의 노력(best effort)”이라는 점이다. 이는 비동기 컨텍스트가 아닌 곳에서 async 소멸자를 가진 타입을 drop하면, 애초에 async 컨텍스트가 아니므로 소멸자를 실행할 가능성이 없다. 이를 해결하는 아이디어로는, 변수를 비동기 컨텍스트 밖으로 이동할 수 없음을 나타내는 let async 바인딩을 쓰자는 것, 혹은 그냥 받아들이고 async 소멸자를 비async 소멸자 대비 최적화로만 취급하자는 것이 있었다.
두 번째 문제는 실제로 트레이트 객체 문제와 매우 비슷하다. async 소멸자가 어떤 상태를 써야 한다면, 그 상태를 어디에 저장할 것인가? 한 가지 옵션은 poll 메서드를 써서 async 소멸자가 상태를 갖지 못하게 하는 것이다. 간단하지만, 자료구조 같은 것에는 문제가 된다. 예컨대 Vec은 어떤 항목의 소멸자를 이미 poll했는지 기록할 방법이 없고, 루프에서 계속 그들의 소멸자를 poll해야 한다. 이는 아마 받아들일 수 없을 것이다. 그러나 상태를 다루면 곧 트레이트 객체에서의 동일한 문제가 다시 떠오른다.
세 번째 문제는 언와인딩(unwinding)과의 상호작용을 어떻게 처리할 것인가이다. 특히 async 소멸자를 통과하여 언와인딩하는 도중 그 소멸자가 Pending을 반환하면 어떻게 될까? 다른 태스크가 실행될 수 있도록, pending 호출이 점프할 비동기 버전의 catch_unwind 같은 것이 필요하다. 이 문제는 앞의 둘보다 풀기 쉬울 것 같지만, 그럼에도 명세화가 필요하다.
나는 async 소멸자의 난점이 async Rust의 최악의 문제 중 하나라고 생각하다가도, 어쩌면 async 소멸자가 별로 쓸모가 없을지도 모른다고 생각하기도 한다. 어떤 결론을 내리든, 이 기능이 선적되려면 많은 설계 작업이 필요하며, 가까운 시일 내에는 오지 못할 것 같다고 본다.
단기·중기 기능과 달리, Rust의 설계에서 신중히 고려해야 할 더 큰 문제들이 있다. 이는 향후 몇 년 안에는 해결할 수 없는 문제들이다. 하지만 언젠가 해결되려면 고려 작업은 언젠가 시작되어야 한다. 나는 Rust의 규칙을 “바꾸는” 이야기를 하고 있다.
현재로서는 Rust가 제대로 지원하지 못하는 가치 있는 타입들이 몇 가지 있다:
(후자의 둘은 보통 사람들이 말하는 “선형 타입(linear types)”으로 묶이지만, 아주 중요한 차이가 있다.)
적어도 앞의 두 범주에 대해서는 강한 동기가 존재한다는 증거가 충분하다고 본다.
자기참조 코루틴과 침습적(intrusive) 자료구조를 지원하려면, Rust에는 결코 다시는 이동하지 않는다고 알려진 타입에 대한 지원이 필요하다. Rust가 이동 불가능 타입을 지원하지 않기 때문에, 우리는 Pin API로 이 기능을 추가했다. 하지만 Pin API에는 큰 결함이 몇 가지 있다. 하나는 API가 투박하고 다루기 어렵다는 점이다. 더 중요한 것은, 이동 불가능 타입을 지원하려면 인터페이스가 명시적으로 옵트인해야 한다는 점이다. Pin 이전에 존재하던 트레이트는 이동 불가능 타입을 다룰 수 있는 능력을 획득할 수 없다.
이 문제가 특히 큰 두 가지 트레이트가 있다:
Iterator: 반복자가 이동 불가능 타입을 지원하지 않기 때문에, 이동 불가능 생성기를 어떻게 지원할지 프로젝트는 교착 상태에 빠져 있다.Drop: drop이 이동 불가능 타입을 지원하지 않기 때문에, pin-project 같은 크레이트가 핀 고정된 타입의 필드에 접근하기 위해 필요한 것처럼, 난해한 함의가 따라온다. 이는 매우 기괴하고 혼란스럽고, Drop이 이동 불가능 타입을 지원했다면 불필요했을 것이다.반면 Rust에 Move 트레이트가 있다면 이 문제들은 사라질 것이다. 자기참조 생성기는 단지 Move를 구현하지 않으면 되고, 자연스럽게 동작한다. Pin 타입은 완전히 폐기될 수 있고, Move를 구현하지 않는 타입에 대한 참조는 Unpin을 구현하지 않는 타입에 대한 핀 참조와 동일한 의미론을 가진다. 물론, 이는 꽤 큰 에디션 경계 변경을 요구할 것이다.
스코프드 태스크 삼중곤란(trilemma)은 잊을 수 없는 타입을 지지하는 강력한 근거를 제시한다. 스택리스 코루틴은 소멸자 기반의 동시 빌림 트릭을 사용할 수 없다. 작동시키는 유일한 방법은 클로저 전달 방식의 “내부” 스타일을 사용하는 것인데, Rust는 스택리스 코루틴을 선택하면서 이 방식을 포기했다. Rust 설계의 이 두 바람직한 측면 간의 양립 불가능성은, 잊을 수 없는 타입 지원을 하지 않기로 한 결정이 잘못된 선택이었음을 강하게 시사한다.
이 글을 “4개년 계획”이라고 제목 붙인 데는 이유가 있다. 만약 Rust가 이러한 근본적 변화를 수용한다면, 이는 에디션 경계를 넘어 이루어져야 하며, 2024 에디션의 일부로 할 수 있으리라 보지 않는다. 그렇다면 목표는 지금으로부터 4년 뒤인 2027 에디션이 된다. 그러나 프로젝트는 이 변화에 대한 결정을 조만간, 향후 2년 내에 내려야 하며, 그 결정에는 생성기에 대한 임시 해법(예: 반복자로 쓰기 전에 반드시 핀 고정하도록 요구)이 포함되어야 한다.
나는 올해 내 블로그에서 이 변화를 위해 필요한 것을 탐구해 왔다. 이는 Rust 프로젝트가 진지하게 검토해야 한다고 생각하기 때문이다. 내년에도 계속 이 이슈에 집중할 생각이다. 각 옵션의 함의를 충분히 이해해야 하기 때문이다. 나는 이를 협업적 과정으로 만들 방안을 찾고 있지만, 선택지가 제한적이다. 나의 목표는 특정 권고안을 만드는 것(물론 의견은 있을 것이다)이라기보다는, 이 이슈를 해결하기 위한 옵션 공간 전체를 이해하는 데 있다.
자기참조 생성자 문제를 다루는 서로 다른 옵션들 사이의 정확한 트레이드오프는 무엇인가? “잊을 수 없는(unforgettable)” 타입을 지원하는 것과 “드롭 불가능(undroppable)” 타입을 지원하는 것 사이에는 어떤 서로 다른 요구가 있는가? 만약 Move가 추가된다면, Pin은 어떻게 에디션 경계를 넘어 제거될 수 있는가? 이런 종류의 질문에 답하고자 한다.
하지만 나는 이러한 타입을 지원하는 것이 2015년 안정화 이후 Rust에 가해지는 가장 큰 변화가 될 것이며, 그 변화는 프로젝트와 커뮤니티 모두에게 막대한 비용을 가져올 것임을 잘 안다. 또한 이런 타입들을 지원할 가치가 정말 있는지에 대해 유효한 반론들(예: 트레이트 객체와의 고통스러운 상호작용)도 존재함을 안다. 이러한 이유로, Rust 프로젝트는 이 아이디어를 검토할 때 궁극적으로는 아무것도 하지 않는 것이 옳은 결과일 가능성도 포함해야 한다.
일반적으로, 나는 설계 프로세스가 이 단계에 이른 Rust에 큰 변화를 가하는 것에 회의적이다. 지금 Rust에 필요한 것은 이미 약속한 기능들 — 외부 반복자, 스택리스 코루틴, 모노모픽 제네릭, 크기 미지수 트레이트 객체 타입 — 의 통합을 마무리하는 일이라고 본다. 특히 이동성과 선형 타입 규칙을 바꾸는 것이 정당하다고 느끼는 이유는, 이러한 기존 기능들의 통합에 미치는 함의 때문이다.
이 글은 또다시 길어졌다. 이번 글에서는 언어 변화에 초점을 맞추기로 했다. 다음 글에서는 표준 라이브러리와 async 라이브러리 생태계에 집중하고, AsyncIterator 인터페이스에 대한 별도의 글도 쓸 생각이다. 한 가지 더 언급하고 싶은 점이 있다. 이번 글과 이전 글 어디에도 넣을 자리를 찾지 못했는데, 2019년에 벌어졌던 await 연산자의 최종 문법을 둘러싼 논쟁과 관련된다.
모르는 분을 위해 덧붙이면, Rust의 await 연산자가 전치(prefix) 연산자여야 하는지(다른 언어들처럼) 후치(postfix) 연산자여야 하는지에 대한 큰 논쟁이 있었다. 1000개가 넘는 댓글이 달릴 정도로 과도한 관심을 끌었다. 상황은 이랬다. 언어 팀 거의 모두가 연산자는 후치여야 한다는 데 합의했지만, 나만 홀로 반대했다. 이 시점에서 새로운 논거가 등장하거나 누군가 마음을 바꿀 기미는 없었다. 나는 이 상태를 몇 달 동안 방치했다. 이는 내 실수였다. 다수 의견에 내가 양보하지 않으면 선적할 방법이 없다는 것은 분명했다. 그럼에도 한동안 그러지 않았다. 그러는 사이 이미 나온 주장들을 반복하는 더 많은 “커뮤니티 피드백”이 쏟아지도록 방치했고, 모두를 — 특히 나 자신을 — 소진시켰다.
이 경험에서 내가 배운 교훈은 정말로 중요한 요인과 그렇지 않은 요인을 구분하는 것이다. 어떤 이슈에 대해 완강해지려면, 그것이 왜 중요한지 깊은 이유를 말할 수 있어야 하며, 그 이유는 문법 옵션 사이의 약간의 편의성과 미학 차이보다 더 절실해야 한다. 그 이후로 기술적 문제에 관여할 때 이 교훈을 마음에 새기려 노력해 왔다.
나는 Rust 프로젝트가 이 경험에서 잘못된 교훈을 얻었다고 걱정한다. 프로젝트는 여전히(Graydon이 여기서 언급했듯) 충분한 아이데이션과 브레인스토밍을 거치면 결국 모든 논쟁에 대한 윈-윈 해법을 발견할 수 있다는 규범을 따른다. 힘든 결정을 받아들이기보다는, 이러한 논쟁들이 무기한 열려 있음을 허용함으로써 생기는 소진의 해결책을 프로젝트는 안으로 움츠리며 찾았다. 설계 결정은 이제 주로 Zulip 스레드와 HackMD 문서 같은 인덱싱되지 않는 형식으로 문서화된다. 공개된 설계 표현이 있다 하더라도, 서로 다른 기여자들이 운영하는 여러 블로그 가운데 하나일 뿐이다. 외부자 입장에서는, 프로젝트가 무엇을 우선순위로 보는지, 이런 것들의 현재 상태가 어떤지 이해하기가 거의 불가능하다.
나는 프로젝트와 커뮤니티의 관계가 지금처럼 나빴던 때를 본 적이 없다. 하지만 그 커뮤니티에는 귀중한 전문성이 있다. 스스로를 닫는 것은 해결책이 아니다. 프로젝트 구성원과 커뮤니티 구성원 사이의 상호 신뢰와 존중의 관계가, 현재의 적대와 불만의 상황이 아니라, 다시 구축되는 모습을 보고 싶다. 이 점과 관련해, 지난 몇 달간 설계 이슈에 관해 내게 연락하고 교류해 준 프로젝트 구성원들께 감사를 전한다.