이미지 로딩 중...
AI Generated
2025. 11. 14. · 6 Views
Rust OS 개발 Box와 Vec으로 동적 메모리 정복하기
OS 개발에서 필수적인 Box와 Vec의 활용법을 실전 예제와 함께 알아봅니다. 힙 메모리 할당부터 동적 배열 관리까지, 실무에서 바로 쓸 수 있는 노하우를 담았습니다.
목차
- Box로 시작하는 힙 메모리 할당 - 스택 오버플로우 해결의 첫걸음
- Vec으로 동적 배열 마스터하기 - 크기를 모를 때의 완벽한 해답
- Vec의 내부 구조 이해하기 - ptr, len, capacity의 삼위일체
- Box와 Vec의 메모리 할당자 커스터마이징 - 나만의 allocator 구현하기
- Vec를 활용한 커널 자료구조 패턴 - 순환 버퍼와 우선순위 큐
- Box를 이용한 재귀 자료구조 - 트리와 링크드 리스트 구현
- Vec의 고급 메서드 활용 - retain, dedup, split_off로 효율성 극대화
- Box의 메모리 레이아웃 최적화 - #[repr(C)]와 메모리 정렬
- Vec를 이용한 메모리 풀 구현 - 객체 재사용으로 할당 비용 줄이기
- Box와 Vec의 성능 비교 - 언제 무엇을 쓸 것인가
1. Box로 시작하는 힙 메모리 할당 - 스택 오버플로우 해결의 첫걸음
시작하며
여러분이 OS 커널 코드를 작성하다가 큰 데이터 구조체를 스택에 할당했는데 갑자기 스택 오버플로우가 발생한 경험이 있나요? 특히 embedded 환경이나 커널 공간에서는 스택 크기가 제한적이어서 조금만 큰 배열이나 구조체를 선언해도 문제가 발생합니다.
이런 문제는 실무 OS 개발에서 정말 자주 발생합니다. 스택은 크기가 제한적이고 자동으로 관리되지만, 큰 데이터나 생명주기가 긴 데이터를 다루기에는 적합하지 않습니다.
게다가 스택 오버플로우는 디버깅하기도 까다롭죠. 바로 이럴 때 필요한 것이 Box입니다.
Box는 데이터를 힙에 할당하여 스택 부담을 줄이고, 소유권 기반으로 안전하게 메모리를 관리할 수 있게 해줍니다.
개요
간단히 말해서, Box<T>는 힙에 할당된 데이터를 가리키는 스마트 포인터입니다. OS 개발에서 Box가 필요한 이유는 명확합니다.
커널 스택은 보통 4KB~16KB로 제한되어 있고, 큰 버퍼나 복잡한 자료구조를 스택에 올리면 즉시 오버플로우가 발생합니다. 예를 들어, 페이지 테이블이나 큰 I/O 버퍼를 다룰 때 Box를 사용하면 안전하게 힙 공간을 활용할 수 있습니다.
기존 C에서는 malloc/free로 수동 관리했다면, Rust의 Box는 스코프를 벗어나면 자동으로 메모리를 해제합니다. 이는 메모리 누수와 댕글링 포인터 문제를 원천 차단합니다.
Box의 핵심 특징은 세 가지입니다: (1) 힙 할당으로 스택 압박 해소, (2) 소유권 이동으로 안전한 메모리 관리, (3) Deref trait으로 투명한 접근. 이러한 특징들이 OS 개발에서 안정성과 성능을 동시에 보장합니다.
코드 예제
// OS 커널에서 큰 페이지 프레임 할당 예제
struct PageFrame {
data: [u8; 4096], // 4KB 페이지
}
// Box로 힙에 할당
let page = Box::new(PageFrame {
data: [0; 4096],
});
// 자동으로 Deref되어 직접 접근 가능
page.data[0] = 0xFF;
// 스코프 종료 시 자동 해제
drop(page); // 명시적 해제도 가능
설명
이것이 하는 일: Box는 생성 시 힙 메모리를 할당하고, 데이터를 그곳에 저장한 뒤, 스택에는 포인터만 유지합니다. 첫 번째로, Box::new()가 호출되면 Rust의 글로벌 할당자(allocator)를 통해 힙 공간을 확보합니다.
OS 개발에서는 이 할당자를 직접 구현해야 하는데, 보통 buddy allocator나 slab allocator를 사용합니다. 4096바이트 크기의 PageFrame 구조체가 힙에 할당되고, 스택에는 8바이트(64비트 시스템 기준) 포인터만 남습니다.
그 다음으로, Deref trait 덕분에 page.data[0]처럼 직접 접근할 수 있습니다. 내부적으로는 포인터 역참조가 일어나지만, 코드상으로는 일반 구조체처럼 사용할 수 있죠.
이는 ergonomic한 코드 작성을 가능하게 합니다. 마지막으로, drop(page)가 호출되거나 스코프가 끝나면 Drop trait이 자동 실행됩니다.
이때 할당자의 dealloc 함수가 호출되어 힙 메모리가 반환되며, 댕글링 포인터나 메모리 누수 없이 안전하게 정리됩니다. 여러분이 이 코드를 사용하면 스택 크기 제약에서 벗어나 큰 데이터 구조를 안전하게 다룰 수 있습니다.
또한 소유권 시스템 덕분에 누가 메모리를 해제할 책임이 있는지 명확하고, 컴파일 타임에 메모리 안전성이 보장됩니다. 특히 커널 개발처럼 디버깅이 어려운 환경에서 이런 안전성은 엄청난 생산성 향상을 가져옵니다.
실전 팁
💡 Box::leak()를 사용하면 Box의 소유권을 포기하고 'static 참조를 얻을 수 있습니다. 커널 초기화 시 영구적인 자료구조를 만들 때 유용합니다
💡 Box는 기본적으로 정렬(alignment)을 보장하지만, 특수한 정렬이 필요하면 #[repr(align(N))]과 함께 사용하세요. DMA 버퍼 등에서 필수적입니다
💡 no_std 환경에서 Box를 사용하려면 alloc 크레이트를 활성화하고 글로벌 할당자를 반드시 구현해야 합니다. #[global_allocator]를 잊지 마세요
💡 Box::from_raw()와 Box::into_raw()로 raw pointer와 상호변환이 가능합니다. FFI나 하드웨어 레지스터 매핑 시 활용하세요
💡 큰 데이터를 함수 인자로 넘길 때 Box로 감싸면 포인터만 복사되어 성능이 향상됩니다. 단, 소유권이 이동하므로 참조가 필요하면 &Box<T>를 사용하세요
2. Vec으로 동적 배열 마스터하기 - 크기를 모를 때의 완벽한 해답
시작하며
여러분이 디바이스 드라이버를 작성하면서 들어올 데이터의 개수를 미리 알 수 없는 상황에 처한 적이 있나요? 네트워크 패킷 버퍼나 파일 시스템의 inode 리스트처럼 런타임에 크기가 결정되는 경우가 많습니다.
이런 문제는 C에서 realloc을 쓰며 괴로워한 경험이 있다면 공감하실 겁니다. 수동으로 메모리를 재할당하고, 기존 데이터를 복사하고, 이전 메모리를 해제하는 과정은 에러가 발생하기 쉽고 코드도 지저분해집니다.
바로 이럴 때 필요한 것이 Vec입니다. Vec는 자동으로 크기를 조정하는 동적 배열로, 성장과 축소를 안전하게 관리하며 편리한 API까지 제공합니다.
개요
간단히 말해서, Vec<T>는 힙에 할당되고 크기가 자동 조정되는 동적 배열입니다. 왜 Vec가 필요한지는 실무를 보면 명확합니다.
OS 커널에서 프로세스 리스트, 메모리 페이지 프레임 관리, 인터럽트 핸들러 큐 등 수많은 곳에서 개수를 예측할 수 없는 데이터를 다룹니다. Vec는 이런 상황에서 push/pop으로 간편하게 추가/제거하며, 내부적으로 capacity를 조절해 효율적으로 메모리를 사용합니다.
기존 C의 가변 배열은 수동 관리가 필요했다면, Vec는 자동으로 재할당과 복사를 처리합니다. 게다가 bounds checking으로 인덱스 범위를 벗어나는 접근도 방지합니다.
Vec의 핵심 특징은: (1) 자동 capacity 증가로 재할당 최소화, (2) 연속된 메모리 배치로 캐시 친화적, (3) Iterator API로 함수형 스타일 지원. 이러한 특징들이 성능과 안전성을 동시에 제공합니다.
코드 예제
// 페이지 프레임을 동적으로 관리하는 예제
let mut free_pages: Vec<usize> = Vec::new();
// capacity 미리 확보로 재할당 방지
free_pages.reserve(1024);
// 페이지 추가
for addr in (0x100000..0x200000).step_by(4096) {
free_pages.push(addr);
}
// 페이지 할당 (마지막 요소 제거)
if let Some(page_addr) = free_pages.pop() {
// 페이지 사용
}
설명
이것이 하는 일: Vec는 힙에 연속된 메모리 블록을 할당하고, 길이(len)와 용량(capacity)을 추적하며, 필요시 자동으로 재할당합니다. 첫 번째로, Vec::new()는 초기에 메모리를 할당하지 않습니다.
capacity는 0이고 실제 할당은 첫 push 시 발생합니다. reserve(1024)를 호출하면 미리 1024개 요소를 담을 공간을 확보하여, 이후 push에서 재할당 오버헤드를 피할 수 있습니다.
이는 성능 최적화의 핵심입니다. 그 다음으로, push가 호출될 때마다 len이 증가합니다.
len이 capacity에 도달하면 Vec는 자동으로 더 큰 메모리(보통 2배)를 할당하고, 기존 데이터를 복사한 뒤 이전 메모리를 해제합니다. 이 과정이 투명하게 일어나므로 개발자는 신경 쓸 필요가 없죠.
마지막으로, pop()은 마지막 요소를 제거하고 Option<T>로 반환합니다. 벡터가 비어있으면 None을 반환해 안전하게 처리할 수 있습니다.
내부적으로 len만 감소시키고 실제 메모리는 해제하지 않아 성능상 유리합니다. 여러분이 이 코드를 사용하면 동적 데이터를 안전하고 효율적으로 관리할 수 있습니다.
메모리 누수 걱정 없이 자동 정리되며, bounds checking으로 버퍼 오버플로우도 방지됩니다. 특히 OS 개발에서 자주 바뀌는 리스트 관리에 최적입니다.
Iterator API를 활용하면 filter, map 등으로 함수형 스타일 코드도 작성할 수 있어 가독성과 안전성이 크게 향상됩니다.
실전 팁
💡 Vec::with_capacity(n)으로 초기 capacity를 지정하면 불필요한 재할당을 피할 수 있습니다. 크기를 대략 알고 있다면 반드시 사용하세요
💡 shrink_to_fit()으로 사용하지 않는 capacity를 반환할 수 있지만, 재할당 비용이 있으므로 메모리가 부족할 때만 사용하세요
💡 into_boxed_slice()로 Vec를 Box<[T]>로 변환하면 불필요한 capacity가 제거된 고정 크기 슬라이스를 얻습니다. 읽기 전용 데이터에 유용합니다
💡 Vec::from_raw_parts()로 raw pointer에서 Vec를 재구성할 수 있지만, len/capacity/pointer가 정확해야 합니다. DMA 버퍼 등 하드웨어 메모리를 다룰 때 주의해서 사용하세요
💡 no_std에서 Vec를 쓰려면 alloc 크레이트와 글로벌 할당자가 필요합니다. 임베디드에서는 heapless 크레이트의 고정 크기 Vec도 고려하세요
3. Vec의 내부 구조 이해하기 - ptr, len, capacity의 삼위일체
시작하며
여러분이 Vec의 성능을 최적화하려고 할 때, 내부에서 어떻게 동작하는지 모르면 답답하셨을 겁니다. 왜 어떤 경우에는 빠르고 어떤 경우에는 느린지, 메모리는 정확히 얼마나 사용하는지 알기 어렵죠.
이런 블랙박스 같은 상황은 OS 개발에서 치명적입니다. 커널 공간에서는 메모리와 성능 예측이 필수적이며, 예상치 못한 재할당이나 메모리 사용은 시스템 전체의 안정성을 해칠 수 있습니다.
바로 이럴 때 필요한 것이 Vec 내부 구조에 대한 깊은 이해입니다. ptr, len, capacity 세 필드가 어떻게 협력하는지 알면 효율적인 코드를 작성할 수 있습니다.
개요
간단히 말해서, Vec는 내부적으로 세 개의 필드로 구성됩니다: ptr(힙 메모리 포인터), len(현재 원소 개수), capacity(할당된 공간 크기). 왜 이 구조가 중요한지는 메모리 레이아웃을 보면 알 수 있습니다.
ptr은 실제 데이터가 있는 힙 메모리를 가리키고, len은 유효한 원소가 몇 개인지, capacity는 재할당 없이 몇 개까지 담을 수 있는지를 나타냅니다. 예를 들어, capacity 100인 Vec에 len 50만큼 채워졌다면, 50개는 더 push할 수 있습니다.
기존 C 배열은 크기가 고정이었다면, Vec는 len과 capacity를 분리해 동적 성장을 가능하게 합니다. len < capacity일 때는 재할당 없이 빠르게 추가할 수 있죠.
Vec 내부 구조의 핵심은: (1) ptr로 연속 메모리 보장, (2) len으로 현재 사용량 추적, (3) capacity로 재할당 시점 결정. 이 세 요소의 조화가 Vec의 효율성을 만듭니다.
코드 예제
// Vec 내부 구조 시뮬레이션
struct MyVec<T> {
ptr: *mut T, // 힙 메모리 포인터
len: usize, // 현재 원소 개수
capacity: usize, // 할당된 공간
}
let mut v = Vec::with_capacity(10);
// ptr: 힙 메모리 주소, len: 0, capacity: 10
v.push(42);
// ptr: 동일, len: 1, capacity: 10 (재할당 없음)
v.reserve(100);
// ptr: 새 주소(재할당), len: 1, capacity: 111
설명
이것이 하는 일: Vec는 세 필드를 유지하며 메모리를 관리하고, 필요시 재할당으로 공간을 확장합니다. 첫 번째로, Vec::with_capacity(10)은 allocator를 통해 T 타입 10개 크기의 힙 메모리를 할당하고 ptr에 저장합니다.
이때 len은 0이지만 capacity는 10입니다. 실제 메모리는 할당되었지만 유효한 데이터는 없는 상태죠.
이는 초기화와 할당을 분리하여 성능을 최적화하는 Rust의 철학입니다. 그 다음으로, push(42)가 호출되면 ptr[len] 위치에 42를 쓰고 len을 1 증가시킵니다.
len(1) < capacity(10)이므로 재할당이 일어나지 않아 매우 빠릅니다. 이처럼 capacity에 여유가 있을 때는 O(1) 시간에 추가할 수 있습니다.
마지막으로, reserve(100)은 현재 capacity가 요청보다 작으므로 재할당을 트리거합니다. 새로운 메모리(보통 max(current * 2, requested) = 111)를 할당하고, 기존 len(1)개 원소를 복사한 뒤, 이전 메모리를 해제합니다.
ptr은 새 주소를 가리키고 capacity는 111로 업데이트됩니다. 여러분이 이 구조를 이해하면 Vec 사용 시 언제 재할당이 일어나는지 예측할 수 있습니다.
성능이 중요한 루프에서는 미리 reserve로 capacity를 확보하여 재할당을 피하고, 메모리가 부족한 환경에서는 shrink_to_fit으로 불필요한 capacity를 정리할 수 있습니다. 또한 unsafe 코드에서 Vec::from_raw_parts를 쓸 때도 이 세 필드의 불변식을 지켜야 메모리 안전성이 보장됩니다.
실전 팁
💡 len()과 capacity()로 현재 상태를 확인할 수 있습니다. 성능 디버깅 시 재할당 빈도를 파악하는 데 유용합니다
💡 Vec의 메모리 크기는 size_of::<T>() * capacity + 24바이트(ptr+len+capacity) 입니다. 메모리 예산을 계산할 때 참고하세요
💡 clear()는 len을 0으로 만들지만 capacity는 유지합니다. 재사용할 Vec라면 clear()로 재할당을 피하세요
💡 as_ptr()과 as_mut_ptr()로 raw pointer를 얻을 수 있지만, len을 넘어선 접근은 UB입니다. 항상 len 범위 내에서만 사용하세요
💡 no_std 환경에서는 힙 메모리가 부족할 수 있으므로, reserve나 push 실패를 대비한 에러 처리를 고려하세요. try_reserve 같은 fallible API를 사용하는 것도 방법입니다
4. Box와 Vec의 메모리 할당자 커스터마이징 - 나만의 allocator 구현하기
시작하며
여러분이 OS 커널을 개발하면서 기본 할당자가 없어서 Box나 Vec를 쓸 수 없었던 경험이 있나요? no_std 환경에서는 표준 라이브러리의 할당자를 사용할 수 없어 직접 구현해야 합니다.
이런 문제는 bare-metal 프로그래밍의 가장 큰 장벽 중 하나입니다. 할당자 없이는 동적 메모리 할당이 불가능하고, 이는 현대적인 데이터 구조 사용을 막습니다.
게다가 성능 특성이나 메모리 단편화를 제어하고 싶을 때도 커스텀 할당자가 필요합니다. 바로 이럴 때 필요한 것이 GlobalAlloc trait 구현입니다.
자신만의 할당자를 만들어 Box와 Vec를 사용할 수 있게 되고, 메모리 할당 정책도 완전히 제어할 수 있습니다.
개요
간단히 말해서, GlobalAlloc trait을 구현하면 Rust의 글로벌 메모리 할당자를 커스터마이징할 수 있습니다. 왜 커스텀 할당자가 필요한지는 OS 개발 상황을 보면 명확합니다.
커널은 물리 메모리를 직접 관리해야 하고, buddy allocator나 slab allocator 같은 특수한 전략이 필요합니다. 또한 실시간 시스템에서는 할당 시간 예측성이 중요하고, 임베디드에서는 메모리 단편화 최소화가 필수적입니다.
예를 들어, DMA 메모리는 물리적으로 연속되어야 하므로 특별한 할당 정책이 필요합니다. 기존에는 할당자가 블랙박스였다면, GlobalAlloc 구현으로 alloc/dealloc의 모든 측면을 제어할 수 있습니다.
할당 크기 추적, 메모리 풀 관리, 디버깅 정보 수집 등이 가능해집니다. GlobalAlloc의 핵심은: (1) alloc과 dealloc 두 메서드만 구현하면 됨, (2) #[global_allocator]로 등록, (3) 모든 Box, Vec, String 등이 자동으로 사용.
이러한 간결함이 커스터마이징을 쉽게 만듭니다.
코드 예제
use core::alloc::{GlobalAlloc, Layout};
struct BumpAllocator {
heap_start: usize,
heap_end: usize,
next: usize,
}
unsafe impl GlobalAlloc for BumpAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// 정렬 맞추기
let alloc_start = align_up(self.next, layout.align());
let alloc_end = alloc_start + layout.size();
if alloc_end > self.heap_end {
core::ptr::null_mut() // OOM
} else {
self.next = alloc_end;
alloc_start as *mut u8
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// Bump allocator는 개별 해제 안 함
}
}
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
heap_start: 0x_4444_4444_0000,
heap_end: 0x_4444_4444_0000 + 1024 * 1024, // 1MB
next: 0x_4444_4444_0000,
};
설명
이것이 하는 일: GlobalAlloc trait의 alloc과 dealloc을 구현하여 메모리 할당 정책을 정의하고, 글로벌 할당자로 등록합니다. 첫 번째로, BumpAllocator 구조체는 간단한 bump allocator(선형 할당자)를 구현합니다.
heap_start부터 heap_end까지가 사용 가능한 메모리이고, next는 다음 할당 위치를 가리킵니다. alloc이 호출되면 Layout에서 요청된 크기와 정렬을 가져와 align_up으로 정렬을 맞추고, 충분한 공간이 있는지 확인합니다.
공간이 있으면 next를 증가시키고 포인터를 반환하며, 없으면 null을 반환해 할당 실패를 알립니다. 그 다음으로, dealloc은 bump allocator의 특성상 개별 해제를 지원하지 않습니다.
모든 메모리는 힙 전체를 리셋할 때만 해제됩니다. 이는 단순하지만 메모리 단편화가 없고 할당이 매우 빠르다는 장점이 있습니다.
단기 수명 객체가 많은 경우에 적합하죠. 마지막으로, #[global_allocator] 속성으로 ALLOCATOR를 글로벌 할당자로 등록하면, 이후 모든 Box::new(), Vec::push() 등이 자동으로 이 할당자를 사용합니다.
컴파일러가 alloc 호출을 BumpAllocator::alloc으로 연결해주므로, 애플리케이션 코드는 할당자를 의식할 필요가 없습니다. 여러분이 이 코드를 사용하면 OS 커널에서 동적 메모리 할당을 완전히 제어할 수 있습니다.
Buddy allocator로 메모리 단편화를 줄이거나, Slab allocator로 같은 크기 객체의 할당을 최적화하거나, 통계 수집을 위해 할당 횟수와 크기를 추적할 수도 있습니다. 또한 디버그 빌드에서는 메모리 누수 탐지나 double-free 검사를 추가하여 안정성을 높일 수 있습니다.
실무에서는 linked_list_allocator나 buddy_system_allocator 같은 기존 구현체를 활용하는 것도 좋은 선택입니다.
실전 팁
💡 Layout::from_size_align_unchecked는 unsafe하므로, 항상 size와 align이 유효한지 확인하세요. 잘못된 값은 UB를 일으킵니다
💡 alloc에서 null 반환 시 Rust는 패닉하므로, OOM 핸들러를 #[alloc_error_handler]로 정의해야 합니다. 커널에서는 복구 불가능한 에러 처리가 필요합니다
💡 멀티코어 환경에서는 GlobalAlloc의 메서드가 여러 스레드에서 동시 호출될 수 있으므로, 내부 상태를 보호하는 동기화(spinlock 등)가 필수입니다
💡 정렬 요구사항을 무시하면 하드웨어 예외가 발생할 수 있습니다. 특히 SIMD나 atomic 타입은 엄격한 정렬이 필요합니다
💡 디버깅을 위해 할당/해제마다 로그를 남기면 메모리 누수를 추적하기 쉽습니다. 단, 성능 오버헤드가 있으므로 릴리스 빌드에서는 조건부 컴파일로 제거하세요
5. Vec를 활용한 커널 자료구조 패턴 - 순환 버퍼와 우선순위 큐
시작하며
여러분이 커널에서 인터럽트 핸들러나 스케줄러를 구현할 때 효율적인 자료구조가 필요했던 경험이 있나요? 네트워크 패킷 버퍼는 순환 큐가 필요하고, 태스크 스케줄링은 우선순위 큐가 필요합니다.
이런 문제는 OS의 성능과 직결됩니다. 잘못된 자료구조 선택은 불필요한 메모리 복사나 O(n) 연산을 초래하여 시스템 전체의 응답성을 해칩니다.
특히 실시간 제약이 있는 커널에서는 치명적이죠. 바로 이럴 때 필요한 것이 Vec 기반 커널 자료구조입니다.
Vec의 연속 메모리와 동적 크기 조정 능력을 활용하여 효율적인 순환 버퍼와 우선순위 큐를 구현할 수 있습니다.
개요
간단히 말해서, Vec의 인덱싱과 슬라이싱을 활용하면 커널에 필요한 고성능 자료구조를 쉽게 만들 수 있습니다. 왜 Vec 기반 구조가 좋은지는 성능 특성을 보면 알 수 있습니다.
연속 메모리는 캐시 친화적이어서 linked list보다 훨씬 빠릅니다. 순환 버퍼는 head/tail 인덱스만 이동하여 O(1)에 enqueue/dequeue하고, 힙 기반 우선순위 큐는 O(log n)에 최댓값을 추출합니다.
예를 들어, 네트워크 드라이버에서 패킷을 처리할 때 순환 버퍼를 쓰면 메모리 복사 없이 빠르게 처리할 수 있습니다. 기존 C의 배열 기반 구현은 고정 크기였다면, Vec는 동적으로 크기를 조정하여 부하에 따라 확장할 수 있습니다.
평소에는 작은 메모리로 운영하다가 트래픽이 몰리면 자동 확장되죠. Vec 기반 자료구조의 핵심은: (1) 인덱스 연산으로 빠른 접근, (2) 슬라이싱으로 효율적인 부분 처리, (3) swap_remove나 rotate 같은 최적화 메서드.
이러한 기능들이 커널 수준의 성능을 가능하게 합니다.
코드 예제
// 순환 버퍼 구현
struct RingBuffer<T> {
buffer: Vec<T>,
head: usize, // 읽기 위치
tail: usize, // 쓰기 위치
size: usize, // 현재 원소 개수
}
impl<T> RingBuffer<T> {
fn new(capacity: usize) -> Self {
RingBuffer {
buffer: Vec::with_capacity(capacity),
head: 0,
tail: 0,
size: 0,
}
}
fn enqueue(&mut self, item: T) -> Result<(), T> {
if self.size == self.buffer.capacity() {
return Err(item); // 버퍼 가득참
}
self.buffer.insert(self.tail, item);
self.tail = (self.tail + 1) % self.buffer.capacity();
self.size += 1;
Ok(())
}
fn dequeue(&mut self) -> Option<T> {
if self.size == 0 {
return None;
}
let item = self.buffer.remove(self.head);
self.head = (self.head + 1) % self.buffer.capacity();
self.size -= 1;
Some(item)
}
}
설명
이것이 하는 일: 순환 버퍼는 고정 크기 Vec에서 head/tail 인덱스를 순환시키며 FIFO 큐를 구현합니다. 첫 번째로, new()에서 Vec::with_capacity로 미리 공간을 확보합니다.
이는 런타임 재할당을 방지하여 예측 가능한 성능을 보장합니다. head와 tail은 0으로 초기화되고, size는 현재 버퍼에 담긴 원소 개수를 추적합니다.
capacity가 고정되므로 메모리 사용량도 예측 가능합니다. 그 다음으로, enqueue는 tail 위치에 원소를 삽입하고 tail을 증가시킵니다.
modulo 연산(%)으로 tail이 capacity를 넘으면 0으로 순환합니다. size가 capacity에 도달하면 Err를 반환해 오버플로우를 방지합니다.
이는 커널에서 백프레셔(backpressure)를 구현하는 방법입니다. 마지막으로, dequeue는 head 위치의 원소를 제거하고 head를 증가시킵니다.
size가 0이면 None을 반환해 언더플로우를 방지합니다. remove는 Vec에서 원소를 빼내므로, 실제로는 더 효율적인 구현에서는 Option<T> 배열과 읽기만으로 처리합니다.
여러분이 이 코드를 사용하면 인터럽트 핸들러에서 고속으로 이벤트를 큐잉할 수 있습니다. 예를 들어 네트워크 카드가 패킷을 받으면 순환 버퍼에 넣고, 메인 루프에서 dequeue하여 처리하는 패턴이 일반적입니다.
우선순위 큐는 BinaryHeap을 사용하거나 직접 힙을 구현하여 스케줄러의 ready queue를 관리할 수 있습니다. Vec 기반이므로 정렬이나 검색도 빠르고, Iterator로 함수형 처리도 가능합니다.
실전 팁
💡 실제 성능을 위해서는 Vec<Option<T>>를 쓰고 remove 대신 take()로 읽기만 하세요. insert/remove는 메모리 이동이 발생해 느립니다
💡 capacity를 2의 거듭제곱으로 설정하면 % 연산을 비트 AND로 최적화할 수 있습니다. tail & (capacity - 1)이 훨씬 빠릅니다
💡 멀티프로듀서/멀티컨슈머 환경에서는 head/tail 업데이트에 atomic 연산과 메모리 순서를 고려해야 합니다. crossbeam 크레이트를 참고하세요
💡 우선순위 큐는 std::collections::BinaryHeap을 쓰거나, 직접 힙을 구현할 수 있습니다. heapify 연산으로 O(n)에 초기화하고 push/pop은 O(log n)입니다
💡 커널에서는 인터럽트 컨텍스트에서 할당을 피해야 하므로, 부팅 시 충분한 capacity를 미리 확보하고 런타임에는 재할당이 일어나지 않도록 주의하세요
6. Box를 이용한 재귀 자료구조 - 트리와 링크드 리스트 구현
시작하며
여러분이 파일 시스템의 디렉토리 트리나 프로세스 스케줄러의 계층 구조를 구현하려 할 때 컴파일 에러를 본 적 있나요? Rust에서 재귀 타입(자기 자신을 포함하는 구조체)은 크기를 알 수 없어 직접 사용할 수 없습니다.
이런 문제는 Rust의 크기 결정 시스템 때문입니다. 컴파일러는 모든 타입의 크기를 컴파일 타임에 알아야 하는데, struct Node { next: Node }는 무한 크기가 되어 불가능합니다.
이는 트리, 그래프, 링크드 리스트 같은 기본 자료구조조차 만들 수 없다는 뜻입니다. 바로 이럴 때 필요한 것이 Box를 활용한 간접 참조(indirection)입니다.
Box는 고정 크기 포인터이므로 재귀 타입을 가능하게 하며, 소유권 기반으로 메모리도 안전하게 관리합니다.
개요
간단히 말해서, Box<T>를 사용하면 재귀적인 자료구조를 컴파일 가능한 형태로 만들 수 있습니다. 왜 Box가 재귀 타입에 필요한지는 메모리 레이아웃을 보면 명확합니다.
struct Node { next: Box<Node> }는 next 필드가 포인터(8바이트)이므로 Node의 크기가 결정됩니다. Box 내부의 실제 Node는 힙에 있으므로 무한 재귀가 발생하지 않죠.
예를 들어, 파일 시스템에서 디렉토리는 하위 디렉토리를 포함하는 트리 구조인데, Box를 쓰면 간단히 표현할 수 있습니다. 기존 C에서는 포인터로 재귀 구조를 만들었지만 수동 메모리 관리가 필요했다면, Rust의 Box는 자동으로 해제하여 메모리 누수를 방지합니다.
소유권 시스템으로 누가 메모리를 소유하는지도 명확합니다. Box 기반 재귀 구조의 핵심은: (1) 고정 크기 포인터로 재귀 가능, (2) Option<Box<T>>로 종료 조건 표현, (3) 패턴 매칭으로 안전한 순회.
이러한 조합이 안전하고 표현력 높은 자료구조를 만듭니다.
코드 예제
// 바이너리 트리 구현
struct TreeNode<T> {
value: T,
left: Option<Box<TreeNode<T>>>,
right: Option<Box<TreeNode<T>>>,
}
impl<T> TreeNode<T> {
fn new(value: T) -> Self {
TreeNode {
value,
left: None,
right: None,
}
}
fn insert_left(&mut self, value: T) {
self.left = Some(Box::new(TreeNode::new(value)));
}
fn insert_right(&mut self, value: T) {
self.right = Some(Box::new(TreeNode::new(value)));
}
}
// 사용 예
let mut root = TreeNode::new(10);
root.insert_left(5);
root.insert_right(15);
설명
이것이 하는 일: Box는 고정 크기 포인터로 재귀 타입을 가능하게 하고, Option으로 종료 조건을 표현합니다. 첫 번째로, TreeNode 정의에서 left와 right는 Option<Box<TreeNode<T>>> 타입입니다.
Option은 자식이 없을 수 있음(None)을 표현하고, Box는 재귀를 가능하게 합니다. TreeNode의 크기는 sizeof(T) + 16바이트(두 Option<Box>)로 고정되며, 실제 자식 노드는 힙에 할당됩니다.
이는 깊이에 상관없이 스택 사용량이 일정하다는 뜻입니다. 그 다음으로, insert_left는 새 TreeNode를 Box로 감싸고 Some으로 래핑하여 left에 할당합니다.
Box::new는 힙에 노드를 할당하고, 소유권이 left로 이동합니다. 이전에 left에 노드가 있었다면 자동으로 Drop되어 메모리가 해제됩니다.
이는 덮어쓰기 시 메모리 누수가 없음을 보장합니다. 마지막으로, 트리를 순회하거나 삭제할 때 패턴 매칭을 사용합니다.
if let Some(ref mut left) = node.left로 안전하게 접근하고, None이면 순회를 종료합니다. 재귀 함수로 전위/중위/후위 순회를 구현할 수 있으며, drop시 자동으로 모든 자식 노드가 재귀적으로 해제됩니다.
여러분이 이 코드를 사용하면 파일 시스템의 inode 트리, 프로세스의 부모-자식 관계, 메모리 페이지 테이블 같은 계층 구조를 안전하게 구현할 수 있습니다. C의 포인터 기반 트리는 double-free나 use-after-free 버그가 흔했지만, Rust는 컴파일 타임에 방지합니다.
또한 링크드 리스트도 같은 패턴으로 만들 수 있으며, std::collections::LinkedList보다 더 최적화된 커스텀 구현도 가능합니다.
실전 팁
💡 깊은 트리를 drop하면 재귀 호출로 스택 오버플로우가 발생할 수 있습니다. 반복적(iterative) 해제 로직을 구현하거나 스택 크기를 늘리세요
💡 Rc<RefCell<TreeNode>>를 쓰면 여러 부모를 가진 그래프를 만들 수 있지만, 순환 참조 시 메모리 누수가 발생합니다. Weak를 사용해 순환을 끊으세요
💡 트리 순회는 재귀보다 스택 기반 반복이 더 안전합니다. Vec<&TreeNode>를 스택으로 사용하여 깊이우선탐색을 구현하세요
💡 불균형 트리는 성능이 떨어지므로, AVL이나 Red-Black 트리로 자동 균형을 유지하는 것을 고려하세요. 복잡하지만 O(log n) 보장됩니다
💡 no_std 환경에서는 Box 대신 arena allocator를 사용하는 것도 방법입니다. typed-arena 크레이트로 한 번에 할당하고 한 번에 해제할 수 있습니다
7. Vec의 고급 메서드 활용 - retain, dedup, split_off로 효율성 극대화
시작하며
여러분이 커널에서 메모리 페이지 리스트를 정리하거나 중복된 이벤트를 제거할 때 비효율적인 루프를 작성한 적이 있나요? 직접 반복문을 돌며 조건을 확인하고 원소를 제거하는 코드는 읽기 어렵고 성능도 좋지 않습니다.
이런 문제는 일반적인 패턴이 반복되는데도 매번 새로 작성해야 한다는 점입니다. 필터링, 중복 제거, 분할 같은 작업은 OS 개발에서 매우 흔하지만, 올바르고 효율적으로 구현하기는 쉽지 않습니다.
바로 이럴 때 필요한 것이 Vec의 고급 메서드들입니다. retain, dedup, split_off 같은 메서드는 일반적인 패턴을 최적화된 형태로 제공하여 코드를 간결하고 빠르게 만듭니다.
개요
간단히 말해서, Vec는 필터링, 중복 제거, 분할 같은 흔한 작업을 위한 최적화된 메서드를 제공합니다. 왜 이 메서드들이 중요한지는 성능과 안전성을 보면 알 수 있습니다.
retain은 조건을 만족하는 원소만 남기고 나머지를 제거하는데, 내부적으로 한 번의 순회로 처리하여 O(n)입니다. dedup은 연속된 중복을 제거하며, 정렬된 Vec에 사용하면 모든 중복을 제거할 수 있습니다.
split_off는 Vec를 특정 인덱스에서 분할하여 두 개의 Vec로 만듭니다. 예를 들어, 프로세스 리스트에서 좀비 프로세스만 필터링하거나, 이벤트 로그에서 중복을 제거할 때 유용합니다.
기존에는 직접 루프를 작성했다면, 이 메서드들은 의도를 명확히 드러내고 최적화된 구현을 제공합니다. 또한 메모리 복사를 최소화하여 성능도 우수합니다.
Vec 고급 메서드의 핵심은: (1) 클로저 기반 조건 처리, (2) in-place 연산으로 복사 최소화, (3) 명확한 의도 전달. 이러한 특징들이 읽기 쉽고 빠른 코드를 만듭니다.
코드 예제
// 메모리 페이지 관리 예제
let mut pages: Vec<PageFrame> = get_all_pages();
// 사용 중인 페이지만 유지
pages.retain(|page| page.is_in_use());
// 페이지 주소로 정렬
pages.sort_by_key(|page| page.addr);
// 중복된 페이지 제거 (정렬 후)
pages.dedup_by_key(|page| page.addr);
// 처음 100개와 나머지 분할
let rest = pages.split_off(100);
// swap_remove로 O(1) 제거 (순서 무관할 때)
pages.swap_remove(10); // 인덱스 10 제거
설명
이것이 하는 일: Vec의 고급 메서드는 일반적인 패턴을 최적화된 형태로 제공하여 한 번의 순회로 처리합니다. 첫 번째로, retain은 클로저를 받아 각 원소에 대해 평가하고, true를 반환하는 원소만 남깁니다.
내부적으로는 두 개의 포인터(읽기/쓰기)를 유지하며 한 번의 순회로 처리합니다. is_in_use()가 false인 페이지는 자동으로 drop되고, true인 페이지는 앞쪽으로 이동합니다.
이는 filter().collect()보다 효율적입니다(새 Vec 할당 없음). 그 다음으로, dedup_by_key는 정렬된 Vec에서 연속된 중복을 제거합니다.
sort_by_key로 주소 순으로 정렬한 뒤 dedup_by_key를 호출하면, 같은 주소의 페이지가 연속되어 있으므로 모든 중복이 제거됩니다. 내부적으로 윈도우 슬라이딩으로 O(n)에 처리되며, 제거된 원소는 즉시 drop됩니다.
마지막으로, split_off(100)은 인덱스 100부터 끝까지를 새 Vec로 분리합니다. 원본 Vec는 0..100만 남고, 반환된 Vec는 100..끝을 가집니다.
내부적으로 메모리 복사가 아니라 포인터와 len/capacity 조정으로 처리되어 매우 빠릅니다. swap_remove는 제거할 원소를 마지막 원소와 교환한 뒤 pop하므로 O(1)이지만 순서가 바뀝니다.
여러분이 이 메서드들을 사용하면 복잡한 데이터 처리 로직을 간결하게 작성할 수 있습니다. 예를 들어 인터럽트 큐에서 처리 완료된 항목을 제거하거나, 메모리 블록 리스트를 크기별로 분할하거나, 네트워크 패킷에서 중복을 제거하는 작업이 한 줄로 가능합니다.
또한 Iterator API(filter, map 등)와 조합하면 함수형 스타일로 더 강력한 처리를 할 수 있습니다. drain 메서드로 범위를 제거하며 반복할 수도 있어 매우 유연합니다.
실전 팁
💡 retain은 Vec를 재할당하지 않으므로 capacity가 유지됩니다. 메모리를 회수하려면 shrink_to_fit을 추가로 호출하세요
💡 dedup은 정렬 없이 연속 중복만 제거합니다. 모든 중복 제거는 sort + dedup 조합을 사용하거나, HashSet으로 변환하는 것도 방법입니다
💡 split_off는 원본을 수정하므로 소유권이 필요합니다. 불변 참조로는 사용할 수 없으니 주의하세요
💡 swap_remove는 순서를 유지하지 않습니다. 순서가 중요하면 remove를 쓰되, O(n)임을 인지하세요. 빈번한 제거는 LinkedList나 다른 자료구조를 고려하세요
💡 drain(range)은 지정 범위의 원소를 제거하며 iterator로 반환합니다. 제거하면서 처리할 때 유용하며, 범위 밖 원소는 유지됩니다
8. Box의 메모리 레이아웃 최적화 - #[repr(C)]와 메모리 정렬
시작하며
여러분이 하드웨어와 직접 통신하는 드라이버를 작성할 때 구조체 레이아웃이 예상과 다른 경험을 한 적 있나요? DMA 버퍼나 MMIO 레지스터 매핑 시 정확한 메모리 레이아웃이 필수적인데, Rust의 기본 레이아웃은 최적화를 위해 필드 순서를 재배치합니다.
이런 문제는 하드웨어 인터페이스에서 치명적입니다. 네트워크 카드나 디스크 컨트롤러는 특정 바이트 오프셋에 특정 필드가 있어야 하는데, 재배치되면 완전히 다른 데이터를 읽게 됩니다.
또한 정렬 요구사항을 지키지 않으면 하드웨어 예외가 발생할 수 있습니다. 바로 이럴 때 필요한 것이 #[repr(C)]와 정렬 속성입니다.
C와 호환되는 레이아웃을 강제하고, 특정 정렬을 보장하여 하드웨어와 안전하게 통신할 수 있습니다.
개요
간단히 말해서, #[repr(C)]는 C 언어와 동일한 메모리 레이아웃을 보장하고, #[repr(align(N))]는 특정 바이트 정렬을 강제합니다. 왜 레이아웃 제어가 필요한지는 FFI와 하드웨어 프로그래밍을 보면 명확합니다.
Rust의 기본 레이아웃은 컴파일러가 최적화를 위해 필드 순서를 바꿀 수 있지만, #[repr(C)]를 쓰면 선언 순서대로 배치됩니다. 또한 DMA는 특정 정렬(예: 4KB)을 요구하는데, #[repr(align(4096))]로 보장할 수 있습니다.
예를 들어, PCI 디바이스의 레지스터 맵을 구조체로 표현할 때 정확한 오프셋이 생명입니다. 기존에는 수동으로 오프셋을 계산했다면, repr 속성으로 컴파일러가 보장하게 할 수 있습니다.
또한 static assertion으로 레이아웃을 검증할 수도 있습니다. 레이아웃 제어의 핵심은: (1) #[repr(C)]로 필드 순서 고정, (2) #[repr(align(N))]로 정렬 보장, (3) #[repr(packed)]로 패딩 제거.
이러한 도구들이 하드웨어 레벨 프로그래밍을 가능하게 합니다.
코드 예제
// PCI 디바이스 레지스터 구조체
#[repr(C, align(4096))] // C 레이아웃 + 4KB 정렬
struct PciRegisters {
vendor_id: u16, // 오프셋 0
device_id: u16, // 오프셋 2
command: u16, // 오프셋 4
status: u16, // 오프셋 6
// ... 더 많은 레지스터
}
// DMA 버퍼 (페이지 정렬 필수)
#[repr(align(4096))]
struct DmaBuffer {
data: [u8; 8192], // 2 페이지
}
// Box로 힙에 할당 (정렬 유지)
let regs = Box::new(PciRegisters {
vendor_id: 0x8086,
device_id: 0x1234,
command: 0,
status: 0,
});
// 주소 확인 (4096의 배수여야 함)
let addr = &*regs as *const _ as usize;
assert_eq!(addr % 4096, 0);
설명
이것이 하는 일: repr 속성은 컴파일러에게 특정 메모리 레이아웃을 강제하고, Box는 힙 할당 시 정렬을 보장합니다. 첫 번째로, #[repr(C, align(4096))]는 두 가지를 지정합니다.
repr(C)는 필드를 선언 순서대로 배치하고 C의 패딩 규칙을 따릅니다. align(4096)은 구조체 전체가 4096바이트 경계에 정렬되도록 강제합니다.
PciRegisters는 vendor_id가 오프셋 0, device_id가 오프셋 2에 정확히 위치하며, 이는 PCI 스펙과 일치합니다. 그 다음으로, Box::new(PciRegisters {...})는 힙에 할당하면서 align(4096) 요구사항을 준수합니다.
글로벌 할당자의 alloc 메서드는 Layout에서 정렬 정보를 받아 적절한 주소를 반환합니다. 만약 정렬을 만족하지 못하면 할당이 실패하거나 패닉이 발생합니다.
따라서 커스텀 할당자를 구현할 때는 정렬을 반드시 처리해야 합니다. 마지막으로, assert_eq!(addr % 4096, 0)으로 런타임에 정렬을 검증합니다.
&*regs로 Box를 역참조하여 참조를 얻고, as *const _로 raw pointer로 변환한 뒤, as usize로 주소 값을 얻습니다. 4096으로 나눈 나머지가 0이면 올바르게 정렬되었음을 확인할 수 있습니다.
여러분이 이 코드를 사용하면 MMIO 레지스터를 안전하게 매핑하고 접근할 수 있습니다. 예를 들어 volatile_read/write로 레지스터를 읽고 쓸 때 정확한 오프셋이 보장됩니다.
DMA 버퍼도 페이지 정렬을 만족하여 하드웨어가 직접 접근할 수 있습니다. repr(packed)를 쓰면 패딩이 제거되어 더 정확한 레이아웃을 얻을 수 있지만, 정렬되지 않은 접근은 UB이므로 주의해야 합니다.
FFI에서 C 구조체와 상호작용할 때도 repr(C)는 필수입니다.
실전 팁
💡 #[repr(packed)]는 모든 패딩을 제거하지만, 정렬되지 않은 필드 접근은 UB입니다. &를 통한 참조 생성을 피하고 ptr::read_unaligned를 사용하세요
💡 repr(C)와 repr(Rust)의 크기가 다를 수 있습니다. size_of로 확인하고, static_assertions 크레이트로 컴파일 타임 검증을 추가하세요
💡 정렬이 큰 구조체를 스택에 올리면 스택 오버플로우가 발생할 수 있습니다. Box나 static으로 힙/데이터 섹션에 할당하세요
💡 no_std에서 커스텀 할당자를 쓸 때는 Layout::align을 반드시 확인하고, 정렬을 만족하는 주소를 반환해야 합니다. align_up 헬퍼 함수를 활용하세요
💡 하드웨어 레지스터 접근 시 volatile::Volatile<T> 래퍼를 사용하여 컴파일러 최적화를 방지하세요. 일반 읽기/쓰기는 최적화로 제거될 수 있습니다
9. Vec를 이용한 메모리 풀 구현 - 객체 재사용으로 할당 비용 줄이기
시작하며
여러분이 빈번하게 생성/소멸되는 객체를 다루면서 할당 오버헤드로 성능이 떨어진 경험이 있나요? 네트워크 패킷 처리나 태스크 스케줄링에서 초당 수천 개의 객체가 생성되고 사라지면 할당자 병목이 발생합니다.
이런 문제는 힙 할당의 본질적인 비용 때문입니다. 할당자를 호출하고, 메타데이터를 업데이트하고, 메모리를 초기화하는 과정은 생각보다 느립니다.
특히 멀티코어에서 할당자 락 경합은 확장성을 해칩니다. 바로 이럴 때 필요한 것이 메모리 풀(object pool)입니다.
Vec로 미리 할당된 객체를 관리하고 재사용하면 힙 할당 빈도를 극적으로 줄일 수 있습니다.
개요
간단히 말해서, 메모리 풀은 미리 할당한 객체들을 Vec에 보관하고, 필요할 때 꺼내 쓰고 반납하는 패턴입니다. 왜 메모리 풀이 효과적인지는 할당 패턴을 보면 알 수 있습니다.
같은 크기의 객체가 반복적으로 생성/소멸될 때, 매번 할당자를 호출하는 대신 풀에서 재사용하면 할당 비용이 거의 사라집니다. Vec::pop()과 Vec::push()는 O(1)이고 락도 필요 없습니다(단일 스레드).
예를 들어, 네트워크 드라이버에서 패킷 버퍼를 풀로 관리하면 처리량이 크게 향상됩니다. 기존에는 매번 Box::new()와 drop을 반복했다면, 풀은 초기화 시 한 번만 할당하고 계속 재사용합니다.
메모리 단편화도 줄어들고 캐시 지역성도 향상됩니다. 메모리 풀의 핵심은: (1) Vec로 사용 가능 객체 관리, (2) acquire/release로 대여/반납, (3) 초기 용량으로 할당 최소화.
이러한 설계가 고성능 시스템을 가능하게 합니다.
코드 예제
// 간단한 객체 풀 구현
struct Pool<T> {
objects: Vec<T>,
}
impl<T> Pool<T> {
fn with_capacity(cap: usize, init: impl Fn() -> T) -> Self {
let mut objects = Vec::with_capacity(cap);
for _ in 0..cap {
objects.push(init());
}
Pool { objects }
}
fn acquire(&mut self) -> Option<T> {
self.objects.pop()
}
fn release(&mut self, obj: T) {
self.objects.push(obj);
}
}
// 패킷 버퍼 풀 사용 예
let mut packet_pool = Pool::with_capacity(1024, || {
vec![0u8; 1500] // 1500바이트 패킷 버퍼
});
// 버퍼 획득
if let Some(mut buffer) = packet_pool.acquire() {
// 패킷 처리
buffer[0] = 0xFF;
// 처리 후 반납
packet_pool.release(buffer);
}
설명
이것이 하는 일: 메모리 풀은 초기화 시 객체를 미리 생성하고, 런타임에는 Vec의 pop/push로만 관리합니다. 첫 번째로, with_capacity(1024, init)는 1024개의 객체를 미리 생성합니다.
init 클로저가 1024번 호출되어 각 객체를 초기화하고 Vec에 추가합니다. 이 과정에서 힙 할당이 발생하지만, 이후 런타임에는 할당이 없습니다.
모든 객체가 Vec에 저장되어 바로 사용 가능한 상태입니다. 그 다음으로, acquire()는 Vec::pop()으로 마지막 객체를 꺼냅니다.
pop은 O(1)이고 메모리 이동도 없어 매우 빠릅니다. Option을 반환하므로 풀이 비었을 때 None으로 안전하게 처리할 수 있습니다.
풀이 비면 할당 실패이므로, 미리 충분한 용량을 확보하거나 동적으로 확장하는 로직을 추가할 수 있습니다. 마지막으로, release(obj)는 사용 완료된 객체를 Vec::push()로 돌려놓습니다.
객체는 재초기화되지 않고 그대로 풀에 반환되므로, 다음 acquire에서 즉시 사용할 수 있습니다. 만약 재초기화가 필요하면 release에서 reset() 같은 메서드를 호출하면 됩니다.
여러분이 이 코드를 사용하면 빈번한 할당을 회피하여 성능을 크게 향상시킬 수 있습니다. 네트워크 패킷, 태스크 구조체, I/O 버퍼 등 수명이 짧고 크기가 일정한 객체에 최적입니다.
멀티스레드 환경에서는 스레드별 풀을 만들거나, crossbeam::queue로 락 없는 풀을 구현할 수도 있습니다. 또한 SmallVec을 사용하면 작은 객체는 스택에, 큰 객체는 힙에 할당하여 더욱 최적화할 수 있습니다.
실무에서는 object-pool이나 sharded-slab 같은 크레이트를 활용하는 것도 좋습니다.
실전 팁
💡 풀 크기는 부하 테스트로 결정하세요. 너무 작으면 acquire 실패가 많고, 너무 크면 메모리 낭비입니다. 모니터링으로 적정 크기를 찾으세요
💡 객체를 release할 때 민감한 데이터를 남기지 마세요. 암호화 키나 개인정보는 반드시 zeroize하거나 재초기화하세요
💡 Vec 대신 VecDeque를 쓰면 FIFO 순서로 재사용하여 캐시 히트율을 높일 수 있습니다. 최근 사용된 객체가 캐시에 있을 가능성이 높습니다
💡 acquire 실패 시 동적 확장을 고려하세요. reserve_exact로 정확한 크기만큼 확장하고, 고수위선(high watermark)을 추적하여 다음 실행에 반영하세요
💡 Drop trait을 구현하여 acquire한 객체가 scope를 벗어나면 자동으로 release되게 할 수 있습니다. RAII 패턴으로 누락을 방지하세요
10. Box와 Vec의 성능 비교 - 언제 무엇을 쓸 것인가
시작하며
여러분이 Box와 Vec 중 어느 것을 써야 할지 고민한 적이 있나요? 둘 다 힙 할당을 사용하지만 성능 특성과 사용 사례가 다릅니다.
잘못 선택하면 불필요한 메모리 사용이나 성능 저하가 발생합니다. 이런 문제는 각 타입의 본질을 이해하지 못해서 생깁니다.
Box는 단일 값의 소유권 관리에 최적화되어 있고, Vec는 동적 배열 관리에 특화되어 있습니다. 상황에 맞지 않는 선택은 코드를 복잡하게 만들고 성능도 해칩니다.
바로 이럴 때 필요한 것이 Box와 Vec의 성능 특성 이해입니다. 각각의 장단점과 적합한 사용 사례를 알면 올바른 선택을 할 수 있습니다.
개요
간단히 말해서, Box는 단일 값의 힙 할당에, Vec는 가변 크기 컬렉션에 적합합니다. 왜 구분이 중요한지는 메모리 오버헤드를 보면 알 수 있습니다.
Box<T>는 포인터만 추가(8바이트)되지만, Vec<T>는 ptr + len + capacity(24바이트)가 필요합니다. 단일 값이라면 Box가 메모리를 절약합니다.
반면 여러 값이면 Vec가 연속 메모리로 캐시 효율이 높습니다. 예를 들어, 큰 구조체 하나는 Box로, 여러 이벤트는 Vec로 관리하는 것이 적합합니다.
기존에 무조건 Vec를 썼다면, 단일 값은 Box로 바꿔 오버헤드를 줄일 수 있습니다. 반대로 Box 배열보다 Vec가 할당 횟수를 줄여줍니다.
선택의 핵심은: (1) 단일 vs 복수, (2) 고정 vs 가변 크기, (3) 메모리 vs 기능. 이러한 기준이 올바른 타입 선택을 안내합니다.
코드 예제
// Box: 큰 단일 구조체
struct HugeStruct {
data: [u8; 1_000_000],
}
let huge = Box::new(HugeStruct { data: [0; 1_000_000] });
// 메모리: 1MB(힙) + 8바이트(스택)
// Vec: 여러 작은 값
let mut events: Vec<Event> = Vec::new();
events.push(Event::KeyPress);
events.push(Event::MouseMove);
// 메모리: N * sizeof(Event)(힙) + 24바이트(스택)
// Box<[T]>: 크기 고정 슬라이스
let fixed: Box<[u8]> = vec![0; 100].into_boxed_slice();
// 메모리: 100바이트(힙) + 16바이트(스택, ptr+len)
// 성능 비교
// Box::new(): 1회 할당, O(1)
// Vec::push(): capacity 초과 시 재할당, 평균 O(1)
// Vec::with_capacity(): 재할당 없음, O(1)
설명
이것이 하는 일: Box와 Vec는 서로 다른 사용 사례에 최적화된 메모리 레이아웃과 API를 제공합니다. 첫 번째로, Box<HugeStruct>는 1MB 데이터를 힙에 할당하고 스택에는 8바이트 포인터만 유지합니다.
할당은 한 번만 일어나고 크기 조정이 없으므로 매우 예측 가능합니다. 큰 데이터를 함수 인자로 넘길 때 Box로 감싸면 복사 비용이 포인터 크기(8바이트)로 줄어듭니다.
Deref로 투명하게 접근할 수 있어 사용이 간편합니다. 그 다음으로, Vec<Event>는 동적으로 크기가 변하는 이벤트 리스트를 관리합니다.
초기 capacity가 0이고 첫 push에서 메모리를 할당하며, capacity를 초과하면 재할당(보통 2배)됩니다. 스택에는 ptr, len, capacity 세 필드(24바이트)가 있습니다.
재할당은 비용이 있지만 분할 상환(amortized) O(1)이므로 전체적으로 효율적입니다. 마지막으로, Box<[u8]>는 Vec를 고정 크기 슬라이스로 변환한 것입니다.
into_boxed_slice()로 불필요한 capacity를 제거하고, 크기 조정이 불가능한 읽기 전용 슬라이스가 됩니다. 스택 오버헤드는 16바이트(ptr + len)로 Vec보다 8바이트 작지만, push/pop 같은 동적 연산은 불가능합니다.
여러분이 이 지식을 활용하면 상황에 맞는 최적의 타입을 선택할 수 있습니다. 큰 구조체 하나는 Box, 여러 요소는 Vec, 고정 크기 배열은 Box<[T]>를 사용하세요.
성능이 중요하면 Vec::with_capacity로 재할당을 피하고, 메모리가 중요하면 shrink_to_fit으로 capacity를 정리하세요. 또한 SmallVec처럼 작은 크기는 스택에, 큰 크기는 힙에 할당하는 하이브리드 타입도 고려할 수 있습니다.
Cow(Clone on Write)로 읽기가 많은 경우 복사를 지연시키는 것도 유용합니다.
실전 팁
💡 Box<[T; N]> 대신 Box<[T]>를 사용하면 크기 정보가 런타임에 저장되어 유연성이 높아집니다. 단, 타입 시스템에서 크기를 알 수 없게 됩니다
💡 Vec::into_boxed_slice()는 capacity를 제거하므로 메모리를 절약하지만, 다시 Vec로 변환할 수 없습니다. 읽기 전용 데이터에만 사용하세요
💡 Arc<[T]>를 쓰면 여러 소유자가 슬라이스를 공유할 수 있습니다. 읽기 전용 공유 데이터에 유용하며, 복사 없이 참조 카운트만 증가합니다
💡 벤치마크로 실제 성능을 측정하세요. 이론적 분석과 실제 성능은 다를 수 있으며, 캐시 효과나 브랜치 예측이 큰 영향을 미칩니다
💡 profile-guided optimization(PGO)을 사용하면 실제 워크로드 기반으로 최적화할 수 있습니다. rustc에 -C profile-use 옵션을 전달하세요