프로그래밍/Rust

Rust 기초 - 소유권(1)

신규범 2023. 3. 22. 18:23

원래는 함수와 같이 진행하려고 했으나 Rust에서 소유권이 중요한 개념이라 따로 분리해서 진행합니다.

소유권(Ownership)이란?

소유권은 Rust에서 메모리 관리를 하는 매우 중요한 핵심기능이다. 다른 프로그래밍 언어에서는 garbage collector가 백그라운드에서 끊임없이 작동하거나, 자동적인 메모리 관리 기능이 없어서 프로그래머가 명시적으로 관리해주어야 한다.

Rust에서는 메모리 관리의 몇가지 규칙으로 이루어진 소유권 시스템을 통하여 메모리를 관리한다. 이 소유권을 통하여 동작하는 기능들은 런타임 비용이 발생하지 않는다.


스택(Stack)과 힙(Heap)

소유권에 대해 이야기 하기전에 우선 메모리에 관하여 이야기 해보자.

컴퓨터의 운영체제(OS)에서는 프로그램의 실행을 위하여 다양한 메모리 공간을 제공하고 있다.

그 중 코드상에서 런타임 동안 사용되는 부분이 스택과 힙이다.

스택(Stack)

  • Last In First Out 구조
  • 고정된 메모리 사이즈를 가진 값이여야 할당 가능하다
  • 새로운 데이터가 항상 꼭대기에 존재하기 때문에 접근속도가 빠르다
  • 지역변수, 매개변수 등이 저장된다
    • 함수가 끝날때 해당하는 모든 지역변수는 스택에서 삭제된다

힙(Heap)

  • 크기가 결정되어 있지 않거나, 변경될 수 있는 데이터를 저장한다
  • 할당할 경우 저장된 지점의 주소(포인터)를 반환 - 이를 다시 스택에 저장하여 불러올 수 있다
  • 스택에서 포인터를 부르고 - 포인터를 따라 힙에 저장된 데이터에 접근 하는 방식이기 때문에 스택보다 느리다

OS에서 제공하는 메모리 구조

코드의 어느 부분이 힙의 어떤 데이터를 사용하는지 추적하는 것, 힙의 중복된 데이터의 양을 최소화하는 것, 그리고 힙 내에 사용하지 않는 데이터를 제거하여 공간이 모자라지 않게 하는 것은 모두 소유권과 관계된 문제들입니다. 여러분이 소유권을 이해하고 나면, 여러분은 더이상 스택과 힙에 대한 생각이 자주 필요치 않게 될겁니다만, 힙 데이터를 관리하는 것이 곧 소유권의 존재 이유임을 알게 되는 것은 이것이 어떤 방식으로 작동하는지 설명하는데 도움을 줄 수 있습니다.


소유권 규칙

소유권은 아래의 규칙을 따른다

  1. 러스트의 각각의 값은 해당값의 오너(owner)라고 불리우는 변수를 갖고 있다
  2. 한번에 딱 하나의 오너만 존재할 수 있다
  3. 오너가 스코프 밖으로 벗어나는 때, 값은 버려진다(dropped)

값에 대한 소유권

우선 간단한 예로부터 소유권에 대해 알아보자
아래와 같은 main() 함수가 있다고 했을때 main 함수 스코프 안에서 x는 1이라는 값의 소유권을 부여받는다.

fn main() {  // 스코프 시작
    let x = 1;
    // 스코프가 끝나면서 1은 버려진다
}

여기서 main()함수가 끝이나면 더이상 x에 대한 호출은 없기 때문에 규칙 3번에 의해서 값은 메모리에서 해제된다

소유권 - 스택과 힙

소유권은 결국 런타임 동작중 메모리를 효율적으로 관리하기 위하여 설계되었다. 스택에 저장 되는 경우, 힙에 저장 되는 경우 전제 하에 각각의 동작 차이에 대해 알아보자.

힙의 경우

힙에 저장되는 데이터중 String을 예시로 들어 진행 해보자 한다. Rust에서는 String에 ::from 을 이용하여 변경가능하고 커질수 있는 텍스트를 지원하기위한 스트링 type이다.

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

위와 같이 s1에 String을 할당 했을 경우 메모리에서 할당되는 구조는 아래의 그림과 같다. String::from을 통하여 저장한 String은 사이즈가 결정되지 않았기 때문에 힙에 저장되고, s1에는 저장된 메모리 주소를 나타내는 포인터, 길이, 용량이 스택에 저장이 된다.

이 상황에서 새로운 변수 s2를 아래와 같이 정의한다고 해보자.

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

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

이때 s1에는 String 데이터의 주소값이 들어가 있었기 때문에 s2은 s1과 같은 포인터, 길이, 용량을 저장하게 될 것이다. 그렇다면 힙에 저장되어 있던 실제 데이터는 어떻게 될까?

정답부터 말하자면 이 경우에는 소유권 규칙 2번 "한번에 딱 하나의 오너만 존재할 수 있다"를 따르기 때문에 3번째 s1을 호출하는 부분에서 컴파일 에러가 발생한다. 만약 s1이 그대로 남아있다면, s1과 s2가 모두 String::from("hello")의 소유권을 가지게 되기 때문에 스코프가 끝나면서 s1, s2 각각 메모리를 해제하려고 할 것이고(double free 문제), 이는 메모리 손상을 야기함과 동시에 보안 취약성 문제를 일으킬 가능성이 있다.

이러한 이유로 Rust에서는 힙과 상호작용하는 변수와 데이터에 대해서는 이동(Move) 이라는 방식으로 동작한다. 소유권은 하나의 오너만 존재하기 때문에 s2에 소유권이 부여되는 시점에서 s1의 값은 무효화 된다. 다른 언어에서 처럼 주소값만 불러오는 얕은 복사(shallow copy)가 아닌 기존 값을 무효화하기 때문에 이동이라는 명칭으로 사용한다.

물론 실제로 데이터를 복사(deep copy)하고 싶을 경우또한 일어날 수 있기 때문에 아래와 같이 .clone() 을 이용하여 heap의 데이터까지 복사하여 사용 할 수 있다. 이럴 경우 s1, s2 둘 다 잘 작동한다. 깊은 복사의 경우 많은 코스트를 사용할 수 있기 때문에 clone은 이러한 부분을 나타내는 시각적인 시시 또한 나타낸다.

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

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

깊은 복사로만 진행 할 경우 s1, s2 둘다 사용가능하고, 메모리 문제또한 해결될 수 있지만, 데이터의 크기가 클 경우 많은 비용이 발생 할 수 있고, 무차별 적으로 힙의 메모리를 계속 늘린다면 이 때문에 여러 오류들을 야기할 수 있기 때문에 이동이라는 방식을 채택한 것으로 생각된다.

스택의 경우

위의 힙의 경우와 비슷한 코드로 한번 생각해보자

let x = 5;
let y = x;

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

이 경우에는 힙과 다르게 잘 동작한다. 모든 데이터가 스택에만 저장되는 경우, 참조해야할 부분이 없다. 그렇기에 깊은 복사, 얕은 복사의 차이가 없게 되고, Move를 통한 무효화를 진행할 필요가 없다.

스택에 저장되는 타입은 아래와 같다.

  • u32와 같은 모든 정수형 타입들
  • true와 false값을 갖는 부울린 타입 bool
  • f64와 같은 모든 부동 소수점 타입들
  • Copy가 가능한 타입만으로 구성된 튜플들. (i32, i32)는 Copy가 되지만, (i32, String)은 안된다.

소유권 - 함수

함수의 값을 넘기는 것은 변수에 대입하는것과 유사하게 동작한다. 위의 변수 타입에 따른 이동와 복사와 같이, 힙에 저장되는 변수 s(String type)의 경우 takes_ownership 의 some_string으로 소유권이 넘어가고 함수가 끝나면서 해제되지만, 변수 x의 경우 정수형이기 때문에 복사되어 함수에 대입해도 유효하게 동작한다.

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가 스코프 밖으로 벗어났습니다. 별다른 일은 발생하지 않습니다.

함수에서 값이 반환 되는 경우도 위와 유사하게 소유권의 이전이 이루어진다. 아래 코드에서처럼 s1은 gives_ownership의 값을 반환받아 String::from("hello")의 값을 가지게 되었고, s2는 takes_and_gives_back에 의하여 소유권을 s3에 이동 후 무효화 되었다.

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는 스코프 밖으로 벗어났으며 drop이 호출됩니다. s2는 스코프 밖으로
  // 벗어났지만 이동되었으므로 아무 일도 일어나지 않습니다. s1은 스코프 밖으로
  // 벗어나서 drop이 호출됩니다.

fn gives_ownership() -> String {             // gives_ownership 함수가 반환 값을
                                             // 호출한 쪽으로 이동시킵니다.

    let some_string = String::from("hello"); // some_string이 스코프 안에 들어왔습니다.

    some_string                              // some_string이 반환되고, 호출한 쪽의
                                             // 함수로 이동됩니다.
}

// takes_and_gives_back 함수는 String을 하나 받아서 다른 하나를 반환합니다.
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프
                                                      // 안으로 들어왔습니다.

    a_string  // a_string은 반환되고, 호출한 쪽의 함수로 이동됩니다.
}

여기까지 Rust의 설계 핵심중 하나인 소유권의 기본적인 내용에 대하여 다루어 보았다.

이 다음에는 소유권의 참조과 빌림에 대하여 다룰 예정이다.