Rust-Lang의 소유권 이해하기

이번에도 파이썬으로 풀었던 알고리즘 코드를 러스트로 변환해 볼 예정이었다. 수 찾기라는 문제인데 해당 문제는 10만개의 숫자가 주어지는 만큼 빠른 속도로 탐색이 이뤄져야 하는 문제다. 필자는 직접 이진 탐색 트리를 구현하여 해결하였고 러스트에서도 그러할 계획이었다. 다만 이 문제를 해결하기 위해선 두가지의 지식을 알아야 했는데

  • 러스트에선 어떻게 구조체를 구현하는가?
  • 러스트에선 어떻게 구조체의 자가 참조를 구현하는가?

위 두개와 관련된 지식이 필요하여 찾아봤으나 난관에 봉착했다. C 혹은 C++처럼 접근하려고 했던게 나의 큰 잘못이었다. 러스트에선 안전한 메모리의 관리를 위해서 소유권이라는 독창적인 방식을 사용하고 있으며 나는 그것을 이해해야 한다.


스택과 힙

스택과 힙은 둘다 런타임에 사용할 수 있는 메모리다. 스택은 탑에서 푸시와 팝이 일어나는 구조이기 때문에 매우 빠르다. 위로 쌓고 아래로 지우는 식이다. 스택에서 고정된 크기를 가진 데이터만 넣을 수 있다는 특징은 스택을 더 빠르게 한다. 그렇다면 고정된 크기를 가지지 않은 데이터는 어떻게 할까? 힙에 저장하게 된다. 힙에 공간이 있는지 물어보고 운영체제가 적당한 힙의 크기를 포인터 주소로 돌려준다.

스택에서의 작업이 가장 빠르지만 힙을 사용하지 않을 수는 없다. 힙에서 효율을 높이기 위해선 프로세서에게 열일(?)을 시켜야 하는데 프로세서는 근접한 메모리에 대한 작업이 일어날 때 효율적이다. 순차 리스트와 연결 리스트의 읽기 속도의 차이를 생각해보면 이해하기 쉬울 것이다. 러스트는 소유권이라는 개념을 가지고 있으며 이 소유권은 힙을 관리하기 위한 수단이 된다. 러스트의 소유권에는 다음과 같은 규칙이 있다.

  1. 러스트는 각각의 값은 해당값의 오너라고 불리우는 변수를 가지고 있다.
  2. 한번에 딱 하나의 오너만 존재할 수 있다.
  3. 오너가 스코프 값을 벗어날 때 값은 버려진다.


스코프

프로그래밍을 배워본 사람이라면 스코프라는 개념에 대해서 알고 있을 것이다. 블록으로 감싸져 있는 부분이 하나의 스코프로 파이썬과 C에선 다음과 같이 표기한다.

if __name__ == '__main__':
    x = 0
    return x
int main(void)
{
    int x = 0;
    return x;
}

대부분의 언어에서 변수들은 이 스코프를 벗어나면 (대체적으로) 메모리를 반납한다. 힙에 등록된 변수들과 자바스크립트의 var 변수를 제외하곤 말이다. 힙에 등록된 변수들은 언어에 특성에 따라 메모리가 관리된다. 파이썬에선 참조중이지 않은 변수를 가비지 컬렉션이 찾아내어 수집하고 C에선 프로그래머가 직접 해제하므로써 관리하는데 러스트는 어떻게 관리되는걸까?


소유권

변수의 모든 소유권은 모든 순간 똑같은 패턴을 따른다. 어떤 값을 다른 변수에 대입하면 값이 이동된다. 힙에 데이터를 갖고 있는 변수가 스코프 밖으로 벗어나면 해당 데이터가 다른 변수에 의해 소유되도록 이동하지 않는 한 drop에 의해 제거된다. 일례로 다음 코드를 살펴보자.

fn main() {
    let my_name = String::from("Jino Bae");
    show_my_name(my_name);
    println!("{}", my_name);
}

fn show_my_name(name: String) {
    println!("{}", name);
}

위 코드의 결과는 무엇일까? 아마도 대부분 아래와 같은 결과를 예상할 것이다.

Jino Bae
Jino Bae

하지만 틀렸다. 러스트에선 컴파일 에러를 발생시킨다.

fn main() {
    let my_name = String::from("Jino Bae"); // 1. my_name 생성됨
    show_my_name(my_name); // 2. show_my_name으로 my_name이 이동함
    println!("{}", my_name); // 5. my_name이 존재하지 않음
}

fn show_my_name(name: String) { // 3. my_name이 이동되었음
    println!("{}", name);
} // 4. my_name이 스코프를 벗어나 drop에 의해 삭제됨

기존의 프로그래밍 언어들은 매게변수를 넘기면 대게는 복사된다. 하지만 러스트에선 '어떤 값을 다른 변수에 대입하면 값이 이동된다.' 이제 이 말이 가슴에 와닿는다. 만일 my_name을 다시 살리고자 한다면 return하여 다시 이동을 시켜주거나 참조자를 사용해야 한다.


참조자

fn main() {
    let my_name = String::from("Jino Bae");
    show_my_name(&my_name);
    println!("{}", my_name);
}

fn show_my_name(name: &String) {
    println!("{}", name);
}

위와같이 참조자를 사용하면 변수의 소유권이 넘어가지 않으며 스코프를 벗어나도 참조자가 가리키는 값은 사라지지 않는다. 따라서 위 코드는 우리가 일반적으로 예상했던 동일한 결과를 출력한다. 러스트에선 이러한 것을 '빌림'이라고 표현하며 빌려온 값에는 아무런 변화를 줄 수 없다. 기본적으로 불변의 상태로 빌려온다.

fn main() {
    let mut my_name = String::from("Jino Bae");
    show_my_name(&mut my_name);
    println!("{}", my_name);
}

fn show_my_name(name: &mut String) {
    *name = String::from("Aram Kim");
    println!("{}", name);
}

위와같이 &mut 키워드를 사용하면 가변 참조자를 전달하여 값을 변경시킬 수 있다. 다만 이 가변 참조자는 굉장히 제한적으로 사용된다. 한 스코프에서 한 변수에 두 개 이상의 가변 참조자가 생성된 것은 오류로 간주되며 한 스코프의 특정 변수에 대해서 불변 참조자가 이미 존재하는 상황에서 가변 참조자가 선언되는 것도 오류로 간주된다.


마무리

러스트의 소유권을 공부하면서 이전에 작성했던 코드에서 왜 그런 오류가 발생했는지에 대해서 어느정도 감이 잡혔다. 공부를 하면서 눈에 아른아른거렸던 코드가 있었는데 특히 이 부분이다.

for y in prime_lists {
    ...
}

prime_lists.push(x); // ERROR

반복문에 이터레이터로 백터를 넘기는 것도 이동으로 간주되는 것 같다. 그래서 하단에 다시 push를 시도하자 오류가 발생했던 거였다. 일단은 컴파일러가 시키는대로 &를 붙였서 해결했던 거였는데 저런 문제를 해결하기 위해서 반복문에 참조자를 사용하여 백터에 넘겨주도록 하였던 것 같다.

for y in &prime_lists {
    let f_y = y as f32; // ERROR
}

반복문에서 y라는 변수는 정수형 참조자로 전달되었는데 prime_lists에서 직접 액세스되는 것으로 생각된다. 그걸 해결하기 위해서 *이라는 키워드를 사용했는데 *에 대한 존재는 아직까진 잘 모르겠다. 러스트... 어렵긴한데 굉장히 재밌는 언어인 것 같다.


참고

이 글이 도움이 되었나요?

신고하기
0분 전
작성된 댓글이 없습니다. 첫 댓글을 달아보세요!
    댓글을 작성하려면 로그인이 필요합니다.