0%

Rust 소유권이란

러스트 소유권 원리에 대해 공부한 내용을 정리합니다.


모든 프로그램은 실행중인 동안 컴퓨터 메모리를 사용 및 관리한다. 예를 들어 C++의 경우는 해당 프로그램을 작성할 때 메모리 할당 및 해제를 프로그래머가 직접 작성해야 하며, Java의 경우 가비지 콜렉터를 통해 사용되지 않는 메모리를 주기적으로 찾아 메모리를 자동 해제한다. 그렇다면 러스트는 어떻게 메모리를 관리하는가?

소유권

러스트는 앞서 말한 두 방식 모두 사용하지 않는다. 러스트는 컴파일 하는 시점에 컴파일러가 여러 규칙으로 작성된 소유권 시스템에 의해 메모리를 관리한다. 즉, 메모리 할당에 문제가 있는 코드는 컴파일되는 과정에서 찾아낼 수 있다.

NOTE
러스트의 소유권을 더 자세히 이해하기 위해서는 스택(stack) 메모리와 힙(heap) 메모리에 대해서 알아야 한다. 스택 메모리는 컴파일 할 때부터 크기가 고정된 데이터를 저장하며, LIFO(Last in, First out)방식으로 데이터가 사용된다. 반면 힙 메모리의 경우는 컴파일 할 때에는 크기를 알 수 없다가 프로그램 실행 시 들어오는 데이터를 저장 및 관리한다. 힙 메모리는 언제 어떤 크기의 데이터가 들어올 지 알 수 없기에 스택 메모리처럼 간단히 데이터를 쌓을 수 없다. 그래서 운영체제의 도움을 받아 데이터가 들어올 당시 사용가능한 메모리의 정보를 받고, 이에 저장한다.
힙 메모리의 저장방식을 자세히 보자. 프로그램이 실행 중에 데이터를 저장해야하는 상황이 왔다. 그러면 프로그램은 해당 데이터의 크기를 가지고 운영체제에 힙 메모리 할당을 요청한다. 요청을 받은 운영체제는 사용가능한 메모리 중 해당 데이터 크기에 맞는 크기를 찾은 후 해당 메모리 공간을 사용중으로 변경하고 그 주소를 프로그램에 넘긴다. 여기서 그 주소가 포인터(pointer)이다. 결국 모든 언어에서 관리하고 싶은 부분은 정확하게 힙 메모리이다. 힙 메모리를 어떻게 할당하고 해제하는지에 따라서 프로그램의 성능이 좋아질 수 있기 때문이다.

소유권 규칙

소유권에 적용되는 규칙은 다음과 같다.

  • 러스트가 다루는 값은 소유자(owner)라는 변수를 가진다.
  • 특정 시점에서 값의 소유자는 단 한 명이다.
  • 소유자가 소유 범위를 벗어나면 그 값은 제거된다.

위 규칙에서 중요한 키워드는 소유자, 소유 범위 이다. 이에 대하여 더 자세히 알아보자.

소유자

소유자는 메모리에 입력된 값을 가진 변수를 의미한다. 프로그램이 실행되면서 우리는 다양한 값들을 하나의 변수에 할당하여 사용하곤 한다. 그렇다면 물리적인 메모리에 저장된 값을 가진 변수는 어떻게 설정되는가? 이런 문제들은 해당 변수를 다른 변수로 이동(move)하거나 복사(copy)할 때 직면하게 된다.

변수 이동(move)

1
2
3
4
5
6
// case 1
let x = 3;
let y = x;
// case 2
let s1 = String::from("Hello");
let s2 = s1

위 코드는 모두 첫 번째 변수에 특정 값을 할당한 후, 이를 두 번째 변수에 대입하는 코드이다. 하지만 메모리에서의 case 1과 case 2의 작동방식은 동일하지 않다. 먼저 case 1의 경우에는 변수 x,y에 대한 값이 각각 스택 메모리에 저장된다. 즉, 변수 x를 호출했을 때 불러오는 데이터의 위치와 변수 y를 호출했을 때 불러오는 데이터의 위치가 다른 것이다. 하지만 case 2의 경우, 컴파일 시 크기와 내용을 알 수 없는 변수로 저장되었기에 힙 메모리를 사용하게 된다. 힙 메모리의 경우, 변수에는 포인터 정보가 들어가 있기에 이 값을 가져오는 경우 포인터 정보가 복사된다. 즉, 실제 힙 메모리에 들어있는 데이터가 복사되는 것이 아니라 기존 데이터를 가리키는 포인터가 2개 생긴다는 의미이다. 하지만 이런 방식은 이후 러스트가 메모리 할당을 해제할 때 문제가 발생한다. 여러 개의 변수들이 하나의 데이터 위치를 가리키고 있기에 하나의 값을 해제하는 코드가 여러번 실행되고 이에 따라 이미 없는 값을 해제하려고 한다는 이중 해제 에러가 발생한다. 이는 안전성 버그 중 하나이다. 따라서 힙 메모리를 사용하는 변수의 경우 러스트는 하나의 값에는 하나의 포인터만 허용한다. 즉, 위 코드는 s1에서 s2로 값이 이동하는 것이고, 포인터 정보 또한 s2로 이동한다.

1
2
3
4
5
6
7
8
9
error[E0382]: borrow of moved value: `x`
--> test.rs:4:19
|
2 | let x = String::from("Hello");
| - move occurs because `x` has type `String`, which does not implement the `Copy` trait
3 | let y = x;
| - value moved here
4 | println!("{}",x);
| ^ value borrowed here after move

변수 복사(copy)

위에서의 설명으로 어느정도 이해가 되었을 것이다. 러스트에서는 스택 메모리에 저장되는 고정 변수들만 복사가 가능하다. 이렇게 복사가 가능하다는 것을 러스트식 표현으로 copy 트레이트(trait)를 제공한다고 한다. 트레이트란 러스트 컴파일러에게 특정 데이터 타입 별로 어떤 기능을 실행할 수 있고, 또 공유할 수 있는지 알려주는 방법이다. 즉 copy 트레이트, 복사도 특정 데이터에서만 가능하다는 의미이다. 러스트에서 복사가 가능한 데이터 타입들은 다음과 같다.

  • 모든 정수형 타입
  • Boolean 타입
  • 문자 타입(char)
  • 부동 소수점 타입
  • 위 타입으로 구성된 튜플

변수 복제(clone)

그렇다면 러스트에서는 힙 메모리에 사용된 변수는 복사가 불가능할까? 당연히 그렇지는 않다. 변수 복제(clone)이라는 메서드를 사용하면 된다. 변수 복제(clone)는 다음과 같이 사용할 수 있다.

1
2
3
// case 2
let s1 = String::from("Hello");
let s2 = s1.clone();

해당 방식은 변수 크기에 따라서 매우 오래 걸리는 작업이 될 수 있으니 주의해서 사용하자.

소유 범위

규칙에서 등장하는 소유 범위란 무엇일까? 내가 이해한 바로는 소유 범위란 특정 값이 할당된 변수가 계속 그 값을 유지하고 있는 범주이다. 예시를 들어보자. 우리는 빨간색의 열매를 사과라고 부르며, 대한민국 안에서는 모두 사과라는 글자가 무엇을 의미하는지 알고 있다. 하지만 머나먼 핀란드에 가서 사과를 외쳐본다면 우리가 원하는 결과를 얻을 수 없을 것이다. 러스트의 소유 범위도 이와 동일한 논리라고 생각한다. 어떤 함수에서 선언한 변수는 그 함수 내에서만 선언했던 의미를 가지며, 소유 범위를 벗어난 순간 의미를 잃는다. 러스트는 함수를 기반으로 범위가 설정된다. 아래의 코드를 통해 범위가 어떻게 이동하는지 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let s1 = String::from("it's test");
let s2 = 7;
let t1 = test_heap(s1);
let t2 = test_stack(s2);
}

fn test_heap(temp:String){
println!("{}", temp);
}

fn test_stack(temp:i32){
println!("{}", temp);
}

먼저 s1의 값은 힙 메모리에, s2는 스택 메모리에 할당된다. 그 다음 s1test_heap() 범위로 이동하면서 자연스럽게 값이 해제된다. 즉, 힙 메모리에 저장된 변수는 다른 함수로 이동하면서 drop 되는 것이다. 그 후 이동된 s1 값도 test_heap()가 종료되면서 t1으로 할당된다. 반면 s2의 경우는 스택 메모리에 저장되기에 test_stack()으로 이동해도 그대로 값이 남아있는다. 따라서 현재까지 할당된 변수들은 s2, t1, t2이다. 그리고 main()가 종료되면서 모든 변수들이 할당 해제된다.