Rust의 Vec<T>가 내부적으로 어떻게 구성되고 동작하는지, 원시 포인터에서 NonNull, Unique, RawVec에 이르는 레이어를 따라가며 메모리 안전성과 제로 코스트 추상화가 어떻게 구현되는지 살펴봅니다.
Rust API 문서에서 std::vec::Vec의 Vec
구조체 정의를 읽다가 흥미로운 게 눈에 들어왔습니다.
pub struct Vec<T, A = Global>
where
A: Allocator,
{ /* private fields */ }
바로 너, { /* private fields */ }
! "대체 나한테 뭘 숨기고 있는 거지?" 라고 생각했습니다. 내가 거대한 음모에 빨려 들어가고 있는 걸까요? 문서는 이 필드들에 대해 아무런 힌트도 주지 않고, 대신 자료구조의 시각적 표현만 보여줍니다:
ptr len capacity
+--------+--------+--------+
| 0x0123 | 2 | 4 |
+--------+--------+--------+
|
v
Heap +--------+--------+--------+--------+
| 'a' | 'b' | uninit | uninit |
+--------+--------+--------+--------+
분명 우리 Vec
에는 ptr
, len
, capacity
라는 세 필드가 있을 겁니다. 하지만 확실히 하려면 소스 코드로 가봐야죠. 토끼굴로 내려가 이 오래된 미스터리를 파헤칠 준비가 되셨나요?
std::vec
안의 Vec
구조체 정의로 뛰어들어가 보면 다음과 같습니다:
pub struct Vec<T, A: Allocator = Global> {
buf: RawVec<T, A>,
len: usize,
}
참고
Allocator
타입은 여기서는 완전히 무시하겠습니다. 이건 글 하나를 통째로 써도 될 만큼 큰 주제예요.
야호, len
은 있네요! 좋아요... 이건 쉬웠습니다. 이제 남은 건 ptr
와 capacity
뿐이죠. 금방 끝나려나요?
아니요, 그럴 리가요!
"이 수상한 RawVec<T, A>
는 또 뭐지?" 라고 스스로에게 묻게 됩니다. 그리고 대체 ptr
와 capacity
는 어디 있죠? 자, 빵부스러기를 따라가 봅시다!
Rust API 문서의 검색창에 RawVec
를 치면... 아무것도 안 나옵니다!?
역시나! 뭔가 우리에게서 숨기려는 게 있군요!
좋아요, 진정합시다. 심호흡하고 소스 코드를 직접 보죠:
pub(crate) struct RawVec<T, A: Allocator = Global> {
inner: RawVecInner<A>,
_marker: PhantomData<T>,
}
아하, 그래서 문서에서 못 찾은 거군요. 크레이트 내부에서만 공개되는 pub(crate)
이라 외부에선 접근할 수 없었습니다. 좋아요, 미스터리 하나 해결. 그런데 RawVecInner<A>
타입은 또 뭐고, PhantomData<T>
1는 또 뭡니까!? 이 토끼굴은 얼마나 깊은 거죠?
RawVecInner<A>
를 보면 그림이 좀 더 선명해집니다:
struct RawVecInner<A: Allocator = Global> {
ptr: Unique<u8>,
cap: Cap,
alloc: A,
}
하하, 아니네요... 그래도 약간은요. 마침내 잃어버린 ptr
와 cap
(capacity)을 찾았습니다! 하지만 둘 다 새로운 타입으로 감싸져 있네요. 이제 세 겹이나 들어왔는데 끝이 안 보입니다. 그래도 여기까지 왔는데 멈출 수는 없죠?
참고
Cap
은 최소/최대 경계를 관리하는 타입일 뿐이라 여기서는 깊게 들어가지 않겠습니다. 대신 여기와 여기를 참고하세요.
그렇다면 Unique<u8>
은 뭘까요?
pub struct Unique<T: PointeeSized> {
pointer: NonNull<T>,
_marker: PhantomData<T>,
}
여기엔 놀라움이 없습니다. 또 다른 래퍼 타입 NonNull<T>
일 뿐이죠.
pub struct NonNull<T: PointeeSized> {
pointer: *const T,
}
잠깐?! 끝난 걸까요? 그런 것 같네요! 할렐루야, 이제 Vec
스택 전체에 대한 대략적인 조망을 얻었습니다! 그 비밀을 풀어볼까요?
우리의 여정은 대략 다음과 같았습니다:
Vec<T>
는 ...를 담고RawVec<T>
는 ...를 담고RawVecInner
는 ...를 담고Unique<u8>
는 ...를 담고NonNull<u8>
는 ...를 담고*const u8
(원시 포인터)을 담습니다휴, 추상화가 꽤 많네요. 그런데 이게 뭘 말해줄까요? 표준 라이브러리 엔지니어들이 왜 이런 경로를 택했는지를 이해하려면 각 레이어의 목적을 먼저 알아야 합니다. 바닥부터 시작해서 Vec<Mountain>
의 정상까지 올라가 봅시다!
가장 밑바닥부터 시작합니다. 우리의 토대이자 전체 구조를 규정하는 변치 않는 베이스캠프: *const u8.
이는 메모리 위치를 참조하는 가장 원시적인 방법으로, 원시 포인터(raw pointer)라고도 합니다. 그저 메모리 주소, 숫자일 뿐이죠. 공식 문서는 이게 위험한 도구라고 말합니다. null일 수 있고, 덩글(dangle)일 수 있고, 정렬이 안 맞을 수도 있습니다. 수명(lifetime) 정보도 없어서, 가리키는 데이터가 여전히 유효한지 컴파일러가 알 수 없습니다.
이걸 사용하려면 unsafe 블록으로 들어가 _"내가 뭘 하고 있는지 안다"_라고 컴파일러에게 말해야 합니다. 하지만 메모리를 관리하려면 먼저 메모리 주소 자체를 직접 다룰 수 있어야 하기에, 이건 필수적인 출발점입니다.
중요
이 글의 Reddit 스레드에서 좋은 질문이 나왔습니다. 어떤 사용자(u/Anaxamander57)가 _"왜 포인터가 u8인가요?"_라고 물었죠. Rust에서 포인터는 usize 기반인데 말입니다.
답은 이렇습니다: 네, 포인터의 값(주소)은 usize 기반이지만, 포인터가 가리키는 것은 메모리의 단일 바이트(u8), 즉 데이터의 시작점입니다. 실제로 이 바이트들은 '타입이 지워진(type-erased)' 바이트입니다(u/Hy-o-pye님 감사합니다).
왜 타입이 지워졌을까요? 이 레벨에서는 데이터의 타입이 중요하지 않기 때문입니다. 그저 늘어날 수 있는 메모리 버퍼일 뿐이죠.
안전을 더하는 첫 단계는, 올라가는 동안 필요한 도구가 모두 손에 있는지 확인하는 것입니다! 그래서 공구 벨트를 확인해 NonNull<u8>이 있는지 봅니다.
간단하지만 엄청나게 중요한 래퍼로, 이제 우리의 *const u8
에 단 하나의 결정적 보장을 더합니다: 포인터는 절대 null이 아니다. null 포인터는 다른 언어들에서 무수한 버그와 크래시의 원인(악명 높은 "수십억 달러짜리 실수")이었습니다. 이 비null 보장을 타입 시스템에 직접 인코딩함으로써, Rust는 이 부류의 오류를 통째로 제거할 수 있습니다.
이것은 환상적인 컴파일러 최적화도 가능하게 합니다. NonNull<T>
는 절대로 null이 될 수 없으므로, 컴파일러는 Option<NonNull<T>>
를 볼 때 None 변형을 0 주소로 표현할 수 있음을 압니다. 즉, Option<NonNull<T>>
는 일반 원시 포인터와 정확히 동일한 크기를 차지합니다! 우리가 구축 중인 안전한 추상화의 첫걸음이자, 동시에 제로 코스트 추상화입니다.
경고
다음 섹션은 현재 다소 단순화되어 있고, 솔직히 말해 제가 대충 쓴 부분입니다. Unique
에 관한 좋은 Reddit 토론이 있어, 약간 더 조사한 뒤 이 부분을 다시 쓸 예정입니다.
우리는 계속 올라가다 Unique<u8>라는 길을 만납니다... 마치 우리가 이 길을 처음 오르는 사람들 같군요. 이제 바위에 우리 이름을 새겨 우리의 것임을 남겨봅시다.
이 레이어는 NonNull
위에 소유권(ownership)의 개념을 더합니다. 포인터가 유효하고 null이 아닐 뿐 아니라, 그 포인터가 가리키는 메모리에 대해 우리가 배타적(유일한) 접근 권한을 가진다는 약속입니다.
Unique의 소스 코드 문서는 이 타입이 "자신의 내용을 소유한다"고 말합니다. 이는 필요 없게 되었을 때 데이터를 정리(drop)할 책임이 있다는, Rust식 표현입니다.
아시다시피, 이것이 가비지 컬렉터 없이 메모리 안전을 지키는 Rust의 방식입니다. Unique 포인터가 유일한 소유자라는 사실을 바탕으로, 컴파일러는 빌림(borrowing)에 대한 엄격한 규칙을 적용하고 데이터 레이스를 방지할 수 있습니다. 이건 오롯이 컴파일 타임에 메모리 안전 규칙을 강제하기 위해 컴파일러가 필요로 하는 정보를 제공하기 위한 제로 코스트 추상화입니다.
이름을 새기고 나니(왜 등반 도중에 그랬을까요?!) 숨이 턱까지 차올라 위에서 도움을 요청해야 합니다. 위에 있는 친절한 사람들이 우리에게 밧줄을 내려주려면, 먼저 밧줄을 어디서 구해야겠죠.
RawVec<T>는 "메모리 버퍼를 관리하는 저수준 유틸리티"입니다. 실제로 메모리 할당자와 대화하는 구성요소로, 힙에 메모리 블록을 요청하고, 필요할 때(보통 용량을 두 배로) 키우며, 벡터가 드롭될 때 꼭 필요한 해제 작업까지 담당합니다.
이미 배웠듯이, 이 레이어는 용량(할당된 전체 공간)만 알고 있습니다. 실제로 몇 개의 요소가 초기화되어 사용 중인지는 추적하지 않습니다. 이런 관심사의 분리는 RawVec을 완벽하고 재사용 가능한 빌딩 블록으로 만들어 줍니다. 표준 라이브러리의 다른 컬렉션 타입들, 예를 들어 확장 가능한 메모리 버퍼가 필요한 VecDeque<T>도 바퀴를 다시 발명하지 않고 이 컴포넌트를 재사용할 수 있습니다.
알아두면 좋아요
앞서 봤던 RawVecInner
타입 기억나시나요? 이것은 영리한 컴파일 타임 최적화입니다. 로직을 분리함으로써, T에 대해 제네릭이 아닌 부분(RawVecInner
)은 생성하는 모든 Vec<T>
마다 중복되지 않아 컴파일 속도를 높이는 데 도움이 됩니다.
마침내 Vec<Top>에 도착했습니다. 이제 깃발을 꽂을 시간!
모든 걸 한데 모으는 것이 바로 이 레이어입니다. 메모리를 관리하는 RawVec을 보유하고, 마지막 퍼즐 조각인 len
을 추가합니다.
이 레이어는 지금까지 우리가 배운 모든 unsafe 복잡성을 감춘 공개 API 역할을 합니다. Vec은 할당된 블록(capacity) 내에서 몇 개의 요소가 초기화되어 있는지(len)를 압니다. 그리고 초기화된 요소만 접근할 수 있도록 보장하는 책임을 집니다. 덕분에 우리가 매일 사랑하는 안전한 메서드들 — push
, pop
, insert
, 인덱싱 등 — 이 제공됩니다.
각 단계는 바로 아래 단계를 토대로 새로운 보장과 책임을 더해, 완전히 안전하고 효율적이며 강력한 자료구조로 완성됩니다.
제 삶은 여전히 그다지 사건이 없는 모양입니다. 큰 음모도, 감춰진 진실도 없었어요... 단지 아주 훌륭한 엔지니어링만 있었을 뿐.
Vec<T>
의 비공개 필드가 무엇인지 파헤치려다 아주 흥미로운 것을 발견했습니다. 바로 훌륭한 API 설계입니다. Rust 엔지니어들이 가장 흔한 타입 중 하나를 어떻게 바닥부터 쌓아 올렸는지 보았습니다. unsafe 포인터에서 출발해, 층층이 신중하게 감싸 올려 마침내 완전히 안전하고 인체공학적인 형태가 되는 그 과정이요.
각 레이어는 Vec
의 계약을 충족하는 데 결정적입니다. 이는 추상화와 관심사 분리의 힘에 대한 증거입니다. "음모"는 복잡성을 관리하는 방법에 관한 것이었죠. 퍼즐의 각 조각이 한 가지에 집중해 잘 해내도록 하고, 그 과정에서 표준 라이브러리의 다른 부분을 움직이게 하는 재사용 가능한 타입들을 제공하는 것입니다.
어쩌면 다음에 벡터에 요소를 하나 push
할 때, 그 이면에서 안전하고 효율적으로 모든 것을 가능하게 만드는 추상화의 스택을 떠올릴지도 모릅니다. 그것 자체로 꽤 근사한 비밀을 하나 알아낸 셈이겠죠.
Vec
의 내부 구조를 들여다보면서, 표준 라이브러리의 이면에서 무슨 일이 벌어지는지 조금 더 잘 이해하게 되었습니다.
안타깝게도 Vec
을 이루는 복잡한 구조의 일부분만 살짝 건드렸을 뿐입니다(그래도 이해에 도움이 될 만큼은 했기를 바랍니다). 모든 조각을 세세하게 설명하기에는 이 글이 너무 길어지니, 아마도 Rust의 속살을 더 잘 설명하는 Under the hood 글을 더 쓰게 될지도 모르겠습니다. 이 글을 읽는 동안에도 분명한 질문들이 떠오르죠. "NonNull
은 어떻게 그 보장을 지킬까?" 혹은 "RawVec
은 메모리를 어떻게 관리할까?"
이 글에서 살펴본 타입들 각각은, 잠깐 언급만 했던 것들(Allocator
, PhantomData
)까지 포함해, 글 하나씩을 쓸 가치가 있습니다. 그러니 각자 직접 살펴보시길 권합니다. Rust 문서는 정말 훌륭하고, 현대 IDE의 정의로 이동(go to definition) 같은 기능을 이용하면 공개 Rust API를 이루는 부분들을 명확히 이해할 때까지 이리저리 쉽게 넘나들 수 있습니다.
PhantomData<T>
를 설명하는 것은 이 글의 범위를 벗어납니다. 아마 다른 글에서 다뤄볼지도 모르겠습니다. ↩