Rust 프로그래밍

Rust - 메모리를 자동으로 제때 해지한다?

plas 2020. 7. 5. 15:54

C/C++ 언어는 메모리를 프로그래머가 직접 delete합니다. 모든 메모리를 할당한 역순으로 한 방울도 남기지 않고 해지해야 메모리 리크 없는 프로그램이 됩니다. 그런가하면 여기 저기 흩어져 있는 힙메모리를 가리키는 포인터들은 언제든 댕글링 참조가 될 위험에 노출되어 있습니다. 이것은 정말 사람의 노력이 너무 많이 들어야 하는 무식한 방법이죠.

그런가 하면 자바처럼 또는 대부분의 언어들처럼 프로그래머는 메모리를 쓰기만 하고 해지는 신경 안 쓸래 하는 가비지콜렉션(GC) 모델이 있습니다. 필요하면 언제든 객체를 만들고 아무 생각없이 쓰면 됩니다. 메모리 용량을 넉넉히 늘리는 것이 사람이 직접 손으로 고생하는 것보다 편하고 또 안전하다 라고 자바에서는 주장하고 있지요.

파이썬처럼 아예 응용 프로그램만 만드는 언어라면 별 문제가 없겠지만 자바 정도면 범용 언어고 시스템 프로그래밍까지는 아니더라도 상당히 오래동안 돌아야 하는 프로그램을 만들어야 합니다. 또한 성능도 중요한데, 그런 프로그램에서 자바나 파이썬은 메모리 관리의 한계 때문에 도저히 C++ 수준의 성능을 낼 수가 없습니다. 메모리 사용량 점점 늘다가 느닷없이 GC를 돌린다고 느려지는 소프트웨어를 좋아할 고객은 없을 것입니다.

근데 사실 지금까지는 GC냐 delete냐 둘중의 하나라는 이분법이었고 C++처럼 직접 delete 하는 언어의 한계(비용 및 안전성)가 너무 명확하니까 최근에 나온 언어들도 GC를 선택하고 메모리와 성능 문제에 뚜렷한 해결책을 내놓지 못했습니다.

Rust는 역시 정공법을 택한 것 같습니다. 메모리가 누출되지 않게 하겠다 즉 GC를 쓰지 않겠다는 것이지요. 필요 없어진 메모리를 1바이트도 누수없이 깨끗이 그것도 필요없어지는 즉시 해지하겠다고 합니다. 프로그래머에게 주는 부담을 최소화하고 컴파일러가 자동으로 해주겠다라는 원대한 꿈을 가지고 시작한 것입니다.

어떻게 그게 가능할까? 사실 이건 Rust 언어 전체의 설계를 아우르는 큰 문제다 보니 간단하게 설명하긴 어렵지만 한번 시작해 보도록 하겠습니다.

프로그램이 사용하는 메모리는 스택과 힙으로 나누어집니다. 앞의 메모리관리 글에서 설명했듯이 스택에 할당되는 변수는 상당히 저렴하게 자동으로 할당 해지가 잘 되고 스택 메모리는 실행시간에 접근도 빠릅니다. 그러나 안타깝게도 스택 메모리는 한정되어 있어 우리는 메모리를 많이 써야 하는 대부분의 객체를 힙에 할당합니다. 힙은 요청할 때 필요한 크기만큼 할당받고, 대신 다 쓰고 나면 해지해 주어야 합니다. 힙에 할당된 객체를 다 쓴 후에 해지하는 것이 메모리 관리의 문제가 되는 부분이죠. 힙 메모리 해지를 위해 다음 세 가지를 결정해야 합니다.

(1) 어떤 객체를 다 썼다는 것은 어떻게 알 것인가?

(2) 다 쓴 객체를 언제 해지하는가?

(3) 댕글링 참조는 어떻게 해결할 것인가?

C++에서는 이 세 가지 문제에 답이 (1) 프로그래머가 판단한다 (2) 프로그래머가 직접 delete를 호출한다  (3) 프로그래머가 책임진다 입니다.

Rust에서는 프로그래머가 직접 delete 하지 않고 컴파일러가 알아서 그 부분을 해결해 주겠다는 것입니다. 대신 프로그래머는 컴파일러가 정확히 판단할 수 있게 엄격한 규칙에 따라 변수를 사용해 주어야 합니다. 결국 객체의 해지는 변수에 의해 결정되어야 하므로 변수의 선언과 사용에서 지정된 규칙을 지킨다면 컴파일러는 어느 변수를 언제 해지해야 할지 알 수 있습니다. 러스트에서는 객체의 메모리 해지를 drop이라고 표현합니다.

변수를 drop한다는 것은 변수의 수명이 다하고 그 변수가 가리키는 힙 객체를 해지하는 것을 의미합니다. 이 때 변수의 수명을 결정하는 것은 범위입니다. 지역변수의 경우 함수가 종료하면 변수의 수명이 다하고 drop됩니다.

함수에서 정의한 지역변수만 생각하면 스택에 의해 함수가 시작될 때 변수와 객체가 생기고 반환할 때 자동으로 해지됩니다. 그렇지만 힙 객체의 경우는 좀 다르죠. 다음의 자바 코드에서 변수와 객체의 관계를 바인딩의 관점에서 살펴보겠습니다. 바인딩이란 변수와 객체의 연결관계를 말합니다.

1	... void main(...) {
2		String s1 = "hello";
3		String s2 = s1;
4		int len = calculate_length(s2);
5		System.out.printf("The length of %s is %d.\n", s2, len);
6	}
7	int calculate_lengthg(String s) {
8		int lenght = s.lenth();
9		return length;
10	}

main 함수가 시작되면 지역변수 s1과 s2가 생기고 그 참조를 가질 영역이 스택에 생깁니다. 2번 줄에서 스트링 객체가 하나 생기고 s1이 그것을 가리킵니다. 그리고 3번 줄에서 s2도 s1과 같은 것을 가리키게 됩니다(그림의②). 이것을 자바에서는 GC의 관점에서 힙에 있는 이 스트링 객체의 참조 카운트가 2가 되었다 라고 합니다. 즉 2개의 변수가 이 메모리를 가리키고 있다는 뜻이지요. 그런 다음 calculate_length 함수로 들어가게 되면 이제 s라는 변수가 생기고 이것도 "hello" 객체를 가리킵니다(그림의). 참조카운트가 3이 되었지요? 10번줄에서 s의 바인딩이 해지되고 스트링 객체의 참조카운트는 2로 줄어들 것입니다(그림의).

그리고 6번 줄에서 두 개의 변수의 바인딩이 종료하면서 우리의 스트링 객체는 참조카운트가 0이 되고 즉 쓰레기가 되었습니다. 물론 자바에서는 이것이 쓰레기가 되는 시점에 아무도 관심이 없습니다. 쓰레기 치우는 것은 GC의 담당이므로 나중에 GC가 불려진 시점에 알아서 그 힙 객체의 참조카운트를 전부 계산해야 합니다.

이제 그 과정을 Rust의 관점에서 돌아보도록 하죠. 어떻게 하면 Rust는 "Lee"라는 스트링 객체를 해지해야 할 시점을 알 수 있을까요? 여기서 Rust의 Ownership 개념이 나옵니다. 프로그램에서 힙 객체는 이리 저리 전달되고 바인딩될 수 있는데, 프로그램 실행 중에 모든 객체의 owner는 항상 하나의 변수여야 하고 어느 것인지 분명해야 합니다. 다른 바인딩이 생길 때 Ownership이 옮겨지는 거죠. 이것을 move라고 합니다. 프로그래머와 컴파일러가 그것을 명시적으로 추적할 수 있어야 합니다. 마지막에 바인딩된 변수가 그 객체에 대해 Ownership을 가집니다. 

https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html (figure 4-2)

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    let (s2, len) = calculate_length(s2);
    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String
    (s, length)
}

위의 예에서 먼저 객체가 생성되고 s1이 바인딩됩니다. 이 경우 s1이 ownership을 가집니다. 그리고 다음 줄에서 s2가 그 객체에 바인딩됩니다. 그럼 s2가 ownership을 가집니다. s1의 ownership은? drop됩니다. Rust는 s1의 ownership이 s2로 옮겨졌다(move)고 하고 s1 변수는 더이상 사용할 수 없게 됩니다. (그림의②) 이것을 러스트에서는 변수의 무효화(invalid)라고 합니다.

다음으로 함수의 매개변수를 생각해 보겠습니다. 위의 calculate_length 함수 호출에서 s 매개변수에 s2가 전달되는데, 이것도 역시 move입니다. 그럼 이 함수의 매개변수 s가 객체의 Ownership을 가져가고 s2는 사용할 수 없는 변수가 됩니다(그림의). 이것을 대여(borrow)이라고 합니다. Rust에서는 한 번에 하나의 변수만 객체에 대해 Ownership을 가질 수 있습니다. 여기서 ownership이란? 바로 객체의 해지 시점을 결정하는 변수라는 것입니다. owner인 변수의 범위가 끝나면 그 객체는 drop될 것입니다. 그런데 위의 예에서 calculate_length 함수가 끝난 후에 다시 s2 변수를 printf 함수에서 사용해야 한다면? 함수로부터 ownership을 돌려받아야 되는 거죠. 리턴 값으로 받아 다시 s2에 지정합니다(그림의).

그래서 Rust에서는 아주 낯선 형태의 함수 호출 구문이 나오게 됩니다. Rust에서 함수는 반환값 뿐 아니라 Ownership을 돌려주어야 하는 변수도 리턴하게 됩니다. 콤마로 연결된 여러 개의 값을 리턴할 수 있고 그것을 지정할 수 있습니다.

이렇게 프로그램 안에서 항상 객체의 Ownership을 가진 변수가 한 개만 존재합니다. 바인딩이 해지된다는 것은 변수의 범위가 끝나서 없어지거나 그 변수에 let에 의해 다른 객체가 바인딩되는 경우입니다. 이러한 변수 사용이 보장된다면 컴파일러는 언제 객체가 해지되어야 할지 정확히 알 수 있습니다.

그리고 Ownership을 전달하지 않은 채로 그 변수의 바인딩이 해지되면 그 객체는 소멸됩니다. 위의 예에서 함수 호출 후 s2를 사용하지 않는다면 돌려받을 필요가 없고 그 경우는 함수에서 오너십을 가진 변수의 바인딩이 종료하면서 객체도 같이 drop됩니다.