힙 (Heap)
스택은 매우 효율적이지만 모든 상황을 해결해 주지는 못합니다. 컴파일 시점에 데이터의 크기를 미리 알 수 없는 경우는 어떻게 해야 할까요? 컬렉션, 문자열, 그리고 실행 중에 크기가 변하는 동적 데이터들은 스택에 통째로 할당할 수 없습니다. 바로 이때 **힙(Heap)**이 등장합니다.
힙 할당 (Heap Allocation)
힙은 아주 커다란 메모리 덩어리, 혹은 거대한 배열이라고 생각하면 이해하기 쉽습니다. 힙에 데이터를 저장하고 싶을 때는 **할당자(Allocator)**라는 특별한 프로그램에 필요한 만큼의 공간을 예약해달라고 요청해야 합니다. 이 과정을 힙 할당이라고 부릅니다. 할당이 성공하면 할당자는 예약된 메모리 블록의 시작 위치를 가리키는 **포인터(Pointer)**를 돌려줍니다.
수동 관리의 필요성
힙은 스택과 달리 데이터가 차곡차곡 쌓이지 않습니다. 대신 빈 공간이라면 어디든 위치할 수 있죠.
+---+---+---+---+---+---+-...-+-...-+---+---+---+---+---+---+---+
| 할당 1 | 여유 | ... | ... | 할당 N | 여유 |
+---+---+---+---+---+---+ ... + ... +---+---+---+---+---+---+---+
힙의 어느 부분이 사용 중이고 어디가 비어 있는지 관리하는 것은 할당자의 역할입니다. 하지만 할당자가 메모리를 다 썼다고 해서 자동으로 치워주지는 않습니다. 더 이상 필요 없는 메모리는 할당자를 다시 호출하여 명시적으로 **해제(Free)**해야 합니다.
성능 (Performance)
힙의 유연함에는 대가가 따릅니다. 힙 할당은 스택 할당보다 훨씬 느립니다. 메모리를 관리하는 데 드는 부수적인 작업(오버헤드)이 훨씬 많기 때문이죠. 성능 최적화 가이드에서 “힙 할당을 최소화하고 가능하면 스택을 활용하라“는 조언을 자주 보게 되는 이유가 바로 이것입니다.
String의 메모리 레이아웃
String 타입의 변수를 만들 때 Rust는 이를 힙에 할당합니다1. 어떤 텍스트가 들어올지 미리 알 수 없으므로 스택에 딱 맞는 공간을 비워둘 수 없기 때문입니다. 흥미로운 점은 String의 데이터 전체가 힙에 있는 것이 아니라, 일부 정보는 스택에도 저장된다는 사실입니다. 구체적으로는 다음 세 가지 정보를 스택에 유지합니다.
- 데이터가 저장된 힙 영역을 가리키는 포인터(Pointer)
- 문자열의 길이(Length): 현재 담고 있는 텍스트의 바이트 수
- 문자열의 용량(Capacity): 할당자로부터 예약받은 전체 바이트 수
예를 들어 다음 코드를 실행해 봅시다.
let mut s = String::with_capacity(5);
이때 메모리는 다음과 같이 구성됩니다.
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 0 | 5 |
+--|------+--------+----------+
|
|
v
+---+---+---+---+---+
힙: | ? | ? | ? | ? | ? |
+---+---+---+---+---+
최대 5바이트를 담을 수 있는 공간을 요청했으므로, 할당자는 힙에 5바이트를 예약하고 그 시작 주소를 알려줍니다. 하지만 아직 아무 글자도 넣지 않았으므로 길이는 0이고 용량은 5가 됩니다.
여기에 텍스트를 추가해 볼까요?
s.push_str("Hey");
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 3 | 5 |
+--| ----+--------+----------+
|
|
v
+---+---+---+---+---+
힙: | H | e | y | ? | ? |
+---+---+---+---+---+
이제 s는 3바이트의 텍스트를 담고 있습니다. 길이는 3으로 업데이트되지만, 예약해둔 공간(용량)은 여전히 5입니다.
usize 타입
스택에 포인터, 길이, 용량을 저장하려면 공간이 얼마나 필요할까요? 이는 여러분이 사용하는 컴퓨터의 아키텍처에 따라 달라집니다.
메모리의 모든 위치는 숫자로 된 주소(Address)를 가집니다. 컴퓨터가 한 번에 다룰 수 있는 메모리 양에 따라 이 주소값의 크기가 달라지는데, 요즘 대부분의 컴퓨터는 32비트나 64비트 시스템을 사용합니다.
Rust는 이러한 차이를 추상화하기 위해 usize라는 타입을 제공합니다.
usize는 해당 시스템에서 메모리 주소를 표현하는 데 필요한 만큼의 크기를 갖는 부호 없는 정수입니다. 32비트 컴퓨터에서는 u32와 같고, 64비트 컴퓨터에서는 u64와 크기가 같습니다. 포인터, 길이, 용량 모두 Rust에서는 이 usize 타입으로 표현됩니다2.
힙 메모리와 std::mem::size_of
std::mem::size_of 함수는 특정 타입이 스택에서 차지하는 공간의 크기(타입 크기)를 알려줍니다.
그렇다면
String이 힙에 들고 있는 실제 데이터는size_of결과에 포함되지 않나요?
네, 포함되지 않습니다! 힙에 할당된 메모리는 String이 관리하는 외부 리소스일 뿐, 컴파일러는 이를 String 타입 자체의 크기로 보지 않습니다.
std::mem::size_of는 포인터를 통해 연결된 힙 메모리의 크기까지는 알지 못하며 관심도 없습니다. 안타깝게도 특정 값이 런타임에 사용하는 전체 힙 메모리 양을 측정하는 표준 함수는 없습니다. String::capacity()처럼 개별 타입이 제공하는 메서드를 쓰거나, DHAT 같은 메모리 프로파일링 도구를 사용하여 확인해야 합니다.
Exercise
The exercise for this section is located in 03_ticket_v1/09_heap