Rust 비동기에서 웹 개발자를 위한 Pin 이해하기
Ethan Miller
Product Engineer · Leapcell

소개: Rust 비동기의 강력한 기능 활용
웹 개발은 고성능의 동시성 서비스를 점점 더 요구함에 따라, Rust는 강력한 백엔드 시스템을 구축하는 데 매력적인 선택지로 떠올랐습니다. Rust의 소유권 모델과 제로 비용 추상화는 개발자가 강력한 메모리 안전 보장과 함께 매우 효율적인 코드를 작성할 수 있도록 지원합니다. Rust에서 현대적인 동시성 프로그래밍의 초석은 async/await 구문으로, 동기적으로 보이는 비동기 코드를 작성할 수 있게 하여 복잡한 연산을 훨씬 쉽게 이해할 수 있도록 합니다.
그러나 async/await의 세련된 표면 아래에는 종종 오해받는 중요한 개념인 Pin이 있습니다. 더 높은 수준의 추상화에 익숙한 웹 개발자에게 Pin의 필요성은 불필요한 복잡성으로 보일 수 있습니다. 그러나 Pin을 이해하는 것은 단순한 학술적 연습이 아니라, 특히 웹 서버에서 흔히 발생하는 장기 실행 futures와 복잡한 상태 기계를 다룰 때 올바르고 효율적이며 안전한 비동기 Rust 코드를 작성하는 데 기본적인 것입니다. 이 글에서는 Pin을 이해하기 쉽게 설명하고, 그 목적과 왜 비동기 Rust 도구 모음의 필수적인 부분인지 설명합니다.
핵심 개념: 기초 다지기
Pin 자체를 자세히 살펴보기 전에, Pin이 존재하는 이유를 이해하는 데 필수적인 몇 가지 기초 개념을 간략하게 살펴보겠습니다.
Futures와 비동기 상태 기계
Rust에서 async fn 또는 async 블록은 Future로 컴파일됩니다. Future는 곧 사용 가능해질 수 있는 값을 나타내는 트레이트입니다. Future 트레이트의 핵심 메서드는 poll입니다.
pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; }
future를 await하면 런타임은 Poll::Ready(value)를 반환할 때까지 해당 future의 poll 메서드를 반복적으로 호출합니다. Poll::Pending을 반환하면 future가 아직 준비되지 않았음을 의미하며, 런타임은 나중에 다시 시도합니다.
중요하게도, async fn은 상태 기계로 컴파일됩니다. 각 await 지점은 이 기계의 상태에 해당합니다. future가 일시 중지되면 (Poll::Pending 반환), 범위 내에 있는 모든 지역 변수를 포함한 현재 상태를 저장합니다. 다시 polls되면 저장된 상태에서 실행을 재개합니다.
자체 참조 구조체
자신의 데이터에 대한 참조를 포함하는 구조체를 생각해 보세요. 예를 들어:
struct SelfReferential<'a> { data: String, pointer_to_data: &'a str, }
이러한 구조체를 생성하고 메모리에서 이동한 다음, pointer_to_data는 data가 이동했더라도 여전히 data의 이전 메모리 위치를 가리키기 때문에 유효하지 않게 됩니다. 이는 Rust가 일반적으로 특정 주의 없이 자체 참조 구조체를 방지하는 이유에 대한 고전적인 예입니다.
문제: Futures와 자체 참조
이제 이를 futures에 다시 연결해 보겠습니다. async fn이 상태 기계로 컴파일될 때 암시적으로 자체 참조를 생성할 수 있습니다. 예를 들어, await 지점 앞에 선언된 변수는 await 지점 후에 참조될 수 있습니다. 해당 변수가 이동하면 (예: 스택에 저장되어 있고 future의 전체 스택 프레임이 이동하는 경우) 참조가 유효하지 않게 됩니다.
이 예시를 고려해 보세요:
async fn my_async_function() { let mut my_string = String::from("Hello"); // 'my_string'은 여기에 있음 let my_pointer = &mut my_string; // 'my_pointer'는 'my_string'을 참조함 // 이 await 지점은 future를 일시 중지합니다. // 'my_string'과 'my_pointer'는 future의 상태의 일부입니다. some_other_async_operation().await; // future가 여기에 이동되었다면 'my_pointer'는 유효하지 않을 것입니다. println!("{}", my_pointer); }
my_async_function이 Pin이 없는 세계의 일반 Future이고 런타임이 메모리 위치를 이동할 수 있다면(poll 호출 사이), my_pointer는 댕글링 참조가 되어 정의되지 않은 동작으로 이어질 것입니다. 이것이 바로 Pin이 해결하는 문제입니다.
Future가 Pin을 필요로 하는 이유: 메모리 안정성 보장
Pin은 값의 메모리 안정성을 보장합니다. T 타입의 값이 Pin::new로 래핑되면, Pin이 래핑된 동안 T는 현재 메모리 위치에서 이동되지 않음을 의미합니다. 이 보장은 자체 참조를 포함하는 future에 매우 중요합니다.
Pin의 계약: Unpin 트레이트
Pin 구조체 자체는 매우 간단합니다.
pub struct Pin<P> { /* ... */ }
주로 포인터 P의 래퍼입니다. 실제 마법은 메서드, 특히 deref 및 deref_mut_unpin과 Unpin 트레이트와의 상호 작용에서 나옵니다.
Unpin 트레이트는 자동 트레이트입니다. T 타입은 Pin으로 래핑된 후 이동해도 안전하면 Unpin입니다. Rust의 대부분의 타입은 기본적으로 Unpin입니다 (예: i32, String, Vec<T>). Unpin이 아닌 타입은 자체 참조를 포함하기 때문에 메모리 안정성이 필요한 타입입니다.
async await에서 생성된 Future는 자체 참조를 포함하는 경우 기본적으로 Unpin이 아닙니다. 이것이 매우 중요합니다: 기본적으로 Rust 컴파일러는 메모리 안정성에 의존하는 future가 !Unpin으로 표시되도록 합니다.
Pin이 정의되지 않은 동작을 방지하는 방법
Future의 poll 메서드에서 self: Pin<&mut Self>를 보면, 런타임이 Future(또는 최소한 해당 mutable 참조)가 고정됨을 보장한다는 것을 의미합니다. 이 보장은 컴파일러가 유효하지 않은 포인터의 위험 없이 안전하게 자체 참조가 있는 상태 기계를 생성할 수 있도록 합니다.
예시를 통해 설명해 보겠습니다:
use std::pin::Pin; use std::future::Future; use std::task::{Context, Poll}; use std::cell::RefCell; use std::rc::Rc; // 자체 참조를 포함할 수 있는 간단한 Future라고 가정합니다. // (이것은 단순화된 예시이며, 실제 async fn 컴파일은 더 복잡합니다) struct MySelfReferentialFuture { data: String, // 실제 async fn에서는 이 'next_state'가 await 지점을 가로질러 // 'data'에 대한 참조를 암시적으로 포함할 수 있습니다. // 데모를 위해, 이것이 메모리 안정성이 필요한 생성된 상태 변수라고 가정해 봅시다. } impl Future for MySelfReferentialFuture { type Output = String; // Pin<&mut Self>에 주목하세요. fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> { // `self`가 Pin<&mut Self>이므로, `self.data`가 이동하지 않을 것임을 압니다. // 이 future가 일시 중지되고 다시 시작되더라도 이러한 참조는 유효하게 유지될 것입니다. // IF the future itself relies on this stability. println!("Polling future: {}", self.data); Poll::Ready(self.get_mut().data.clone() + " completed!") } } // 일반적으로 async future를 실행하는 방법 (단순화됨) fn run_future<F: Future>(f: F) -> F::Output { // 실제 런타임에서는 future가 힙에 할당되고 (Box::pin), // 그런 다음 반복적으로 polls됩니다. let mut pinned_future = Box::pin(f); // 이 Box::pin이 실제 '고정' 작업을 수행합니다. let waker_ref = &futures::task::noop_waker_ref(); let mut context = Context::from_waker(waker_ref); loop { match pinned_future.as_mut().poll(&mut context) { // as_mut()는 Box<Pin<F>>를 Pin<&mut F>로 변환합니다. Poll::Ready(val) => return val, Poll::Pending => { /* 실제 런타임에서는 이벤트를 기다릴 것입니다. */ } } } } fn main() { let my_future = MySelfReferentialFuture { data: String::from("Initial state"), }; let result = run_future(my_future); println!("Future result: {}", result); // 실제 async 블록을 사용하는 또 다른 예시 let my_async_block = async { let mut value = 10; let ptr = &mut value; // ptr는 'value'를 참조합니다. println!("Value before await: {}", ptr); tokio::time::sleep(std::time::Duration::from_millis(10)).await; // 실제 await라고 가정 *ptr += 5; // await 후 'ptr'를 통해 'value'에 접근 println!("Value after await: {}", ptr); *ptr }; // 이를 실행하려면 일반적으로 Tokio와 같은 런타임을 사용합니다: // tokio::runtime::Builder::new_current_thread() // .enable_time() // .build() // .unwrap() // .block_on(my_async_block); }
핵심은 async 블록 또는 async fn이 컴파일되고 실행될 때, 자체 참조를 포함하는 경우 컴파일러는 !Unpin인 Future 구현을 생성하고 런타임은 이 future를 (예: Box::pin을 사용하여) 할당하고 고정한다는 것입니다. 이 조합은 future의 메모리 위치가 실행 중 안정적으로 유지됨을 보장하여, 댕글링 포인터와 정의되지 않은 동작의 위험을 제거합니다.
Pin과 웹 개발
웹 개발 맥락에서, 특히 Axum 또는 Actix-web과 같은 프레임워크를 사용하면 futures를 광범위하게 다루게 될 것입니다. 때때로 직접 Pin::new 또는 Box::pin을 호출하지 않더라도, 이러한 작업은 백그라운드에서 발생하고 있습니다.
예를 들어, 핸들러 함수에서 Future를 반환할 때:
async fn my_handler() -> String { let user_name = fetch_user_from_db().await; // 이가 String을 반환한다고 가정 format!("Hello, {}", user_name) }
my_handler 함수 자체는 불투명한 impl Future<Output = String>를 반환합니다. 웹 서버 프레임워크(예: Axum의 기본인 Tokio)는 이 future를 가져와 힙에 할당하고, Pin으로 고정한 다음, 완료될 때까지 polls할 책임이 있습니다. 이 future의 내부 상태 기계는 자체 참조를 포함할 수 있지만, 런타임에 의해 고정되기 때문에 모든 것이 안전하게 유지됩니다.
웹 개발자로서 명시적인 Pin 사용을 접할 수 있는 경우:
- 사용자 정의 Future 또는 Stream 구현: 고도로 특수한 비동기 데이터 구조 또는 저수준 구성 요소를 구축하는 경우, 직접
Future또는Stream을 구현해야 할 수 있으며, 이는Pin을 직접 노출합니다. unsafe코드 작업: 성능 또는 FFI를 위해unsafeRust로 내려가는 경우,Pin의 보장은 원시 포인터 관리 및 UB 방지에 중요해집니다. 일반적인 웹 애플리케이션에서는 덜 일반적이지만, 고도로 최적화된 구성 요소의 경우 가능성이 있습니다.- 고급 비동기 패턴: 때로는 futures를 복잡한 데이터 구조에 저장하거나 재배열해야 합니다.
Pin을 이해하면 future가 언제 안전하게 이동할 수 있는지 (이동 가능하다면Unpin) 그리고 언제 이동할 수 없는지를 추론하는 데 도움이 됩니다.
결론: 메모리 안전을 위한 Pin 보장
Rust 비동기 생태계의 Pin은 async/await로 생성된 상태 기계, 특히 자체 참조 데이터 구조에 대한 메모리 안정성을 보장하는 정교한 메커니즘입니다.

