learn

소유권 관점에서 본 Stack과 Heap

소유권 관점에서 본 Stack과 Heap

why-ownership-exists|소유권이 푸는 4가지 문제가 실제로는 stack과 heap 어디에서 일어나는지 분리해서 보면, *"왜 소유권 시스템이 이렇게 생겼는가"*가 더 명확해진다.

4가지 문제의 발생 위치

문제주로 발생하는 곳이유
Use-after-freeHeapfree/delete가 heap 메모리에만 적용되니까
Double freeHeap같은 이유
Dangling pointerStack + Heap 둘 다stack 변수 주소를 반환하거나, heap을 free한 뒤 포인터를 쓰는 경우
Data raceHeap이 압도적으로 많음스레드 간 공유 데이터는 보통 heap에 있음

4가지 중 두 개(use-after-free, double free)는 heap 전용 문제이며, dangling pointer는 stack에서도 일어난다.

Stack은 왜 자동으로 안전한가

Stack 변수의 수명은 스코프와 정확히 일치한다.

void foo() {
    int x = 42;     // ← 스택에 할당
    // ... 사용 ...
}                   // ← 함수 끝나면 자동으로 사라짐

들어가면 생기고, 나가면 사라진다. 컴파일러가 결정하지, 프로그래머가 명시적으로 해제하지 않는다. 그래서 use-after-free나 double free가 언어 차원에서 일어날 수 없다.

Heap은 다르다.

void foo() {
    int* p = malloc(sizeof(int));   // ← 힙에 할당
    *p = 42;
    // ... 언제 free할지는 프로그래머 책임 ...
}                                    // ← 함수 끝나도 안 사라짐

Heap 메모리의 수명이 스코프와 분리되어 있다. free/delete를 명시적으로 해야 사라진다. 그걸 잊으면 누수, 두 번 하면 double free, 해제 후 쓰면 use-after-free가 된다.

**모든 문제는 "메모리의 수명을 사람이 관리해야 한다"는 데서 비롯된다. 그리고 그 부담은 거의 전적으로 heap에 있다.

Stack에서도 일어나는 문제: dangling pointer

Stack도 완전히 안전하지는 않다. Stack 변수의 주소를 그 스코프 바깥으로 들고 나가면 문제가 된다.

int* foo() {
    int x = 42;
    return &x;       // ← 함수가 끝나면 x는 사라짐. 반환된 포인터는 dangling.
}

int main() {
    int* p = foo();
    *p;              // ← 이미 사라진 stack 자리. 미정의 동작.
}

x는 stack에 있어 함수가 끝나면 자동으로 해제된다. 그런데 그 주소를 반환했으니, 호출자가 받은 포인터는 이미 사라진 자리를 가리킨다.

C++에도 같은 문제가 있다.

std::string& foo() {
    std::string s = "hello";
    return s;        // ← 같은 문제. dangling reference.
}

이건 free를 잘못해서 생긴 게 아니라, 자동으로 해제되는 stack 변수를 그 너머로 끌고 나가서 생긴 문제다.

Rust 소유권은 둘 다 잡는다

Rust의 소유권/빌림 시스템은 heap 문제와 stack 문제를 모두 컴파일 타임에 차단한다. 메커니즘이 통합되어 있다.

Heap 문제 차단 (use-after-free, double free)

fn main() {
    let s = String::from("hello");   // 힙에 할당
    let s2 = s;                       // 소유권 이동. s는 무효화.
    println!("{}", s);                // ❌ borrow of moved value
}

소유권 규칙으로 *"한 시점에 한 값에는 소유자가 정확히 하나"*를 강제한다. 소유자가 스코프를 벗어나는 순간 정확히 한 번 해제되니 double free가 불가능하고, 이동된 뒤에는 원래 변수를 쓸 수 없으니 use-after-free도 불가능하다.

Stack 문제 차단 (dangling reference)

fn foo() -> &i32 {
    let x = 42;
    &x              // ❌ returns a reference to data owned by the current function
}

borrowing-rules|빌림 검사기(borrow checker)가 *"x는 함수가 끝나면 사라지는데, 그 참조를 반환하면 호출자가 받은 참조는 dangling이 된다"*를 컴파일 타임에 잡아낸다. 라이프타임('a) 시스템이 바로 이 문제를 위해 존재한다.

Data race 차단

let mut v = vec![1, 2, 3];
let r1 = &v;
let r2 = &mut v;     // ❌ cannot borrow as mutable

빌림 규칙이 컴파일 타임에 *"동시에 mutable 접근이 일어날 수 없다"*를 보장해 데이터 경합을 차단한다.

Stack/Heap 매트릭스

Stack 메모리:
  ✅ 자동 해제 → use-after-free, double free 자체가 불가능
  ⚠️  Dangling pointer 위험 (스코프 밖으로 주소를 들고 나가면)
  → Rust는 라이프타임으로 컴파일 타임 차단

Heap 메모리:
  ⚠️  Use-after-free 위험
  ⚠️  Double free 위험
  ⚠️  Memory leak 위험
  ⚠️  Dangling pointer 위험
  → Rust는 소유권으로 컴파일 타임 차단

소유권 시스템의 주된 동기heap 메모리의 수명 관리를 사람 손에서 컴파일러로 옮기는 것이다. 다만 그걸 만들면서 stack 참조의 안전성도 같이 잡는 시스템으로 설계되었다.

같은 추상화로 둘을 다루는 우아함

흥미로운 건, Rust의 소유권 모델이 *"메모리 수명을 어떻게 추적할 것인가"*라는 한 가지 질문에 대한 일반화된 답이라는 점이다.

Stack 변수든 heap 변수든, 결국 언젠가는 사라진다. 그리고 *"사라진 메모리를 가리키는 포인터를 막는다"*는 같은 문제의 두 얼굴이다.

  • Stack: 변수가 스코프 끝에서 사라짐 → 그 시점 이후 참조 금지
  • Heap: 소유자가 스코프 끝에서 drop을 호출해 사라짐 → 그 시점 이후 참조 금지

같은 추상화로 처리할 수 있어, 라이프타임이라는 하나의 메커니즘이 둘을 모두 다룬다. 이게 Rust 설계의 우아한 점이다.


한 줄 요약

4가지 메모리 안전성 문제는 주로 heap에서 발생(use-after-free, double free는 heap 전용)하지만, dangling pointer는 stack에서도 일어난다. Rust 소유권은 heap 메모리 관리를 사람 손에서 컴파일러로 옮기는 게 주 목적이지만, 그 시스템이 stack 참조의 안전성도 같이 보장하도록 설계되었다.