- Published on
Rust self-referential struct, Pin으로 안전하게
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로를 참조하는 구조체(self-referential struct)는 Rust에서 자주 “하고 싶은데 하면 안 되는” 대표 사례입니다. 예를 들어 버퍼를 소유하면서, 그 버퍼 내부를 가리키는 슬라이스나 포인터를 필드로 함께 들고 있으면 편할 것 같습니다. 하지만 Rust의 기본 이동(move) 모델에서는 값이 메모리에서 다른 위치로 옮겨질 수 있고, 그 순간 내부 포인터가 가리키는 주소가 깨지면서 미정의 동작(UB)로 이어질 수 있습니다.
이 글에서는
- self-referential struct가 왜 위험한지
Pin이 무엇을 “보장”하고 무엇을 “보장하지 않는지”PhantomPinned로Unpin을 막아 이동을 금지하는 패턴- 안전한 생성자/메서드 API를 어떻게 설계하는지
를 Rust 코드 예제로 정리합니다.
중간중간, “컴파일은 되는데 런타임에서 폭발할 수 있는” 종류의 문제를 어떻게 사전에 차단하는지가 핵심입니다. 배포 스크립트에서 실패를 조기에 잡는 방식이랑 비슷한 결입니다. 예를 들어 셸 스크립트에서도 set -euo pipefail로 실패를 조기에 표면화하듯, Rust에서는 타입/트레이트/라이프타임과 Pin으로 “나쁜 상태”를 표현 불가능하게 만드는 쪽으로 설계합니다. 관련해서는 bash set -euo pipefail로 배포 스크립트 실패 잡기도 함께 읽어볼 만합니다.
self-referential struct가 위험한 이유
가장 흔한 욕심은 이런 형태입니다.
String또는Vec같은 소유 버퍼를 필드로 가진다.- 동시에 그 버퍼 내부를 가리키는
&str또는 포인터를 또 다른 필드로 가진다.
문제는 Rust에서 값은 기본적으로 이동 가능합니다. 이동은 “복사”가 아니라 “메모리 위치가 바뀔 수 있음”을 의미합니다. 스택에 있던 구조체가 다른 스택 프레임으로 이동하거나, Vec에 push되면서 재배치되거나, mem::swap 같은 연산이 일어나면 주소가 달라질 수 있습니다.
내부 포인터는 예전 주소를 계속 가리키므로 곧바로 댕글링 포인터가 됩니다.
나쁜 예시: 내부 슬라이스를 들고 있기
아래 코드는 개념적으로 “이렇게 하고 싶다”에 가깝고, 실제로는 라이프타임 때문에 대부분 컴파일이 막힙니다. 하지만 핵심 위험을 보여주기 위해 일부는 포인터로 우회합니다.
use std::ptr::NonNull;
struct Bad {
buf: String,
// buf 내부를 가리키는 포인터(위험)
ptr: NonNull<u8>,
len: usize,
}
impl Bad {
fn new(s: &str) -> Self {
let mut buf = String::from(s);
let ptr = NonNull::new(buf.as_mut_ptr()).unwrap();
let len = buf.len();
Self { buf, ptr, len }
}
unsafe fn as_str(&self) -> &str {
let slice = std::slice::from_raw_parts(self.ptr.as_ptr(), self.len);
std::str::from_utf8_unchecked(slice)
}
}
fn main() {
let a = Bad::new("hello");
// 여기서 a가 이동하면 ptr은 더 이상 유효하지 않을 수 있음
let b = a; // move
unsafe {
println!("{}", b.as_str());
}
}
이 코드는 특정 상황에서는 “운 좋게” 동작할 수 있지만, 이동이 일어나는 순간 내부 포인터는 깨질 수 있습니다. Rust가 막고자 하는 UB의 전형입니다.
Pin이 해결하는 문제의 범위
Pin은 한마디로 “이 값은 더 이상 메모리 상에서 이동하지 않는다”고 취급하게 만드는 래퍼입니다. 여기서 중요한 포인트는 두 가지입니다.
Pin은 보통 힙에 할당된 값(Box)과 결합해 사용합니다. 예:Pin<Box<T>>Pin이 자동으로 모든 것을 안전하게 만들어주지는 않습니다. “이동 금지”라는 전제 하에서만 self-reference가 성립합니다.
즉, self-referential struct를 만들려면
- 구조체가 이동 불가능해야 하고
- 그 이동 불가능성이 타입 시스템에 의해 강제되어야 합니다.
여기서 등장하는 것이 Unpin과 PhantomPinned입니다.
Unpin과 PhantomPinned: 이동 불가능한 타입 만들기
Rust에서 대부분의 타입은 Unpin입니다. Unpin이면 Pin으로 감싸도 “사실상 이동 가능”합니다. 반대로 Unpin이 아니면, Pin은 강하게 “이 값을 움직이지 말라”고 요구합니다.
PhantomPinned는 타입이 자동으로 Unpin이 되는 것을 막는 표준 라이브러리 마커입니다.
PhantomPinned를 필드로 포함하면- 해당 타입은 기본적으로
Unpin이 아니게 됩니다
즉, Pin과 결합될 때 진짜로 이동이 금지됩니다.
패턴: 2단계 초기화로 self-reference 만들기
self-reference는 생성 시점에 “자기 자신의 주소”가 필요합니다. 하지만 일반적인 new에서는 아직 고정된 주소가 없기 때문에 곤란합니다.
그래서 흔히 쓰는 패턴이
- 먼저 힙에 할당해서 주소를 안정화한다 (
Box) - 그 다음
Pin으로 고정한 뒤 내부 포인터를 세팅한다
입니다.
아래 예시는 String을 소유하면서 그 내부를 가리키는 포인터를 저장하고, Pin<Box<Self>>로만 접근하도록 API를 제한합니다.
안전한(에 가깝게 설계된) 예시
use std::marker::PhantomPinned;
use std::pin::Pin;
use std::ptr::NonNull;
struct SelfRef {
buf: String,
ptr: NonNull<u8>,
len: usize,
// 이 필드가 Unpin 자동 구현을 막음
_pin: PhantomPinned,
}
impl SelfRef {
fn new(s: &str) -> Pin<Box<SelfRef>> {
// 1) 일단 ptr은 더미로 두고 박스에 넣어 주소를 안정화
let this = SelfRef {
buf: String::from(s),
ptr: NonNull::dangling(),
len: 0,
_pin: PhantomPinned,
};
let mut boxed = Box::pin(this);
// 2) 이제 pinned 상태에서 내부 포인터를 설정
// 안전성: 이후 이 값은 이동하지 않는다는 전제(Pin + !Unpin)
let buf_ptr = NonNull::new(boxed.buf.as_ptr() as *mut u8).unwrap();
let buf_len = boxed.buf.len();
unsafe {
let mut_ref: Pin<&mut SelfRef> = Pin::as_mut(&mut boxed);
let inner: &mut SelfRef = Pin::get_unchecked_mut(mut_ref);
inner.ptr = buf_ptr;
inner.len = buf_len;
}
boxed
}
fn as_str(self: Pin<&SelfRef>) -> &str {
// 안전성: self가 Pin으로 고정되어 이동하지 않는다는 계약
unsafe {
let slice = std::slice::from_raw_parts(self.ptr.as_ptr(), self.len);
std::str::from_utf8_unchecked(slice)
}
}
fn buf(self: Pin<&SelfRef>) -> &str {
&self.get_ref().buf
}
}
fn main() {
let s = SelfRef::new("hello pin");
let p = s.as_ref();
println!("buf = {}", p.buf());
println!("slice = {}", p.as_str());
}
여기서 안전이 성립하는 조건
위 설계가 의미를 가지려면 다음 조건이 유지되어야 합니다.
SelfRef는PhantomPinned때문에Unpin이 아니어야 합니다.- 외부에서
SelfRef를Pin없이&mut SelfRef로 잡고 이동시키거나,mem::swap등을 할 수 없어야 합니다. ptr이 가리키는 대상(buf)이 재할당되지 않아야 합니다.
마지막 조건이 특히 중요합니다. String은 내용이 변경되면서 재할당이 일어날 수 있습니다. 즉, buf.push_str(...) 같은 연산을 허용하면 buf 내부 버퍼 주소가 바뀌고 ptr이 깨집니다.
그래서 self-referential struct를 설계할 때는 보통
- 내부 버퍼를 불변으로 유지하거나
- 포인터가 가리키는 영역이 절대 재할당되지 않도록 더 강한 제약을 걸거나
- 아예
String대신 고정 버퍼를 쓰거나
같은 식으로 API를 제한합니다.
API 설계 팁: “핀된 상태에서만” 위험한 메서드 노출
핵심은 메서드 시그니처입니다.
fn as_str(&self) -> &str처럼 일반 참조로 열어두면, 호출자는 이 값을 이동시킬 수 있는 컨텍스트에서&self를 얻을 수 있습니다.- 반면
fn as_str(self: Pin<&Self>) -> &str로 만들면 “호출자는 핀된 참조를 가지고 있어야만” 이 메서드를 호출할 수 있습니다.
즉, 위험한 불변식(invariant)을 요구하는 메서드는 Pin<&Self> 또는 Pin<&mut Self>를 요구하도록 설계하는 것이 좋습니다.
추가로, 내부 버퍼를 수정하는 메서드를 제공해야 한다면 다음 중 하나를 선택해야 합니다.
- 수정 메서드를 아예 제공하지 않는다
- 수정 시
ptr을 다시 계산해 갱신한다(여전히 위험하며, 동시 참조와의 관계를 엄격히 통제해야 함) - 내부 버퍼가 재할당되지 않는 컨테이너를 사용한다
Pin이 만능이 아닌 이유: “이동 금지”만 보장한다
Pin이 보장하는 것은 “pinned value 자체의 메모리 위치가 안정적”이라는 점입니다. 하지만 아래는 별개입니다.
- 내부 필드가 가리키는 힙 버퍼가 재할당되지 않는지
- 멀티스레드에서 동시에 접근할 때 데이터 레이스가 없는지
- 포인터가 가리키는 범위가 유효한지(길이, 널 여부 등)
그래서 self-referential struct는 가능하면
- 표준 라이브러리/검증된 크레이트가 제공하는 안전한 추상화로 대체하거나
- 정말 필요한 경우에만 최소 범위로 unsafe를 감싸고
- 불변식을 문서화하고 테스트로 고정
하는 쪽이 좋습니다.
이 관점은 타입 폭발을 줄이기 위해 추론 경계를 세우는 TypeScript의 접근과도 닮았습니다. 복잡도가 커지기 전에 “허용 범위”를 타입으로 고정해버리는 식이죠. 관련해서는 TypeScript 5.5 infer로 타입 폭발 줄이는 법도 참고가 됩니다.
대안: self-reference를 피하는 대표 전략
실무에서는 self-referential struct가 필요해 보이지만, 설계를 바꾸면 피할 수 있는 경우가 많습니다.
1) 인덱스/오프셋 저장
포인터 대신 “시작 오프셋과 길이”를 저장하고, 필요할 때 buf에서 슬라이스를 만들어 반환합니다.
struct OffsetView {
buf: String,
start: usize,
len: usize,
}
impl OffsetView {
fn new(buf: String, start: usize, len: usize) -> Self {
Self { buf, start, len }
}
fn view(&self) -> &str {
&self.buf[self.start..self.start + self.len]
}
}
이 방식은 이동해도 안전합니다. 대신 매번 슬라이스를 계산해야 하고, UTF-8 경계 문제 등 검증이 필요합니다.
2) Arc로 공유하고, 참조는 약하게 들기
소유와 참조를 같은 구조체 안에 두지 말고, 공유 소유자(Arc)와 뷰 객체를 분리합니다.
3) 검증된 크레이트 사용
self-referential struct를 안전하게 다루기 위한 크레이트들이 존재합니다(예: ouroboros). 내부적으로 필요한 unsafe와 핀 관련 패턴을 캡슐화해줍니다. 팀/프로덕션에서는 이런 검증된 구현을 쓰는 것이 유지보수에 유리한 경우가 많습니다.
정리
- self-referential struct는 “이동” 때문에 내부 참조가 깨질 수 있어 기본적으로 위험합니다.
Pin은 값을 메모리에서 이동하지 않게 만들기 위한 도구이며,PhantomPinned로Unpin을 막아야 의미가 강해집니다.- 안전한 설계의 핵심은
Pin<Box<Self>>같은 형태로 생성 후 고정- 위험한 메서드는
self: Pin<&Self>를 요구 - 내부 버퍼 재할당을 유발하는 API를 차단
- unsafe 블록의 범위를 최소화하고 불변식을 문서화
- 가능하면 오프셋 저장, 소유/뷰 분리, 검증된 크레이트 사용으로 self-reference 자체를 피하는 것이 더 낫습니다.
self-referential struct는 Rust의 메모리 모델을 깊게 이해하게 해주는 주제이지만, 동시에 “설계로 피하는 게 최선”인 경우가 많습니다. 정말 필요하다면 Pin을 단순 문법이 아니라 “불변식 강제 장치”로 보고 API를 설계하는 것이 안전한 길입니다.