서론

Rust라는 언어가 간혹가다가 보인다.

한번 대략적으로 알아보자.

기본 개념?

일단 프로그래밍 언어를 크게 두개로 나눠보자.

‘저수준 프로그래밍 언어’와 ‘고수준 프로그래밍 언어’로 나눌 수 있을 것이다.

저수준 프로그래밍 언어는 C로 대표되고, 시스템 프로그래밍 등에 자주 쓰인다. 속도를 중시하고, 개발자에게 자유를 조금 많이 준다.

중요한 비교 항목으로 메모리 관리 방법을 보자. C언어 등의 저수준 언어는 프로그래머가 직접 메모리를 할당하고, 직접 메모리의 할당을 해제한다.

메모리 할당 해제에서 문제가 생길 가능성이 높은게 큰 단점이다. 이중 해제나 해제된 영역의 메모리 참조 등이나, 메모리 누수 등의 문제가 생길 수 있다.

그럼에도 불구하고 쓰이는 이유는 역시 속도와 자유도다. 거의 말도 안돼 보이는 것도 일단 문법으로만 맞으면 되기도 하고, 무엇보다 딱히 프로그램을 느리게 할 요소가 없다.

한편 고수준 프로그래밍 언어는, 조금 극단적인 예로 Python 등이 있고 (요즘 나오는 언어 대부분이다.) 속도보다는 편의성을 중요시한다. 자유도를 제한하는 면이 있다.

메모리 관리 방법을 보면, GC, 즉 가비지 컬랙터의 존재가 중요하다. 프로그래머는 일반적인 변수를 할당하듯 메모리를 할당하고, 해당 메모리에 대한 참조 수를 가비지 컬랙터가 관리하며, 참조하는 변수가 사라졌을 때 주기적으로 GC가 메모리를 정리한다.

역시 프로그래밍이 편하고, 거의 메모리와 관련된 일이 생길 일도 없다. 하지만 무엇보다 느리다.

느리다. 엄청. 아무래도 계속 가비지 컬랙터가 돌고 있어야 하다 보니, 속도가 빨라지기 힘들다. (특히 파이썬은 편리한 프로그래밍과 느린 속도로 유명하다.)

그렇다면 Rust는 어떨까?

일단은 저수준 언어로 치는 모양이다. 메모리 관리가 상당히 안정적이고, 가장 큰 특징이 되어준다.

가비지 컬랙터가 없다. 즉 가비지 컬랙터가 속도를 줄일 걱정이 없다는게 장점이다.

그런데 또 각종 잘못된 할당 해제로 인해 걱정할 필요 역시 없다. 왜냐면 할당 해제 역시 컴파일러가 처리해주기 때문이다.

어떻게 그게 가능할까? 싶은 꿈의 스펙이다.

뭐, 대신 프로그래밍이 어렵다. C같은 언어가 예상치 못한 타이밍에 런타임 에러가 난다면, 러스트는 예상치 못한 타이밍에 컴파일 에러가 나는 수가 있다.

러스트는 어떻게 메모리를 관리하는걸까?

소유권

먼저 가장 기본 원칙을 살펴보자.

스코프 안에서 선언된 변수는 스코프 밖으로 나가면 할당이 해제된다.

굉장히 단순한 원칙이다. 뭐든 스코프 안에서만 유지된다는 직관적인 원칙이기도 하고.

하지만 이것만으로 끝난다면 다른 언어가 그렇게 고생을 할 필요는 없다. 당연히 더 복잡한 문제가 있다.

복사 대신 이동

힙에 할당된 변수를 다른 변수에 대입하면?

let s = String::from("Hello");
let s2 = s;

이런 경우 스코프 밖으로 나갈 때 s와 s2를 둘 다 할당 해제를 하려고 하면 문제가 생긴다.

왜냐면 둘은 같은 메모리 영역을 가르키고 있기 때문에, 하나를 해제하면 다른 하나는 이미 해제된 상태가 되기 때문이다.

이 상황을 Rust는 어떻게 해결하느냐, 하면, s2 = s를 처리할 때 s는 더이상 Hello str을 가르키지 않는다.

즉, 주소가 복사되는 것이 아닌 이동되는 것이다.

위 코드에서 이후에 s를 접근하려고 하면 컴파일 에러가 뜬다.

그렇게 문제 하나를 해결한다.

함수 매개변수

함수 매개변수로 힙에 할당된 값을 넣거나, 아니면 값을 반환할 때에도 역시 위처럼 이동이 일어난다.

{
let s = String::from("Hello");
foo(s); // s의 소유권이 사라짐
// 여기서 s를 참조하려고 하면 컴파일 에러!
}

fn foo(a: String) {
// a를 가지고 무언가 하는 내용...
}

위의 주석처럼 함수를 부른 이후에는 값을 참조할 수 없다. 튜플과 반환값을 이용하면 어떻게 우회할 수는 있겠지만, 좀 더 편한 방법을 제공한다.

무언가 포인터를 떠오르게 하는 (물론 전혀 다른 개념이다) 문법인 ‘참조자’를 이용해서 소유권을 빌려준다.

이 곳에 나오는 예제 코드를 보자.

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

    let len = calculate_length(&s1);

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

fn calculate_length(s: &String) -> usize {
    s.len()
}

&를 사용해서 함수 내에서 s1 값을 ‘참조’한다. s의 타입은 &String이며, 이 값은 s1의 주소를 가지고 있다.

참고로 그냥 저런 참조자로는 s1값을 수정할 수 없다. 값을 수정하기 위해서는 mutable한 참조자가 필요하다.

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

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

이 때 참조자는 다음과 같이만 존재할 수 있다.

  • 여러 개의 정적 참조자, 가변 참조자 없음.
  • 가변 참조자 단 하나, 정적 참조자 없음.

마치 데이터베이스에서 S락/X락의 충돌 조건을 보는 것과 비슷하고, 그렇게 정해진 이유도 비슷하다.

무언가 잘못되는 것을 막아주는 용도다.

매달린 (Dangling) 참조자

C에서 자주 하는 실수 중 하나에 그런게 있다.

C의 힙에 할당된 포인터는 직접 할당을 해제하기 전까지는 살아 있지만, 스택에 할당된 값은 스코프를 벗어나면 사라지지 않는가.

이 때 스코프 내부의 값을 포인터로 참조하려고 하면 런타임 에러가 날 확률이 생긴다.

러스트에선 이런 상황이다.

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

s에 대한 참조는 dangle을 벗어나면서 없어지는데, 그 참조자는 main으로 넘어가는거다.

문제가 생길께 뻔한 코드고, 이는 컴파일 에러다.

정리

Rust의 메모리 관리 방법을 알아봤다.

모든 생길 수 있는 메모리 관련 문제를 컴파일 시간에 해결한다는 것이 인상적이다.

그렇기 때문에 빠르고 정확한 코드를 적을 수 있지만… 그만큼 프로그래머가 신경써야 할 일이 많다.

요즘 조금씩 조금씩 올라오는 언어 중 하나라고 한다.

이것 말고도 언어를 이루는 요소야 많긴 하지만… 마치 Haskell의 가장 큰 특징이 모나드이듯 Rust의 가장 큰 특징은 소유권이 아닐까 싶다.