Rust Pin과 Unpin 파헤치기: 비동기 연산의 기초
Olivia Novak
Dev Intern · Leapcell

소개
async/await
로 구동되는 Rust의 비동기 프로그래밍 모델은 개발자가 동시적이고 논블로킹 코드를 작성하는 방식을 혁신했습니다. 이는 Rust 언어 자체의 특징인 탁월한 성능과 메모리 안전성을 제공합니다. 그러나 우아한 await
구문 뒤에는, 특히 Future
내의 자체 참조 구조체를 다룰 때 데이터 무결성을 보장하도록 설계된 정교한 메커니즘이 있습니다. 이 메커니즘은 주로 Pin
과 Unpin
트레이트를 중심으로 구축됩니다. 이러한 개념을 제대로 이해하지 못하면 강력하고 안전한 비동기 Rust 코드를 작성하는 것이 상당한 어려움이 될 수 있습니다. 이 글은 Pin
과 Unpin
을 명확히 밝히고, 그 목적, 기본 원리, Rust의 Future
에 대한 실제적 의미를 탐구하여 궁극적으로 더 효과적이고 안전한 비동기 애플리케이션을 작성하도록 돕는 것을 목표로 합니다.
Pin과 Unpin 심층 분석
Pin
과 Unpin
의 복잡성을 자세히 살펴보기 전에, 그 역할을 이해하는 데 중요한 몇 가지 기본 개념을 먼저 명확히 하겠습니다.
필수 용어
- Future: Rust에서
Future
는 아직 값을 사용할 수 없을 수도 있음을 나타내는 트레이트입니다. 비동기 계산의 핵심 추상화입니다.Future
는 실행 프로그램에 의해 "폴링(polled)"되고, 준비되면 결과를 생성합니다. - 자체 참조 구조체 (Self-Referential Structs): 자신의 데이터에 대한 포인터나 참조를 포함하는 구조체입니다. 예를 들어, 구조체는 동일한 구조체 내의 다른 필드를 참조하는 필드를 가질 수 있습니다. 이러한 구조체는 메모리에서 이동될 수 있다면 본질적으로 문제가 됩니다. 왜냐하면 구조체를 이동하면 내부 포인터가 무효화되어 사용 후 무료화 오류(use-after-free error) 또는 메모리 손상으로 이어질 수 있기 때문입니다.
- 이동 의미론 (Move Semantics): Rust에서는 기본적으로 값이 이동됩니다. 값이 이동되면 해당 데이터는 새 메모리 위치로 복사되고 이전 위치는 무효한 것으로 간주됩니다. 이는 소유권 안전성을 보장합니다.
- Dropped: 값이 범위를 벗어나면 해당 소멸자 (
Drop
트레이트 구현)가 호출되어 리소스를 해제합니다. - 투영 (Projecting): 고정된(pinned) 구조체 내의 필드에 대한 참조를 얻는 것을 의미합니다. 이 작업은
Pin
이 적용하는 불변성을 유지하기 위해 신중하게 관리되어야 합니다.
문제점: 자체 참조 Future와 이동
Rust의 async fn
을 생각해 봅시다. 컴파일될 때 상태 머신으로 변환되어 Future
트레이트를 구현합니다. 이 상태 머신은 await
지점 간에 자신의 데이터에 대한 참조를 저장해야 할 수 있습니다.
예를 들어, async fn
은 개념적으로 다음과 같이 보일 수 있습니다.
async fn example_future() -> u32 { let mut data = 0; // ... 일부 계산 let ptr = &mut data; // 이것은 이 future의 상태 안에 있는 `data`를 가리킵니다 // ... 잠재적으로 `ptr` 사용 // 무언가를 await하여 future를 일시 중지할 수 있습니다 some_other_future().await; // ... 다시 시작, `ptr`은 여전히 유효하고 `data`를 가리켜야 합니다 *ptr += 1; data }
Future
의 상태 ( data
와 ptr
포함)가 await
호출 간에 메모리에서 자유롭게 이동될 수 있다면, ptr
은 끊어진(dangling) 참조가 될 것입니다. 이것은 Rust의 소유권 모델이 엄격하게 방지하는 치명적인 메모리 안전성 위반입니다.
해결책: Pin과 Unpin
여기서 Pin
이 등장합니다. Pin<P>
는 가리키는 대상(P가 가리키는 데이터)이 버려질 때까지 현재 메모리 위치에서 이동되지 않도록 보장하는 래퍼입니다. Pin
은 본질적으로 데이터를 제자리에 "고정"합니다.
Pin<P>
: 이 타입은P
가 가리키는 데이터가P
가 버려질 때까지 이동되지 않는다는 보증을 표현합니다.Pin
자체가 이동되는 것을 막지는 않는다는 것을 이해하는 것이 중요합니다. 그것은 가리키는 대상이 이동되는 것을 막습니다.Unpin
트레이트:Unpin
트레이트는 자동 트레이트(Send
및Sync
와 유사)입니다.T
타입은 내부적으로 "이동 불가능하게" 만드는 필드를 포함하거나 명시적으로 옵트 아웃하지 않는 한 자동으로Unpin
을 구현합니다. 대부분의 기본 타입,Vec
과 같은 컬렉션, 참조는Unpin
입니다.T
타입이Unpin
을 구현하면,Pin<&mut T>
와&mut T
는 메모리 의미론에 있어 거의 동일하게 작동합니다. 즉Unpin
T는Pin<&mut T>
뒤에 있더라도 이동할 수 있습니다. 이는Pin
이 이동이 필요한 데이터(즉,Unpin
을 구현하지 않는 데이터)에 대해서만 이동 금지 의미론을 강제하기 때문입니다.
핵심은 잠재적으로 자체 참조 포인터( async fn
에 의해 생성된 상태 머신과 같은)를 포함하는 모든 Future
는 Unpin
을 구현하지 않는다는 것입니다. 이는 그러한 Future
가 올바르게 실행되려면 메모리에서 Pin
ned 상태로 유지되어야 함을 의미합니다.
Pin
이 안전을 보장하는 방법
- 제한된 API:
Pin<P>
의 API는 실수로 인한 고정 해제 또는 이동을 방지하도록 설계되었습니다. 예를 들어T
가Unpin
이 아닌 경우Pin<&mut T>
에서 직접&mut T
를 얻을 수 없습니다.&T
또는Pin<&mut T::Field>
(투영)만 얻을 수 있습니다. Future
트레이트 요구 사항:Future
트레이트 자체는poll
메서드에서self
를Pin<&mut Self>
로 요구합니다:fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
. 이는 실행 프로그램이Future
를poll
할 때,Future
의 상태가 메모리에서 안정적으로 유지됨을 보장합니다.Box::pin
:Unpin
을 구현하지 않는 타입T
에 대한Pin<&mut T>
를 생성하는 일반적인 방법은Box::pin(value)
를 사용하는 것입니다. 이것은value
를 힙에 할당한 다음, 힙 할당이Pin
의 수명 동안 이동되지 않도록 보장합니다.
실제 예제: 자체 참조 Future
개념적으로 단순화된 자체 참조 구조체( async fn
이 내부적으로 생성하는)를 사용하여 설명해 보겠습니다.
use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use std::ptr; // 원시 포인터 조작용, 일반적으로 안전한 Rust에서는 직접 사용하지 않음 // 이 구조체는 async fn에 의해 생성되었다고 가정 // 이것은 데이터와 그 자체 내의 데이터에 대한 참조를 보유합니다. struct SelfReferentialFuture<'a> { data: u32, ptr_to_data: *const u32, // 시연을 위한 원시 포인터; `&'a u32`는 Pin 없이는 라이프타임 문제가 발생할 수 있음 _marker: std::marker::PhantomData<&'a ()>, // 라이프타임 'a에 대한 마커 } impl<'a> SelfReferentialFuture<'a> { // 이것은 본질적으로 async fn이 첫 번째 poll 중에 해야 할 일입니다. // 자체 참조를 초기화합니다. fn new(initial_data: u32) -> Pin<Box<SelfReferentialFuture<'a>>> { let mut s = SelfReferentialFuture { data: initial_data, ptr_to_data: ptr::null(), // null로 초기화, 나중에 설정됨 _marker: std::marker::PhantomData, }; // Box::pin은 할당된 이후 `s`가 힙에서 이동되지 않음을 보장하므로 안전합니다. let mut boxed = Box::pin(s); // 그런 다음 자체 참조를 초기화합니다. SelfReferentialFuture가 Unpin이라면 `Pin::get_mut` 등이 필요하지만, // 그렇지 않으므로 Pin을 안전하지 않은 `&mut`로 조심스럽게 캐스팅하여 포인터를 설정할 수 있습니다. // 실제 async fn 구현에서는 컴파일러가 내부 타입을 사용하여 안전하게 처리합니다. unsafe { let mutable_ref: Pin<&mut Self> = Pin::as_mut(&mut boxed); let raw_ptr: *const u32 = &mutable_ref.get_unchecked_mut().data as *const u32; mutable_ref.get_unchecked_mut().ptr_to_data = raw_ptr; } boxed } } // 올바른 고정이 필요한 모든 타입(예: 자체 참조)은 Unpin을 구현하면 안 됩니다. // 컴파일러는 자동으로 `async fn` future가 `Unpin`을 구현하지 않음을 보장합니다. // #[forbid(unstable_features)] // 이것이 컴파일러 마법의 효과입니다 // impl<'a> Unpin for SelfReferentialFuture<'a> {} // 이것은 WRONG하고 unsafe할 것입니다! impl<'a> Future for SelfReferentialFuture<'a> { type Output = u32; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { println!("Polling future..."); // 안전: `self`가 고정되었음을 보장하므로, `self.data`는 이동되지 않습니다. // `ptr_to_data`가 `self.data`를 가리키므로 안전하게 역참조할 수 있습니다. // `get_unchecked_mut`는 unsafe하지만, 고정된 값을 수정하는 데 필요합니다. // 안전한 코드에서는 일반적으로 `Pin<&mut T>`를 `Pin<&mut T::Field>`로 투영합니다. let current_data = unsafe { let self_mut = self.get_unchecked_mut(); // 우리의 가정을 검증합니다: 포인터가 여전히 우리 데이터의 데이터를 가리킵니다 assert_eq!(self_mut.ptr_to_data, &self_mut.data as *const u32); *self_mut.ptr_to_data }; if current_data < 5 { println!("Current data: {}, incrementing...", current_data); unsafe { let self_mut = self.get_unchecked_mut(); self_mut.data += 1; } cx.waker().wake_by_ref(); // 실행 프로그램을 폴링하도록 깨웁니다 Poll::Pending } else { println!("Data reached 5. Future complete."); Poll::Ready(current_data) } } } // 시연을 위한 간단한 실행 프로그램 fn block_on<F: Future>(f: F) -> F::Output { let mut f = Box::pin(f); let waker = futures::task::noop_waker(); // 간단한 "아무것도 안 하는" 웨이커 let mut cx = Context::from_waker(&waker); loop { match f.as_mut().poll(&mut cx) { Poll::Ready(val) => return val, Poll::Pending => { // 실제 실행 프로그램에서는 웨이크 신호를 기다립니다. // 이 예제에서는 준비될 때까지 루프를 돕니다. std::thread::yield_now(); // 다른 스레드에 양보 } } } } fn main() { let my_future = SelfReferentialFuture::new(0); let result = block_on(my_future); println!("Future finished with result: {}", result); // 이것은 개념적인 async fn을 시연합니다: async fn increment_to_five() -> u32 { let mut x = 0; loop { if x >= 5 { return x; } println!("Async fn: x = {}, waiting...", x); x += 1; // 여기에 실제 비동기 작업을 상상해 보세요 tokio::time::sleep(std::time::Duration::from_millis(10)).await; } } // `block_on`은 모든 `Future`를 받을 수 있습니다. `async fn`은 익명 future 타입을 반환합니다. let result_async_fn = block_on(increment_to_five()); println!("Async fn finished with result: {}", result_async_fn); }
SelfReferentialFuture
예제에서:
SelfReferentialFuture::new
는Box::pin
을 사용하여 힙에 구조체를 생성합니다. 이 첫 번째 단계는SelfReferentialFuture
를 위한 할당된 메모리가 이동되지 않도록 보장하므로 중요합니다.- 그런 다음
ptr_to_data
를 초기화하여 동일한 힙 할당 내의data
를 가리키도록 합니다. poll
메서드는self: Pin<&mut Self>
를 받습니다. 이Pin
보장은ptr_to_data
가 설정된 이후data
가 이동되지 않았음을 안전하게 가정할 수 있음을 의미하며,ptr_to_data
의 역참조를 안전하게 할 수 있습니다.
async fn increment_to_five()
는 내부적으로 x
변수를 관리하고 잠재적으로 자체 참조를 가지고 있다면 (예: 루프 내에서 x
에 대한 참조를 가져온다면) 매우 유사한 상태 머신으로 컴파일됩니다. 핵심은 컴파일러가 이 생성된 상태 머신 Future
타입이 Unpin
을 구현하지 않아 실행 프로그램(block_on
여기)에 의해 안전한 실행을 위해 Pin
ning 되어야 함을 보장한다는 것입니다.
Pin::project
및 #[pin_project]
get_unchecked_mut
를 사용하여 원시 포인터를 직접 조작하는 것은 일반적으로 안전하지 않지만, 고정된 구조체 내의 필드를 관리하는 일반적이고 더 안전한 방법은 "투영"을 통하는 것입니다. Pin<&mut Struct>
가 있고, Struct
에 field
라는 필드가 있다면, 일반적으로 Unpin
필드에 대한 Pin<&mut StructField>
또는 고정되지 않은 필드에 대한 Pin<&mut StructField>
를 얻을 수 있습니다.
복잡한 자체 참조 타입을 위해서는 이러한 투영을 수동으로 만드는 것이 지루하고 오류가 발생하기 쉽습니다. pin-project
크레이트의 #[pin_project]
속성은 이를 크게 단순화합니다. 수동 unsafe
코드를 요구하지 않고 필요한 Pin
투영 메서드를 자동으로 생성하여 정확성과 안전성을 보장합니다.
// pin_project를 사용한 예제 (크레이트 없이는 실행할 수 없는 개념적 예제) // #[pin_project::pin_project] struct MyFutureStruct { #[pin] // 이 필드도 고정되어야 합니다 inner_future: SomeOtherFuture, data: u32, // 잠재적으로 더 많은 필드 } // impl Future for MyFutureFuture { // type Output = (); // fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { // let mut this = self.project(); // `this`는 inner_future에 대해 `Pin<&mut SomeOtherFuture>`를 갖게 됩니다 // this.inner_future.poll(cx); // 고정된 내부 future를 폴링합니다 // // ... `this.data`에 접근합니다. 이것은 &mut u32입니다 // Poll::Pending // } // }
Unpin
은 언제 유용한가?
T
타입이 Unpin
인 경우, Pin<&mut T>
뒤에 있더라도 이동하는 것이 안전하다는 것을 의미합니다. 그러면 Pin<&mut T>
는 본질적으로 &mut T
처럼 작동합니다. 대부분의 타입은 Unpin
입니다. Unpin
이 아닌 타입은 이동으로 인해 무효화될 내부 포인터를 가지고 있거나 다른 내부 불변성을 가진 타입입니다.
Unpin
은 옵트아웃(opt-out) 트레이트입니다. 당신의 타입이 이동으로 인해 무효화될 내부 포인터를 가지고 있지 않다면, 일반적으로 Unpin
이어야 합니다. async fn
생성 상태 머신은 Unpin
이 아닌 타입의 주요 예입니다.
결론
Pin
과 Unpin
은 Rust의 비동기 프로그래밍 모델에서 메모리 안전성을 이해하기 위한 기본적인 개념입니다. Pin
은 데이터가 고정된 메모리 위치에 유지되도록 보장하여, async/await
상태 머신의 내부 작동에 필수적인 자체 참조 구조체의 안전한 생성 및 조작을 가능하게 합니다. Pin
은 그러한 데이터의 실수로 인한 이동을 방지함으로써 내부 포인터가 유효하게 유지되도록 보장하여 일반적인 메모리 오류 클래스를 방지합니다. 이러한 트레이트를 이해하는 것은 단순히 async/await
를 사용하는 것을 넘어 Rust의 동시적인 Future의 강력하고 안전한 기반을 진정으로 이해하는 단계로 나아가는 것입니다. Pin
과 Unpin
을 마스터하는 것은 Rust의 비동기 환경을 자신 있게 탐색하고 고성능의 내결함성 애플리케이션을 구축하는 데 핵심입니다.