Rust Ownership

Rust Ownership

Rust는 GC가 없는 언어이다. 보통은 언어가 힙 메모리를 관리하기 위해 GC를 사용하거나 개발자가 직접 관리하는 두 가지 노선을 선택해 왔지만, Rust는 조금 독자적인 방법을 선택했다. 각 변수가 사용하는 메모리에 대한 소유권을 하나만 유지하면 GC가 필요 없다는 점을 이용한다. 만약 하나의 변수에 힙 영역 데이터가 묶여있다면 해당 변수가 더 이상 접근 불가능한 상태가 되었을 때 메모리를 곧바로 해제해버리면 된다. 실제로 러스트를 사용하다 보면 힙 메모리를 free 하지 않아서 간단한 프로그램을 쓸 때 꼭 GC가 있는 언어처럼 느껴진다. 이번 글에서는 소유권 및 그와 연관된 여러 러스트의 컨셉을 정리했다.

Ownership (소유권)

GC가 있는 언어 같다고 했지만, 개인적으로 사실 조금 이질적인 느낌이 있다. Ownership은 러스트를 잘 쓰기 위해 숙련도가 요구되는 주범이다.

The Rust Programming Language 책에서는 소유권을 “규칙”이라고 설명한다. 소유권과 관련된 규칙들을 컴파일 타임에 모두 확인하고 하나라도 지켜지지 않는다면 컴파일되지 않는다. 컴파일 타임에 확인되므로 런타임에서는 퍼포먼스에 영향을 주지 않는다.

규칙은 다음과 같다.

  • 각 값은 모두 주인(Owner)을 가지고 있다.
  • 한 번에 하나의 Owner를 갖는다.
  • Owner가 스코프를 벗어나면 값은 정리된다.

값은 하나의 식별자를 주인으로 갖게 된다고 이해하면 좋을 것 같다. 식별자는 러스트의 변수 스코프에 의해 접근 불가능해지는 순간이 오는데, 이때 값들을 모두 정리한다.

Variable

변수는 자신이 담고 있는 값이 어느 정도의 메모리를 사용해 할당되는지 알고 있어야 한다. 예를 들어서 다음과 같이 u8 타입이 있다면 컴파일러 입장에서 이 타입은 1바이트를 사용한다는 것을 알 수 있다.

1
let v = 1_u8;

하지만 그렇지 않은 경우도 있다. 예를 들어 가변 길이의 벡터라든지, 문자열을 필드로 가지고 있는 구조체라든지 사용자에게 입력받는 경우 런타임에 데이터가 결정되므로 메모리 크기를 컴파일 타임에 알 수 없다.

일반적으로 언어에서 이러한 경우는 힙에 데이터를 넣는 방식으로 해결한다. 보통 동적 할당한다고 표현하는데, 런타임에 메모리를 필요한 만큼 힙 메모리에 할당해 사용한다. 메모리를 관리해야 하는 상황은 이렇게 힙에 할당하는 상황이다. GC 역시 힙 영역의 메모리 관리에 대해 얘기를 한다.

GC에 대해서 작성한 글

Rust의 String 타입이 힙을 사용하면서, 아주 친근한 데이터 타입으로, Ownership을 설명하기 적합하다.

1
2
3
{
let s = String::from("hello");
} // scope 종료 이후 s와 해당 메모리는 정리된다.

Rust는 이렇게 스코프가 종료되는 시점에 drop 함수를 호출하는데, 이 함수는 작성자가 메모리를 free 하기 위한 코드가 담겨있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct MyType {
v: u8,
}

impl Drop for MyType {
fn drop(&mut self) {
println!("drop my type")
}
}

fn main() {
let s = MyType { v: 1 };
println!("{}", s.v);
}

// 1
// drop my type

drop 함수는 예시에서 보이는 것처럼 Drop 트레잇을 구현한 것이다. 러스트를 모르지만, 컨셉이 궁금해서 온 사람들은 Drop이 인터페이스라고 생각하면 될 것 같다.

이렇게 소유권은 하나만 갖게 하면서 소유권을 가진 식별자가 스코프를 벗어날 때 Drop 하므로 GC가 필요 없다. 마치 Reference Count를 하나로 유지하는 것과 비슷하다. C 언어를 잘 알지는 못하지만, 패키지를 사용할 때 잘 만들어진 패키지의 경우 사용된 데이터 타입을 어디서 free 하는 책임을 갖는지 주석으로 명시한다고 들었는데, 러스트는 마치 이 방향을 컴파일 타임에 규칙으로서 명시해 문제를 해결하는 느낌이다.

러스트처럼 변수 스코프가 종료되는 시점에 패턴을 C++에서는 RAII(Resource Acquisition Is Initialization)이라는 이름으로 부른다.

이제 String 타입을 통해 소유권에 대해 조금 더 자세히 설명한다.

소유권 이전

본격적인 소유권 이전에 대한 설명 전에 스택 위에서 할당되는 값으로 보자. 다음과 같이 y = x처럼 다른 식별자에 값을 복사해 넣는 것을 비교해볼 예정이다.

1
2
3
4
5
let mut x = 1;
let mut y = x;
x += 1;
y -= 1;
println!("x: {} / y: {}", x, y); // 2, 0

x는 1을 할당 받고 yx를 할당 받았지만, 둘 다 값이 복사되어 서로 다른 메모리에 있는 값을 보게 된다. 이는 정수형처럼 정해진 사이즈를 가진 값인 경우 할당 연산을 수행할 때 새로운 값이 스택에 복사되어 메모리를 따로 받게 되기 때문이다. 구구절절 설명했지만, 일반적으로 프로그래밍 언어에서 우리는 이러한 상황을 아주 자연스럽게 받아들일 수 있다.

이제 힙을 사용하는 경우를 확인해 보자. 힙을 사용하는 데이터 중에 일반적이라고 생각되는 자바스크립트의 코드를 살펴보면 다음과 같이 동작한다.

1
2
3
4
let obj = { data: 10 };
let copeid = obj;
copied.data = 100;
console.log(obj); // { data: 100 }

우리는 이전에 사용했던 obj 객체에 접근할 수도 있고, 같은 힙 메모리를 공유하며 각자의 수정 사항을 모두 동일하게 확인할 수 있다.

러스트는 아주 독특하게 동작하는데, 결과만 말하자면 힙에 있는 데이터를 가리키고 있는 식별자는 다른 식별자에 힙 포인터를 복사해 넣는 순간 해당 데이터에 대한 소유권을 이전한다. 그리고 소유권을 잃은 식별자는 더 이상 접근할 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
let s1 = String::from("hello");
let s2 = s1;
println!("s1: {} / s2: {}", s1, s2);

/*
let s1 = String::from("hello");
-- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
let s2 = s1;
-- value moved here
println!("s1: {} / s2: {}", s1, s2);
^^ value borrowed here after move
*/

이해가 어려운 말들이 나오는데, 동작을 설명하기에 앞서 먼저 String 타입을 간단히 설명하자면 아래와 같이 문자열 맨 앞을 가리키는 포인터, len, 그리고 capacity를 스택에 담는 구조이다.

문자열과 메모리

len은 문자열의 길이를 뜻하고 capacity는 바이트로 표시한 컨텐츠의 메모리 크기를 의미한다.

따라서 s2 식별자에 s1을 할당하는 동작은 앞서 정수로 설명한 경우와 동일하게 위 세 개 정보를 스택에 복사하는 것과 같다. 힙에 있는 데이터는 복제되지 않는다.

스택 데이터만 복사

우리는 앞서 Rust의 동작 중에 식별자가 스코프에 벗어나면 사용되던 값들을 모두 정리한다고 배웠는데, 위와 같은 구조에서는 문자열이 두 번(s1Drop && s2Drop) 정리되는 상황이 생긴다. 즉, 두 번 free를 하는 것과 같고 이는 메모리 충돌을 발생시킨다.

메모리 안전성을 위해서 러스트는 이런 상황에서 s1이 더 이상 유효하지 않다고 판단해버린다. 즉 말해서 러스트는s1이 스코프를 벗어나든 아니든 Drop과 관련된 로직을 수행할 필요가 없다.

다른 언어에서는 이렇게 값을 복사하는 과정에 포인터 내부의 값을 복사하지 않는 것을 얕은 복사라고 표현한다. Rust는 이 동작이 모든 데이터에 적용된다. 자동으로 깊은 복사를 수행하는 경우가 없다. 책에서 설명하는 방식으로는 Rust에서 이런 동작을 move라고 한다는데, 한국어로는 이전이라고 표현하면 될 것 같다.

조금 명시적으로 말하자면 힙 포인터를 얕은 복사하는 경우 발생하는 동작이 “이전”이라고 표현할 수 있을 것 같다.

Copy & Clone

힙에 있는 데이터까지 복제하는 작업을 할 때는 일반적으로 Clone이라는 Trait을 구현한다. String 타입도 마찬가지로 Clone 타입을 구현하고 있다.

1
2
3
4
5
6
7
8
9
10
11
#[cfg(not(no_global_oom_handling))]
#[stable(feature = "rust1", since = "1.0.0")]
impl Clone for String {
fn clone(&self) -> Self {
String { vec: self.vec.clone() }
}

fn clone_from(&mut self, source: &Self) {
self.vec.clone_from(&source.vec);
}
}
1
2
3
4
5
6
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);
}

위 동작은 다음 그림처럼 힙 데이터 역시 복사해서 새로운 식별자인 s2에 담는다. s1의 소유권은 그대로 유지된다.

문자열 Clone


다시 스택에 할당되는 값만 가진 이 코드로 돌아와 보자.

1
2
3
4
5
let mut x = 1;
let mut y = x;
x += 1;
y -= 1;
println!("x: {} / y: {}", x, y); // 2, 0

스택에 할당되는 값의 경우 할당 연산을 수행할 때 소유권 이전이 발생하지 않고 값을 복사해버린다. 여기서는 얕은 복사니 깊은 복사니 하는 것이 의미가 없다. 이렇게 소유권 이전 없이 값을 간단히 스택에서 복사해버릴 수 있는 타입들은 모두 Copy Trait을 구현하고 있다. Copy 타입은 러스트 시스템의 특별한 어노테이션으로서, 만약 이 Trait을 구현하고 있는 타입이라면 할당 연산을 할 때 소유권을 이전하지 않는다.

1
2
let s1 = String::from("hello");
-- move occurs because `s1` has type `String`, which does not implement the `Copy` trait

아까 에러 메시지를 다시 한번 보면, StringCopy Trait을 구현하고 있지 않기 때문에 move가 발생한다고 설명한다.

Copy 타입을 직접 구현하도록 할 수 있는데, 등호 연산을 오버로딩하는 느낌이 아니라 그냥 스택에서 값을 복사할 수 있는 타입의 경우 그 자격을 명시하는 정도이다. Copy를 구현하려면 대상 타입이 Clone을 구현하고, 그 타입 자체 혹은 타입을 구성하는 다른 필드들 모두가 Drop Trait을 구현하고 있지 않아야 한다. 즉, 타입을 구성하는 모든 필드 및 값이 스택에 할당될 수 있어야 한다는 것을 의미한다.

1
2
3
4
5
6
7
8
9
10
impl Copy for MyType {}
impl Clone for MyType {
fn clone(&self) -> Self {
*self
}
}

// or
#[derive(Copy, Clone)]
struct MyStruct;

Copy Trait은 위 코드처럼, 구현해야 하는 메소드가 없다. 이는 러스트가 의도적으로 오버로딩을 구현하지 못하도록 막은 것이고, 이를 통해 임의 코드가 런타임에서 실행되지 않도록 막는다.

Copy가 구현될 수 있는 규칙을 설명할 때 책에서는 위와 같이 Clone + Not Drop으로 설명하지만, 코드 주석에서는 필드가 모두 Copy를 구현해야 한다고 설명한다. 즉, 기본 타입들은 모두 Not Drop이라면 Copy를 구현하고 있는 것 같다.

위 코드에서 Copy를 구현하는 두 가지 방법은 미묘한 차이가 있는데, 이번 글의 범위를 벗어난다. 궁금하다면 Derivable Traits 문서Rust Copy 코드의 주석을 보자.

Copy Trait을 직접 구현해야 하는 일은 거의 드물다. Copy가 구현되어 있다면 최적화가 되어있고 clone 메소드가 아니라 할당 연산자를 사용할 수 있음을 의미하므로 코드가 더 간결해질 수는 있다. Copy를 구현하고 있는 기본 타입들은 다음과 같다.

  • 모든 정수형 타입들 (u32, u16, …)
  • Boolean 타입
  • 부동소수 타입 (f64, …)
  • 모든 캐릭터 타입 (char)
  • Copy 구현체들을 담고 있는 튜플 ((i32, i32))

함수와 Ownership

함수 파라미터로 값을 넘기는 것이나 리턴 값으로 넘기는 것 모두 할당 연산과 비슷한 동작을 한다. 할당과 마찬가지로 값을 복사하기 때문에 파라미터로 Copy를 구현하지 않은 값을 넣거나, 리턴하는 경우 소유권 이전이 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct MyType {
v: u8,
}

impl MyType {
fn from(v: u8) -> Self {
MyType { v }
}
}

impl Drop for MyType {
fn drop(&mut self) {
println!("drop my type")
}
}

fn main() {
let s = MyType::from(1);
take_ownership(s);
println!("finish");
}

fn take_ownership(param: MyType) {
println!("{}", param.v)
}

// 1
// drop my type
// finish

stake_ownership 함수에 넘겨질 때 소유권 이전이 발생한다. 따라서 take_ownership 이후로는 접근이 불가능하다. take_ownership 함수가 끝날 때 해당 변수의 스코프가 종료되므로 drop 함수를 수행한다. 리턴하는 값이 Drop을 구현하고 있는 경우도 마찬가지로 리턴 값에 대한 소유권이 할당받는 식별자에게 넘어가게 된다. 따라서 같은 스코프에 변경된 값을 유지하고 싶으면 다시 함수에서 바깥으로 소유권을 전달해야 한다.

1
2
3
4
5
6
7
8
fn main() {
let s = String::from("hello");
let (s, slen) = calculate_length(s);
}

fn calculate_length(s: String) -> (String, usize) {
(s, s.len())
}

이러한 동작이 사실 매우 귀찮기 때문에 러스트에서는 값은 참조하지만, 소유권은 넘겨주지 않는 방법으로 함수를 사용할 수 있도록 했다.

참조와 소유권 대여

위와 같은 상황에서 소유권을 넘기지 않으려면 참조(Reference)를 넘긴다. 원본 데이터에 접근하지 않고 스택의 데이터를 참조하는 자료형으로 파라미터에 전달된다.

참조형
참조형으로 전달된 값은 기본적으로 불변 자료형이고, 참조하는 식별자이기 때문에 식별자 스코프가 종료되어도 Drop을 수행하지 않는다.

1
2
3
4
5
6
7
8
9
fn main() {
let s = String::from("hello");
let len = calculate_length(&s);
println!("{} length: {}", s, len); // hello length: 5
}

fn calculate_length(s: &String) -> usize {
s.len()
}

위 코드처럼, s 식별자를 다른 함수에 참조형으로 전달하게 되면 해당 함수 이후에도 s를 사용할 수 있고, 해당 값을 유지하기 위한 리턴도 필요 없게 된다. 다만 다음과 같이 값을 변경하려고 하는 경우는 컴파일 에러가 발생한다.

1
2
3
4
fn change(s: &String) {
s.push_str(", world");
// compile error
}

위에서 짧게 언급한 것처럼 기본적으로 참조형이 불변형(Immutable Reference)이기 때문이다.

원래 러스트는 기본적으로 값을 불변형으로 선언한다. let으로 선언된 모든 식별자는 불변 식별자이다. 만약 값을 바꾸려면 mut 키워드를 식별자 앞에 선언해서 해당 식별자가 가변적임을 컴파일러에 알려야 한다.

참조한 변수를 가변적(Mutable Reference)으로 사용하려면 다른 변수들처럼 mut 키워드를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
fn main() {
// 여기도 mutable 한 변수가 되도록 `mut` 키워드를 사용한다.
let mut s = String::from("hello");
let len = calculate_length(&s);
change(&mut s);
println!("{} length: {}", s, len); // hello, world length: 5
}

fn change(s: &mut String) {
s.push_str(", world");
}

러스트는 참조형을 사용할 때 두 가지 안전 장치가 있다.

  • Data Race를 방지하기 위한 특징
  • 쓰레깃값을 만들지 않기 위한 특징

Mutable Reference Data Race 방지

일단 Data Race는 다음과 같은 상황을 얘기한다.

  • 두 개 이상의 포인터가 같은 데이터에 접근 가능
  • 최소 하나의 포인터가 데이터 쓰기가 가능
  • 데이터 접근을 동기화하는 메커니즘 부재

위 세 개의 상황이 동시에 발생하고 있을 때 Data Race 상태라고 볼 수 있다. 동시성 문제의 Race Condition과 유사한데, 데이터 변경과 읽기 순서에 따라 결과가 달라질 수 있는 상황이다.

Data Race

이 문제는 미묘한 상황과 복잡한 코드에 의해 런타임에 찾기가 어려운 경우가 많지만, 러스트는 뚱뚱한 컴파일러를 지향하는 언어답게 사전에 컴파일 단계에서 이를 방지해준다.

방지하는 방법은 Reference를 전달할 때 Shared Lock, Exclusive Lock을 거는 것처럼 동작을 제한한다. Mutable Reference의 스코프를 벗어나기 전까지 Exclusive Lock처럼 다른 Reference가 걸리는 것을 막는다. 읽기 전용 Reference는 Shared Lock처럼 다른 읽기 전용 Reference가 걸리는 것은 막지 않는다. 하지만 Mutable Reference는 동시에 사용될 수 없다.

1
2
3
4
5
6
fn main() {
let mut s = String::from("hello");
let ref1 = &mut s;
let ref2 = &mut s; // compile error
println!("{} {}", ref1, ref2)
}

다음과 같이 Mutable Reference를 동시에 Borrow 해줄 수 없다는 에러가 나온다. Immutable Reference가 먼저 있어도 비슷한 컴파일 에러가 발생한다.

1
2
3
4
5
6
7
8
9
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:27:20
|
26 | let ref1 = &mut s;
| ------ first mutable borrow occurs here
27 | let ref2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
28 | println!("{} {}", ref1, ref2)
| ---- first borrow later used here

참조형의 스코프는 마지막으로 사용된 시점까지 유지되므로 다음과 같은 경우는 문제없이 코드가 동작한다.

1
2
3
4
5
6
7
8
fn main() {
let mut s = String::from("hello");
let ref1 = &s;
println!("{}", ref1); // hello // 컴파일러는 여기서 `ref1` 스코프가 종료된다고 판단한다.
let ref2 = &mut s;
change(ref2);
println!("{}", ref2); // hello, world
}

Dangling Reference

Dangling Reference는 free 된 레퍼런스를 식별자로 가지고 있는 경우를 말한다. 러스트에서는 이를 컴파일러가 절대 Dangling Reference를 가지고 있지 않도록 보장해준다. 만약 어떤 데이터가 참조되고 있다면 해당 참조 식별자의 스코프가 종료되기 전까지 데이터의 스코프가 끝나지 않도록 해야 한다.

1
2
3
4
5
6
7
8
fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String {
let s = String::from("hello");
&s
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn dangle() -> &'static String {
| ~~~~~~~~

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` due to previous error

라이프타임 어노테이션에 대해서는 이 글에서 다루지 않는다. 자세한 내용은 이 링크에서 확인하면 좋을 것 같다.

Reference

Author

changhoi

Posted on

2022-11-21

Updated on

2022-11-21

Licensed under

댓글

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×