Published on

Rust self-referential 구조체가 불가능한 이유와 Pin

Authors

Rust를 쓰다 보면 “필드를 하나 소유하고, 다른 필드는 그 필드를 가리키는 참조를 들고 싶은” 순간이 옵니다. 예를 들어 String을 소유하면서 동시에 그 String의 슬라이스 &str를 구조체 안에 캐시하고 싶을 수 있습니다. 이게 바로 self-referential(자기 참조) 구조체입니다.

하지만 Rust에서는 이런 형태가 안전하게 일반 구조체로는 표현 불가능합니다. 이유는 단순히 “컴파일러가 못하게 막는다”가 아니라, Rust의 메모리 모델에서 값의 이동(move)참조의 유효성이 충돌하기 때문입니다.

이 글에서는 왜 불가능한지부터, PinPhantomPinned로 어떤 식으로 해결하는지, 그리고 실제로 쓰기 좋은 패턴까지 정리합니다. 빌림/가변 빌림 충돌이 함께 나타나는 경우는 별도 글인 Rust E0502·E0499 빌림 충돌 6패턴 해결도 같이 보면 좋습니다.

self-referential 구조체가 왜 위험한가

핵심은 이 한 문장입니다.

  • Rust에서 대부분의 값은 언제든 이동될 수 있고
  • 참조 &T대상이 이동되지 않는다는 전제 위에서만 안전합니다

구조체 내부에 “자기 자신의 필드를 가리키는 참조”가 있으면, 그 구조체가 이동되는 순간 내부 참조는 옛 주소를 가리키는 댕글링 포인터가 됩니다.

실패 예제: 소유 필드와 그에 대한 참조를 함께 저장

아래 코드는 의도는 명확하지만, Rust는 이를 허용하지 않습니다.

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

impl<'a> Bad<'a> {
    fn new(s: String) -> Bad<'a> {
        let view = &s[..];
        Bad { buf: s, view }
    }
}

이게 허용되면 어떤 일이 생길까요.

  1. views의 내부 버퍼를 가리킵니다
  2. Bad { buf: s, view }를 만드는 순간 sbuf로 이동합니다
  3. 이동이 일어나면 String 값 자체의 위치도 바뀔 수 있고, 더 중요한 건 “참조가 가리키는 대상이 구조체 내부로 들어갔다”는 사실을 컴파일러가 일반적으로 안전하게 추론하기 어렵습니다

게다가 구조체가 이후에 또 이동될 수 있습니다.

let a = Bad::new("hello".to_string());
let b = a; // move

b로 이동하는 순간, a 내부의 view는 여전히 “이전 위치의 buf”를 가리키는 형태가 되어 버립니다. Rust는 이런 형태를 원천적으로 막습니다.

왜 컴파일러가 “알아서 고정”해주지 않나

가능한 언어도 있습니다. 하지만 Rust는 다음을 지키려 합니다.

  • 이동이 기본이며(값语 semantics)
  • GC 없이도 안전해야 하고
  • 참조의 유효성은 컴파일 타임에 증명되어야 합니다

즉 “이 구조체는 절대 이동하지 않는다” 같은 조건을 명시적으로 모델링해야 합니다. 그 역할을 하는 것이 Pin입니다.

해결 키워드: Pin과 Unpin

Pin은 “이 값은 이제부터 메모리 상 위치가 고정(pinned) 되었으니, 안전하지 않은 방식으로는 이동시키지 마라”라는 계약을 제공합니다.

  • Pin<P>는 포인터 P가 가리키는 값을 핀합니다
  • 대부분의 타입은 기본적으로 Unpin이라서, 핀해도 사실상 이동 가능(제약이 약함)
  • self-referential 같은 타입은 “이동하면 깨진다”를 표현하기 위해 Unpin이 아니어야 함

여기서 PhantomPinned가 등장합니다.

PhantomPinned로 “이 타입은 Unpin이 아니다”를 선언

PhantomPinned 필드를 넣으면 해당 타입은 자동으로 Unpin이 구현되지 않습니다. 즉, 핀된 뒤에는 안전한 API로는 이동할 수 없게 됩니다.

아래는 전형적인 패턴입니다.

  • buf를 소유
  • ptrbuf 내부를 가리키는 포인터를 저장
  • 구조체는 PhantomPinned!Unpin을 선언
  • 생성은 Pin<Box<T>>로 수행하며, 핀된 뒤에 포인터를 초기화

Pin과 PhantomPinned로 self-referential 구조체 만들기

중요 포인트는 “참조 &str를 직접 들고 있기”보다 “raw pointer를 들고, 핀된 상태에서만 안전하게 참조로 변환”하는 방식이 현실적이라는 점입니다.

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

struct SelfRef {
    buf: String,
    // buf 내부를 가리키는 포인터(초기에는 비어 있음)
    view: Option<NonNull<str>>,
    _pin: PhantomPinned,
}

impl SelfRef {
    fn new(s: String) -> Pin<Box<SelfRef>> {
        // 1) 먼저 힙에 할당하고 핀한다
        let mut boxed = Box::pin(SelfRef {
            buf: s,
            view: None,
            _pin: PhantomPinned,
        });

        // 2) 핀된 상태에서 buf의 슬라이스를 만들고, 포인터를 저장
        // Pin<Box<T>>에서 T에 대한 가변 접근은 매우 제한적이므로,
        // 안전한 범위에서만 내부 필드를 초기화한다.
        let slice: &str = &boxed.buf[..];
        let ptr = NonNull::from(slice);

        // 3) view 필드 설정
        // 안전: boxed는 이제 핀되어 이동되지 않으며, ptr은 boxed.buf를 가리킨다.
        unsafe {
            let mut_ref: Pin<&mut SelfRef> = Pin::as_mut(&mut boxed);
            let this: &mut SelfRef = Pin::get_unchecked_mut(mut_ref);
            this.view = Some(ptr);
        }

        boxed
    }

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

    fn view(self: Pin<&SelfRef>) -> &str {
        let this = self.get_ref();
        let ptr = this.view.expect("view must be initialized");
        // 안전: SelfRef는 핀된 뒤 이동되지 않으며, ptr은 buf를 가리킨다.
        unsafe { ptr.as_ref() }
    }
}

이 코드에서 안전성이 성립하는 조건

  1. SelfRefPhantomPinned 때문에 Unpin이 아니며
  2. Pin<Box<SelfRef>>로 생성된 뒤 안전한 API로는 이동할 수 없고
  3. view는 핀된 이후에만 초기화되며
  4. view()Pin<&SelfRef>를 요구하므로 “핀된 상태에서만” 내부 포인터를 역참조합니다

즉 “이 타입은 핀된 뒤에는 절대 이동하지 않는다”를 타입 시스템으로 강제합니다.

흔한 함정: Pin을 썼는데도 이동이 되는 경우

Pin은 만능이 아닙니다. 다음 실수를 자주 합니다.

1) 타입이 여전히 Unpin이면, Pin은 큰 의미가 없다

대부분 타입은 자동으로 Unpin입니다. Pin<Box<T>>를 들고 있어도, T: Unpin이면 Pin은 사실상 제약이 약해집니다.

self-referential을 표현하려면 PhantomPinned 같은 방식으로 !Unpin을 만들어야 합니다.

2) 스택에 만들고 나중에 Pin을 씌우려는 시도

Pin<&mut T>를 얻었다고 해서 그 값이 “처음부터 이동 불가였던 것”이 되지는 않습니다. 이미 이동 가능한 값이었다면, 그 사이에 이동이 발생할 여지가 있습니다.

그래서 보통은 Box::pin 같은 API로 “할당과 동시에 핀”을 합니다.

3) 내부 포인터가 가리키는 대상이 변하는 경우

위 예제는 bufString이고 view&buf[..]를 가리킵니다. 그런데 buf를 수정(특히 push_str 등으로 재할당)하면 String의 내부 버퍼가 바뀔 수 있습니다. 그러면 view 포인터는 무효가 됩니다.

따라서 self-referential 구조체에서는 보통 다음 중 하나를 선택합니다.

  • buf를 불변으로 유지(초기화 후 수정 금지)
  • 내부 참조가 의존하는 데이터 구조를 재할당하지 않도록 설계
  • 참조 대신 인덱스 범위(예: start..end)를 저장하고 접근 시 슬라이스를 계산

더 안전한 대안: 포인터 대신 “인덱스 캐시”로 설계

가능하다면 self-referential 자체를 피하는 편이 유지보수에 좋습니다. 대표적인 우회는 “참조를 저장하지 말고 위치 정보만 저장”하는 것입니다.

struct SliceByIndex {
    buf: String,
    start: usize,
    end: usize,
}

impl SliceByIndex {
    fn new(buf: String, start: usize, end: usize) -> Self {
        Self { buf, start, end }
    }

    fn view(&self) -> &str {
        &self.buf[self.start..self.end]
    }
}

이 방식은 구조체가 이동해도 문제가 없습니다. self-referential이 필요한 이유가 “캐시”라면, 인덱스 캐시가 충분한 경우가 많습니다.

실전 팁: 어떤 경우에 Pin 기반 설계를 선택할까

다음 조건이면 Pin 기반 self-referential이 실용적입니다.

  • 내부 포인터가 반드시 필요하다(FFI, intrusive 자료구조, async state machine 등)
  • 초기화 이후 메모리 위치 고정이 설계의 핵심이다
  • 불변식(invariant)을 문서화하고, 핀된 상태에서만 접근하도록 API를 설계할 수 있다

반대로 단순히 “슬라이스를 저장해두고 싶다” 정도면 인덱스 방식이나 소유 구조(예: Arc로 공유)로 바꾸는 편이 낫습니다.

마무리

Rust에서 self-referential 구조체가 기본적으로 금지되는 이유는 “이동이 기본인 값 모델”과 “참조의 주소 안정성”이 정면 충돌하기 때문입니다. 이를 해결하려면 이동 불가를 타입 시스템에 명시해야 하고, 그 도구가 Pin이며 PhantomPinned는 “이 타입은 Unpin이 아니다”를 선언하는 핵심 장치입니다.

다만 Pin 기반 설계는 unsafe를 동반하는 경우가 많고, 불변식을 깨면 즉시 UB로 이어질 수 있습니다. 가능하면 인덱스 캐시 등으로 문제를 재정의하는 것도 적극적으로 고려하세요.

추가로 빌림 규칙과 충돌하는 실제 컴파일 에러 패턴까지 함께 정리하고 싶다면 Rust E0502·E0499 빌림 충돌 6패턴 해결도 같이 읽으면 self-referential을 둘러싼 에러 메시지 해석이 훨씬 쉬워집니다.