Programming/Rust

쪼잔한 Rust 4.1. 소유권이 무엇이냐

동건 2018. 8. 9. 22:37


Chapter 4. 소유권 이해하기

소유권(ownership)은 Rust의 특유한 개념으로, garbage collector 없이도 메모리를 안전하게 사용할 수 있도록 보장해준다. 따라서 Rust에서 소유권이 어떻게 작동하는 지 아는 것은 중요한 일이다. 이번 챕터에서 소유권과 관련된 borrowing, slice 등의 여러 가지 개념과 Rust가 어떻게 데이터를 메모리에 관리하는 지에 대해 얘기해보겠다.



Chapter 4.1. 소유권이란?

모든 프로그램은 실행하는 동안 컴퓨터의 메모리를 관리해야 한다. 어떤 언어는 실행하는 동안 사용하지 않는 메모리를 계속 추적해서 관리하는 garbage collection을 사용하기도 하고, 어떤 언어는 직접 메모리를 할당하고 풀어줘야 한다. Rust는 또 다른 방식을 사용한다: 컴파일 시점에서 컴파일러가 체크하는 ownership 시스템의 규칙을 통해 메모리를 관리한다. 따라서 ownership은 프로그램의 런타임 성능을 저하시키지 않는다.

원문에 stack과 heap에 대한 매우 좋은 설명이 제공된다. 참고하시길 바란다.


소유권의 규칙

자, 이제 ownership의 규칙을 살펴보자. 앞으로 나올 예제들을 보면서 아래와 같은 규칙을 계속 생각하길 바란다.

  • Rust에서의 모든 값은 owner라는 변수를 가지고 있다.
  • 한 시점에서 오직 하나의 owner만 존재할 수 있다.
  • Owner가 scope에서 벗어나면, 그 값은 제거된다.


변수의 Scope

Ownership의 첫 예제로, 변수들의 scope에 대해 볼 것이다. Scope은 프로그램에서 어떤 변수가 살아있는 범위를 말한다. 아래 변수를 보자.

역자 주: valid 라는 단어를 "살아있다" 라는 의미로 옮겼다. 따라서 invalidate의 경우는 "변수를 죽인다"고 추후 번역할 것이다.

let s = "hello";

변수 s는 string literal, 즉 이 프로그램에서 하드 코딩된 string 값을 가지고 있다. 이 변수는 선언된 시점부터 그 scope이 끝날 때까지 살아있을 것이다. 같은 코드에 약간의 설명을 덧붙여본다.

예제 4-1: 변수와 그 변수가 살아있는 scope

{                      // s는 선언되지 않았으니까 아직 살아있지 않다
    let s = "hello";   // s는 이 시점부터 살아난다

    // s로 무슨 일이든 한다
}                      // scope가 끝나므로, s는 더 이상 살아있지 않다

여기서 본 scope와 변수 생명 주기의 관계는 다른 프로그래밍 언어에서의 개념과 비슷하다. 이를 바탕으로 String 타입을 공부해보겠다.


String 타입

위에서 string literal이라는 것을 봤다. String literal은 프로그램에 string 값이 하드코딩된 것을 말한다. String literal은 편리하지만, 텍스트를 사용하는 모든 상황에서 적합하진 않다. 그 이유를 몇 가지 들어보자면, string literal은 불가변적(immutable)이라는 점, 사용자의 input으로 받아야 할 때처럼 코드를 작성하는 시점에서 string 값이 정해지지 않을 수도 있다는 점을 들 수 있다. 이런 상황에 쓰기 위해 Rust는 String이라는 또 다른 string 타입을 제공한다. 이 타입은 heap에 할당되어서 컴파일 시점에 정할 수 없는 용량의 텍스트를 저장할 수 있다. 아래와 같이 string literal 값을 통해 String을 만들 수 있다.

let s = String::from("hello");

이 변수는 값을 바꿀 수 있다:

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

s.push_str(", world!"); // push_str()는 String에 literal을 덧붙인다

println!("{}", s); // `hello, world!`를 출력할 것이다

무엇이 다르길래 string literal은 불변값인데 String은 가변적일까? 그 대답은 두 타입이 메모리를 다루는 방법에 있다.


메모리와 그 할당

String literal의 경우, 우리는 컴파일 시점에서 이미 그 내용을 알고 있기 때문에 그 텍스트가 최종 실행 파일에 직접 하드코딩된다. 그렇기 때문에 string literal이 빠르고 효율적인 것이다. 하지만 이런 성질은 string literal이 불가변적이기 때문에 가능한 것이다. 안타깝게도 우리는 컴파일 시점에 알 수 없는 크기의 텍스트나 런타임에서 그 사이즈가 바뀔지도 모르는 텍스트를 위해서 binary 실행파일에다가 메모리 뭉치를 올려놓을 수는 없다.

가변적이고 그 크기가 변할 수 있는 String 타입을 지원하기 위해, 우리는 컴파일 시점에서는 알 수 없는 일정량의 메모리를 heap에 할당해야 한다. 좀 더 자세히 설명하자면:

  • 메모리는 런타임에 운영체제로부터 요청받아와야 한다.
  • String으로 할 일이 다 끝나면, 그 메모리를 다시 운영체제에 돌려주는 방식이 필요하다.

첫 요건은 우리가 직접 수행하는 것이다: String::from을 호출할 때, 이 method는 필요한 양의 메모리를 요청한다. 이는 프로그래밍 언어에 있어서 꽤나 보편적인 것이다.

하지만 두 번째 요건은 얘기가 다르다. Garbage collector (GC)를 사용하는 언어라면, GC가 계속 변수들을 추적해서 더 이상 사용되지 않는 메모리를 청소하고 다니므로, 우리는 메모리를 청소하는 것을 걱정할 필요가 없다. GC가 없으면 그 일은 우리의 책임으로 돌아온다: 더 이상 사용되지 않는 메모리를 파악하고, 그 메모리를 반환하는 코드를 명시적으로 써야하는 것이다, 그 메모리를 직접 요청했듯이 말이다.

Rust는 다른 길을 택했다: 변수가 그 scope를 벗어나면 그에 연결된 메모리는 자동적으로 반환되는 것이다. 위에서 봤던 string literal 예제를 String으로 바꿔보면 아래와 같다.

{
    let s = String::from("hello"); // s는 이 시점부터 살아있다

    // s로 무슨 일이든 한다
}                                  // 여기서 scope가 끝나기 때문에, s는 더 이상
                                   // 살아있지 않는다

String을 사용하기 위해 운영체제에서 빌려왔던 메모리를 돌려주기에 아주 적합한 시점이 바로 s가 그 scope에서 나갈 때이다. 변수가 scope 밖으로 나갈 때, Rust는 우리를 위해 특별한 함수를 호출한다. drop이란 이름의 함수로, String을 구현한 사람(the author of String)이 메모리를 반환하는 코드를 작성하는 장소이다. Rust는 scope가 끝나는 시점(})에서 자동으로 drop을 호출해준다.

참고: C++에서는, 이렇게 해당 아이템의 생명 주기가 끝날 때 그 자원을 반환하는 것을 Resource Acquisition Is Initialization (RAII) 이라고 부르기도 한다. RAII 패턴을 알고 있다면, Rust의 drop 함수는 친숙하게 다가올 수 있다.

이 패턴은 Rust 코드를 작성하는 데 있어서 굉장한 영향력을 가지고 있다. 지금 보기엔 간단해 보일 수 있지만, 여러 변수들이 heap에 할당된 데이터를 사용해서 복잡해지면 코드의 행태는 예측하기 어렵게 된다. 이런 상황들을 탐험해보자.


변수와 데이터가 오가는 법: 이동(Move)

Rust에서 하나의 데이터가 여러 변수를 옮겨다니는 방법은 여러 가지가 있다. 아래 정수 타입의 예제 4-2를 보자.

예제 4-2: 정수 타입 변수 xy에 할당하기

let x = 5;
let y = x;

아마도 우리는 위 코드가 무엇을 하는 지 알 수 있을 것이다: "값 5x에 할당한다, 그리고는 x의 값을 y에도 할당한다." 이제 x, y 두 변수가 있고, 둘 모두 5라는 값을 가지고 있다. 실제로 위 코드의 결과는 그렇게 되는 것이 맞다. 왜냐하면 정수 타입은 이미 그 크기가 정해져 있기 때문에, 두 개의 5 값이 stack에 들어갈 수 있기 때문이다.

이제 String 버전의 예제를 보자.

let s1 = String::from("hello");
let s2 = s1;

정수 타입의 예제와 굉장히 비슷하다. 그래서 똑같은 방식으로 작동할 것이리라 생각할 수 있다: 그 똑같은 방식이 뭐냐면, s1의 값을 복사해서 s2에 할당할 것이라는 것이다. 하지만 이는 맞는 말이 아니다.

아래 그림에서 String이 실제로 어떻게 작동하는 지 보여준다. 그림의 왼쪽에서 String을 이루고 있는 세 가지 부품을 볼 수 있다: string의 내용을 담고 있는 메모리의 포인터, length, 그리고 capacity 이렇게 세 가지다. 이 세 데이터의 그룹은 stack에 저장되어 있다. 그림의 오른쪽에는 string 내용을 담고 있는 메모리가 heap에 할당되어 있는 것을 보여준다.


String in memory, The Rust book, chapter 4.1.그림 4-1: "hello"를 가지고 있는 s1의 타입인 String의 메모리 표현


Length는 현재 String이 담고 있는 내용이 어느 정도의 메모리를 차지하고 있는지(byte 단위로)를 나타낸다. Capacity는 String이 운영 체제로부터 할당받은 메모리의 총량을 나타낸 byte 단위의 값이다. Length와 capacity의 차이는 분명히 의미가 있지만, 현재 다룰 내용은 아니다. 따라서 capacity는 일단 무시하자.

s2s1을 할당할 때 String 자료가 복사된다는 말은 stack에 있는 포인터와 length, capacity가 복사된다는 의미이다. 포인터가 가리키는 heap 위의 내용을 복사하지는 않는다. Stack에 있는 자료만을 복사하는 것을 그림으로 표현하면 아래와 같다.


s1 and s2 pointing to the same value, The Rust book, chapter 4.1.그림 4-2: s1의 pointer, length, capacity를 복사한 s2의 메모리 표현



위 그림과 아래 그림이 나타내는 바는 다르다. 아래 그림은 Rust가 heap의 내용까지 복사했을 때 맞는 설명이 될 것이다. 만약 Rust가 이렇게 행동한다면, s2 = s1은 런타임 성능을 생각해봤을 때, heap에 있는 자료가 클 경우 매우 비싼 작업이 될 것이다.


s1 and s2 to two places, The Rust book, chapter 4.1.그림 4-3: Rust가 heap 데이터까지 카피한다는 가정 하의 s2 = s1



변수가 scope 밖을 나가면, Rust는 자동적으로 drop 함수를 호출해서 그 변수가 가지고 있는 heap 메모리를 치운다고 이전에 소개했었다. 하지만 그림 4-2을 보면 두 포인터가 같은 heap의 위치를 가리키고 있는 것을 알 수 있다. 그렇다면 문제가 생긴다: s2s1이 scope 바깥으로 나간다면, 이 둘을 동일한 메모리를 청소하려고 달려들 것이다. 이러한 문제를 double free error 라고 말하며, 이전에 언급한 메모리 안전을 위협하는 버그 중 하나이다. 메모리를 두 번 청소하는 것은 메모리를 오염시킬 수 있어서, 보안 취약점으로 이어질 수 있는 가능성을 만들어낸다.

메모리의 안전을 보장하기 위해, Rust가 이 상황에서 한 가지 더 하는 일이 있다. Heap에 할당되어 있는 메모리를 복사하는 대신, Rust는 더 이상 s1이 살아있다고 여기지 않기로 한다. 그래서 Rust는 s1이 scope 밖으로 나오게 될 때 더 이상 heap 메모리를 제거하려고 들지 않게 된다. s2가 만들어진 이후에 s1을 사용하려고 한다면, 이는 실패할 것이다:

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);

You’ll get an error like this because Rust prevents you from using the invalidated reference: Rust는 살아있지 않은 변수를 참조하는 것을 거부하기 때문에 위 코드는 에러가 날 것이다.

error[E0382]: use of moved value: `s1`
 --> src/main.rs:5:28
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value used here after move
  |
  = note: move occurs because `s1` has type `std::string::String`, which does
  not implement the `Copy` trait

만약 다른 언어를 사용하면서 _얕은 복사 (shallow copy)_와 _깊은 복사 (deep copy)_에 대해 들어봤다면, heap 데이터를 복사하지 않고 stack에 있는 포인터와 length, capacity만을 복사하는 것이 얕은 복사의 개념과 비슷하다. 하지만 Rust는 첫 번째 변수를 죽이기 때문에, 얕은 복사와 정확히 같지는 않아서 _이동 (move)_라고 부른다. 그러므로 위와 같은 상황에서는 s1s2로 _이동했다_고 한다. 이를 표현한 것이 그림 4-4이다.


s1 moved to s2, The Rust book, chapter 4.1.그림 4-4: s1이 죽은 후의 메모리 표현



이렇게 해서 문제가 해결됐다! s2만이 살아있으므로, s2가 scope 밖으로 나간다면, s2만이 heap에 할당된 메모리를 청소하므로 문제없이 끝나게 된다.

여기서 볼 수 있는 언어 설계적인 선택이 있다: Rust는 절대 자동적으로 당신의 데이터를 "깊게" 복사하지 않는다. 따라서 Rust가 행하는 어떠한 복사든지 런타임 성능을 고려해서 값 싼 복사를 한다고 생각하면 된다.


변수와 데이터가 오가는 법: Clone

Stack 데이터 뿐만 아니라 heap에 있는 것까지 깊게 복사하고 싶다면, clone이라는 공통 method를 사용하면 된다.

아래는 clone method를 실행하는 예제이다:

let s1 = String::from("hello");
let s2 = s1.clone();

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

위 코드는 heap 데이터를 복사하는 그림 4-3의 작업을 에러 없이 잘 수행한다.

clone을 호출하는 상황을 본다면, 당신은 이제 충분히 clone 작업을 위한 어떤 코드가 실행될 것이고, 그 코드는 비싼 작업을 할 수도 있다고 알 수 있을 것이다. clone은 뭐가 다른 복사가 일어난다는 것을 알려주는 시각적 지표가 된다.


Stack-Only 데이터의 경우: Copy

아직 논의하지 않은 것이 하나 더 있다. 아래 코드는 예제 4-2처럼 정수 타입의 자료를 사용하는, 그리고 올바르게 작동하는 코드이다:

let x = 5;
let y = x;

println!("x = {}, y = {}", x, y);

하지만 이 코드는 지금까지 우리가 배워왔던 내용과 모순된다: clone을 호출하지 않았는데도 y로 옮겨가지 않고 x는 여전히 살아있다.

정수형과 같은 자료는 컴파일 시점에 이미 그 크기를 알고 있기 때문에 stack에 그 내용이 온전히 저장되기 때문에, 실제 값을 복사하는 것이 굉장히 빠르게 이루어 진다. 이는 우리가 y 변수를 생성한 후에 x를 죽일 필요가 없음을 의미한다. 즉, 이 상황에서는 깊은 복사와 얕은 복사의 구분이 없기 때문에 clone을 사용하는 것은 기존 얕은 복사를 하는 것과 전혀 다른 것이 없어서, 그냥 x를 살려놓아도 되는 것이다.

Rust는 Copy trait이라는 특별한 annotation을 갖고 있다. 이것은 정수형과 같이 stack에 저장되는 타입에 설치할 수 있다 (챕터 10에서 trait에 대해 더 설명할 것이다). 어떤 타입이 Copy trait을 가지고 있다면, 복사 당한 변수는 여전히 사용 가능하게 된다. Drop trait을 가지고 있는 타입은 절대 Copy trait을 장착시킬 수 없다. 만약 어떤 값이 scope 밖으로 나갈 때 특별한 뭔가를 원하는데, 거기에 더해서 Copy annotation이 그 타입에 장착되어 있다면, 이는 컴파일 시점에서 에러를 낼 것이다. 당신의 타입에 Copy annotation을 어떻게 추가하는지 알아보고 싶다면, 부록 C "Derivative Traits"를 찾아보길 권한다.

그래서 어떤 타입들이 Copy trait을 가지고 있을까? 모든 타입에 대해 공식 문서를 찾아보는 것이 확실한 방법이겠지만, 일반적인 규칙을 살명하자면, 간단한 scalar 값을 가지는 타입들과 heap 메모리 할당을 필요로 하지 않는 타입들이 Copy이다. 아래 간단히 정리해보겠다:

  • u32와 같은 모든 정수형 타입.
  • truefalse를 가지는 Boolean 타입 bool.
  • f64와 같은 모든 부동 소수점 타입.
  • 문자 타입 char.
  • Copy 타입으로 구성된 tuple. 예를 들어 (i32, i32)Copy이지만 (i32, String)은 아니다.


소유권과 함수

함수에 어떤 값을 전달하는 것은 변수에 그 값을 할당하는 것과 의미적으로 비슷하다. 변수 할당과 마찬가지로 이동 또는 복사의 방식으로 함수에 값이 전달된다. 예제 4-3에서는 변수가 scope 안팍으로 드나드는 것을 설명했다.

예제 4-3: 함수의 ownership과 그 scope

// Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s가 scope 안으로 들어온다

    takes_ownership(s);             // s의 값이 함수 안으로 이동한다
                                    // ... 그리고 여기선 더 이상 살아있지 않다

    let x = 5;                      // x가 scope 안으로 들어온다

    makes_copy(x);                  // x 역시 함수 안으로 이동할 것이다
                                    // 하지만 i32 Copy이므로,
                                    // 이후에 계속 x를 사용해도 된다

} // 여기서 x가 scope 밖으로 나가고, 그 다음에 s도 나간다.
  // 하지만 s의 값은 이미 이동했기 때문에, s에 대해서는 아무 일도 일어나지 않는다.

fn takes_ownership(some_string: String) { // some_string가 scope 안으로 들어온다
    println!("{}", some_string);
} // 여기서 some_string가 scope 밖으로 나가고 `drop`이 호출된다.
  // 변수를 뒷받치고 있던 데이터는 청소된다.

fn makes_copy(some_integer: i32) { // some_integer가 scope 안으로 들어온다
    println!("{}", some_integer);
} // 여기서, some_integer는 scope 밖으로 나간다. 아무 일도 일어나지 않는다.

takes_ownership을 호출한 후에 s를 사용하려 한다면, Rust는 컴파일 시점에서 에러를 던질 것이다. 이런 정적인 체크는 우리의 실수를 막아준다. main 함수에 sx를 사용하는 코드를 추가해서 ownership 규칙에 의해 컴파일 에러가 나오는 것을 확인해보자.


Return 값과 Scope

함수가 값을 내보내는 것 역시 ownership을 옮기는 작입이다. 예제 4-3과 비슷하게 설명한 예제 4-4를 보자.

예제 4-4: Return 값의 ownership 이동

// Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();         // 이 함수의 return 값이 s1로 이동한다

    let s2 = String::from("hello");     // s2가 scope 안으로 들어온다

    let s3 = takes_and_gives_back(s2);  // s2가 이 함수 안으로 이동한다
                                        // 이 함수의 return 값은 s3로 이동한다
} // 여기서, s3는 scope 밖으로 나가고 drop 된다.
  // s2도 scope 밖으로 나가지만 이미 이동했으므로 아무 일도 일어나지 않는다.
  // s1는 scope 밖으로 나가고 drop 된다.

fn gives_ownership() -> String {             // 이 함수의 return 값은 호출하는 곳으로
                                             // 이동될 것이다

    let some_string = String::from("hello"); // some_string가 scope 안으로 들어온다

    some_string                              // some_string이 내보내지고
                                             // 함수 호출 영역으로 이동한다
}

// takes_and_gives_back 함수는 String을 받아서 내보낸다
fn takes_and_gives_back(a_string: String) -> String { // a_string이 scope 안으로
                                                      // 들어온다

    a_string  // a_string이 내보내지고 함수 호출 영역으로 이동한다
}

변수의 ownership이라는 것은 항상 같은 패턴을 따른다: 다른 변수에 할당할 때는 이동한다. Heap 데이터를 갖고 있는 변수가 scope 밖으로 나가면, 그 값은 그 heap 데이터가 다른 변수에 의해 이동되지 않는 한 drop에 의해 청소될 것이다.

모든 함수마다 ownership을 가져가서 ownership을 돌려주는 것은 조금 귀찮은 일이다. 함수가 ownership을 가져가지 않고 그 값을 사용하고 싶으면 어떻게 해야할까? 우리가 함수에 보내주는 모든 것들을 다시 사용하고 싶다면, 그 함수가 그것들을 다시 돌려줘야만 하는 것이라면, 이는 꽤나 짜증나는 일일 것이다. 또한 함수의 body에서 만들어지는 어떤 데이터라도 우리가 사용하고 싶다면, 함수는 그 데이터 역시 내보내줘야만 한다.

함수는 여러 값들을 tuple로 묶어서 내보낼 수 있다. 아래 예제 4-5를 보자.

예제 4-5: 여러 parameter의 ownership 내보내기

// Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

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

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

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len()은 String의 길이를 내보낸다

    (s, length)
}

하지만 이 예제는 일반적인 컨셉이 되기엔 해야할 일이 너무 많다. 다행히도 Rust는 reference라는 기능을 통해 이 컨셉을 지원한다. 이 내용은 다음 섹션에서 이어진다.

반응형