러스트 메모리 부족 - leoseuteu memoli bujog

그런데 컴퓨터에서 수행중인 프로그램은 아주 많으므로 컴퓨터(OS) 관점에서 볼 때 여러 개 프로그램이 필요한 만큼 메모리를 넉넉하게 줄 수는 없습니다. 그래서 컴퓨터는 각 프로그램이 사용하는 메모리를 제한하고 종류를 나누어 규칙에 따라 사용하도록 하고 있습니다. 그래서 메모리는 정적, 스택, 힙 세 종류로 나누어 집니다. 

러스트 메모리 부족 - leoseuteu memoli bujog

정적 메모리는 코드나 데이터가 저장되는 영역입니다. 프로그램이 로드될 때 미리 정해진 크기 만큼 주어지고 프로그램 코드와 필요한 데이터들을 거기에 올려줍니다. 정적 메모리에 저장되는 데이터는 보통 정적변수, 전역변수, 코드에 있는 리터럴값 등이고, 이 메모리는 관리의 대상이 아닙니다. 프로그램 시작과 함께 할당되고 끝나면 소멸될 것이므로 프로그램의 실행 중에는 변동이 없습니다.

프로그램이 실행되는 동안 변화무쌍하게 바뀌는 것은 스택과 힙 영역입니다. 

스택 메모리

프로그램은 주어진 스택 영역 안에서 함수가 호출될 때마다 함수에 필요한 만큼의 메모리를 쌓습니다. 이것이 push/pop 방식이어서 스택 메모리라는 이름이 붙었습니다. 프로그램은 함수 단위로 실행됩니다. 함수가 다른 함수를 호출하고 또 호출하면서 호출 스택이 자라나고 반환될 때 줄어드는 과정을 프로그램과 호출스택에서 설명했습니다. 여기서는 프로그램의 관점에서 스택의 객체가 언제 할당되고 해지되는가를 살펴보겠습니다. 

함수가 호출되면 해당하는 스택 영역이 쌓입니다. 여기에는 지역변수, 매개변수, 기타 함수의 수행에 필요한 메모리 영역이 할당됩니다. 즉 스택 영역에 있는 각 메모리 부분은 프로그램에서는 지역변수나 매개변수에 대응합니다. 이들 변수를 통해 그 메모리에 값을 읽고 쓰게 되는데, 함수가 끝나면 이들 변수와 함께 그 메모리 영역도 사라지게 됩니다. 그래서 스택 메모리는 관리가 매우 쉽습니다. 할당도 해지도 함수와 함께 자동으로 이루어지므로 프로그램에서 할 일은 없습니다. 

스택 메모리를 사용하는데 제약이 몇가지 있습니다. 스택은 함수 호출할 때 자동으로 쌓여야 하므로 컴파일러가 미리 크기를 계산할 수 있어야 합니다. 컴파일러가 크기를 알지 못하는 객체는 스택에 쌓일 수 없습니다. 그래서 C 언어의 지역변수 배열은 크기가 상수여야 합니다. 또한 함수가 끝난 후에도 필요한 데이터라면 스택에 저장할 수 없습니다. 반환값으로 돌려주거나 전역변수에 저장하는 등의 방법으로 함수가 끝난 후에도 남아있도록 옮겨주어야 합니다.

힙 메모리

힙 메모리는 스택과 달리 프로그램이 필요할 때 할당하고 다 쓰면 돌려주는 메모리입니다. 다른 프로그램과 같이 나누어 쓸 수 있는 영역이라고 해서 공유 메모리라고도 합니다. C++이나 자바에서 프로그램이 힙에 메모리를 할당받는 것은 new를 통해 가능합니다. 이 때 프로그램은 크기를 정확히 써서 요청하게 되고 그렇게 받은 메모리에 데이터를 저장하므로서 객체를 생성하게 됩니다. 이때 힙 객체의 위치가 변수에 저장됩니다. C/C++에서는 힙 객체의 주소를 포인터에 저장하고 자바에서는 변수가 참조를 가진다 라고 합니다. 이러한 변수의 주소 또는 참조가 스택에 저장됩니다. 

힙메모리는 공유하는 영역이므로 다 쓰면 돌려주어야 합니다. 또한 스택보다는 훨씬 크기 때문에 이미지나 동영상처럼 많은 메모리를 필요로 하는 것은 힙에 저장해야 합니다. 그리고 이러한 큰 메모리 객체는 프로그램의 실행 중에 계속 필요한 것이 아니고 전체 프로그램의 실행 시간 중에 아주 일부에서만 사용하게 됩니다. 예를 들어 브라우저는 대부분의 경우 텍스트 중심의 화면을 보여주다가 동영상 플레이할 때만 그 메모리를 필요로 하게 될 것입니다. 그래서 프로그램이 실행되는 중에 다쓴 메모리를 어떻게 돌려주느냐의 문제가 중요해 집니다. 

힙 메모리의 수동 해지

C++은 delete를 통해 다 쓴 메모리를 해지합니다. 즉 프로그래머가 판단하여 이제 이 메모리 객체는 더이상 필요하지 않는 시점에 그 메모리를 돌려주게 됩니다. C/C++ 프로그램에서 다쓴 메모리를 해지하지 않고 두는 것을 메모리 누출(memory leak)라고 합니다. 흔히 드는 예로 핵발전소의 열감지 카메라가 있습니다. 핵발전소의 핵연료가 가열되면 폭발하므로 열감지 카메라가 1초마다 열을 감지해서 문제가 있으면 알람을 보내는 프로그램이 있다고 생각해 보죠. 이 프로그램이 1초마다 동작하는 함수에서 메모리를 할당하고 제대로 해지를 안 해서 몇 바이트라도 남았다면? 1초마다 몇 바이트씩 메모리를 잠식하게 됩니다. 그럼 오래지 않아 이 프로그램은 메모리 부족으로 멈추겠지요?

이것을 C/C++ 프로그램에서는 메모리 리크라고 합니다. 요즘은 메모리 리크를 분석해 주는 개발환경도 있고 많이 좋아졌지만 언제 해지할 것인가를 프로그래머가 판단하는 것은 큰 부담입니다. 더이상 필요하지 않을 줄 알고 해지했는데 복잡한 프로그램의 동작에서 나중에 생각지 못한 함수 부분이 불려질 수도 있고 쓰이지 않을 거라고 생각한 변수가 사용될 수 있습니다. 그러면 댕글링참조로 프로그램은 멈추게 됩니다.

메모리 해지를 안하면 메모리 리크, 너무 빨리 하면 댕글링참조... 양쪽이 절벽인데 이에 대한 C/C++의 답은? 니가 잘 알아서 해라 입니다. 

자바의 가비지 콜렉션

C/C++ 프로그램에서는 힙 객체를 할당받아 사용하다가 해지하는 것이 댕글링 참조를 일으켜 프로그램이 죽는 가장 큰 원인이 되었습니다. 또한 해지하지 않아서 생기는 메모리 부족도 문제가 되므로 프로그래머가 메모리 해지를 위해 신경써야 하는 것이 너무 많고 어렵습니다. 그래서 자바는 프로그램의 안전성을 앞세워서 아예 해지를 하지 않는 방법을 생각했습니다. 힙 메모리는 어차피 OS가 관리하는 건데 프로그램이 그걸 해지하느라 고생하지 말고 쓰고 싶은대로 쓰고 놔두면 OS 비슷한 가비지 콜렉터라고 하는 것이 알아서 메모리를 청소해 주겠다는 것입니다.

그럼 어떻게 쓰레기를 찾아서 해지를 해 줄 것인가? 위에서 힙 객체를 할당받으면 그 참조를 스택의 변수에 저장한다고 했죠? 그러니까 가리키는 변수가 없으면(그 지역변수의 함수가 끝나거나 범위를 벗어남) 쓰레기가 되는 것입니다. 그런데 문제는 객체가 또 다른 객체를 가리킬 수도 있다는 점입니다. 객체간의 참조 관계는 힙에 있는 객체들 간의 그래프 형태가 됩니다. 그러면 그 그래프 상에서 스택에 연결되어 있지 않은 모든 객체는 쓰레기가 됩니다. 말이 쉽지 프로그램이 가지고 있는 힙의 모든 객체들 간에 그래프를 모두 훑어야 하는 일이 됩니다. 이런 어려운 일을 GC가 해내고 있는 거죠.

GC의 장점은 프로그래머에게 힙객체의 해지 부담을 갖지 않게 해주는 것입니다. 메모리 좀 넉넉하게 쓰는 대신 개발자의 시간과 노력을 아껴 더 유용한 곳에 쓰게 하는 것이 좋겠다는 것이지요. 이것이 많은 개발자들이 C/C++보다 자바나 파이썬처럼 GC 사용 언어를 선호하는 이유라고 할 수 있습니다. 

그럼 GC에 비해 C/C++처럼 직접 프로그래머가 고생하면서 게다가 안전성까지 위협받는 방법이 아직도 선택되는 이유는 무엇일까요? 그 이유는 성능입니다. GC 방식이 가지는 성능 상의 문제를 생각해 보겠습니다.

힙 객체의 할당은 시스템 호출을 통해 OS에게 요청해야 합니다. 이것 자체가 상당한 성능상 부담을 가져옵니다.

다음은 접근할 때를 생각해 보겠습니다. 자바나 파이썬은 변수가 참조하는 모든 객체를 힙에 생성합니다. (크기를 아는 기본타입 변수는 제외) C/C++은 배열이나 객체를 스택에 할당할 수 있게 해줍니다. 스택 객체는 변수가 사용될 때 바로 값을 읽고 쓸 수 있습니다. 현재 수행 중인 함수의 스택 영역은 캐싱되어 있고 데이터 접근이 빠릅니다. 반면 힙 객체는 참조든 주소든 그것을 스택에서 레지스터로 가져오고 그 주소로 다시한번 메모리를 접근하여 데이터를 가져와야 합니다. 이것을 간접접근이라고 합니다. 메모리 접근이 두번 발생하는 것이지요. 프로그램의 성능은 메모리 접근의 회수로 결정된다고 볼 수 있습니다. 위 그림에서 CPU와 메모리 간에 화살표 부분을 왔다갔다 하는 것은 CPU의 사이클 입장에서 보면 너무 긴 시간입니다. 그러므로 힙 객체는 스택 객체보다 훨씬 더 접근 시간이 길다라고 얘기할 수 있습니다. 

또한 GC는 프로그램이 실행되는 중 어느 시점에 프로그램을 정지시키고 돌아야 합니다. 그리고 이건 시스템 영역이라 프로그램으로서는 언제 도는지 알지 못하고 예상할 수도 없습니다. 일반적으로 GC는 메모리가 부족할 때 동작합니다. 수시로 돌기에는 너무나 부하가 큰 기능이죠. 사양이 낮은 컴퓨터에서는 프로그램이 메모리가 부족해서 점점 느려지다가 어느 순간 프로그램이 멈추고 GC가 도는 것을 느낄 수 있습니다. 

또하나 문제는 GC 방식은 메모리를 제깍제깍 해지하는 언어에 비해 메모리 사용량이 늘어날 수밖에 없습니다. 앞에서 예를 들었던 동영상 플레이 프로그램처럼 아주 많은 메모리를 짧은 시간 사용해야 하는 프로그램에서는 GC 언어는 좋은 선택이 아닙니다. 원자력 발전소의 열감지 카메라 프로그램도 마찬가지죠. 즉 계산량이 많고 성능을 보장해야 하고 메모리 제약이 있는 프로그램은 GC 언어로 작성하기에 적합하지 않습니다. 

그래서 비GC 언어의 성능의 장점은 살리면서 프로그래머의 부담과 안전성의 위협은 없애겠다는 것이 러스트의 메모리 관리의 야심찬 자동 해지입니다. 프로그래머의 부담을 없애고 댕글링참조의 가능성도 없애고 컴파일러가 자동으로 메모리를 즉시 모두 해지해 주겠다고 합니다. GC와 비GC 언어의 이러한 양극단 사이에서 고민해야 했던 상황에서 이것은 정말 놀라운 이야기입니다. It's too good to believe!!

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로 줄어들 것입니다(그림의④).

러스트 메모리 부족 - leoseuteu memoli bujog

그리고 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)라고 합니다.

러스트 메모리 부족 - leoseuteu memoli bujog

다음으로 함수의 매개변수를 생각해 보겠습니다. 위의 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됩니다.