- Published on
Rust self-referential struct를 Pin으로 안전하게
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 필드가 같은 struct 내부를 참조하는 self-referential struct는 Rust에서 대표적인 “만들기 어렵고, 억지로 만들면 위험한” 패턴입니다. 이유는 단순합니다. 값이 메모리에서 이동(move) 되면 내부 참조가 가리키는 주소가 바뀌어 댕글링 참조가 되기 때문입니다.
이 글에서는 다음을 목표로 합니다.
- self-referential struct가 왜 안전하지 않은지
Pin이 무엇을 보장하는지 (그리고 무엇을 보장하지 않는지)PhantomPinned로Unpin을 막고, 안전한 API로 감싸는 방법- 실전에서 자주 쓰는 “자기 자신을 참조하는” 형태를 대체하는 설계
참고로, Rust의
Pin은 비동기(async) 상태머신이 내부적으로 self-reference를 가질 수 있는 이유를 설명하는 핵심 도구이기도 합니다.
self-referential struct가 위험한 이유
아래 같은 구조를 상상해봅시다.
buf: String에 데이터를 담고slice: &str가buf의 일부를 가리킨다
겉보기엔 합리적이지만, 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이면 안 됩니다. 그래서PhantomPinned로Unpin을 막습니다.
나쁜 예시: 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는 안전하게 설계합니다.
Unpin과 PhantomPinned를 이해해야 하는 이유
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>>를 만들었다고 끝이 아닙니다. T가 Unpin이면, 특정 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을 사용해 이동을 차단하고, PhantomPinned로 Unpin을 막으며, unsafe는 생성/초기화 구간에만 국소화해야 합니다.
다만 실무에서는 많은 경우 self-reference 자체를 설계로 제거하는 편이 더 낫습니다. 복잡한 Pin 기반 API를 도입하기 전에, 인덱스/오프셋 기반 접근이나 소유 구조 재설계를 먼저 검토하세요.
추가로, “안전성은 결국 운영 안정성으로 돌아온다”는 관점에서 비슷한 맥락의 실전 트러블슈팅 글도 함께 참고할 만합니다.