- Published on
Rust self-referential 구조체가 안 되는 이유와 Pin
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 한 번쯤 이런 형태를 만들고 싶어집니다. 구조체가 어떤 버퍼를 소유하고, 동시에 그 버퍼의 일부를 가리키는 참조를 필드로 들고 있는 형태입니다. C나 C++에서는 흔히 보이지만, Rust에서는 컴파일러가 강하게 막습니다.
핵심 이유는 단순합니다. Rust의 참조 &T / &mut T 는 가리키는 대상이 살아있고, 주소(메모리 위치)가 유효하다는 전제 위에 서는데, Rust의 값은 기본적으로 언제든 이동(move)될 수 있기 때문입니다. 그리고 self-referential 구조체는 “이동되면 내부 참조가 즉시 깨지는” 형태라서, 안전한 일반 규칙으로는 허용될 수 없습니다.
이 글에서는
- 왜 self-referential 구조체가 안 되는지(정확히 어떤 안전성 조건이 깨지는지)
- 그럼에도 비슷한 요구를 어떻게 풀어야 하는지(인덱스, 소유권 분리)
Pin이 무엇을 보장하고, 무엇을 보장하지 않는지Pin을 써야 하는 대표 사례(특히Future/async)
를 코드와 함께 정리합니다.
관련해서 “자원 생명주기/정리(클린업)가 누락되면 어떤 사고가 나는가”라는 관점은 다른 언어에서도 비슷합니다. 예를 들어 React의 이펙트 정리 누락은 메모리 누수로 이어질 수 있는데, 이 글도 결국 “참조의 유효성/수명”을 엄격히 다루는 이야기입니다: React 메모리 누수? useEffect 클린업 9가지
self-referential 구조체가 왜 위험한가
먼저 “내부 버퍼 + 내부 참조”를 그대로 표현하려고 하면 이런 코드가 떠오릅니다.
struct Bad<'a> {
buf: String,
slice: &'a str,
}
impl<'a> Bad<'a> {
fn new(s: String) -> Self {
let slice = &s[..];
Self { buf: s, slice }
}
}
이 코드는 컴파일되지 않습니다. 이유는 slice 가 s 를 빌렸는데, s 는 곧 buf 로 이동(move)됩니다. Rust는 “값을 이동하면 주소가 바뀔 수 있다”는 모델을 갖고 있으므로, 이동 이후에도 slice 가 유효하다고 증명할 수 없습니다.
이동(move)이 왜 주소를 바꿀 수 있나
Rust에서 let x = some_value; 로 바인딩된 값은 스택에 놓일 수 있고, 그 값이 다른 변수로 이동되거나 함수 인자로 전달되면서 새 스택 슬롯으로 복사 이동될 수 있습니다(개념적으로). 컴파일러 최적화에 따라 실제 메모리 배치는 달라질 수 있지만, 언어 차원에서 “이동 시 주소가 고정된다”는 보장을 하지 않습니다.
self-referential 구조체는 내부에 & 참조를 들고 있는데, 참조는 결국 “어떤 주소를 가리키는 포인터”입니다. 구조체가 이동되면 buf 의 주소가 바뀔 수 있고, 그러면 slice 는 옛 주소를 계속 가리켜 댕글링(dangling) 참조가 됩니다.
이 문제는 단지 “생명주기(lifetime) 표기”로 해결되지 않습니다. lifetime은 얼마나 오래 살아있는가를 표현하는 것이지, 어디에 고정되어 있는가(주소 안정성)를 표현하지 못합니다.
흔한 우회: 참조 대신 인덱스를 저장하라
대부분의 경우 self-referential 구조체가 필요한 이유는 “버퍼에서 특정 구간을 빠르게 재사용하고 싶다”입니다. 이때 참조를 저장하는 대신, 범위(range)나 오프셋을 저장하면 이동 문제에서 자유로워집니다.
struct Good {
buf: String,
range: std::ops::Range<usize>,
}
impl Good {
fn new(buf: String) -> Self {
let range = 0..buf.len();
Self { buf, range }
}
fn slice(&self) -> &str {
&self.buf[self.range.clone()]
}
}
이 방식은
- 구조체가 이동해도
buf자체는 여전히 유효하고 range는 값(숫자)이라서 주소 안정성과 무관하며slice()를 호출할 때마다 그 시점의buf에서 참조를 만들어 반환
하므로 안전합니다.
물론 매번 슬라이스를 만들어야 하고, 내부 불변 조건(예: range 가 항상 UTF-8 경계인지)을 유지해야 하는 부담은 생깁니다. 하지만 Rust에서는 이쪽이 “기본값”에 가깝습니다.
그럼에도 필요할 때: Pin의 등장 배경
그럼에도 self-referential이 “정말로” 필요한 영역이 있습니다. 대표적으로 async/await의 Future 는 상태 머신으로 컴파일되며, 내부에 자기 자신을 가리키는 형태(정확히는 내부 필드가 다른 필드의 주소에 의존하는 형태)가 될 수 있습니다.
이때 필요한 것이 Pin 입니다.
Pin<P>는 어떤 포인터 타입P(예:Box<T>,&mut T)가 가리키는 값을 이동할 수 없게 고정(pinning) 했다는 논리적 약속입니다.- 정확히는 “이 값이
Unpin이 아니라면, 안전한 코드로는 더 이상 움직일 수 없다”를 표현합니다.
중요한 점: Pin 은 “주소가 절대 바뀌지 않는다”를 마법처럼 보장하는 키워드가 아니라, 이동을 금지하는 API 제약을 제공합니다. 그래서 Pin 은 항상 “어떤 저장소에 올려놓고(보통 힙) 그 뒤로는 움직이지 않겠다”는 설계와 함께 사용됩니다.
Unpin: 대부분의 타입이 기본으로 갖는 성질
대부분의 타입은 Unpin 입니다. Unpin 이라는 트레잇은 “이 타입은 pinning되어도 이동해도 안전하다”는 뜻입니다.
String,Vec<T>, 일반적인 구조체 대부분은Unpin- self-referential처럼 “주소에 의존하는” 타입만
!Unpin이 필요
따라서 Pin<Box<T>> 를 만들더라도, T: Unpin 이면 사실상 일반 Box<T> 와 크게 다르지 않게 다룰 수 있습니다.
Pin으로 해결되는 것과 해결되지 않는 것
Pin이 해결하는 것: “이동 금지”
아래는 Pin<Box<T>> 로 값을 힙에 두고, 그 포인터가 가리키는 T 를 움직이지 않겠다는 의도를 표현합니다.
use std::pin::Pin;
struct Node {
value: String,
}
fn pinned() {
let p: Pin<Box<Node>> = Box::pin(Node { value: "hi".into() });
// p 자체(Box 포인터)는 move될 수 있지만,
// p가 가리키는 Node의 메모리 위치는 고정된 것으로 취급됩니다.
let _moved_p = p;
}
여기서 “포인터 자체는 이동되는데, 대상은 고정”이라는 점이 중요합니다. Box 는 힙 메모리를 가리키는 포인터 값이므로, Box 값을 복사 이동해도 힙의 할당 위치는 그대로입니다.
Pin이 해결하지 못하는 것: 안전한 self-referential 생성
Pin 이 있다고 해서 “구조체 생성 중에 자기 내부를 가리키는 참조를 안전하게 만들기”가 자동으로 되지 않습니다.
왜냐하면 self-referential을 만들려면 보통 이런 순서가 필요합니다.
- 구조체를 먼저 메모리에 배치(주소 확보)
- 그 주소를 기반으로 내부 참조 필드를 채움
- 이후 이동 금지
하지만 Rust에서 1)과 2)를 안전하게 일반화하기가 매우 어렵습니다. 그래서 실무에서는 보통 다음 중 하나를 택합니다.
- 애초에 참조를 저장하지 않고 인덱스를 저장(앞에서 소개)
- 구조를 분리해 “소유자(owner)와 뷰(view)”를 분리
- 검증된 크레이트를 사용(예:
ouroboros,self_cell등) - 정말 필요한 경우
unsafe로 직접 불변 조건을 증명
Pin 은 3) “이후 이동 금지” 쪽을 담당하는 도구에 가깝습니다.
Pin을 체감하는 대표 사례: Future와 poll
Future 의 핵심 메서드는 대략 이런 형태입니다.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
trait MyFuture {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
여기서 self 가 Pin<&mut Self> 인 이유는, poll 이 여러 번 호출되며 내부 상태를 바꾸는 동안 Future 값이 이동하면 안 되기 때문입니다.
- async 상태 머신은 내부에 버퍼/포인터/참조 유사 구조를 들 수 있음
- 한 번
poll을 시작한 Future는 동일한 메모리 위치에서 계속 진행되어야 안전
이 설계 덕분에, async 런타임은 Future를 컬렉션에 넣어 스케줄링하더라도 “실제 Future 객체는 움직이지 않게” 관리할 수 있습니다.
Pin을 사용할 때 자주 만나는 패턴
Box::pin 으로 힙에 고정하기
가장 흔한 고정 방식입니다.
use std::pin::Pin;
struct Task {
data: Vec<u8>,
}
fn make_task() -> Pin<Box<Task>> {
Box::pin(Task { data: vec![1, 2, 3] })
}
이렇게 반환하면 호출자는 Task 를 “고정된 객체”로 다룰 수 있고, Task: !Unpin 인 경우에도 안전한 API를 설계할 수 있습니다.
pin! 매크로로 스택에 고정하기
표준 라이브러리에는 std::pin::pin! 매크로가 있어 스택 변수에 대해 pinning을 도와줍니다. 다만 스택 pinning은 스코프를 벗어나면 끝이고, 참조가 밖으로 새어 나가면 곤란하므로 사용 맥락이 제한적입니다.
코드 예시는 다음처럼 이해하면 됩니다.
use std::pin::pin;
fn demo() {
let mut x = 10;
let mut px = pin!(x);
// px: Pin<&mut i32>
*px.as_mut().get_mut() += 1;
}
여기서도 get_mut() 은 T: Unpin 일 때만 의미 있게 안전합니다. !Unpin 타입이면 내부를 함부로 &mut T 로 꺼내는 순간 “이동 금지 규칙을 깨는 메서드”들이 가능해지므로 API가 제한됩니다.
self-referential이 필요해 보일 때의 설계 체크리스트
실무에서 “Pin을 써서 self-referential 구조체를 만들자”로 바로 가면 복잡도가 급상승합니다. 다음 순서로 재검토하는 편이 안전합니다.
정말 참조를 필드로 저장해야 하나?
- 대부분은
Range/오프셋/인덱스로 대체 가능
- 대부분은
소유권과 뷰를 분리할 수 있나?
Owner는 버퍼를 소유View<'a>는 필요할 때만 빌려서 생성
주소 안정성이 정말 필요한가?
- 필요하다면
Box/Arc같은 안정적인 간접 참조 뒤에 두고 - 이동은 포인터 값만 이동되게 만들기
- 필요하다면
검증된 크레이트를 쓸 수 있나?
- self-referential을 안전하게 캡슐화한 도구를 쓰면
unsafe범위를 줄일 수 있음
- self-referential을 안전하게 캡슐화한 도구를 쓰면
그래도
unsafe가 필요하다면, 불변 조건을 문서화했나?- “생성 이후 절대 이동하지 않는다”
- “내부 참조는 buf의 특정 범위를 가리킨다”
- “drop 순서/수명 관계는 이렇다”
이런 체크리스트는 사실 동시성/리소스 관리 문제에서도 비슷하게 반복됩니다. 예를 들어 Go에서 채널/고루틴이 누수되는 패턴도 “종료 조건과 소유권(누가 닫는가) 불명확”에서 출발합니다: Go 채널 데드락·goroutine leak 7가지 패턴
결론
- Rust에서 self-referential 구조체가 기본적으로 금지되는 이유는 move로 인한 주소 변경 가능성 때문에 내부 참조가 쉽게 무효화되기 때문입니다.
- lifetime은 “얼마나 오래 사는가”를 표현하지만, self-referential의 핵심인 “주소 안정성”을 표현하지 못합니다.
Pin은 “이 값은 더 이상 이동하지 않는다”는 제약을 API로 강제하는 도구이며, 특히 asyncFuture처럼 이동 금지가 필수인 타입을 가능하게 합니다.- 다만
Pin이 self-referential 생성 문제를 자동으로 해결해주지는 않습니다. 실무에서는 인덱스 저장, 소유/뷰 분리, 검증된 크레이트 사용을 먼저 고려하는 것이 유지보수에 유리합니다.
다음 글을 더 읽고 싶다면, Rust async에서 Pin 이 실제로 어떻게 스케줄러/런타임과 맞물리는지(예: !Unpin Future를 어떻게 보관하는지, poll 호출 규약)는 별도의 주제로 깊게 파볼 만합니다.