AI 뉴스

Rust의 std::pin::Pin — 이동을 막는 도구가 아니라, 주소 안정성을 보장하는 도구

노동1호 2026. 7. 1. 19:01

async/await를 깊이 파고들면 한 번쯤 마주치는 키워드가 Pin<&mut Self>다. Future::poll의 시그니처가 &mut self가 아니라 Pin<&mut Self>인 이유, 자기 참조 구조체를 만들 때 PhantomPinned을 넣어야 하는 이유, 그리고 Box::pinstd::pin::pin!이 진짜로 하는 일 — 이 글에서는 std::pin::Pin이 무엇이고, 무엇이 아닌지 정리한다.

rust async pinning

1. `Pin`은 "이동을 막는 도구"가 아니다

Pin

포인터 래퍼다. 핵심은 한 문장으로 요약된다.

> "Pin은 값을 물리적으로 이동하지 못하게 막지 않는다. 대신 그 포인터를 통해 값이 이동되지 않는다는 타입 수준 보장이다."

즉, Pin 자체가 무언가를 못 움직이게 고정하는 게 아니라, "이 포인터가 가리키는 값은 이 포인터를 통해서는 이동되지 않는다" 라는 약속을 타입 시스템에 표현하는 것이다. 약속이 깨지면 정의되지 않은 동작(UB)으로 가는 영역이고, 그 때문에 unsafe가 등장한다.

이 약속이 필요한 이유는 크게 두 가지다.

  • 자기 참조(self-referential) 타입: 구조체 내부의 한 필드가 같은 구조체의 다른 필드를 가리키는 경우, 그 값이 이동하면 내부 포인터가 댕글링이 된다.
  • async/await가 생성하는 future: .await를 넘어 살아남는 지역 변수와 참조가 컴파일러 생성 상태 머신의 필드가 되면서, 결과 future가 사실상 자기 참조가 된다.

2. 왜 자기 참조가 문제인가

다음 구조체를 보자.


struct SelfRef {
    data: i32,
    ptr:  *const i32,
}

ptrself.data의 주소를 가리킨다고 가정한다. 이 인스턴스를 다른 변수로 대입하거나 함수에서 반환하면 Rust의 memcpy 기반 이동이 일어나면서 메모리 주소가 바뀔 수 있다. 원시 포인터 ptr은 여전히 옛 주소를 가리키므로 댕글링 포인터가 된다.

이 문제를 풀려면 두 가지 방법이 있다.

  1. 이동 자체를 금지한다.
  2. 이동을 허용하되 모든 참조를 새 주소로 갱신한다(추적 GC 같은 비용이 든다).

Rust는 두 번째를 거부하고 첫 번째를 채택했다. 그리고 그 "이동 금지" 표현이 Pin이다.

3. `async`/`await`에서의 진짜 동기


pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll;
}

.await를 한 번만 써도 컴파일러는 다음과 같은 상태 머신을 만든다.

  • 시작 상태: await 이전의 로컬 변수 보관
  • Pending 상태: await 이후 살아남은 로컬 변수 보관
  • 완료 상태: 반환값 보관

여기서 .await 이전의 변수와 이후의 변수가 같은 future 내부에서 서로를 참조할 수 있다. 실행기(executor)가 poll을 부른 뒤 그 future를 다른 태스크 큐로 옮기거나 슬롯에 재배치하면, 내부의 가리키는 주소가 무효화된다. 그래서 poll&mut self 대신 Pin<&mut Self>를 받는다. 실행기는 한 번 poll한 future를 다시 옮기지 않겠다고 약속해야 한다.

4. `Pin`이 막는 것과 허용하는 것

Pin

고정된 포인터를 통해 가리키는 값을 안전한 코드로 이동하지 못하게 막는다. 다만 값의 일반적인 변경은 허용한다. 즉 다음은 가능하다.

  • 고정된 타입의 메서드가 필드를 변경하는 것(Unpin인 경우)
  • 단, pinning에 의존하는 필드를 값 밖으로 이동시키는 것은 금지

Pin<&mut T>에서 &mut T를 다시 꺼내려면 T: Unpin이어야 한다.


impl<'a, T: ?Sized> Pin<&'a T> {
    pub const fn get_ref(self) -> &'a T { ... }
}

impl<'a, T: ?Sized> Pin<&'a mut T> {
    pub const fn get_mut(self) -> &'a mut T
    where T: Unpin
    { ... }
}
rust async pinning

T: !Unpin이면 안전한 경로로는 &mut T를 얻을 수 없다. Pin::get_unchecked_mut 같은 unsafe 메서드를 써야 하며, 그 메서드의 호출자가 "값이 이 참조 밖으로 이동되지 않는다"는 약속을 코드 차원에서 지켜야 한다.

5. `Unpin`과 `PhantomPinned`

Unpin자동 트레이트다.


// std::marker
pub auto trait Unpin {}

Rust의 거의 모든 타입은 이동되어도 안전하므로 자동으로 Unpin을 구현한다(i32, String, Vec 등). !Unpin이 되려면 명시적으로 그만둬야 한다. 표준 도구가 PhantomPinned다.


use std::marker::PhantomPinned;

struct SelfRef {
    data: i32,
    ptr: *const i32,
    _phantom: PhantomPinned, // makes the entire struct !Unpin
}

자기 참조 구조체는 보통 원시 포인터로 만들어지는데, 컴파일러가 그걸 자동 감지하지 못한다. 그래서 개발자가 "Unpin을 포기하겠다"는 의도를 PhantomPinned 필드 한 개로 표현한다. 이게 없으면 안전한 코드가 Pin에서 &mut T를 꺼내 값을 이동시킬 수 있고, 그렇게 되면 자기 참조를 만든 unsafe 코드의 가정이 깨진다.

6. `Pin`을 만드는 세 가지 길

`Pin::new` — `Unpin` 타입만


let mut value = 42;
let pinned = Pin::new(&mut value);

Unpin 타입은 pinning에 의존하지 않으므로 Pin으로 감싸는 것 자체는 항상 안전하다. 사실상 no-op 보장에 가깝다.

`std::pin::pin!` — 스택에 핀하기


use std::pin::pin;

let future = pin!(async {
    println!("Hello");
});

이 매크로는 지역 변수를 만들고 그 변수를 가리키는 Pin<&mut T>를 돌려준다. 컴파일러가 남은 수명 동안 변수가 이동되지 않게 보장하므로, 스택에서 !Unpin 값을 안전하게 핀할 수 있다. 이름 때문에 오해하기 쉬운데, pin!스택 메모리 자체를 핀하지 않는다. 스택 프레임이 살아 있는 동안 변수의 주소가 일정하게 유지된다는 점만 보장한다.

`Box::pin` — 힙에 핀하기


let pinned = Box::pin(SelfRef { ... });

!Unpin 타입에서 가장 흔한 선택이다. Box가 소유하는 Pin>를 돌려주고, 힙 할당 자체는 이동하지 않으므로 pointee는 Box의 수명 동안 안정적인 메모리 위치를 가진다. Box를 옮겨도 안의 데이터는 같은 힙 주소에 머문다.

`Pin::new_unchecked` — `unsafe` 경로


let pinned = unsafe { Pin::new_unchecked(ptr) };

안전한 생성자가 pointee가 제자리에 남는다는 사실을 증명할 수 없을 때만 쓴다. 호출자는 반환된 Pin의 수명 동안 pointee가 어떤 포인터를 통해서도 다시 이동되지 않는다고 약속해야 하고, 약속이 깨지면 UB다. 보통은 저수준 자료구조를 구현할 때만 등장한다.

7. 언제 직접 신경 써야 하는가

대부분의 Rust 개발자에게 PinUnpin은 배경에서 조용히 동작한다. 직접 부딪히는 경우는 두 가지다.

  • async 코드를 소비할 때 — future를 직접 poll해야 하거나, pinned future를 요구하는 API에 넘겨야 하면 Box::pin(future)로 힙에 핀하거나 std::pin::pin!(future)로 스택에 핀한다.
  • Future를 직접 구현할 때 — 사용자 정의 상태 머신이나 저수준 async 원시 타입을 작성한다면 Pin<&mut Self>를 다루고, PhantomPinnedunsafe를 함께 관리해야 한다.

Pin은 주소 민감 타입 문제를 다루는 Rust의 zero-cost 해법이다. 가비지 컬렉터 없이도 메모리 안전성 보장을 유지하면서 async/await와 자기 참조 추상화를 함께 쓸 수 있게 만든다. 다만 그 비용은 "개발자가 pinning 불변식을 명시적으로 표현해야 한다"는 형태로 컴파일 시점에 옮겨졌을 뿐이다.

8. 자주 오해하는 포인트 정리

  • Pin값의 이동 자체를 무력화하는 도구가 아니다. "이 포인터 경로로는 값이 이동되지 않는다"는 타입 수준 약속이다.
  • PhantomPinned!Unpin을 표현하는 가장 흔한 표준 방식이지만, !Unpin을 직접 쓰는 건 nightly Rust에서만 가능하다.
  • pin!스택 자체를 핀하지 않는다. 변수의 주소가 그 지역 변수의 수명 동안 유지된다는 점만 보장한다.
  • Future::poll을 일반 개발자가 직접 부르는 일은 거의 없다. Tokio, smol, pollster 같은 실행기가 poll을 호출한다.

요약

std::pin::Pin은 포인터 래퍼로, "이 포인터가 가리키는 값은 이 포인터를 통해서는 이동되지 않는다"는 타입 수준 보장을 표현한다. 이 보장이 필요한 곳은 자기 참조 구조체와 async/await가 생성하는 future 두 곳이고, 그 외 대부분의 코드에서는 Box::pin이나 std::pin::pin!만 쓰면 된다. 자기 참조를 만들 때는 PhantomPinned 필드를 넣어 타입을 !Unpin으로 표시하는 것이 표준 패턴이다.

참고 자료