- Published on
Rust self-referential 구조체가 불가능한 이유와 Pin
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 “필드를 하나 소유하고, 다른 필드는 그 필드를 가리키는 참조를 들고 싶은” 순간이 옵니다. 예를 들어 String을 소유하면서 동시에 그 String의 슬라이스 &str를 구조체 안에 캐시하고 싶을 수 있습니다. 이게 바로 self-referential(자기 참조) 구조체입니다.
하지만 Rust에서는 이런 형태가 안전하게 일반 구조체로는 표현 불가능합니다. 이유는 단순히 “컴파일러가 못하게 막는다”가 아니라, Rust의 메모리 모델에서 값의 이동(move) 과 참조의 유효성이 충돌하기 때문입니다.
이 글에서는 왜 불가능한지부터, Pin과 PhantomPinned로 어떤 식으로 해결하는지, 그리고 실제로 쓰기 좋은 패턴까지 정리합니다. 빌림/가변 빌림 충돌이 함께 나타나는 경우는 별도 글인 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 }
}
}
이게 허용되면 어떤 일이 생길까요.
view는s의 내부 버퍼를 가리킵니다Bad { buf: s, view }를 만드는 순간s는buf로 이동합니다- 이동이 일어나면
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를 소유ptr에buf내부를 가리키는 포인터를 저장- 구조체는
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() }
}
}
이 코드에서 안전성이 성립하는 조건
SelfRef는PhantomPinned때문에Unpin이 아니며Pin<Box<SelfRef>>로 생성된 뒤 안전한 API로는 이동할 수 없고view는 핀된 이후에만 초기화되며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) 내부 포인터가 가리키는 대상이 변하는 경우
위 예제는 buf가 String이고 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을 둘러싼 에러 메시지 해석이 훨씬 쉬워집니다.