Rust - Ownership (소유권) 정리

Chan Heo·2024년 10월 13일

Rust 공부

목록 보기
1/7
post-thumbnail

Rust만의 메모리 관리 규칙인 Ownership(소유권) 개념에 대해 정리해 보려 합니다.

소유권이란 무엇인가

소유권은 러스트 프로그램의 메모리 관리법을 지배하는 규칙 모음입니다.

모든 프로그램은 작동하는 동안 컴퓨터의 메모리 사용 방법을 관리해야 합니다. 몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 정기적으로 찾는 방식을 채택했고, 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 택했습니다. 이때 러스트는 제3의 방식을 택했습니다: 소유권 (ownership) 이라는 시스템을 만들고, 컴파일러가 컴파일 중에 검사할 여러 규칙을 정해 메모리를 관리하는 방식이지요.

→ The Rust Programming Language 교재 번역본에 나와 있는 소유권에 대한 설명입니다.

시프 강의 소유권 설명
→ 현재 수강중인 ‘시스템 프로그래밍’ 과목의 Lec03 - Memory Ownership 강의자료 발췌
즉, 앞으로 알아볼 ‘소유권’이라는 개념은 Rust 프로그램이 메모리를 관리하는 rule들의 모음이며, runtime cost에 영향을 미치지 않는 zero-cost abstraction에 해당합니다.

소유권 규칙

소유권 규칙부터 알아보겠습니다. 앞으로 나올 내용을 보는 동안 다음 규칙을 명심하세요:

  • 러스트에서, 각각의 값은 소유자 (owner) 가 정해져 있습니다.
  • 한 값의 소유자는 동시에 여럿 존재할 수 없습니다.
  • 소유자가 스코프 밖으로 벗어날 때, 값은 버려집니다 (dropped).

String 타입

소유권에 대해 더 자세히 이해하기 위해, 교재에서는 힙에 데이터를 저장하는 String 타입을 활용하고 있습니다.

문자열 리터럴은 쓰기 편하지만, 불변성을 지니고 있고(immutable), 사용자 입력을 받는 등 컴파일 타임에 값을 알 수 없는 경우에는 사용할 수 없습니다. 반면, String 타입은 힙에 할당된 데이터를 다루기 때문에, 컴파일 타임에 크기를 알 수 없는 텍스트도 저장할 수 있습니다. 

String 타입은 다음과 같이 from 함수와 문자열 리터럴을 이용해 생성 가능합니다.

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

메모리와 할당

String 타입은 힙에 메모리를 할당하는 방식을 사용하기 때문에 텍스트 내용 및 크기를 변경할 수 있습니다. 하지만 이는 다음을 의미하기도 합니다:

  • 실행 중 메모리 할당자로부터 메모리를 요청해야 합니다.
  • String 사용을 마쳤을 때 메모리를 해제할 (즉, 할당자에게 메모리를 반납할) 방법이 필요합니다.

첫번째는 앞에서 본 String::from을 호출하면서 해결했습니다. 그렇다면 두 번째는 어떨까요?

먼저 기존의 언어들에서 메모리를 해제하던 방식에 대해 알아보겠습니다.

  • 가비지 컬렉터 (garbage collector, GC) 를 갖는 언어에서는 GC가 사용하지 않는 메모리를 찾아 없애주므로 프로그래머가 신경 쓸 필요 없습니다.
  • GC가 없는 대부분의 언어에서는 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 합니다. → 이 작업은 역사적으로 어려운 프로그래밍 문제였습니다.
    • 프로그래머가 놓친 부분이 있다면 메모리 낭비가 발생하고, 메모리 해제 시점을 너무 일찍 잡으면 유효하지 않은 변수가 생깁니다.
    • 두 번 해제할 경우도 마찬가지로 버그가 발생합니다. 따라서 allocate (할당) 과 free (해제) 가 하나씩 짝짓도록 만들어야 합니다.

Rust에서는 이 문제를 변수가 자신이 소속된 스코프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결했습니다.

    {
        let s = String::from("hello"); // s는 이 지점부터 유효합니다

        // s를 가지고 무언가 합니다
    }                                  // 이 스코프가 종료되었고, s는 더 이상
                                       // 유효하지 않습니다.

위 코드에는 String에서 사용한 메모리를 자연스럽게 해제하는 지점이 있습니다.

변수와 데이터 간 상호작용 방식: 이동

Rust에서는 동일한 데이터에 여러 변수가 서로 다른 방식으로 상호작용할 수 있습니다. 먼저, 첫 번째 케이스입니다.

    let x = 5;
    let y = x;

5를 x에 바인딩하고, x 값의 복사본을 만들어 y에 바인딩하시오’ 그럼 xy 두 변수가 생길 겁니다. 각각의 값은 5가 되겠죠. 실제로도 이와 같은데, 정수형 값은 크기가 정해진 단순한 값이기 때문입니다. 이는 다시 말해, 두 5 값은 스택에 푸시된다는 뜻입니다.

앞선 예제를 String으로 바꾼 두 번째 케이스입니다.

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

이 코드는 전혀 다른 방식으로 동작합니다.

시프 강의자료 move 설명
마찬가지로 ‘시스템 프로그래밍’ 강의 자료 중 위 코드의 설명에 대한 그림을 캡처한 사진입니다.

s2s1을 대입하면, String 데이터가 복사됩니다. 여기서 말하는 데이터는 스택에 있는 포인터, 길이, 용량 값을 말하며, 포인터가 가리키는 힙 영역의 데이터는 복사되지 않습니다.

다만, 이 경우 s1s2가 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제 (double free) 에러가 발생할 겁니다. 또한, Rust 프로그램 상에서는 앞서 말했던 소유권 규칙 2번('한 값의 소유자는 동시에 여럿 존재할 수 없습니다.')에 위배되는 것이기도 합니다.

메모리 안정성을 보장하기 위해서, Rust는 let s2 = s1; 라인 뒤로는 s1이 더 이상 유효하지 않다고 판단합니다. 이로써 Rust는 s1이 스코프를 벗어나더라도 아무것도 해제할 필요가 없어집니다. s2가 만들어진 이후에 s1을 사용하는 경우 어떤 일이 생기는지 확인해 보면, 작동하지 않음을 알 수 있습니다.

이 개념은 다른 프로그래밍 언어에서 말하는 얕은 복사(shallow copy)와 유사하지만, Rust에서는 기존의 변수를 무효화하기 때문에 이를 이동(move)이라 합니다.

변수와 데이터 간 상호작용 방식: 클론

String의 힙 데이터까지 깊이 복사하고 싶을 땐 clone이라는 공용 메서드를 사용할 수 있습니다. (메서드 문법은 5장에서 다룰 예정이지만, 메서드라는 개념은 대부분의 프로그래밍 언어가 갖는 특성이기 때문에 이미 다뤄보셨을 겁니다.)

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

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

스택에만 저장되는 데이터: 복사

    let x = 5;
    let y = x;

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

위 코드는 정상적으로 동작합니다.

이유는 정수형 등 컴파일 타임에 크기가 고정되는 타입은 모두 스택에 저장되기 때문입니다. 스택에 저장되니, 복사본을 빠르게 만들 수 있고, 따라서 굳이 y를 생성하고 나면 x를 무효화할 필요가 없습니다. 다시 말해 이런 경우엔 깊은 복사와 얕은 복사 간에 차이가 없습니다. 여기선 clone을 호출해도 얕은 복사와 차이가 없으니 생략해도 상관없죠.

소유권과 함수

함수에 파라미터를 전달하는 것은 변수에 값을 대입할 때와 유사합니다. 함수에 변수를 전달하면 대입 연산과 마찬가지로 이동 혹은 복사가 일어나게 됩니다.

fn main() {
    let s = String::from("hello");  // s가 스코프 안으로 들어옵니다

    takes_ownership(s);             // s의 값이 함수로 이동됩니다...
                                    // ... 따라서 여기서는 더 이상 유효하지 않습니다

    let x = 5;                      // x가 스코프 안으로 들어옵니다

    makes_copy(x);                  // x가 함수로 이동될 것입니다만,
                                    // i32는 Copy이므로 앞으로 계속 x를
                                    // 사용해도 좋습니다

} // 여기서 x가 스코프 밖으로 벗어나고 s도 그렇게 됩니다. 그러나 s의 값이 이동되었으므로
  // 별다른 일이 발생하지 않습니다.

fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어옵니다
    println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어나고 `drop`이 호출됩니다.
  // 메모리가 해제됩니다.

fn makes_copy(some_integer: i32) { // some_integer가 스코프 안으로 들어옵니다
    println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어납니다. 별다른 일이 발생하지 않습니다.

위 코드에서 주석으로 표시된 설명을 참고해 보면 도움이 될 것 같습니다. takes_ownership 함수를 호출한 이후 s를 사용하려 하면, 이미 값의 이동이 일어났으므로 컴파일 에러가 발생합니다.

fn main() {
    let s1 = gives_ownership();         // gives_ownership이 자신의 반환 값을 s1로
                                        // 이동시킵니다

    let s2 = String::from("hello");     // s2가 스코프 안으로 들어옵니다

    let s3 = takes_and_gives_back(s2);  // s2는 takes_and_gives_back로 이동되는데,
                                        // 이 함수 또한 자신의 반환 값을 s3로
                                        // 이동시킵니다
} // 여기서 s3가 스코프 밖으로 벗어나면서 버려집니다. s2는 이동되어서 아무 일도
  // 일어나지 않습니다. s1은 스코프 밖으로 벗어나고 버려집니다.

fn gives_ownership() -> String {             // gives_ownership은 자신의 반환 값을
                                             // 자신의 호출자 함수로 이동시킬
                                             // 것입니다

    let some_string = String::from("yours"); // some_string이 스코프 안으로 들어옵니다

    some_string                              // some_string이 반환되고
                                             // 호출자 함수 쪽으로
                                             // 이동합니다
}

// 이 함수는 String을 취하고 같은 것을 반환합니다
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프 안으로
                                                      // 들어옵니다

    a_string  // a_string이 반환되고 호출자 함수 쪽으로 이동합니다
}

위 코드에서는 주석으로 함수에서 값이 반환될 때 값의 이동에 대해 설명하고 있습니다.
상황은 다양할지라도, 변수의 소유권 규칙은 언제나 동일합니다.

어떤 값을 다른 변수에 대입하면 값이 이동하고, 힙에 데이터를 갖는 변수가 스코프를 벗어나면, 사전에 해당 데이터가 이동하여 소유권이 다른 변수에 이동되지 않은 이상 drop 에 의해 데이터가 제거됩니다. 이 본질을 기억하면, 아무리 상황이 복잡하더라도 소유권 변화에 대한 흐름을 금방 파악할 수 있을 것입니다.

References

https://doc.rust-kr.org/ch04-01-what-is-ownership.html

profile
안녕하세요:)

0개의 댓글