Rust의 Ownership: GC 없는 메모리 안전

1. 개요

Ownership은 Rust가 Java나 Go처럼 가비지 컬렉터(GC) 없이 메모리 안전성을 보장하고, C처럼 직접 malloc/free를 호출할 필요가 없도록 하는 핵심 메커니즘입니다. 이 글에서는 세 가지 ownership 규칙, borrowing 개념, lifetime, 그리고 흔한 컴파일 타임 오류를 설명합니다. 이 글을 읽은 후에는 Rust가 컴파일 시점에 “까다로운” 이유와 대신 dangling pointer, double free, 또는 런타임 data race가 없는 이유를 이해할 수 있습니다.

2. 문제: GC 없는 메모리 관리

Java 같은 언어는 GC를 사용해 메모리를 정리합니다 — 편리하지만 CPU를 소비하고 예측할 수 없는 pause를 일으킵니다. C/C++는 완전한 제어를 주지만 double free, use-after-free, memory leak이 발생하기 쉽습니다. Rust는 제3의 방식을 선택했습니다: 컴파일러가 누가 어떤 데이터를 “소유”하는지 추적하고, 소유자가 scope를 벗어나면 자동으로 해제합니다. 런타임 오버헤드가 없고, GC 스레드가 필요하지 않습니다.

fn main() {
    let s = String::from("Non La Dev");
    println!("{s}");
} // s가 scope를 벗어나면, Rust가 자동으로 drop(s)를 호출해 힙 메모리를 해제합니다

String::from 줄은 힙에 메모리를 할당합니다. 변수 s가 } 밖으로 나가면, Rust는 자동으로 drop 호출을 삽입합니다 — finally { free(s) }와 비슷하지만 컴파일러가 생성하므로 직접 작성할 필요가 없습니다.

3. 세 가지 Ownership 규칙

Rust는 정확히 세 가지 규칙이 있으며, 이를 숙지하면 메커니즘의 80%를 이해할 수 있습니다:

  1. Rust의 모든 값은 owner라는 변수를 가집니다.
  2. 한 번에 하나의 owner만 존재할 수 있습니다.
  3. owner가 scope를 벗어나면, 값은 drop (해제)됩니다.

3.1. Move semantics

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // ownership은 s1에서 s2로 "이동"됩니다

    println!("{s2}");
    // println!("{s1}"); // 컴파일 오류: s1이 이동되었습니다
}

Java와 달리(변수 할당은 reference만 복사하므로 둘 다 같은 객체를 가리킵니다, GC가 나머지를 처리합니다), Rust는 let s2 = s1을 소유권 이전으로 봅니다. 그 이후로 s1은 더 이상 유효하지 않습니다. 마지막 줄의 주석을 제거하면, 다음 오류를 받게 됩니다:

error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:5:20
   |
2  |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `String`
3  |     let s2 = s1;
   |              -- value moved here
4  |
5  |     println!("{s1}");
   |                ^^^^ value borrowed here after move

이는 double free 오류를 근본적으로 방지합니다: s1과 s2가 모두 같은 힙 영역을 가리키는 포인터를 가지고 있다면, 둘 다 scope를 벗어날 때 Rust는 두 번 drop을 시도하게 되어 crash를 일으킵니다. 컴파일러는 컴파일 시점에 이를 차단합니다.

3.2. 두 개를 모두 유지하려면 Clone 사용

정말 두 개의 독립적인 복사본이 필요하다면, .clone()을 명시적으로 호출하세요:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // 힙 영역의 deep copy

    println!("{s1} - {s2}"); // 둘 다 유효합니다
}

.clone()은 새로운 힙 영역을 할당하고 내용을 복사합니다. 런타임 비용이 명확하고, Rust는 명시적 호출을 강제하여 누군가 실수로 거대한 데이터 구조를 복사하는 것을 방지합니다 — 많은 언어가 암시적으로 복사하는 것과는 다릅니다.

3.3. 단순 데이터 타입을 위한 Copy trait

fn main() {
    let x = 5;
    let y = x; // copy, move 아님

    println!("x = {x}, y = {y}"); // 둘 다 사용할 수 있습니다
}

i32, bool, char 같은 기본 타입이나 이러한 타입만 포함하는 tuple은 Copy trait를 구현합니다. 이들은 크기가 고정되어 있고 완전히 스택에 있으므로, 복사가 이동보다 비용이 적어 Rust는 원본 변수를 무효화하는 대신 자동으로 복사합니다.

참고: 타입은 Copy와 Drop을 동시에 구현할 수 없습니다. 타입에 정리해야 할 리소스(heap, file handle 등)가 있으면 Copy를 구현할 수 없습니다.

4. 함수에 전달할 때의 Ownership

함수에 변수를 전달하는 것도 변수 할당과 동일하게 move semantics를 따릅니다:

fn print_order(order: String) {
    println!("Order: {order}");
} // order는 여기서 drop되고, caller에서가 아닙니다

fn main() {
    let order = String::from("ORDER-001");
    print_order(order);
    // println!("{order}"); // 오류: order가 함수로 이동되었습니다
}

print_order(order)를 호출하면, order의 ownership이 함수의 파라미터로 이전됩니다. 함수가 끝나면, 값은 함수 내에서 drop됩니다 — main은 더 이상 order를 사용할 권리가 없습니다. 이것이 borrowing(다음 섹션)이 데이터를 “읽기만 위해 빌리고” 소유권을 이전하지 않으려고 할 때 거의 항상 필요한 이유입니다.

5. Borrowing: 소유권을 빼앗지 않고 빌려주기

5.1. Immutable reference (&T)

fn print_order(order: &String) {
    println!("Order: {order}");
}

fn main() {
    let order = String::from("ORDER-001");
    print_order(&order); // 빌려주기, move 아님
    println!("여전히 사용 가능: {order}");
}

& 기호는 reference를 만듭니다 — 소유권을 가져가지 않고 데이터를 빌려줍니다. print_order 함수는 읽기만 하고, order를 소유하지 않습니다. 함수가 실행된 후, main은 여전히 원본 변수를 모두 사용할 수 있습니다.

5.2. Mutable reference (&mut T)

fn add_discount(total: &mut f64) {
    *total -= 10.0;
}

fn main() {
    let mut total = 100.0;
    add_discount(&mut total);
    println!("Total: {total}"); // Total: 90
}

&mut는 수정하기 위해 빌려줍니다. 호출하려면, 원본 변수는 mut으로 선언되어야 합니다. * 기호는 dereference하고 reference가 가리키는 메모리 영역에 새 값을 할당하는 데 사용됩니다.

5.3. Borrowing 규칙

Rust는 컴파일 타임에 두 가지 차용 규칙을 적용하며, 이를 통칭 borrow checker라고 합니다:

  • 한 번에 하나의 mutable reference이거나 여러 개의 immutable reference만 가능합니다 — 섞일 수 없습니다.
  • Reference는 절대 원본 데이터보다 오래 살 수 없습니다(dangling reference 방지).
fn main() {
    let mut total = 100.0;

    let r1 = &total;
    let r2 = &total; // OK: 동시에 여러 immutable reference
    println!("{r1} {r2}");

    let r3 = &mut total; // r1, r2가 여전히 이 지점 이후에 사용되면 오류
    println!("{r3}");
}

r1, r2가 첫 번째 println! 이후에 더 이상 사용되지 않으면, 컴파일러는(Non-Lexical Lifetimes 덕분에) 아래에서 r3이 mutable을 빌려줄 수 있도록 합니다. 하지만 r3이 나타난 후 r1을 사용하려고 하면, 다음 오류가 발생합니다:

error[E0502]: cannot borrow `total` as mutable because it is also borrowed as immutable

이 규칙은 data race를 컴파일 타임에 완전히 제거합니다: 같은 메모리 영역에 대해 동시에 읽고 쓸 수 없습니다.

6. Lifetime: reference가 “공허한 것”을 가리키지 않도록 보장

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let order1 = String::from("ORDER-001");
    let order2 = String::from("ORDER-002-LONG");

    let result = longest(order1.as_str(), order2.as_str());
    println!("더 긴 것: {result}");
}

'a 기호는 lifetime annotation으로, 컴파일러에게 “반환된 reference는 x와 y 모두보다 오래 살 수 없습니다”라고 알립니다. Rust는 이 경우 여러 reference 간의 생명-사망 관계를 자동으로 추론할 수 없으므로, 명시적으로 선언해야 합니다. 대부분의 경우 Rust는 lifetime을 자동으로 추론합니다(lifetime elision)하므로, 직접 'a를 작성할 일이 거의 없습니다 — 함수가 여러 reference를 받고 그 중 하나를 반환할 때만 필요합니다.

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |                                 ^ expected named lifetime parameter

'a가 없으면, 컴파일러는 반환된 reference가 x 또는 y와 연결되었는지 알 수 없으므로 위와 같은 오류를 보고합니다.

7. 여러 owner가 필요할 때: Rc와 RefCell

단일 소유자 ownership은 때때로 너무 엄격합니다. 예를 들어, 트리나 그래프 같은 데이터 구조를 만들 때 여러 노드가 같은 값을 참조해야 합니다.

<!-- 종속성 불필요, Rc/RefCell은 std에 포함됨 -->
use std::cell::RefCell;
use std::rc::Rc;

struct Customer {
    name: String,
    balance: RefCell<f64>,
}

fn main() {
    let customer = Rc::new(Customer {
        name: String::from("Nón Lá"),
        balance: RefCell::new(0.0),
    });

    let c1 = Rc::clone(&customer);
    let c2 = Rc::clone(&customer);

    *c1.balance.borrow_mut() += 50.0;
    *c2.balance.borrow_mut() += 30.0;

    println!("{} balance = {}", customer.name, customer.balance.borrow());
    println!("Reference 개수: {}", Rc::strong_count(&customer));
}

Rc<T>(Reference Counted)는 여러 owner가 불변 데이터를 가리킬 수 있게 하며, 런타임에 reference 개수를 세고, count가 0이 되면 자동으로 drop됩니다. RefCell<T>는 컴파일 타임 대신 런타임에 mutable borrow을 허용합니다(interior mutability) — 대신 borrowing 규칙을 위반하면(예: 동시에 두 번 borrow_mut() 호출), 컴파일 오류 대신 프로그램이 panic합니다.

Nón Lá balance = 80
Reference 개수: 3

참고: Rc/RefCell은 단일 스레드에만 사용합니다. 여러 스레드 간에 데이터를 공유해야 한다면, Arc(atomic reference count)를 Mutex 또는 RwLock과 함께 사용하세요. Rc<RefCell<T>>를 남용하지 마세요 — “borrow checker가 컴파일 타임에 오류를 잡는” Rust의 가장 큰 장점을 잃게 됩니다.

8. Ownership 동작을 검증하는 테스트 작성

#[derive(Clone)]
struct Order {
    id: String,
    amount: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.amount -= order.amount * percent / 100.0;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn should_keep_original_order_untouched_when_cloned() {
        let original = Order { id: "ORD-1".into(), amount: 100.0 };
        let mut copy = original.clone();

        apply_discount(&mut copy, 10.0);

        assert_eq!(original.amount, 100.0); // 원본은 변경되지 않음
        assert_eq!(copy.amount, 90.0);
    }

    #[test]
    fn should_mutate_through_mutable_borrow() {
        let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
        apply_discount(&mut order, 25.0);

        assert_eq!(order.amount, 150.0);
    }
}

cargo test를 실행하면 다음 결과를 얻습니다:

running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

첫 번째 테스트는 .clone()이 독립적인 복사본을 만든다는 것을 증명합니다 — copy를 수정해도 original에 영향을 주지 않습니다. 두 번째 테스트는 &mut이 reference를 통해 원본 데이터를 실제로 변경한다는 것을 확인합니다.

9. 일반적인 오류

  • Use after move: 변수를 이동한 후에 사용하려고 시도합니다(함수, 새로운 변수, struct로). 두 개의 복사본이 필요하면 .clone()으로 해결하거나, reference(&T)를 전달하도록 변경하세요.
  • Cannot borrow as mutable more than once: 하나의 &mut을 가지고 있으면서 동시에 다른 & 또는 &mut을 만들려고 합니다. 이전 reference의 scope를 줄이면 해결됩니다(블록 {}을 사용하거나 더 일찍 끝나도록).
  • Dangling reference: 함수가 끝날 때 drop되는 로컬 변수를 가리키는 reference를 반환합니다. Rust는 이 오류를 컴파일 타임에 완전히 차단합니다 — missing lifetime specifier 또는 borrowed value does not live long enough 오류를 보게 됩니다.
  • .clone() 남용으로 borrow checker 회피: 컴파일 관점에서는 유효하지만 불필요한 성능 낭비입니다. 각 .clone()을 다음과 같이 생각하세요: “정말 독립적인 복사본이 필요한가, 아니면 단지 ownership을 이해하는 것을 피하고 있는 건가?”

10. 엄격한 ownership이 필요하지 않은 경우

짧은 스크립트, 빠른 프로토타입, 또는 항상 작고 성능/안전성 요구사항이 높지 않은 데이터를 다루고 있다면, borrow checker를 배우고 따르는 비용이 정당하지 않을 수 있습니다 — Python이나 Go 같은 언어가 더 빠른 개발 속도를 제공합니다. Ownership은 높은 성능과 엄격한 메모리 안전성이 필요한 시스템에서 가장 빛나갑니다: 운영 체제, embedded, 게임 엔진, 또는 낮은 지연 시간이 필요하고 GC pause를 허용하지 않는 백엔드 서비스.

11. 결론

Ownership은 Rust가 모순되어 보이는 것을 달성하는 토대입니다: GC를 가진 언어처럼 메모리 안전성을 갖추면서도 C/C++만큼의 성능을 제공합니다. 세 가지 규칙인 owner-move-drop, 하나의 mutable 또는 여러 immutable이라는 borrowing 규칙, 그리고 lifetime annotation이 함께 작동하여 컴파일러가 런타임 crash 대신 컴파일 타임에 메모리 안전하지 않은 오류를 잡을 수 있게 합니다. 처음에는 borrow checker와 컴파일러와 “싸우는” 일이 많을 수 있지만, 익숙해지면 runtime에서 감시할 필요 없이 훨씬 적은 버그를 가진 코드를 작성하게 됩니다.

rustownershipmemory-safetysystems-programmingborrow-checker