Rust 소유권 설명해드림 1편

Rust에서 소유권(Ownership)이란 Rust로 작성한 프로그램이 시스템 메모리를 관리하는 일련의 규칙을 의미합니다.

개발자들이 직접 힙 메모리를 사용하고 반환하는 C, C++, 메모리 관리 작업을 가비지콜렉터가 대신해주는 Java, 파이썬을 비롯한 대부분의 언어들과 다르게, Rust 는 소유권 시스템을 통해 메모리를 할당하고 다 쓴 메모리를 반환합니다.

그리고, 이러한 규칙들이 잘 지켜지고 있는지를 컴파일러가 검사합니다.

소유권 시스템은 시스템 프로그래밍 언어로써 메모리를 굉장히 효율적으로 사용하면서도, 메모리 누수와 같은 개발자의 실수로부터 자유로운 코드를 작성할 수 있는 비법 중 하나이기 때문에 잘 이해하는 것이 중요합니다.

스택 메모리와 힙 메모리

Rust와 같은 시스템 프로그래밍 언어에서는 특정 데이터를 스택 메모리에 저장할지, 힙 메모리에 저장할지를 결정하는 것에 따라 프로그램의 효율이 달라집니다.

스택 메모리는 고정된 크기의 메모리 영역으로, 특정 함수나 스코프를 호출했을 때 그 안에서 사용하는 로컬 변수, 포인터 등이 하나의 스택에 저장됩니다.

그리고 이 스택은 기존 스택들의 위에 쌓입니다. 현재 실행 중인 프로그램은 가장 위에 위치한 스택의 주소와 크기를 알고 있기 때문에 비교적 짫은 시간 내에 이 값에 접근할 수 있습니다.

반면, 힙 메모리는 앞으로 변할 수 있는 데이터를 저장하는 메모리 영역입니다. 이 때문에 힙 메모리를 사용하기 위해서는, 메모리에서 사용하기에 적당한 주소를 찾아내고 난 후 이 주소를 가리키는 포인터를 생성해 현재 스택에 저장합니다.

이러한 한 단계의 과정이 존재하기 때문에, 데이터를 할당하는 것, 가져오는 것 모두 비교적 느립니다.

Rust 프로그래밍을 익하면서 특정 기능이 어떤 메모리 영역을 사용하는지도 함께 생각해두면 앞으로의 코드 작성에 큰 도움이 될 것입니다.

소유권 규칙

소유권 시스템은 크게 3가지의 규칙을 따릅니다.

  • 모든 값은 소유자가 있다
  • 소유자는 특정 시점에 단 하나만 존재한다
  • 현재 스코프에서 소유자가 사라지면 값 역시 사라지고 메모리를 반환한다

변수 스코프란?

변수 스코프란 특정 변수가 존재하는 범위를 말하며, 이 범위는 주로 해당 변수를 선언한 함수나 표현식의 중괄호({}) 내입니다.

변수는 자신이 속한 스코프에 (1) 등장한 순간부터 (2) 해당 스코프가 끝날 때까지 값이 인정됩니다.

String 타입으로 소유권 이해하기

이미 Rust 변수와 상수 포스트에서 Rust에서 사용하는 기본 데이터 타입들을 살펴보았는데요.

모든 기본 데이터 타입은 크기가 정해져 있고 앞으로도 변하지 않기 때문에, 스택 메모리에 저장하며 변수 스코프 역시 추적하기 쉽습니다.

또한, Copy by value로 동작하기 때문에 이러한 타입에 대한 메모리 관리는 어려운 작업이 아닙니다.

소유권 시스템이 빛을 발하는 건 String 타입처럼 힙 메모리에 저장되어, 여러 스코프에서 함께 참조하고 있는 타입을 사용할 때입니다.

지금 언급하는 String 타입은 표준 라이브러리에 정의된 타입이며, 컴파일 타임에 크기를 알고 알으로 변하지 않는 string literal과는 다른 객체입니다.

아래와 같이 정의할 수 있는 String은 힙 메모리에 저장되며, 크기가 변할 수 있습니다.

let mut s = String::from("hello, World!");

s.push_str("!!");

println!("{s}");

위 코드에서 String::from() 함수는 힙 메모리에서 일정량 이상의 메모리 영역을 찿아 할당합니다. 이 영역은 변수 s가 자신이 속한 스코프에서 벗어날 때 다시 반환됩니다.

이 때문에 가비지콜렉터가 계속 창조하지 않는 값들을 체크하고 있지 않아도 되는 것이죠.

여기까지는 직관적으로 이해가 가능합니다.

그런데, 아래와 같이 두 변수가 하나의 값을 가리키게 된다면 어떻게 될까요?

let mut s = String::from("hello, World!");

let s2 = s;

위에서 살펴본 원칙대로라면 이 스코프가 끌날 때, 변수 s, s2 모두 동일한 주소의 메모리 반환 시도를 해서 에러가 발생할 것입니다.

힙 메모리 값들은 이동(move)한다

Rust는 이러한 에러를 막기 위해, 위와 같은 경우 변수 s는 아예 더이상 사용하지 않는 변수로 만들어버립니다.

let mut s = String::from("hello, World!");

let s2 = s;

println!("{s}");

error[E0382]: borrow of moved value: `s`
 --> src/main.rs:6:15
  |
2 |     let mut s = String::from("hello, World!");
  |         ----- move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 |
4 |     let s2 = s;
  |              - value moved here
5 |     
6 |     println!("{s}");
  |               ^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
4 |     let s2 = s.clone();
  |               ++++++++

에러 메시지를 보면 변수 s의 값이 s2moved되었다고 표현합니다. 그만큼 변수 s는 아무 값도 가지지 않습니다.

만약, 변수 s의 값을 복사한 후 변수 s2에 대입해서 두 변수 모두 사용하려고 한다면 clone()함수를 사용할 수 있습니다.

let mut s = String::from("hello, World!");

let s2 = s.clone();

println!("{s}");
println!("{s2}");

소유권 시스템과 함수 파라미터

만약, 스택 메모리의 값들을 함수 파라미터로 보낸다면 값이 복사되겠지만, 힙 메모리의 값을 보내면 위에서 보았듯이 move됩니다.

이 때문에 아래 코드에서 변수 s는 더이상 사용되지 않습니다.

fn main() {
    let mut s = String::from("hello, World!");

    takes_ownership(s);

    println!("{s}");
}

fn takes_ownership(s: String) {
    println!("{s}")
}

이와 반대로 함수 내에서 선언한 힙 메모리 값을 리턴한다면 해당 소유권은 리턴값을 대입하는 변수로 이동합니다.

그렇다면, 힙 메모리 값을 파라미터로 받는 함수를 사용하면서도 기존 값도 유지하고 싶다면 아래와 같이, 해당 값을 계속 리턴해야 할까요?

fn main() {
    let s1 = String::from("hello, World!");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();

    (s, length)
}

이러한 일을 방지하도록 Rust는 해당 값의 주소만 건네주는 reference 값을 지원합니다.

이와 관련해서는 2편에서 정리하도록 하겠습니다.