Rust의 async/await 설계 배경에서 왜 `Pin`이 필요한지, 무엇을 보장하는지, 과거에 검토된 대안들이 왜 실패했는지, 그리고 현재 `Pin`이 사용성 면에서 겪는 복잡성 문제를 사례와 함께 설명한다.
Pin 타입(그리고 일반적으로 말하는 고정(pin) 개념)은 Rust 비동기 생태계의 나머지 부분이 서 있는 기초적인 구성요소다. 안타깝게도, 이는 async Rust에서 가장 접근성이 떨어지고 오해받는 요소 중 하나이기도 하다. 이 글은 Pin이 무엇을 달성하는지, 어떻게 등장했는지, 그리고 현재 Pin의 문제가 무엇인지 설명하려는 목적을 가진다.
몇 달 전, Mojo라는 새 언어를 개발 중인 Modular라는 회사의 블로그에 흥미로운 글이 있었다. 그 글의 Rust에서의 Pin에 대한 짧은 논의가, 이 주제에 대한 공론의 시대정신을 간결하게 포착하고 있었다고 느꼈다:
Rust에는 값의 정체성(value identity)이라는 개념이 없습니다. 자신이 가진 멤버를 가리키는 자기참조 구조체의 경우, 객체가 이동하면 그 데이터는 무효해질 수 있는데, 이는 이전 메모리 위치를 가리키게 되기 때문입니다. 이것은 특히 비동기 Rust의 일부에서 복잡성을 폭증시키는데, 그곳에서는 future가 자기참조적이어야 하고 상태를 저장해야 하므로, 이동하지 않음을 보장하기 위해 Self를 Pin으로 감싸야만 합니다. Mojo에서는 객체가 정체성을 가지므로 self.foo를 참조하는 것이 항상 올바른 메모리 위치를 반환하며, 프로그래머가 추가적인 복잡성을 감내할 필요가 없습니다.
이 발언의 일부는 나를 혼란스럽게 한다. “값의 정체성”이라는 용어는 그 글 어디에도 정의되어 있지 않고, Mojo의 다른 문서에서도 찾을 수 없기 때문에, Modular가 Mojo가 Pin이 해결하려는 문제를 어떻게 해결한다고 주장하는지 분명하지 않다. 그럼에도 불구하고, Pin의 사용성에 대한 비판은 잘 제기되었다고 생각한다. 사용자가 Pin과 상호작용하도록 강제될 때 실제로 “복잡성 스파이크”가 존재한다. 내가 쓰고 싶은 표현은 사실 “복잡성 절벽”인데, 사용자가 갑자기 절벽 아래로 밀려 떨어져 이해하기 힘든 비표준적 API의 바다에 빠지는 느낌이기 때문이다. 이는 문제이며, 이 문제가 해결된다면 Rust 사용자에게 매우 큰 가치를 줄 것이다.
마침 이 Rust의 한 구석은 내 영역이다; 자기참조 타입을 지원하기 위해 Rust에 Pin을 추가하자는 아이디어는 내가 냈다. 나는 이 복잡성 스파이크를 어떻게 해소할 수 있을지에 대한 생각이 있으며, 그에 대해 다음 글에서 자세히 이야기할 것이다. 다만 그에 앞서, 내가 아는 한 가장 효율적인 방식으로, Pin이 무엇을 이루는지, 어떻게 생겨났는지, 그리고 왜 현재 사용하기 어려운지를 먼저 설명해야 한다.
왜 Pin이 존재하는지 설명하려면, async/await의 초기 개발로 돌아갈 필요가 있다. 우리가 해결하려던 문제는, async 함수에서 참조를 지원하려면 그 참조를 Future 내부에 저장할 수 있어야 한다는 것이었다. 문제는 그 참조들이 _자기참조(self-references)_일 수 있다는 점, 즉 동일한 객체의 다른 필드를 가리킬 수 있다는 점이었다.
다음 장난감 예제를 보자:
async fn foo<'a>(z: &'a mut i32) { ... }
async fn bar(x: i32, y: i32) -> i32 {
let mut z = x + y;
foo(&mut z).await;
z
}
이 두 함수는 모두 익명 future 타입으로 평가된다. async 함수가 평가되는 future 타입은, 시작할 때, 각 await 지점마다, 그리고 종료할 때 등 중단될 수 있는 각 단계에 대한 상태를 가진다.
우리 예제를 위해, foo가 평가되는 익명 future를 Foo<'a>(여기서 'a는 z 인자의 라이프타임)라고 하고, bar가 평가되는 익명 future를 Bar라고 부르자. 그렇다면 Bar의 내부 상태는 무엇일까? 대략 다음과 같을 것이다:
enum Bar {
// 시작 시에는 인자만 가진다
Start { x: i32, y: i32 },
// 첫 await 시점에는 `z`와 그 `z`를 참조하는 `Foo` future를 가진다
FirstAwait { z: i32, foo: Foo<'?> }
// 완료되면 데이터를 필요로 하지 않는다
Complete,
}
Foo<'_> future의 라이프타임에 '?가 있는 점에 주목하자: 그 라이프타임은 무엇일 수 있을까? Bar에는 라이프타임 파라미터가 없으니 Bar보다 오래 사는 라이프타임이 아니다. 대신 Foo 객체는 자신과 나란히 같은 구조체 안에 저장된 Bar의 z 필드를 빌린다. 이런 이유로 이러한 future 타입을 “자기참조적”이라고 부른다. 그들은 자기 자신 안의 다른 필드를 참조하는 필드를 포함하기 때문이다.
여기서 하나 분명히 해야 할 구분이 있다: Pin의 목표는 사용자가 안전한 Rust로 자기참조 타입을 직접 정의할 수 있게 하는 것이 아니다. 오늘날, Bar를 수작업으로 정의하려 해도 그 FirstAwait 변형을 안전하게 구성할 방법은 사실상 없다. 이것을 가능하게 하는 것은 가치 있는 목표일 수 있지만, Pin의 목표와는 별개다. Pin의 목표는 컴파일러가 async 함수에서 생성한 자기참조 타입, 혹은 tokio 같은 런타임에서 unsafe 코드로 구현된 자기참조 타입을 _조작_하는 일을 안전하게 만드는 것이다.
어떤 방식으로 자기참조 타입이 정의됐든, 일단 그런 값이 존재하면 문제가 발생한다. Bar가 FirstAwait 상태에 들어가 그 안에 자신의 z 필드에 대한 참조를 갖고 있다고 상상해보자. 이때 Bar를 이동하면, 그 참조들은 이제 매달린 포인터가 되어 죽은 메모리를 가리키게 되며, 그 메모리는 다른 값으로 재사용될 수도 있다. 따라서, 한 번 Bar가 FirstAwait 상태로 진입할 수 있게 되면, 그 이후에는 더는 이동하지 않도록 하는 것이 필수적이다. Pin 도입 이전의 Rust에서는, 어떤 객체든 그 소유권을 갖고 있으면, 혹은 가변 참조만 있어도 이동이 가능했다. 그래서 우리가 해결해야 했던 문제는, 특정 시점 이후에는 객체를 이동시킬 수 없다는 요구사항을 표현하는 일이었다.
계속하기 전에, 자주 제안되지만 (적어도 Rust에서는) 작동하지 않는 두 가지 해법에 대해 잠시 이야기하고 싶다. 이 둘은 Pin이 취한 접근과는 꽤 다른 방향을 택한다. 값이 더는 이동할 수 없다고 말하는 대신, 자기참조 값을 결국 이동 가능하도록 만들려는 시도다.
첫 번째는 이동 생성자(move constructor)다. 아이디어는 값이 이동할 때마다 어떤 코드를 실행하여, 값이 파기될 때 소멸자가 실행되는 것처럼, 자기참조 포인터를 “수정”해서 새 위치를 가리키도록 하자는 것이다. 예전 글에서 async Rust의 역사와 함께 이 문제를 논의한 적이 있지만, 이는 실현 가능한 해법이 아니다. Rust에서는 그 포인터들이 값의 “내부”에만 있는 것이 아니라 어디에나 있을 수 있기 때문이다. 예를 들어, 자신의 상태를 가리키는 포인터들의 벡터를 따로 두는 경우도 있을 수 있으며, 그러면 이동 생성자는 그 벡터를 추적할 수 있어야 한다. 결국 이는 가비지 컬렉션과 같은 형태의 런타임 메모리 관리가 필요하며, Rust에는 적합하지 않았다.
이동 생성자가 작동하지 않는 또 다른 이유는, Rust가 아주 초기부터 이동 생성자를 절대 도입하지 않겠다고 못 박았고, 값을 메모리 복사만으로 이동할 수 있다고 가정하는 unsafe 코드가 이미 많이 존재한다는 점이다. 이동 생성자를 추가하는 것은 Rust에 파괴적인 변경이 된다.
때때로 제안되는 또 다른 “비해결책”은 오프셋 포인터(offset pointer)다. 이 경우의 아이디어는, 자기참조를 일반 참조로 컴파일하는 대신, 그것들을 자신을 담고 있는 객체의 주소를 기준으로 한 오프셋으로 컴파일하자는 것이다. 이것이 작동하지 않는 이유는, 어떤 참조가 자기참조가 될지 아닐지를 컴파일 시점에 판단할 수 없기 때문이다. 동일한 값이 분기마다 다르게 될 수 있다. 예를 들어, 앞서의 bar를 다음처럼 변형해보자:
async fn bar(x: i32, y: i32, mut z: &mut i32) {
let mut z2 = x + y;
if random() {
z = &mut z2;
}
foo(z).await;
}
foo를 호출할 시점에 z가 같은 객체 안을 가리키는 포인터일 수도, 다른 곳을 가리키는 포인터일 수도 있다. 이것은 컴파일 시점에 결정할 수 없다. 참조를 오프셋과 일반 참조의 어떤 열거형으로 컴파일해야 할 텐데, async/await 작업 당시에는 비현실적이라고 판단되었다.
이러한 객체들을 이동 가능하게 만드는 옵션을 제거했으니, 이제 우리는 객체가 이동 불가능(immovable) 하다는 요구사항을 가지게 된다. 하지만 사람들이 종종 잘못된 가정을 하기 때문에, 정확히 무엇이 필요한지 명확히 해야 한다.
가장 중요한 점은, 이러한 객체들이 항상 이동 불가능 해야 하는 것은 아니라는 것이다. 대신, 생애 주기의 어떤 구간에서는 자유롭게 이동할 수 있고, 특정 시점부터는 그 이후로는 이동을 멈춰야 한다. 이렇게 하면, 자기참조 future를 다른 future와 합성하는 동안에는 이리저리 옮기다가, 결국 폴링되는 동안 내내 살게 될 자리에 놓을 수 있다. 따라서, 어떤 객체가 더 이상 이동해서는 안 된다는 것을 표현할 방법이 필요했다. 다시 말해, 그 객체가 “제자리에 고정(pinned)”되었다는 것을 표현할 방법이 필요했다.
우리가 이 요구사항을 표현하는 API를 실험하던 중, Ralf Jung이 이 아이디어를 형식화(formalize)해 주었다. Ralf의 모델에서는, async/await 작업 이전에도, 객체는 두 가지 “타입 상태(typestate)” 중 하나에 있을 수 있었다: “owned”(소유됨) 상태에서는 자유롭게 이동 가능하고, “shared”(공유됨) 상태에서는 (그 객체를 가리키는 참조가 존재하기 때문에) 어떤 라이프타임 동안 이동할 수 없다. 자기참조 future 타입을 지원하기 위해, Ralf의 모델에는 세 번째 타입 상태인 “pinned”가 추가되었다.
객체가 pinned 타입 상태에 들어가면, 다시는 이동할 수 없다. 좀 더 구체적으로는, 소멸자가 먼저 실행되지 않고서는 그 메모리가 무효화될 수 없다. 이 정의는 소멸자 없이 메모리를 해제하는 등의 몇몇 모서리 경우도 포함하지만, 소멸자 없이 객체의 메모리를 무효화하는 주된 방법은 객체를 새 위치로 이동시키는 것이다. pinned 타입 상태를 이해하는 가장 쉬운 방법은 “객체가 다시는 이동되지 않도록 요구한다”고 생각하는 것이다.
pinned 타입 상태에 대한 또 하나의 사실은, 대부분의 타입에 대해서는 완전히 무관하다는 점이다. 값의 타입이 자기참조를 전혀 포함할 수 없다면, 그 값을 pinning하는 것은 무의미하다. 그래서 대부분의 타입에 대해서는, 그 타입이 pinned 타입 상태에 들어가는 것을 아예 옵트아웃하여, 원한다면 다시 이동할 수 있도록 하는 것이 바람직하다.
pinned 타입 상태에 대한 더 자세한 설명은 Ralf의 블로그에 있는 Rust의 형식적 모델에 있다. 우리는 pinning의 요구사항을 (먼저 비공식적으로, 그리고 Ralf에 의해 공식적으로) 이해한 뒤, Rust의 표면 언어에 객체가 pinned 타입 상태로 들어감을 표현하는 최선의 방법을 찾는 과제에 직면했다. Ralf의 모델은 언어의 의미론을 설명하지만, 사용자에게 보이는 API나 문법을 명세하지는 않는다. 우리가 결국 선택한 해법이 Pin 타입이었지만, 그것이 처음 시도한 해법은 아니었다.
?MovePin을 시도하기 전에, Move라는 새 트레이트에 기반한 해법을 시도했다. 아이디어는 대부분의 타입은 Move를 구현하고, 그들에 대해서는 아무 것도 바뀌지 않지만, 자기참조를 포함할 수 있는 타입은 Move를 구현하지 않는다는 것이었다. Move를 구현하지 않는 이러한 타입들에 대해서는, 그 타입의 값에 참조를 취하는 순간, 그 값이 pinned 타입 상태로 들어가 더는 이동할 수 없게 된다.
이 정의는 동시에 다소 복잡하다 — 사람들은 종종 Move가 전반적인 이동을 제어한다고 가정하는데, 그게 원래 제안은 아니었다 — 하지만 또 다른 면에서는 다소 직관적이기도 하다 — 참조를 저장하려면 반드시 그 값을 참조해야 하므로, pinned 타입 상태로의 전이를 참조의 취득에 연결하는 것은 안전성에 대한 직선적인 보장을 제공한다. 그리고 이 검사는 컴파일러가 자동으로 구현할 수 있었다: Move를 구현하지 않는 타입에 대해서, 그 타입의 값이 참조된 이후에는 이동을 금지하는 식으로, 비-Copy 타입이 이동된 이후에는 다시 이동할 수 없게 막는 것과 같은 방식이다. 이 동작은 브랜치에서 실제로 구현되기도 했다.
이 설계에는 근본적인 제약이 하나 있다. 나중에 자기참조가 될 값에 참조를 취하더라도, 그 값을 곧바로 제자리에 고정시키고 싶지는 않을 때가 있기 때문이다. 예를 들어, 잠깐 Option에 저장해두었다가 Option::take로 꺼내 쓰고 싶을 수 있다. 이는 아마 원래의 Move 트레이트에서 가장 큰 문제였겠지만, 우리는 그 문제를 제대로 규명하기도 전에, Move를 도입하는 것이 하위 호환적이지 않다는 사실을 먼저 발견했다.
이 점에 대해서는 예전에 쓴 적이 있지만, 다시 강조하겠다. Rust에는 자동 구현되는 “마커 트레이트”에 두 종류가 있다:
Send와 Sync가 있다.?Sized다.우리는 처음부터 Move를 자동 트레이트로 만들 수 없다는 것을 알고 있었다. 가변 참조에서 언제나 이동할 수 있다는 사실에 의존하는 안정화된 API가 존재하기 때문이다. 고전적인 예가 mem::swap으로, 같은 타입의 두 값의 위치를 서로 바꾼다. Move를 구현하지 않는 타입에 대해 스왑을 허용할 수 없지만, 해당 API에는 Move 바운드가 없고, 새 바운드를 추가하는 것은 파괴적인 변경이 된다.
따라서 우리의 가정은, Move를 ?트레이트로 추가해야 한다는 것이었다: ?Move. 기본적으로 모든 제네릭은 Move를 구현하는 것으로 가정되지만, API가 그 파라미터를 이동시킬 필요가 없다면 API에 T: ?Move 바운드를 추가할 수 있다. 이것도 이미 그다지 매력적이지 않았다. 많은 API가 값의 이동 가능성을 필요로 하지 않아서, 아마 ?Move 바운드가 잔뜩 붙게 될 것이고, 그렇게 되면 Rust 문서 전반이 이해하기 더 어려워질 것이다. 하지만 이 계획을 완전히 무너뜨린 것은, Move를 ?트레이트로 추가하는 것 또한 하위 호환적이지 않다는 사실이었다.
문제는 연관 타입(associated types)에 있다. 연관 타입에 ?트레이트 바운드를 추가하는 위치는 트레이트 정의부다. 어떤 트레이트의 연관 타입에 ?트레이트 바운드가 없다면, 그 트레이트를 사용하는 모든 코드는 그 연관 타입이 해당 트레이트를 구현한다고 가정할 수 있다. 더군다나 기존 트레이트에서 그 바운드를 완화하는 것 또한 파괴적인 변경이다. 왜냐하면 그 바운드에 의존하는 코드가 존재할 수 있기 때문이다.
다음은 연관 future 타입이 Move를 구현하는 타입의 동작을 가정하는 IntoFuture를 사용한 예다:
fn swap_into_future<T: IntoFuture>(into_f1: T, into_f2: T) {
let mut f1 = into_f1.into_future();
let mut f2 = into_f2.into_future();
// 트레이트에 `type IntoFuture: ?Move`를 추가하면 오류가 된다:
mem::swap(&mut f1, &mut f2);
}
이 문제는 광범위하다. 많은 기본 연산자가 연관 타입을 수반하기 때문이다. 예를 들어, ?Move 타입에 대한 가변 참조는 DerefMut을 구현할 수도 없다. 포인터의 대상 타입이 연관 타입이기 때문이다:
fn swap_derefs<T: DerefMut<Target: Sized>>(mut r1: T, mut r2: T) {
// 트레이트에 `type Target: ?Move`를 추가하면 오류가 된다:
mem::swap(&mut *r1, &mut *r2);
}
함수의 반환 타입, 이터레이터의 아이템, 인덱스 연산자가 반환하는 값, 산술 연산자가 반환하는 값 등도 마찬가지다. 새로운 ?트레이트를 추가하는 것은 단순히 하위 호환적이지 않으며, 에디션으로 쉽게 해결될 문제도 아니다. 서로 다른 에디션의 크레이트가 함께 합성될 수 있으려면, 트레이트의 인터페이스가 동일하게 유지되어야 하기 때문이다.
Pin이 제약을 고려해, 우리는 완전히 다른 방향으로 문제를 풀기로 했다. 객체의 타입의 속성으로 pinned 타입 상태를 두고 참조될 때마다 그 상태로 들어가게 만드는 대신, 참조 자체의 한 부류를 새로 정의해, 그 참조가 생성될 때 객체를 pinned 타입 상태로 들여보내기로 했다. 이것이 Pin 타입으로 표현된다.
Pin은 어떤 종류의 포인터(내장 참조 타입과 Box 같은 라이브러리 정의 “스마트 포인터” 모두)를 감쌀 수 있는 래퍼 타입이다. 이는 그 포인터가 가리키는 대상이 pinned 타입 상태에 들어가 다시는 이동되어서는 안 됨을 의미한다. 필요한 변경을 최소화하기 위해, 우리는 이동 불가능성을 컴파일러가 직접 강제하지 않고 라이브러리 API로 구현했다. 이는, 코드가 실제로 고정된 객체를 변경해야 할 때, 일반 가변 참조를 통해 그 객체가 이동하지 않음을 보장하면서 접근하기 위해 unsafe API를 사용해야 함을 의미한다.
대부분의 타입은 pinned 타입 상태와 일반 상태 사이에 의미 있는 차이가 없기 때문에, Unpin 자동 트레이트가 추가되었다. 이로써 타입이 자기참조적일 수 없다면, unsafe 없이도 pinned 포인터에서 가변 참조를 얻을 수 있다. Unpin을 구현한다면 Pin에서 객체를 이동시키는 것은 완전히 안전하다. 이는 Move와 매우 비슷하지만, 이 동작을 오직 pinned 포인터에만 연결함으로써, 하위 호환성 문제를 피할 수 있었고, 또한 ?Move 객체는 참조만 취해도 제자리에 고정되어 버리는 원래 문제도 피할 수 있었다. pinning은 pinned 포인터에만 적용되므로, 일반 비고정 참조는 Unpin이 아닌 타입과도 아무 문제 없이 잘 작동한다.
자세한 내용은 Pin 타입과 pin 모듈의 문서에 더 많이 정리되어 있는데, 수년에 걸쳐 지금의 Rust에서의 pinning을 포괄적이고 명확하게 설명하는 자료로 성장했다.
물론 Pin 인터페이스의 가장 큰 장점은 하위 호환성을 지키며 추가할 수 있었다는 점이다. swap 같은, 참조된 데이터를 이동시킬 수 있는 모든 API는 가변 참조를 필요로 하므로, 일단 Pin으로 객체를 고정하면 더는 그런 API를 그 객체에 호출할 수 없다. 하지만 새로운 pinned 타입 상태가 특별한 pinned 참조에만 적용되기 때문에, Rust 언어의 나머지 부분에는 파괴적인 변화를 요구하지 않는다. 이것이 우리가 이 설계를 채택한 이유다. 기존 코드를 깨뜨리지 않고 Rust의 하위 호환성 보장을 위반하지 않으면서 추가할 수 있었기 때문이다.
Pin의 문제들요구사항을 하위 호환적으로 충족했음에도, Pin은 사용성 측면에서 여러 문제를 드러냈다. 사용자가 Pin을 다뤄야 할 때 정말로 “복잡성 스파이크”가 생긴다. 그런데 이 복잡성의 원인은 무엇일까?
한 가지 가설은, Move 트레이트가 컴파일러에 의해 강제되는 반면, Pin은 객체가 고정된 동안 변경하려면 unsafe 코드가 필요하다는 점에 문제가 있다는 것이다. Move 트레이트에서는, 객체를 이동시키지 않는 변경 API에 ?Move를 표시하는 것만으로 자동으로 활성화되었다. 어느 정도 사실이긴 하지만, 이 점을 과장해서는 안 된다. 예를 들어, Pin::set을 사용하면 고정된 객체에 대입하는 것이 이미 완전히 안전하다. 그리고 실제로 고정된 객체를 변경해야 하는 코드는 드물다. 일반적으로 그것은 컴파일러가 async 함수를 future로 내릴 때 생성하는 코드지, 여러분이 직접 작성하는 코드는 아니다.
또 다른 가설(Yosh Wuyts가 여기에서 제기했다)은, Pin이 어려운 이유가 그것이 “조건적”이기 때문이라는 것이다. 이것도 문제의 핵심은 아닌 듯하다. Rust와 프로그래밍에서 “조건적”인 것들은 많고, 오히려 프로그래머의 삶을 더 쉽게 만든다고 칭송받는다. 예컨대, 비-렉시컬 라이프타임(NLL)은 바로 조건문 의 서로 다른 분기에서 라이프타임이 다른 시점에 끝날 수 있게 하는 것이었고, 모두가 Rust를 더 이해하기 쉽게 만들었다고 본다. 아마 Pin(타입)과 Unpin(트레이트) 사이의 관계를 이해하기 어렵게 만든 네이밍 이슈가 있을 수는 있지만, 이것이 문제의 핵심이라고는 생각하지 않는다.
내가 보기에 Pin의 문제는, 그것이 순수한 라이브러리 타입으로 구현된 반면, 일반 참조 타입은 언어 내장 타입으로서 많은 문법적 설탕과 지원을 받기 때문이다. 참조가 가진 많은 좋은 기능들이 pinned 참조를 다룰 때는 사라진다. 이는 경험을 훨씬 더 나쁘게 만들고, 더 중요하게는 많은 사용자의 멘탈 모델을 깨뜨린다. 사용자는 컴파일러가 받아주는 코드에 기반해 참조가 어떻게 동작하는지를 이해해 왔는데, pinned 참조를 다루기 시작하면 비슷한 코드가 더는 받아들여지지 않기 때문이다.
매우 두드러진 예가, 일반 가변 참조에는 있지만 pinned 참조에는 없는 “재대여(reborrowing)” 개념이다. 다음을 보자: &mut T는 Copy를 구현하지 않지만, 그럼에도 불구하고 연속해서 여러 번 인자로 넘기는 것이 완전히 허용된다. 예를 들면:
fn incr(x: &mut i32) {
*x += 1;
}
fn incr_twice(x: &mut i32) {
incr(x);
incr(x);
}
대부분의 사용자들은 왜 이것이 허용되는지 질문조차 하지 않지만, 사실 이는 Rust의 기본 규칙을 위반한다: Copy를 구현하지 않는 타입은 한 번 이상 이동될 수 없다. 그 이유는, 컴파일러에 “재대여”라는 암묵적 강제가 있기 때문이다. 가변 참조가 사용될 때, 컴파일러는 기능적으로 재대여(여러분이 x 대신 &mut *x를 쓴 것과 같이)를 삽입하여, 참조 자체를 이동하는 대신 참조를 다시 빌리도록 한다.
Pin에는 이 편의가 없다. 그것은 Copy를 구현하지 않는 평범한 라이브러리 타입이기 때문이다. 따라서 Pin<&mut T>를 두 번 이상 사용하면, 이동 이후에 값을 사용했다는 오류나, 때로는 더 난해한 라이프타임 오류를 보게 된다. 대신, Pin::as_mut 함수를 사용해 명시적으로 Pin을 재대여해야 한다. 이 차이가 사용자가 Pin을 사용할 때 겪는 많은 혼란의 원인이다.
이야기를 더 할 수도 있다. 위에서 언급한 set의 예를 보라: Pin에 대입하는 것은 안전하지만, set 메서드를 써야 한다. 가변 참조에는 역참조와 대입 연산자를 사용해 그냥 대입할 수 있다. 그러나 Pin에는 그렇지 않고, 특별한 API를 배워야 한다. 이런 특수한 경우가 많이 존재하는데, 그 이유는 Pin이 언어 문법의 지원 없이 존재하는 라이브러리 타입이기 때문이다.
이 범주의 문제 중 단연 최악은 “pinned projection(고정된 프로젝션)” 문제다. “프로젝션”은 필드 접근에 대한 전문 용어다: 어떤 객체에서 그 객체의 필드로 “투영”하는 것(차양이 벽에서 바깥으로 돌출되듯이 투영된다는 의미에서인 듯하다). pinned 프로젝션의 문제는, 객체에 대한 pinned 참조를 가지고 있을 때 그 객체의 필드에 대한 pinned 참조를 얻기가 어렵다는 점이다. 이 문제를 해결하기 위해 pin-project-lite 같은 서드파티 크레이트가 있지만, 매크로를 포함한 복잡한 새 API를 배워야 하며, 이는 pinned 참조가 라이브러리 타입이기 때문에 일반 참조보다 훨씬 다루기 어렵다는 점을 강조한다.
이 영역에서 최악의 부분은 Drop 트레이트와 pinned 프로젝션 사이의 매우 불운한 상호작용이다. 문제는 Drop::drop이 일반 가변 참조를 받기 때문에 발생한다. 다음 가능성을 생각해보자: 자기참조 필드를 가진 타입이 있다. 그 필드로 pin-project 하여 그것을 poll한다. 그 다음 소멸자에서, 그 필드에서 값을 이동해오고(이제 비고정 가변 참조를 가지고 있으므로), 그 future를 스택에 pin한 다음 거기서 poll한다. 이렇게 하면 pinning 보장을 위반한 것이다.
pin-project-lite 같은 크레이트가 사용하는 해결책은 소멸자를 정의할 수 있는 능력을 제한하는 것이다. 실전에서는 그럭저럭 작동하지만, 바로 이 사실 자체가 pinning 보장이 정확히 무엇인지 설명할 때 추가적인 복잡성을 낳는다. 안타깝게도, Drop은 Pin보다 먼저 안정화되었기 때문에, 우리는 이를 우회해야 했다.
사용성의 이러한 문제에도 불구하고, Pin은 Rust에 기여한 작업 중 내가 가장 자랑스럽게 여기는 성취다. 임의의 참조를 포함하는 async 함수를 자기참조 객체로 안전하게 컴파일할 수 있게 했고; 만약 이것이 없었다면 async/await은 지금만큼 쓸만한 기능이 되지 못했을 것이다. 참조는 Rust 사용자가 코드를 작성하는 데 있어 근본적인 요소이기 때문이다. 그리고 우리는 이미 존재하던 언어와 완전히 하위 호환되는 방식으로 이것을 해냈다. Pin은 이제 고성능 네트워크 서비스와 그 밖의 비동기 프로그래밍 활용 사례를 위한 번성하는 생태계의 근본 구성요소다.
하지만 비판에 동의한다: Pin은 복잡성 절벽을 대표하며, 고정된 참조를 다루는 일은 일반 참조를 다루는 일보다 훨씬 어렵다. 그래서 며칠 안에 Pin을 어떻게 개선할 수 있을지라는 주제로 돌아올 것이다. 핵심 개념은 pinned places(고정된 장소) 이다.