Published on

Rust self-referential struct를 Pin으로 안전하게

Authors

서로 다른 필드가 같은 struct 내부를 참조하는 self-referential struct는 Rust에서 대표적인 “만들기 어렵고, 억지로 만들면 위험한” 패턴입니다. 이유는 단순합니다. 값이 메모리에서 이동(move) 되면 내부 참조가 가리키는 주소가 바뀌어 댕글링 참조가 되기 때문입니다.

이 글에서는 다음을 목표로 합니다.

  • self-referential struct가 왜 안전하지 않은지
  • Pin이 무엇을 보장하는지 (그리고 무엇을 보장하지 않는지)
  • PhantomPinnedUnpin을 막고, 안전한 API로 감싸는 방법
  • 실전에서 자주 쓰는 “자기 자신을 참조하는” 형태를 대체하는 설계

참고로, Rust의 Pin은 비동기(async) 상태머신이 내부적으로 self-reference를 가질 수 있는 이유를 설명하는 핵심 도구이기도 합니다.

self-referential struct가 위험한 이유

아래 같은 구조를 상상해봅시다.

  • buf: String에 데이터를 담고
  • slice: &strbuf의 일부를 가리킨다

겉보기엔 합리적이지만, Rust에서 이런 struct를 “그대로” 만들기 어렵습니다. 설령 unsafe로 억지로 만들더라도, struct가 move 되면 buf의 주소가 바뀌고 slice는 옛 주소를 계속 가리킵니다.

move가 발생하는 대표 상황

  • let b = a;로 소유권 이동
  • Vec에 push 하면서 재할당이 일어나 요소가 이동
  • 함수 인자로 값 전달

즉, “내부 포인터/참조가 자기 자신을 가리키는 타입”은 주소가 고정되지 않으면 성립할 수 없습니다.

Pin의 핵심: “이 값은 더 이상 move 되지 않는다”

Pin은 값의 메모리 위치를 고정시키는 추상화입니다.

  • Pin<P>에서 P는 보통 Box<T>, &mut T 같은 포인터 타입
  • Pin은 “포인터가 가리키는 대상 T를 move 하지 않겠다”는 계약을 강제

중요한 포인트는 다음입니다.

  • Pin자동으로 고정(pinning)해주지 않습니다. 고정된 메모리에 올려두고, 그 이후 move를 막는 API를 제공할 뿐입니다.
  • T: Unpin이면 Pin은 사실상 의미가 약해집니다. Unpin 타입은 “move 되어도 괜찮다”고 컴파일러가 판단하는 타입입니다.
  • self-referential struct는 보통 Unpin이면 안 됩니다. 그래서 PhantomPinnedUnpin을 막습니다.

나쁜 예시: unsafe로 self-reference 만들기 (하지 말 것)

아래 코드는 개념 설명을 위한 예시이며, 그대로 따라 하면 안 됩니다.

use std::mem;

struct Bad<'a> {
    buf: String,
    slice: &'a str,
}

fn main() {
    let mut b = Bad {
        buf: "hello world".to_string(),
        slice: "", // 나중에 채우겠다는 발상 자체가 위험 신호
    };

    // 여기서 buf의 내부를 slice가 참조하도록 만들고 싶어도
    // 수명과 이동 문제 때문에 안전하게는 불가능합니다.

    // mem::swap 같은 동작으로 b가 이동하면 slice는 즉시 무효가 됩니다.
    let mut c = Bad { buf: "x".to_string(), slice: "" };
    mem::swap(&mut b, &mut c);
}

Rust가 이걸 안전하게 허용하지 않는 이유가 바로 “이동 시 내부 참조가 깨짐”입니다.

올바른 방향 1: Pin + PhantomPinned로 “이동 불가” 타입 만들기

여기서는 &str 같은 빌린 참조 대신, 원시 포인터를 내부에 저장하고(자기 참조), 외부에 안전한 메서드만 노출하는 패턴을 보여줍니다.

핵심은 다음입니다.

  • 힙에 Box로 할당해서 주소를 안정화
  • PhantomPinned를 넣어 Unpin 자동 구현을 막기
  • 생성 후에만 내부 포인터를 세팅하고, 이후에는 move를 금지

구현 예시

use std::marker::PhantomPinned;
use std::pin::Pin;
use std::ptr::NonNull;

struct SelfRef {
    buf: String,
    // buf 내부를 가리키는 포인터(참조 대신 포인터로 저장)
    slice_ptr: Option<NonNull<str>>,
    _pin: PhantomPinned,
}

impl SelfRef {
    fn new(txt: &str) -> Pin<Box<SelfRef>> {
        let mut boxed = Box::pin(SelfRef {
            buf: txt.to_string(),
            slice_ptr: None,
            _pin: PhantomPinned,
        });

        // Pin<Box<T>>에서 T의 주소는 고정되었다고 가정할 수 있음
        // 이제 buf의 내부를 가리키는 포인터를 세팅
        let ptr = {
            let s: &str = boxed.buf.as_str();
            // &str -> NonNull<str>
            NonNull::from(s)
        };

        // 안전: pinned 상태에서 구조체 자체를 move 하지 않고,
        // slice_ptr 필드만 갱신
        unsafe {
            let mut_ref: Pin<&mut SelfRef> = Pin::as_mut(&mut boxed);
            let this: &mut SelfRef = Pin::get_unchecked_mut(mut_ref);
            this.slice_ptr = Some(ptr);
        }

        boxed
    }

    fn buf(self: Pin<&SelfRef>) -> &str {
        &self.get_ref().buf
    }

    fn slice(self: Pin<&SelfRef>) -> &str {
        let this = self.get_ref();
        let ptr = this.slice_ptr.expect("initialized");
        // 안전: SelfRef는 pinning 이후 이동하지 않으므로 buf의 주소가 유지되고
        // ptr은 buf 내부를 계속 가리킨다.
        unsafe { ptr.as_ref() }
    }
}

fn main() {
    let s = SelfRef::new("hello pinned");

    let b = s.as_ref();
    assert_eq!(b.buf(), "hello pinned");
    assert_eq!(b.slice(), "hello pinned");

    // s는 Pin<Box<_>> 이므로, 일반적인 move로 내부가 이동하지 않습니다.
}

unsafe가 남아있나

self-referential struct는 “컴파일러가 lifetime으로 증명하기 어려운 관계”를 만들기 때문에, 내부 구현에는 보통 unsafe가 일부 필요합니다. 대신 다음을 지켜 “안전한 추상화”로 만들어야 합니다.

  • slice_ptr는 반드시 new에서 한 번만 초기화
  • pinning 이후 buf를 변경하거나 재할당(예: push_str)하지 않기
  • 외부에는 Pin<&Self> 같은 형태로만 접근을 허용

즉, unsafe는 내부에 격리하고, 외부 API는 안전하게 설계합니다.

UnpinPhantomPinned를 이해해야 하는 이유

Rust의 많은 타입은 기본적으로 Unpin입니다. 이는 “Pin으로 감싸도 move가 가능”하다는 뜻입니다.

  • self-referential struct는 move가 일어나면 깨지므로 Unpin이면 안 됨
  • PhantomPinned를 필드로 넣으면 자동으로 Unpin이 되지 않습니다

위 예시에서 _pin: PhantomPinned가 없으면, 타입이 Unpin이 되어 버릴 수 있고, 그러면 Pin<Box<SelfRef>>Box<SelfRef>처럼 취급하며 내부 이동이 가능한 경로가 생길 수 있습니다.

올바른 방향 2: self-reference를 “참조”가 아니라 “인덱스/오프셋”으로 표현

실무에서는 self-referential struct를 꼭 만들어야 하는 경우가 생각보다 적습니다. 대체 설계가 더 단순하고 유지보수에 유리합니다.

예를 들어 buf의 일부를 가리키려면, 포인터/참조 대신 범위를 저장합니다.

#[derive(Debug)]
struct SliceByRange {
    buf: String,
    range: std::ops::Range<usize>,
}

impl SliceByRange {
    fn new(txt: &str, range: std::ops::Range<usize>) -> Self {
        Self { buf: txt.to_string(), range }
    }

    fn slice(&self) -> &str {
        &self.buf[self.range.clone()]
    }
}

fn main() {
    let s = SliceByRange::new("hello world", 0..5);
    assert_eq!(s.slice(), "hello");
}

이 방식은 move가 일어나도 인덱스는 의미를 유지합니다(물론 문자열 UTF-8 경계는 주의해야 합니다). 가능하면 이런 구조가 훨씬 안전합니다.

Pin을 쓸 때 자주 하는 실수

1) Pin이면 내부 필드도 마음대로 바꿔도 된다고 착각

pinning은 “값의 위치”를 고정할 뿐, 내부 불변성을 자동 보장하지 않습니다. 특히 self-reference가 있다면 buf를 변경해 재할당이 발생하는 순간 내부 포인터가 무효가 될 수 있습니다.

대응:

  • self-reference를 만든 뒤에는 참조 대상이 되는 필드를 불변처럼 취급
  • 또는 내부 버퍼를 재할당하지 않는 컨테이너를 사용(예: 고정 용량 버퍼)

2) T: Unpin 여부를 확인하지 않음

Pin<Box<T>>를 만들었다고 끝이 아닙니다. TUnpin이면, 특정 API를 통해 결국 move가 가능해질 수 있습니다.

대응:

  • self-referential 타입에는 PhantomPinned를 넣어 Unpin을 막기
  • 외부에 Pin<&mut T>를 받는 메서드에서 get_unchecked_mut 사용을 최소화

3) “안전한 참조”를 반환하면서 내부에서 포인터를 조작

&str 같은 참조를 반환하려면, 그 참조가 유효한 동안 대상이 변하지 않음을 보장해야 합니다. self-reference 구조에서는 이 보장이 어렵습니다.

대응:

  • 반환 타입을 Pin<&Self> 기반의 메서드로 제한
  • 내부 변경을 제한하거나, 대체 설계(인덱스/오프셋)를 선택

언제 Pin이 진짜로 필요할까

  • Future/async 구현처럼, 상태머신이 자기 자신 내부를 참조할 수 있는 런타임 모델
  • intrusive 자료구조(노드가 자기 주소를 기반으로 연결되는 구조)
  • FFI에서 C 라이브러리가 “주소가 변하면 안 되는” 포인터를 요구하는 경우

그 외에는 대체 설계(오프셋, 인덱스, Arc 공유, 외부 저장소에 두고 핸들로 참조)가 더 단순합니다.

마무리: 안전한 추상화로 unsafe를 가둬라

self-referential struct는 Rust가 의도적으로 “쉽게 못 만들게” 설계한 영역입니다. 정말 필요하다면 Pin을 사용해 이동을 차단하고, PhantomPinnedUnpin을 막으며, unsafe는 생성/초기화 구간에만 국소화해야 합니다.

다만 실무에서는 많은 경우 self-reference 자체를 설계로 제거하는 편이 더 낫습니다. 복잡한 Pin 기반 API를 도입하기 전에, 인덱스/오프셋 기반 접근이나 소유 구조 재설계를 먼저 검토하세요.

추가로, “안전성은 결국 운영 안정성으로 돌아온다”는 관점에서 비슷한 맥락의 실전 트러블슈팅 글도 함께 참고할 만합니다.