비욘드 JS: 러스트 - 오너쉽과 메모리

dante Yoon·2022년 12월 18일
1

beyond js

목록 보기
2/20
post-thumbnail

동영상 강의로 보기

글을 작성하며

안녕하세요, 단테입니다.
오늘은 러스트의 오너쉽과 메모리에 대해 알아보겠습니다.

Ownership

러스트에서 오너쉽이란 메모리에서 값이 관리되는 생명주기를 의미합니다. 오너쉽이라는 개념을 통해 러스트는 메모리 세이프를 보장할 수 있으며 러스트 컴파일러가 자동으로 메모리 할당 해제를 할 수 있게 도와주기도 합니다.

stack and heap

러스트에서 스택은 메모리 공간이며 프로그램이 실행되는 런타임에 자동으로 관리되는 공간입니다. 컴파일 타임에 파악할 수 있는 Scalar Type 값들을 저장하는 공간이며 힙에 비해 저장하는 속도가 빠릅니다.
예를 들어 합수가 호출될 때 함수 내부에 선언된 변수들은 스택 공간안에 저장되며 함수가 끝나고 반환할 때 지역 변수들은 스택에서 사라집니다.

힙은 런타임에서 동적으로 변경될 수 있는 값들을 저장하는 장소입니다. 오너쉽을 가지고 있는 변수에서 경우에 따라 힙에 선언된 메모리 해제를 해주어야 하는 경우가 있습니다. 힙에 값을 할당하게 될 경우 해당값의 주소를 가르키고 있는 것을 포인터라 부르며 이 포인터는 스택에 저장됩니다. 메모리 내부에 있는 빈공간을 memory allocator가 찾아줘야 하므로 스택에 저장하는 것에 비해 느립니다.

Variable Scope

러스트에서 variable scope란 특정 변수를 참조할 수 있는 지역을 의미합니다. 이 범위를 영어로는 visibility라고 하는데요, 이 visibility란 해당 변수에 대한 컴파일러의 인지 여부라고도 볼 수 있습니다.

스코프는 코드 내에 선언된 변수의 위치와 더불어 curly brace {} 로 결정됩니다.

러스트의 변수들은 ownership, 즉 소유권이라고 하는 속성을 가지고 있습니다. value 들로 인해 어떻게 메모리가 점유당했는지 관리하는 속성입니다.

variable이 스코프를 벗어난다면 러스트의 borrow checkervariable에 저장된 value 값을 메모리에서 해제해야 하는지 확인하고, 해당 값이 다른 variable에 의해 사용될 수 있게 합니다.

fn main() {
    // variable x is declared and initialized in the main function
    let x = 5;

    // variable x is in scope within the curly braces of the main function
    println!("The value of x is: {}", x);

    {
        // variable y is declared and initialized within a new block of code
        let y = 10;

        // variable y is in scope within the curly braces of this block
        println!("The value of y is: {}", y);
    }

    // variable y is no longer in scope after the curly braces end
    // the following line will cause a compile-time error because y is not in scope
    // println!("The value of y is: {}", y);
}

main 함수 내부에서 {}를 벗어난 이후 y는 스코프 바깥에서 참조될 수 없습니다.

String type

지난 포스팅에서 Character 타입만 배웠었습니다.

러스트에서 String 타입은 동적으로 변경될 수 있기에 heap에 저장됩니다. 내부적으로 Vec<u8>을 통해 구현되어 있는데요, 이 말은 heap에서 연속된 배열의 바이트로 구성되어있다는 뜻입니다.

memory allocation

자바스크립트나 자바는 Garbage Collection (GC)가 있어 참조되지 않는 값은 메모리에서 자동으로 삭제해줍니다. C는 alloc을 통해 메모리 할당과 해제를 명시적으로 해주어야 합니다.

러스트는 다른 방법을 사용합니다. 변수가 스코프를 벗어날 때 해당 변수가 참조하고 있는 메모리를 자동으로 해제합니다.
앞서 봤던 String 타입을 다시 가져오겠습니다.

이 문자열 타입이 스코프를 벗어날 때 borrow checker는 문자열 변수에 바인딩된 값이 메모리 해제되어야 하는지, 혹은 해당 메모리 주소를 다른 변수로 옮겨야 하는지 판단합니다. 만약 런타임에서 아무 변수에도 바인딩 되지 않는다면 해당 메모리는 비워지게 됩니다.

다음의 예제 코드에서 변수 s는 immutable하며 hello 문자열을 담은 heap의 메모리 주소를 가르킵니다.

변수 s가 내부 {} 스코프로 옮겨지며 outer scope에 속하지 않게 됩니다.
{}가 끝나면 s가 가르키는 문자열은 더 이상 heap에 존재하지 않기 때문에 에러가 발생하게 됩니다.

fn main() {
    // s is a String value that is stored on the heap
    let s = String::from("hello");

    // s is in scope within the curly braces of the main function
    println!("The value of s is: {}", s);

    {
        // s is moved into the new scope and is no longer in the outer scope
        let _s = s;

        // s is in scope within the curly braces of this block
        println!("The value of s is: {}", _s);
    }

    // s is no longer in scope after the curly braces end
    // the following line will cause a compile-time error because s is not in scope
    // println!("The value of s is: {}", s);
}

data copy and move

아래 코드에서 변수 x에 값 5를 바인딩하고 y에 x의 값을 복사했습니다.
이 때 x, y의 값은 stack에 저장됩니다.

    let x = 5;
    let y = x;

러스트는 integer와 같은 Scalar type의 데이터의 정확한 사이즈를 컴파일 타임에 알 수 있기 때문에 heap이 아닌 stack에 저장시킵니다.

이때 move가 아닌 Copy trait가 디폴트로 실행됩니다.

하지만 다음 코드는 에러를 발생시킵니다.

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

    println!("{}, world!", s1);  // compile time error
}

그 이유는 바로 Copy trait이 실행된 것이 아니라 move가 실행되었기 때문인데요.

s2에 s1의 주소를 옮긴 이후 s1은 heap 영역에서 삭제됩니다.

https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#variables-and-data-interacting-with-move

러스트는 타입에 따라 move, copy중 하나를 적용합니다.

clone without move

만약 값을 deep copy하고 싶다면 다음처럼 해야 합니다.

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

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

heap을 도식화 하면 다음과 같습니다.

borrow checker

러스트 컴파일러에는 borrow checker라고 하는 컴포넌트가 있습니다. 작성된 코드에 따라 data race나 segmentation fault와 같은 에러 발생 여부를 참조해주는 컴포넌트입니다.

사진 출처: https://www.reddit.com/r/ProgrammerHumor/comments/iiuxk8/what_rusts_borrow_checker_thinks_of_your_code/

어떻게 동작하는가

우리가 도서관을 운영하는 관리자라면 책을 고객에게 대출해주기 전에 다음 규칙을 준수하는지 먼저 확인해야 합니다.

  • 동일한 책을 여러 명에게 동시에 대출해줄 수 없습니다.

  • 대출 중인 책을 다시 빌려줄 수 없습니다.

  • 대출 중인 책을 수정할 수 없습니다.

    여기서 관리자는 러스트 컴파일러, 고객은 variable입니다. 책들은 변수안에 담길 value 입니다.

관리자는 여러 고객에게 동일한 책을 대출해줄 수 없습니다.

동시에 여러 변수에게 동일한 값을 바인딩해줄 수 없습니다.

관리자는 대출 중인 책의 표지에 수정을 가할 수 없습니다.

value는 immutable하게 borrow된 상태에서 (& reference)를 사용해서 mutable하게 (&mut reference를 사용해서) 수정될 수 없습니다.

위와 같은 borrow checker는 위와 같은 규칙을 기반으로 컴파일 타임에 위배되는 작업이 있지는 않은지 확인해줍니다.

예제 코드를 통해 borrow checker를 활용해 컴파일 타임에 소유권 문제를 점검해보겠습니다.

아래 코드에선 variable x는 5로 초기화 되었습니다. y는 x에 대한 immutable reference를 나타냅니다.
이 말은 y는 x의 value를 빌렸지만 수정하지는 못하는 것입니다.

zx에 대한 mutable reference를 나타냅니다. 이 말은 z는 x의 값을 빌리고 또한 수정할 수도 있다는 의미입니다.

fn main() {
    let x = 5;
    let y = &x;  // y is an immutable reference to x
    let z = &mut x;  // this line will cause a compile-time error because x is already borrowed immutably
}

let z 에서 에러가 발생함을 확인할 수 있습니다. 변수 y에서 x가 immutable하게 borrow 되었기 때문에 바로 아래줄에서 mutable하게 빌리지 못한 것입니다.

data race

앞서서 data race라는 생소한 단어가 등장했습니다. data race는 두 개 이상의 스레드가 동시에 단일 데이터에 접근할 때 쓰기 동작을 하는 스레드가 있는 상황을 의미합니다. 데이터 일관성을 해치는 원인이 될 수 있기 때문에 디버깅이 어렵게 만들고 데이터의 결과가 undefined가 되는 경우가 나타날 수 있습니다.

borrow checker는 이러한 data race를 방지하는 역할또한 하게 됩니다.

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(5);
    let data_clone = data.clone();
    
    println!("Is same reference", *data == *data_clone); //true

    handle.join().unwrap();
}

위 코드에서 변수 data는 메인 스레드에 의해 immutable하게 borrow되었는데요, 동시에 data_clone 은 같은 heap 주소를 가르킵니다.

fn main() {
    let data = Arc::new(5);
    let data_clone = data.clone();

    let handle = thread::spawn(move || {
        // this line will cause a data race because data_clone is being borrowed mutably
        // while data is being borrowed immutably by the main thread
        *data_clone += 1;
    });

    handle.join().unwrap();
}

위와 같이 *data_clone을 통해 heap에 할당된 값을 변경하려고 하면 두 개의 variable이 동시에 동일한 heap 주소를 바라보고 있는 상태에서 값의 변경이 일어나는 것인데, 이를 borrow checker가 방지해줍니다.

Ownership and Function

앞서서 러스트는 타입에 따라 move와 cop를 다르게 적용시킨다고 했는데요,
value가 함수에게 전달될 때 함수는 해당 value에 대한 오너쉽을 가지고 이후 과정을 어떻게 진행함에 따라 메모리 해제 여부를 결정합니다. 이 말은 함수 argument로 전달된 변수는 전달된 이후에 해당 값에 대한 오너쉽을 박탈당하는 것인데요

fn foo(mut x: Vec<i32>) -> Vec<i32> {
    // x is in scope within the function and has ownership of the value
    x.push(5);

    // x is returned and the ownership of the value is transferred back to the caller
    x
}

fn main() {
    // v is a variable that is initialized with a Vec<i32> value
    let v = vec![1, 2, 3];
	
    foo(v);
    // the value of v is moved into the foo function and is no longer in the main function
    // the following line will cause a compile-time error because v is not in scope
    println!("The value of v is: {:?}", v); // <- 에러 발생 
}

위 코드에서 함수 fooVec<i32> 타입을 argument로 사용합니다. 러스트는 Vec<i32> 타입은 Copy trait을 사용하는 것이 아니라 move를 사용하기 때문에 함수 foo 호출 이후 함수 main 스코프에서는 변수 v는 vec![1,2,3] 에 대한 오너쉽을 더 이상 가지지 않습니다.

만약 다시 오너쉽을 되찾고 싶다면 다음 처럼 오너쉽을 취득한 함수가 해당 값을 다시 반환시켜 주고 이 값을 스코프 내부에서 다시 바인딩한 후 사용해야 합니다.

fn main() {

    let v = vec![1, 2, 3];

    let u = foo(v);

    println!("The value of u is: {:?}", u);
 }

Vec<i32>의 값 뿐만 아니라 reference를 주고 받는 경우를 살펴보겠습니다.

fn foo(x: &Vec<i32>) -> &i32 {
    // x is a reference to a Vec<i32> value and does not have ownership of the value
    // the value of x can be accessed but it cannot be modified
    let y = &x[0];

    // y is a reference to an i32 value and does not have ownership of the value
    // the value of y can be accessed but it cannot be modified
    y
}

fn main() {
    // v is a variable that is initialized with a Vec<i32> value
    let v = vec![1, 2, 3];

    // the reference to the value of v is passed to the foo function
    // the value of v is still in the main function and can be accessed
    let x = foo(&v);

    // the value of v can be accessed because it has not been moved
    println!("The value of v is: {:?}", v); // [1,2,3]

    // the value of x can be accessed because it is a reference to a value that is in scope
    println!("The value of x is: {:?}", x); // 1

}

여기서 함수 foo는 변수 x의 오너쉽을 가지고 있지 않습니다. 그저 Vec<i32>의 reference를 가지고 있는 것입니다. reference는 해당 주소가 가르키고 있는 값에 대한 오너쉽이 없습니다. 그저 해당 값을 가르키기만 합니다.

let x = foo(&v) 가 선언된 라인에서 변수 v는 여전히 value에 대한 오너쉽을 유지하고 있습니다. 호출부에서 오너쉽을 잃어버리지 않는 것인데요, 함수 foo 내부에서는 x에 Vec<i32>에 대한 reference만 가지고 있기 때문에 x가 가르키는 값에 대해서 수정을 할 수 없습니다.

만약 수정하고 싶으면 &mut를 사용해야 합니다.

글을 마치며

오늘은 러스트가 다른 프로그래밍 언어와 비교하여 두드러지게 드러나는 차이점인 오너쉽, 그리고 메모리 할당에 대해 알아보았습니다. 공식 문서를 통해 오늘 포스팅을 한번 공부해보시기 바랍니다.

감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글