이미지 로딩 중...

Rust 입문 가이드 스택과 힙 메모리 이해하기 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 2 Views

Rust 입문 가이드 스택과 힙 메모리 이해하기

Rust의 스택과 힙 메모리 구조를 실무 관점에서 깊이 있게 이해합니다. 메모리 할당 방식의 차이와 성능 영향, 그리고 실제 코드에서 어떻게 활용하는지 배웁니다.


목차

  1. 스택 메모리 - 빠르고 예측 가능한 메모리
  2. 힙 메모리 - 유연하고 동적인 메모리
  3. Box<T> - 힙 할당의 기본
  4. String과 &str - 문자열 메모리 관리
  5. Vec<T> - 동적 배열과 메모리 증가
  6. 스택 vs 힙 성능 비교
  7. 메모리 누수 방지 - Drop 트레잇
  8. 참조와 빌림 - 스택에서 힙 접근하기

1. 스택 메모리 - 빠르고 예측 가능한 메모리

시작하며

여러분이 함수를 호출할 때마다 프로그램이 점점 느려진다면 어떨까요? 다행히도 대부분의 함수 호출은 매우 빠릅니다.

그 이유는 바로 스택 메모리 덕분입니다. 실제로 많은 개발자들이 메모리가 어떻게 관리되는지 모른 채 코드를 작성합니다.

하지만 Rust에서는 메모리 관리를 이해하는 것이 곧 성능과 안정성을 보장하는 핵심입니다. 바로 이럴 때 필요한 것이 스택 메모리입니다.

스택은 함수 호출과 지역 변수를 효율적으로 관리하여 프로그램을 빠르게 만들어줍니다.

개요

간단히 말해서, 스택 메모리는 함수가 실행될 때 사용하는 임시 저장 공간입니다. 컴파일 타임에 크기가 정해진 데이터들이 여기에 저장됩니다.

스택은 LIFO(Last In, First Out) 구조로 동작합니다. 마치 접시를 쌓아올리듯이, 가장 나중에 추가된 데이터가 가장 먼저 제거됩니다.

이러한 단순한 구조 덕분에 스택 메모리 할당과 해제는 매우 빠릅니다. 예를 들어, 함수가 호출될 때 지역 변수들을 저장하고 함수가 끝나면 자동으로 정리되는 경우에 매우 유용합니다.

기존에는 메모리 관리를 프로그래머가 직접 해야 했다면, Rust에서는 스택을 활용하여 자동으로 메모리가 정리됩니다. 스택의 핵심 특징은 세 가지입니다.

첫째, 크기가 고정된 데이터만 저장할 수 있습니다. 둘째, 메모리 할당과 해제가 매우 빠릅니다.

셋째, 스코프가 끝나면 자동으로 메모리가 정리됩니다. 이러한 특징들이 Rust의 메모리 안정성과 성능을 동시에 보장하는 이유입니다.

코드 예제

fn calculate_sum(a: i32, b: i32) -> i32 {
    // 지역 변수들은 스택에 저장됩니다
    let x = 10; // 스택에 i32 값 저장
    let y = 20; // 스택에 또 다른 i32 값 저장

    // 계산 결과도 스택에 저장
    let result = a + b + x + y;

    // 함수가 끝나면 x, y, result는 자동으로 스택에서 제거됩니다
    result
} // 이 지점에서 모든 지역 변수가 스택에서 pop됩니다

fn main() {
    let sum = calculate_sum(5, 15); // sum도 스택에 저장
    println!("합계: {}", sum);
}

설명

이것이 하는 일: 위 코드는 스택 메모리가 어떻게 함수 실행 중에 변수들을 관리하는지 보여줍니다. 모든 정수형 변수들이 스택에 저장되고 함수가 끝나면 자동으로 정리됩니다.

첫 번째로, calculate_sum 함수가 호출되면 매개변수 ab가 스택에 push됩니다. 이들은 고정 크기(i32는 4바이트)를 가지므로 스택에 저장하기 적합합니다.

컴파일러는 이미 얼마나 많은 공간이 필요한지 알고 있기 때문에 할당이 즉시 이루어집니다. 그 다음으로, 함수 내부의 지역 변수 x, y, result가 차례로 스택에 쌓입니다.

각 변수가 선언될 때마다 스택 포인터가 이동하며 메모리가 할당됩니다. 이 과정은 단순히 포인터를 증가시키는 것만으로 완료되므로 O(1) 시간 복잡도를 가집니다.

마지막으로, 함수가 종료되는 시점에 모든 지역 변수들이 역순으로 스택에서 제거됩니다. 별도의 메모리 해제 코드를 작성할 필요가 없으며, 단순히 스택 포인터를 함수 시작 전 위치로 되돌리기만 하면 됩니다.

여러분이 이 코드를 사용하면 메모리 누수 걱정 없이 안전한 프로그램을 작성할 수 있습니다. 함수를 아무리 많이 호출해도 스택은 자동으로 정리되며, 예측 가능한 성능을 보장합니다.

또한 디버깅 시 스택 트레이스를 통해 함수 호출 흐름을 명확하게 파악할 수 있습니다.

실전 팁

💡 스택에는 크기가 고정된 타입만 저장하세요. i32, f64, bool, char 같은 기본 타입과 고정 크기 배열([i32; 5])이 여기에 해당합니다.

💡 재귀 함수를 사용할 때는 스택 오버플로우를 조심하세요. 각 재귀 호출마다 스택 프레임이 쌓이므로 깊은 재귀는 스택을 고갈시킬 수 있습니다.

💡 스택 변수는 함수 범위를 벗어나면 사용할 수 없습니다. 함수 밖에서 사용해야 한다면 반환값으로 전달하거나 힙을 사용해야 합니다.

💡 성능이 중요한 코드에서는 가능한 한 스택을 활용하세요. 힙 할당보다 수십 배 빠르며 캐시 친화적입니다.


2. 힙 메모리 - 유연하고 동적인 메모리

시작하며

여러분이 사용자 입력을 받아서 저장해야 하는데, 입력 크기를 미리 알 수 없다면 어떻게 해야 할까요? 컴파일 타임에 크기를 정할 수 없는 데이터들이 있습니다.

이런 문제는 웹 서비스, 파일 처리, 동적 데이터 구조를 다룰 때 필연적으로 발생합니다. 고정된 크기의 스택만으로는 이러한 요구사항을 충족할 수 없습니다.

바로 이럴 때 필요한 것이 힙 메모리입니다. 힙은 런타임에 크기가 결정되는 데이터를 저장하고, 필요한 만큼 메모리를 할당받을 수 있습니다.

개요

간단히 말해서, 힙 메모리는 프로그램 실행 중에 동적으로 할당되는 메모리 영역입니다. 크기를 미리 알 수 없는 데이터나 큰 데이터를 저장하는 데 사용됩니다.

힙 할당은 운영체제에게 메모리를 요청하는 과정입니다. 할당자(allocator)가 사용 가능한 메모리 블록을 찾고, 그 위치를 가리키는 포인터를 반환합니다.

스택보다는 느리지만, 프로그램이 실행되는 동안 필요한 만큼 메모리를 확보할 수 있습니다. 예를 들어, 사용자가 입력한 문자열의 길이가 천 글자일지 만 글자일지 모르는 경우에 매우 유용합니다.

기존 C나 C++에서는 malloc/free나 new/delete로 직접 관리했다면, Rust에서는 Box, String, Vec 같은 스마트 포인터를 통해 안전하게 힙 메모리를 사용할 수 있습니다. 힙의 핵심 특징은 세 가지입니다.

첫째, 런타임에 크기가 결정되는 데이터를 저장할 수 있습니다. 둘째, 데이터가 함수 범위를 벗어나도 유지될 수 있습니다.

셋째, 소유권 시스템을 통해 메모리 안전성이 보장됩니다. 이러한 특징들이 Rust에서 유연하면서도 안전한 메모리 관리를 가능하게 합니다.

코드 예제

fn create_dynamic_data() -> Box<Vec<i32>> {
    // Box는 힙에 데이터를 할당합니다
    let mut data = Box::new(Vec::new());

    // Vec도 내부적으로 힙을 사용합니다
    data.push(1);
    data.push(2);
    data.push(3);

    // 데이터를 반환해도 힙에 있으므로 유효합니다
    data
} // Box의 소유권이 이동되므로 여기서는 해제되지 않음

fn main() {
    let my_data = create_dynamic_data();
    println!("데이터 길이: {}", my_data.len());
    // main 함수가 끝날 때 my_data가 drop되어 힙 메모리 해제
}

설명

이것이 하는 일: 이 코드는 힙 메모리를 사용하여 크기가 가변적인 데이터를 안전하게 관리하는 방법을 보여줍니다. Box와 Vec을 활용하여 동적 할당과 소유권 이동을 구현합니다.

첫 번째로, Box::new(Vec::new())가 실행되면 두 번의 힙 할당이 발생합니다. Box 자체가 힙에 Vec 구조체를 할당하고, Vec는 내부 요소를 저장할 힙 메모리를 추가로 할당합니다.

스택에는 Box 포인터만 저장되므로 고정된 크기(8바이트, 64비트 시스템 기준)만 사용합니다. 그 다음으로, push 메서드를 호출할 때마다 Vec는 필요에 따라 힙 메모리를 재할당합니다.

Vec의 용량(capacity)이 부족하면 더 큰 메모리 블록을 할당하고 기존 데이터를 복사합니다. 이 과정은 자동으로 처리되며, 평균적으로 O(1) 시간에 요소를 추가할 수 있도록 최적화되어 있습니다.

마지막으로, 함수가 data를 반환할 때 소유권이 호출자에게 이동합니다. 힙에 있는 실제 데이터는 그대로 유지되고 포인터만 복사됩니다.

main 함수가 종료될 때 my_data의 소유권이 사라지면서 Rust의 Drop 트레잇이 자동으로 호출되어 힙 메모리가 해제됩니다. 여러분이 이 코드를 사용하면 메모리 크기를 미리 예측할 수 없는 상황에서도 안전하게 프로그래밍할 수 있습니다.

사용자 입력, 파일 내용, 네트워크 데이터 등 동적인 크기의 데이터를 처리할 수 있으며, 메모리 누수나 댕글링 포인터 같은 문제로부터 보호받을 수 있습니다. 또한 소유권 시스템 덕분에 멀티스레드 환경에서도 데이터 경합 없이 안전하게 사용할 수 있습니다.

실전 팁

💡 큰 데이터 구조는 힙에 저장하세요. 스택은 크기가 제한적이므로(보통 수 MB) 큰 배열이나 구조체는 Box로 감싸서 힙에 할당하는 것이 안전합니다.

💡 Vec의 capacity와 len을 구분하세요. len은 실제 요소 개수, capacity는 재할당 없이 저장 가능한 최대 개수입니다. Vec::with_capacity()로 미리 공간을 확보하면 재할당을 줄여 성능을 향상시킬 수 있습니다.

💡 String도 내부적으로 Vec<u8>을 사용하므로 힙 메모리를 사용합니다. 문자열 연결을 반복하면 재할당이 자주 발생하므로 String::with_capacity()format! 매크로를 활용하세요.

💡 힙 할당이 빈번한 코드는 성능 병목이 될 수 있습니다. 프로파일링 도구를 사용하여 불필요한 할당을 찾아내고, 가능하면 스택이나 객체 풀 패턴을 고려하세요.

💡 Rc나 Arc를 사용하면 힙 데이터를 여러 소유자가 공유할 수 있습니다. 하지만 참조 카운팅 오버헤드가 있으므로 정말 필요한 경우에만 사용하세요.


3. Box<T> - 힙 할당의 기본

시작하며

여러분이 대용량 배열을 함수 인자로 전달하려고 하는데 스택 오버플로우 에러가 발생한다면 어떻게 해야 할까요? 큰 데이터를 스택에 저장하려다가 프로그램이 크래시될 수 있습니다.

실제로 많은 초보 개발자들이 스택의 크기 제한을 모르고 큰 배열을 선언하다가 문제를 겪습니다. 특히 재귀 함수에서 큰 데이터를 다룰 때 이런 문제가 자주 발생합니다.

바로 이럴 때 필요한 것이 Box<T>입니다. Box는 데이터를 힙에 할당하면서도 스택의 포인터 하나만으로 관리할 수 있게 해줍니다.

개요

간단히 말해서, Box<T>는 타입 T의 값을 힙에 저장하는 스마트 포인터입니다. 가장 단순하고 오버헤드가 적은 힙 할당 방법입니다.

Box는 Rust에서 가장 기본적인 힙 할당 도구입니다. 포인터 하나의 크기(8바이트)만 스택을 사용하면서 실제 데이터는 힙에 저장합니다.

소유권이 명확하고 참조 카운팅 같은 추가 오버헤드가 없습니다. 예를 들어, 재귀적 데이터 구조(연결 리스트, 트리)를 만들 때나 큰 데이터를 효율적으로 이동시킬 때 매우 유용합니다.

기존에는 데이터를 복사하거나 포인터를 직접 관리해야 했다면, Box를 사용하면 안전하게 힙 메모리를 다룰 수 있습니다. Box의 핵심 특징은 세 가지입니다.

첫째, 단독 소유권을 가지므로 메모리 안전성이 보장됩니다. 둘째, 컴파일 타임에 크기를 알 수 없는 타입을 구체화할 수 있습니다.

셋째, 스코프를 벗어나면 자동으로 힙 메모리가 해제됩니다. 이러한 특징들이 안전하고 효율적인 힙 메모리 관리를 가능하게 합니다.

코드 예제

// 재귀적 데이터 구조 - Box 없이는 불가능
enum List {
    Cons(i32, Box<List>), // Box로 감싸야 크기가 결정됨
    Nil,
}

fn create_large_array() -> Box<[i32; 1000000]> {
    // 4MB 배열을 힙에 할당 - 스택에 두면 오버플로우
    Box::new([0; 1000000])
}

fn main() {
    // 연결 리스트 생성
    let list = List::Cons(1,
        Box::new(List::Cons(2,
            Box::new(List::Cons(3,
                Box::new(List::Nil))))));

    let large = create_large_array();
    println!("큰 배열의 첫 번째 요소: {}", large[0]);
}

설명

이것이 하는 일: 이 코드는 Box를 사용하여 재귀적 데이터 구조를 만들고, 큰 배열을 안전하게 힙에 할당하는 방법을 보여줍니다. Box 없이는 컴파일조차 불가능한 상황들입니다.

첫 번째로, List enum 정의를 보면 Cons 변형이 자기 자신을 포함합니다. 만약 Box로 감싸지 않으면 컴파일러는 이 타입의 크기를 계산할 수 없어서 에러를 발생시킵니다.

Box를 사용하면 포인터의 크기(8바이트)로 고정되므로 컴파일러가 타입 크기를 결정할 수 있습니다. 이는 연결 리스트, 이진 트리, 그래프 같은 재귀 구조를 구현하는 유일한 방법입니다.

그 다음으로, create_large_array 함수는 100만 개의 i32 요소를 가진 배열을 생성합니다. 이는 약 4MB의 메모리를 차지하는데, 대부분의 스택 크기(기본 8MB)에서는 문제가 없어 보이지만 재귀 함수나 중첩 호출에서는 스택 오버플로우를 일으킬 수 있습니다.

Box로 감싸면 실제 배열은 힙에 할당되고 스택에는 8바이트 포인터만 저장됩니다. 마지막으로, main 함수에서 Box로 감싼 값들을 사용할 때 자동으로 역참조(dereference)가 일어납니다.

large[0]처럼 일반 배열처럼 사용할 수 있으며, 함수가 끝나면 모든 Box가 자동으로 drop되어 힙 메모리가 깔끔하게 해제됩니다. 여러분이 이 코드를 사용하면 복잡한 데이터 구조를 안전하게 구현할 수 있습니다.

연결 리스트나 트리 같은 자료구조를 만들 수 있고, 대용량 데이터를 함수 간에 효율적으로 전달할 수 있습니다. 포인터를 직접 다루는 unsafe 코드 없이도 C 수준의 메모리 제어가 가능하며, 컴파일러가 메모리 안전성을 보장해줍니다.

실전 팁

💡 Box::new() 대신 Box::leak()를 사용하면 'static 라이프타임을 얻을 수 있습니다. 프로그램 종료까지 유지해야 하는 전역 상태에 유용하지만, 메모리가 해제되지 않으므로 신중하게 사용하세요.

💡 Box::into_raw()와 Box::from_raw()로 raw 포인터로 변환할 수 있습니다. FFI(Foreign Function Interface)로 C 라이브러리와 통신할 때 필요하지만, unsafe 블록이 필요합니다.

💡 대용량 데이터를 함수로 전달할 때 Box를 사용하면 이동(move) 비용이 포인터 복사 비용으로 줄어듭니다. 메가바이트 단위의 구조체를 다룬다면 Box로 감싸는 것을 고려하세요.

💡 Box<dyn Trait>을 사용하면 트레잇 객체를 만들 수 있습니다. 런타임 다형성이 필요할 때 유용하며, 서로 다른 타입을 하나의 컬렉션에 저장할 수 있습니다.


4. String과 &str - 문자열 메모리 관리

시작하며

여러분이 사용자 입력을 받아 저장하려는데 "expected &str, found String" 같은 에러를 본 적 있나요? Rust의 문자열 타입은 처음에는 혼란스럽지만, 메모리 효율성을 위한 설계입니다.

실무에서 문자열을 잘못 다루면 불필요한 메모리 할당이 발생하거나 댕글링 포인터 문제가 생길 수 있습니다. 특히 웹 서버나 텍스트 처리 애플리케이션에서는 문자열 처리가 성능의 핵심입니다.

바로 이럴 때 필요한 것이 String과 &str의 차이를 이해하는 것입니다. 두 타입은 각각 힙과 스택/정적 메모리를 활용하여 효율적인 문자열 관리를 제공합니다.

개요

간단히 말해서, String은 힙에 할당된 가변 문자열이고, &str은 문자열 슬라이스(불변 참조)입니다. String은 소유권을 가지고, &str은 빌림(borrow)입니다.

String은 Vec<u8>을 기반으로 한 UTF-8 인코딩 문자열입니다. 동적으로 크기가 변할 수 있고, 수정 가능하며, 힙에 데이터를 저장합니다.

반면 &str은 문자열 데이터를 가리키는 참조로, 수정할 수 없고 크기가 고정되어 있습니다. 예를 들어, 문자열 리터럴 "hello"는 &str 타입으로 프로그램 바이너리에 포함되며, 사용자 입력을 받을 때는 String을 사용합니다.

기존에는 char* 하나로 모든 것을 다뤘다면, Rust는 소유권과 빌림을 구분하여 메모리 안전성과 효율성을 모두 얻습니다. 이 두 타입의 핵심 특징을 이해하는 것이 중요합니다.

첫째, String은 소유권을 가지므로 자유롭게 수정할 수 있지만 힙 할당 비용이 있습니다. 둘째, &str은 빌림이므로 오버헤드가 없지만 수정할 수 없습니다.

셋째, String은 &str로 자동 변환(deref coercion)되므로 &str을 받는 함수에 String을 전달할 수 있습니다. 이러한 설계가 Rust에서 문자열을 효율적이면서도 안전하게 다룰 수 있게 합니다.

코드 예제

fn process_string(s: &str) {
    // &str을 받으므로 String과 &str 모두 받을 수 있음
    println!("처리 중: {}", s);
}

fn main() {
    // 문자열 리터럴 - 정적 메모리에 저장된 &str
    let literal: &str = "안녕하세요";

    // String - 힙에 할당된 소유 문자열
    let mut owned = String::from("Hello");
    owned.push_str(", World!"); // 수정 가능

    // String을 &str로 전달
    process_string(&owned);
    process_string(literal);

    // 슬라이싱 - 기존 데이터를 참조
    let slice: &str = &owned[0..5];
    println!("슬라이스: {}", slice);
}

설명

이것이 하는 일: 이 코드는 String과 &str의 차이점과 사용 시나리오를 명확하게 보여줍니다. 메모리 위치와 소유권의 차이가 어떻게 실제 코드에 영향을 주는지 이해할 수 있습니다.

첫 번째로, process_string 함수는 &str 타입을 매개변수로 받습니다. 이렇게 하면 String, &String, &str, 문자열 리터럴 등 모든 형태의 문자열을 받을 수 있습니다.

Rust의 deref coercion 덕분에 String에서 &str로 자동 변환이 일어나기 때문입니다. 함수 설계 시 가능하면 &str을 사용하는 것이 유연성을 높입니다.

그 다음으로, literal 변수는 프로그램 바이너리에 포함된 정적 문자열을 가리킵니다. 이는 힙 할당이 필요 없고, 프로그램이 실행되는 동안 항상 유효합니다('static 라이프타임).

반면 owned 변수는 힙에 새로운 메모리를 할당하고 "Hello" 문자열을 복사합니다. push_str 메서드로 문자열을 수정할 때 필요하면 힙 메모리를 재할당합니다.

마지막으로, 슬라이싱 연산 &owned[0..5]는 새로운 메모리 할당 없이 기존 String의 일부를 참조하는 &str을 만듭니다. 이는 매우 효율적이며, 문자열의 일부만 필요한 경우 복사 없이 처리할 수 있게 해줍니다.

단, UTF-8 경계에서만 슬라이싱해야 하므로 잘못된 인덱스는 패닉을 일으킵니다. 여러분이 이 패턴을 사용하면 문자열 처리 성능을 크게 향상시킬 수 있습니다.

읽기 전용 작업에는 &str을 사용하여 할당을 피하고, 수정이 필요한 경우에만 String을 사용하여 메모리를 효율적으로 관리할 수 있습니다. 또한 함수 인터페이스를 &str로 설계하면 호출자가 더 유연하게 사용할 수 있고, 불필요한 복제를 방지할 수 있습니다.

실전 팁

💡 함수 매개변수는 가능하면 &str을 사용하세요. String을 받으면 소유권이 이동하므로 호출 측에서 clone()을 해야 할 수 있습니다.

💡 String::from()과 to_string()은 동일한 기능입니다. 가독성을 위해 리터럴에는 String::from(), 다른 타입 변환에는 to_string()을 사용하는 것이 관례입니다.

💡 format! 매크로는 새로운 String을 힙에 할당합니다. 반복문 안에서 사용하면 성능이 저하될 수 있으므로, 미리 capacity를 설정한 String에 push_str을 사용하는 것이 좋습니다.

💡 문자열 슬라이싱 시 UTF-8 경계를 확인하세요. chars().nth() 나 char_indices()를 사용하면 안전하게 문자 단위로 접근할 수 있습니다.

💡 Cow<str> 타입을 사용하면 필요할 때만 복제하는 최적화가 가능합니다. 대부분의 경우 빌림으로 동작하고, 수정이 필요한 경우에만 소유 문자열로 변환됩니다.


5. Vec<T> - 동적 배열과 메모리 증가

시작하며

여러분이 데이터를 추가하다가 갑자기 성능이 떨어지는 것을 경험한 적 있나요? Vec는 자동으로 크기를 조절하지만, 그 과정에서 메모리 재할당이 발생합니다.

실제 프로덕션 환경에서 Vec를 잘못 사용하면 예상치 못한 성능 저하가 발생할 수 있습니다. 특히 대량의 데이터를 처리하는 웹 서버나 데이터 파이프라인에서는 Vec의 내부 동작을 이해하는 것이 중요합니다.

바로 이럴 때 필요한 것이 Vec의 capacity 관리입니다. len과 capacity의 차이를 이해하고 적절히 활용하면 불필요한 재할당을 방지할 수 있습니다.

개요

간단히 말해서, Vec<T>는 힙에 저장되는 동적 배열입니다. 크기가 자동으로 증가하며, 연속된 메모리 공간에 요소들이 저장됩니다.

Vec는 세 가지 정보를 가집니다: 포인터(힙 메모리 위치), 길이(현재 요소 개수), 용량(재할당 없이 저장 가능한 최대 개수). 용량이 부족하면 보통 2배씩 증가하는 전략을 사용합니다.

이는 평균 O(1) 시간에 요소를 추가할 수 있게 하는 최적화입니다. 예를 들어, 최종 크기를 알고 있다면 Vec::with_capacity()로 미리 공간을 확보하여 재할당을 완전히 제거할 수 있습니다.

기존 배열은 고정 크기였다면, Vec는 필요에 따라 자동으로 확장되는 유연성을 제공합니다. Vec의 핵심 특징은 세 가지입니다.

첫째, 연속된 메모리에 저장되므로 캐시 친화적이고 인덱스 접근이 O(1)입니다. 둘째, 자동으로 크기가 조절되지만 재할당 비용이 발생할 수 있습니다.

셋째, 스코프를 벗어나면 모든 요소와 힙 메모리가 자동으로 해제됩니다. 이러한 특징들이 Vec를 Rust에서 가장 많이 사용되는 컬렉션으로 만듭니다.

코드 예제

fn demonstrate_vec_growth() {
    // 용량 없이 시작 - 재할당 여러 번 발생
    let mut v1 = Vec::new();
    println!("v1 용량: {}", v1.capacity()); // 0

    for i in 0..10 {
        v1.push(i);
        println!("요소 {}: len={}, cap={}", i, v1.len(), v1.capacity());
    }

    // 미리 용량 확보 - 재할당 없음
    let mut v2 = Vec::with_capacity(10);
    println!("\nv2 초기 용량: {}", v2.capacity()); // 10

    for i in 0..10 {
        v2.push(i);
    }
    println!("v2 최종: len={}, cap={}", v2.len(), v2.capacity());
}

설명

이것이 하는 일: 이 코드는 Vec의 용량 관리 메커니즘을 시각화하여 보여줍니다. 재할당이 언제 발생하는지, 그리고 어떻게 최적화할 수 있는지 명확하게 이해할 수 있습니다.

첫 번째로, Vec::new()로 생성된 v1은 초기 용량이 0입니다. 첫 번째 push가 호출되면 메모리를 할당하고, 그 후에는 용량이 부족할 때마다 재할당이 발생합니다.

출력을 보면 용량이 0 → 4 → 8 → 16 같은 패턴으로 증가하는 것을 확인할 수 있습니다. 각 재할당 시점에서 새로운 메모리 블록을 할당하고 기존 데이터를 모두 복사해야 하므로 비용이 발생합니다.

그 다음으로, Vec::with_capacity(10)으로 생성된 v2는 처음부터 10개의 요소를 저장할 공간을 확보합니다. 이후 10개의 요소를 추가하는 동안 단 한 번의 재할당도 발생하지 않습니다.

메모리 할당은 시작 시 한 번만 일어나고, push 연산은 순수하게 데이터를 복사하는 작업만 수행합니다. 이는 성능 측면에서 훨씬 효율적입니다.

마지막으로, Vec가 스코프를 벗어나면 Drop 트레잇이 자동으로 호출됩니다. 이때 Vec는 자신이 소유한 모든 요소의 drop을 먼저 호출한 후, 힙에 할당된 메모리를 해제합니다.

이 과정은 완전히 자동이며, 개발자가 명시적으로 메모리를 해제할 필요가 없습니다. 여러분이 이 패턴을 사용하면 대량 데이터 처리 시 성능을 크게 개선할 수 있습니다.

예를 들어, 파일에서 10만 줄을 읽어야 한다면 Vec::with_capacity(100000)으로 시작하면 수십 번의 재할당을 피할 수 있습니다. 또한 벤치마크에서 최대 2-3배의 성능 향상을 확인할 수 있으며, 메모리 단편화도 줄일 수 있습니다.

실전 팁

💡 최종 크기를 대략이라도 알고 있다면 항상 with_capacity()를 사용하세요. 과다 예측해도 문제없으며, 실제 사용하지 않는 capacity는 메모리를 차지하지 않습니다.

💡 shrink_to_fit()으로 사용하지 않는 용량을 해제할 수 있습니다. 대량 삭제 후 메모리를 회수하고 싶을 때 유용하지만, 재할당 비용이 있으므로 신중히 사용하세요.

💡 Vec를 반환하는 대신 &[T] 슬라이스를 반환하면 더 유연한 API를 만들 수 있습니다. 호출자가 Vec, 배열, 슬라이스 등 다양한 타입을 전달할 수 있습니다.

💡 iter(), iter_mut(), into_iter()의 차이를 이해하세요. iter()는 불변 참조, iter_mut()는 가변 참조, into_iter()는 소유권을 가져갑니다.

💡 remove()는 O(n) 연산입니다. 순서가 중요하지 않다면 swap_remove()를 사용하면 O(1)에 삭제할 수 있습니다.


6. 스택 vs 힙 성능 비교

시작하며

여러분이 코드를 최적화하려는데 어느 부분부터 시작해야 할지 모르겠다면? 메모리 할당 위치에 따라 성능이 10배 이상 차이날 수 있습니다.

실무에서 성능 프로파일링을 하다 보면 예상치 못한 곳에서 병목이 발생합니다. 특히 반복문 안에서 힙 할당이 빈번하게 일어나면 큰 성능 저하를 일으킬 수 있습니다.

바로 이럴 때 필요한 것이 스택과 힙의 성능 특성을 이해하는 것입니다. 언제 어떤 메모리를 사용할지 적절히 선택하면 성능을 극대화할 수 있습니다.

개요

간단히 말해서, 스택 할당은 힙 할당보다 훨씬 빠릅니다. 하지만 크기와 생명주기에 제약이 있어서 상황에 맞게 선택해야 합니다.

스택 할당은 단순히 스택 포인터를 증가시키는 것만으로 완료되므로 O(1) 시간이 걸리고 CPU 사이클도 매우 적습니다. 반면 힙 할당은 운영체제나 할당자와 통신하여 사용 가능한 메모리 블록을 찾아야 하므로 수백 배 더 느릴 수 있습니다.

또한 힙 메모리는 캐시 지역성이 낮아 캐시 미스가 더 자주 발생합니다. 예를 들어, 타이트한 루프에서 작은 객체를 반복 생성한다면 스택을 사용하는 것이 훨씬 효율적입니다.

기존에는 메모리 할당 비용을 간과하고 편의성만 생각했다면, 성능이 중요한 코드에서는 메모리 위치를 신중히 결정해야 합니다. 두 메모리의 트레이드오프를 이해하는 것이 핵심입니다.

첫째, 스택은 빠르지만 크기가 제한적이고 함수 범위를 벗어날 수 없습니다. 둘째, 힙은 느리지만 크기가 자유롭고 수명을 제어할 수 있습니다.

셋째, 대부분의 경우 스택을 우선 고려하고 필요할 때만 힙을 사용하는 것이 최적입니다. 이러한 원칙을 따르면 메모리 효율성과 성능을 동시에 얻을 수 있습니다.

코드 예제

use std::time::Instant;

fn stack_allocation_test() {
    let start = Instant::now();

    // 스택에 100만 번 할당
    for _ in 0..1_000_000 {
        let x = [0i32; 10]; // 스택에 배열 생성
        std::hint::black_box(x); // 최적화 방지
    }

    println!("스택 할당: {:?}", start.elapsed());
}

fn heap_allocation_test() {
    let start = Instant::now();

    // 힙에 100만 번 할당
    for _ in 0..1_000_000 {
        let x = Box::new([0i32; 10]); // 힙에 배열 생성
        std::hint::black_box(x);
    }

    println!("힙 할당: {:?}", start.elapsed());
}

fn main() {
    stack_allocation_test(); // 약 10ms
    heap_allocation_test();  // 약 100-200ms
}

설명

이것이 하는 일: 이 코드는 동일한 작업을 스택과 힙에서 각각 수행하여 성능 차이를 직접 측정합니다. 실제 벤치마크를 통해 메모리 할당 위치가 성능에 미치는 영향을 정량적으로 보여줍니다.

첫 번째로, stack_allocation_test 함수는 10개의 i32를 가진 배열을 100만 번 스택에 생성합니다. 각 반복마다 스택 포인터를 40바이트(10 × 4바이트) 증가시키고, 루프가 끝나면 포인터를 원래대로 되돌립니다.

이 과정은 기계어로 몇 개의 명령어로 처리되며, 대부분의 시스템에서 10ms 이내에 완료됩니다. black_box는 컴파일러가 루프를 최적화로 제거하는 것을 방지합니다.

그 다음으로, heap_allocation_test 함수는 동일한 배열을 Box를 사용하여 힙에 할당합니다. 각 반복마다 할당자에게 메모리를 요청하고, 할당자는 사용 가능한 메모리 블록을 찾은 후 포인터를 반환합니다.

루프가 끝날 때마다 Box가 drop되면서 메모리 해제도 발생합니다. 이 과정은 시스템 콜이나 복잡한 메모리 관리 로직을 수반하므로 100-200ms 정도 소요됩니다.

마지막으로, 실행 결과를 비교하면 힙 할당이 스택 할당보다 10-20배 느린 것을 확인할 수 있습니다. 이는 할당 자체의 비용뿐만 아니라 캐시 효율성의 차이도 포함합니다.

스택 메모리는 CPU 캐시에 있을 가능성이 높지만, 힙 메모리는 캐시 미스를 더 자주 일으킵니다. 여러분이 이 지식을 활용하면 성능이 중요한 코드를 최적화할 수 있습니다.

게임 엔진의 렌더링 루프, 고빈도 트레이딩 시스템, 실시간 데이터 처리 등에서 힙 할당을 줄이는 것은 필수적입니다. 프로파일러로 힙 할당 핫스팟을 찾아내고, 가능하면 스택이나 객체 풀 패턴으로 대체하면 극적인 성능 향상을 얻을 수 있습니다.

실전 팁

💡 cargo flamegraph나 perf 같은 프로파일러로 힙 할당 핫스팟을 찾으세요. 예상치 못한 곳에서 할당이 발생하는 경우가 많습니다.

💡 SmallVec이나 ArrayVec 같은 크레이트를 사용하면 작은 벡터는 스택에, 큰 벡터는 힙에 자동으로 저장됩니다. 대부분 작지만 가끔 큰 데이터를 다룰 때 유용합니다.

💡 객체 풀(Object Pool) 패턴으로 힙 할당 횟수를 줄일 수 있습니다. 미리 객체를 할당해두고 재사용하면 런타임 할당 비용을 제거할 수 있습니다.

💡 jemalloc이나 mimalloc 같은 대체 할당자를 사용하면 기본 할당자보다 성능이 향상될 수 있습니다. Cargo.toml에서 간단히 설정할 수 있습니다.


7. 메모리 누수 방지 - Drop 트레잇

시작하며

여러분이 C++에서 Rust로 넘어왔다면 delete나 free를 어디에 써야 할지 찾고 있을지도 모릅니다. Rust에서는 그런 코드가 필요 없습니다!

실제로 메모리 누수는 장기간 실행되는 서버 애플리케이션에서 치명적인 문제입니다. 메모리가 점점 증가하다가 결국 시스템이 다운되는 경험을 한 개발자라면 Rust의 자동 메모리 관리가 얼마나 강력한지 알 것입니다.

바로 이럴 때 필요한 것이 Drop 트레잇입니다. Rust는 스코프 기반 자동 메모리 해제를 통해 메모리 누수를 컴파일 타임에 방지합니다.

개요

간단히 말해서, Drop 트레잇은 값이 스코프를 벗어날 때 자동으로 호출되는 소멸자입니다. 메모리뿐만 아니라 파일 핸들, 네트워크 연결 같은 자원도 자동으로 정리합니다.

Rust의 소유권 시스템은 각 값이 정확히 하나의 소유자를 가지도록 보장합니다. 소유자가 스코프를 벗어나면 Rust는 자동으로 drop 함수를 호출하여 자원을 정리합니다.

이는 C++의 RAII(Resource Acquisition Is Initialization) 패턴과 유사하지만, 컴파일러가 강제한다는 점에서 더 안전합니다. 예를 들어, 파일을 열고 닫는 것을 잊어도 스코프가 끝나면 자동으로 닫힙니다.

기존에는 finally 블록이나 defer 문을 사용했다면, Rust는 소유권 시스템을 통해 더 강력한 보장을 제공합니다. Drop 트레잇의 핵심 특징은 세 가지입니다.

첫째, 스코프를 벗어날 때 자동으로 호출되므로 잊어버릴 수 없습니다. 둘째, 중첩된 구조체의 경우 재귀적으로 drop이 호출됩니다.

셋째, 명시적으로 drop() 함수를 호출하여 조기에 자원을 해제할 수 있습니다. 이러한 메커니즘이 Rust를 메모리 안전한 언어로 만드는 핵심입니다.

코드 예제

struct FileHandler {
    name: String,
}

impl Drop for FileHandler {
    fn drop(&mut self) {
        println!("파일 '{}' 닫는 중...", self.name);
        // 실제로는 파일 핸들을 닫는 코드가 여기 들어감
    }
}

fn process_file() {
    let file1 = FileHandler {
        name: String::from("data.txt"),
    };

    let file2 = FileHandler {
        name: String::from("log.txt"),
    };

    println!("파일 처리 중...");

    // 명시적으로 file1을 먼저 닫기
    drop(file1);
    println!("file1이 명시적으로 닫힘");

    // file2는 함수 끝에서 자동으로 drop
} // file2.drop()이 자동 호출됨

fn main() {
    process_file();
    println!("모든 파일이 정리됨");
}

설명

이것이 하는 일: 이 코드는 Rust의 자동 자원 관리 시스템을 보여줍니다. Drop 트레잇을 구현하여 커스텀 정리 로직을 작성하고, 스코프 기반 수명이 어떻게 동작하는지 이해할 수 있습니다.

첫 번째로, FileHandler 구조체는 Drop 트레잇을 구현하여 자원 해제 로직을 정의합니다. 실제 프로그램에서는 std::fs::File이 내부적으로 이렇게 구현되어 있어서 파일이 자동으로 닫힙니다.

drop 메서드는 개발자가 직접 호출할 수 없고, Rust 컴파일러만 호출할 수 있습니다. 그 다음으로, process_file 함수에서 file1file2 두 개의 핸들러를 생성합니다.

선언 순서대로 스택에 쌓이므로, 자동 drop은 역순(LIFO)으로 일어납니다. 즉, file2가 먼저 drop되고 그 다음 file1이 drop됩니다.

하지만 drop(file1)을 명시적으로 호출하여 순서를 제어할 수 있습니다. 마지막으로, 함수가 끝나는 시점에 남아있는 file2의 drop이 자동으로 호출됩니다.

패닉이 발생해도 스택 언와인딩(stack unwinding) 과정에서 모든 변수의 drop이 호출되므로 자원 누수가 발생하지 않습니다. 이는 예외 안전성(exception safety)을 보장하는 핵심 메커니즘입니다.

여러분이 이 패턴을 사용하면 복잡한 자원 관리를 안전하게 구현할 수 있습니다. 데이터베이스 연결, 뮤텍스 락, GPU 메모리, 네트워크 소켓 등 모든 자원을 Drop 트레잇으로 관리하면 누수를 방지할 수 있습니다.

특히 조기 반환(early return)이나 에러 처리가 많은 코드에서도 자원이 항상 정리되는 것을 보장받을 수 있습니다.

실전 팁

💡 Drop을 구현할 때는 멱등성(idempotent)을 고려하세요. 실수로 두 번 호출되어도 안전해야 하며, 내부에 Option을 사용하면 이미 해제된 자원을 추적할 수 있습니다.

💡 std::mem::forget()이나 Box::leak()를 사용하면 drop을 건너뛸 수 있습니다. 전역 상태나 FFI에서 필요하지만, 메모리 누수를 일으키므로 주의해서 사용하세요.

💡 Mutex나 RwLock의 가드도 Drop을 사용합니다. 스코프를 벗어나면 자동으로 락이 해제되므로, 가드를 일찍 drop하여 락 시간을 최소화하세요.

💡 async 코드에서 Drop은 즉시 실행되지 않을 수 있습니다. async 함수가 취소되면 .await 지점까지만 drop이 실행되므로, 중요한 자원은 명시적으로 정리하세요.


8. 참조와 빌림 - 스택에서 힙 접근하기

시작하며

여러분이 큰 데이터를 여러 함수에 전달하려는데 매번 복사하기는 비효율적이라고 느낀 적 있나요? 소유권을 넘기면 원본을 잃게 되고, 복사하면 성능이 저하됩니다.

실무에서 대용량 구조체나 컬렉션을 다룰 때 이런 딜레마에 자주 직면합니다. 특히 읽기 전용 작업이 많은 경우 매번 복사하는 것은 낭비입니다.

바로 이럴 때 필요한 것이 참조(reference)와 빌림(borrowing)입니다. 소유권을 이동하지 않고도 데이터에 접근할 수 있게 해주는 Rust의 핵심 기능입니다.

개요

간단히 말해서, 참조는 데이터를 소유하지 않고 가리키기만 하는 포인터입니다. &T는 불변 참조, &mut T는 가변 참조입니다.

참조는 스택에 저장되는 포인터(8바이트)일 뿐이지만, 가리키는 데이터는 스택이나 힙 어디에든 있을 수 있습니다. Rust의 빌림 검사기(borrow checker)는 컴파일 타임에 참조의 안전성을 보장합니다.

불변 참조는 여러 개 동시에 존재할 수 있지만, 가변 참조는 단 하나만 존재할 수 있습니다. 예를 들어, 10MB 구조체를 함수에 전달할 때 참조를 사용하면 8바이트만 복사하므로 매우 효율적입니다.

기존에는 포인터를 자유롭게 사용하다가 댕글링 포인터나 데이터 경합 문제를 겪었다면, Rust는 컴파일 타임에 이런 문제를 완전히 방지합니다. 참조의 핵심 특징은 세 가지입니다.

첫째, 참조는 항상 유효한 데이터를 가리킵니다(null이 없음). 둘째, 불변 참조는 동시에 여러 개 존재할 수 있어 읽기 작업이 자유롭습니다.

셋째, 가변 참조는 배타적이므로 데이터 경합이 불가능합니다. 이러한 규칙들이 메모리 안전성과 스레드 안전성을 동시에 보장합니다.

코드 예제

fn calculate_length(s: &String) -> usize {
    // s는 String을 빌림, 소유권은 가져가지 않음
    s.len()
} // s가 스코프를 벗어나지만 소유권이 없으므로 drop 안 됨

fn append_text(s: &mut String) {
    // 가변 참조로 원본 수정 가능
    s.push_str(" - 추가됨");
}

fn main() {
    let mut data = String::from("Hello");

    // 불변 참조 - 여러 개 가능
    let len1 = calculate_length(&data);
    let len2 = calculate_length(&data);
    println!("길이: {}, {}", len1, len2);

    // 가변 참조 - 단 하나만 가능
    append_text(&mut data);
    println!("수정된 데이터: {}", data);

    // 힙에 있는 Vec도 동일
    let mut vec = vec![1, 2, 3];
    let first = &vec[0]; // 불변 참조
    println!("첫 요소: {}", first);
}

설명

이것이 하는 일: 이 코드는 참조를 사용하여 소유권 이동 없이 데이터를 공유하는 방법을 보여줍니다. 불변과 가변 참조의 차이, 그리고 빌림 규칙이 어떻게 메모리 안전성을 보장하는지 이해할 수 있습니다.

첫 번째로, calculate_length 함수는 &String 타입을 받습니다. 함수 호출 시 &data는 data의 주소를 스택에 복사하여 전달합니다.

실제 String 데이터(힙에 있음)는 전혀 이동하거나 복사되지 않으며, 8바이트 포인터만 복사됩니다. 함수가 끝나도 참조만 사라질 뿐 원본 데이터는 그대로 유지됩니다.

그 다음으로, append_text 함수는 &mut String을 받아 원본 데이터를 수정합니다. 가변 참조가 활성화된 동안에는 다른 어떤 참조(불변 또는 가변)도 존재할 수 없습니다.

이는 컴파일러가 강제하는 규칙으로, 데이터 경합을 근본적으로 방지합니다. 가변 참조가 스코프를 벗어나면 다시 다른 참조를 만들 수 있습니다.

마지막으로, Vec의 요소를 참조할 때도 동일한 규칙이 적용됩니다. &vec[0]은 Vec의 첫 번째 요소를 가리키는 불변 참조입니다.

이 참조가 살아있는 동안에는 vec를 수정할 수 없습니다. 만약 vec.push(4)를 시도하면 컴파일 에러가 발생합니다.

왜냐하면 push가 재할당을 일으킬 수 있고, 그러면 기존 참조가 무효화되기 때문입니다. 여러분이 이 패턴을 사용하면 성능과 안전성을 동시에 얻을 수 있습니다.

대용량 데이터를 복사 없이 여러 곳에서 읽을 수 있고, 수정이 필요한 경우에만 가변 참조를 사용하여 의도를 명확히 할 수 있습니다. 또한 컴파일러가 모든 참조의 안전성을 검증하므로, 런타임 에러나 디버깅 시간을 크게 줄일 수 있습니다.

실전 팁

💡 함수 매개변수는 가능하면 참조로 받으세요. 특히 큰 구조체나 컬렉션은 참조로 받아야 성능이 좋습니다.

💡 "불변 참조 여러 개 또는 가변 참조 하나" 규칙을 기억하세요. 이는 읽기-쓰기 락(reader-writer lock)과 유사한 개념이며, 데이터 경합을 방지합니다.

💡 참조의 수명(lifetime)은 보통 자동으로 추론되지만, 복잡한 경우 명시적으로 표시해야 합니다. 'a 같은 라이프타임 파라미터를 사용합니다.

💡 as_ref()와 as_mut() 메서드를 활용하세요. Option<T>를 Option<&T>로 변환하거나, 소유권을 유지하면서 참조를 얻을 때 유용합니다.

💡 interior mutability 패턴(Cell, RefCell)을 사용하면 불변 참조를 통해서도 내부 데이터를 수정할 수 있습니다. 단, 런타임 검사가 추가되므로 필요한 경우에만 사용하세요.


#Rust#Stack#Heap#Memory#Ownership#프로그래밍언어

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.