티스토리 뷰

Backend

Rust Memory Management

지마켓 선현상 2021. 5. 7. 14:36

After GC, Post GC

Memory는 프로그래밍에 꽤 중요하고 기술적인 부분입니다. 만약 독자께서 malloc, calloc, free 등에 어색함이 없다면 더욱 Memory를 다루는 것에 기술적인 특징들을 공감하실 수도 있겠습니다. 그래서 또 Memory를 다룬다는 점은 꽤 신중하고 기교가 높아야 했으며 문제가 복잡해질수록 이 과정에서의 버그나 Side-effect의 해결이 쉽지 않습니다. 이런 고통(?)에 대한 공감은 어렵지 않은 일이죠. 그래서 등장한 것이 바로 Garbage Collection 입니다. Garbage Collection은 무려 1959년에 John McCarthy에 의해 Lisp에서 등장하는 역사를 가지고 있습니다.

 

벗어나는 이야기지만 Robert C. Martin은 Three Paradigms 라는 글에서 Programming은 제약을 딱 하나씩만 더해가면서 Paradigm 을 만들어 왔다고 설명합니다. 그는 이른바 Structured Programming 은 goto 문법을 if/else, while 등의 구조적인 흐름으로 대체하는 제약이고 Object Oriented Programming 또한 Function Pointer를 Polymorphism 규칙 하에서만 가능하도록 제약한 것이며 Functional Programming은 변수에 대한 assignment를 없애서 만든 것이라고 설명합니다. 그는 Programming의 역사를 제약이 더해져 가는 역사로 보면서 매우 적절하고 단순하며 효과적인 제약들은 우리를 이롭게 한다고 말합니다. 딱 하나의 제약을 한 문장으로 설명하여 하나의 Programming Paradigm 을 설명하는 통찰력은 정말 놀랍습니다.

 

Garbage Collection 또한 하나의 제약을 더해서 우리를 이롭게 해주는 기술로 떠올랐습니다. 그 제약은 개발자가 직접 Memory를 관리하지 못하게 하는 것입니다. 이 제약은 Programming Paradigm을 규정할 정도의 효과는 아니었지만 개발자의 여러 고통들로부터 해방시켜주는 매우 이로운 변화였음에는 분명합니다. 많은 언어들, 특히 현재 가장 인기 있는 언어들은 거의 모두 Garbage Collection에 대한 기능과 설정을 제공하고 있으니까요. 하지만 Garbage Collection은 또 하나의 다루기 힘든 부하가 되기도 합니다. 이에 Go 언어에서는 재미있는 컨셉을 선보입니다. Go 언어는 뛰어난 escape 분석을 통해서 불필요한 Heap 할당을 방지하여 Stack 할당으로 판단하며 Heap 할당을 위해서는 개발자의 약간에 고려를 포함하게 함으로써 Garbage Collector 의 성능을 극적으로 끌어올리고 있습니다.

 

Go 언어에서의 함수는 반환에 대하여 return by value과 return by reference를 구분하게 됩니다. (call by value, call by ref 와 유사) 사용된 객체가 반환에 포함되지 않거나 결과적으로 return by value 된다면 이 객체가 new 를 통한 생성이었다 하더라도 heap이 아닌 stack에 할당하고 함수를 빠져나가는 (escape) 시점에 회수됩니다. 이를 통해서 세 가지 조건에 만족한 경우에만 heap에 생성되면서 Garbage Collecting의 전통적인 가설 중 하나인 수명이 짧은 객체를 다뤄야 할 경우를 크게 줄이는 효과를 보고 있습니다.

  1. new에 의해 생성
  2. return에 포함됨 (함수 외부로 공개됨)
  3. return by reference 됨

개발자는 여전히 메모리에 대한 직접적인 관리는 하지 못합니다. 하지만 return by reference를 특정하기 위한 문법을 표현하므로써 제약을 크게 무너뜨리지 않으면서 Garbage Collection의 효율을 높이고 있습니다. 이후, Garbage Collecting의 성능을 위한 알고리즘에 대한 연구의 방향에 신선한 길을 열었는지도 모르겠습니다. 요즘 크게 인기를 얻고 있는 언어 Rust에서는 매우 색다른 메모리 관리에 관한 기술을 사용합니다.

Rust, 그리고 Ownership

Deno는 Rust를 구현언어로 개발되었습니다. 그리고 핵심 중 하나인 Event loop 에 관한 기술을 Rust의 라이브러리인 Tokio를 기반으로 하고 있습니다. 이에 자연스럽게 Deno를 진행하던 중 Rust에 대한 관심으로 이어졌습니다. Rust에 대한 작은 호기심 중에 매우 놀라운 특징을 마주하게 되었습니다. Rust는 Memory 관리를 수동으로 맡기지 않으면서도 Garbage Collection을 하지 않는다는 점입니다. 이는 Rust의 Ownership 이라는 개념에 기인합니다.

이 Ownership은 새롭다보니 언뜻 이해가 쉽지는 않았지만 다음과 같이 요약할 수 있습니다.

  • 모든 value는 하나의 참조(owner)에 의해서만 접근 가능하다
  • 만약 다른 참조가 선언된다면 Ownership은 기존 참조에서 새 참조로 옮겨온다
  • 기존 참조는 Ownership이 회수되고 더 이상 해당 value를 참조하지 못한다
  • 현재 Ownership이 소유된 참조가 scope 이 끝나서 회수되면 해당 value도 회수된다

한 문장으로 말하면 value는 어떤 순간에도 한 시점엔 하나의 참조만을 유지한다는 것입니다. 과연 이런 방식으로 프로그래밍이 가능할까? 하는 의문도 들었습니다. 간단한 예제를 통해서 저희가 마주한 당황스러움을 공유해 보겠습니다.

let name = String::from("Rust");
let alias = name;

println!("hello, {}", name);
// error[E0382]: use of moved value: `name`

use of moved value 라는 에러는 생소하기까지 합니다. 직역해보면 “옮겨진 값을 사용했다” 에러입니다. 풀이해보면 name에 “Rust”가 할당된 순간 name은 “Rust”의 owner가 됩니다. 그리고 alias에 name을 할당하면 실제로는 name에 할당된 “Rust”가 alias에 할당됩니다. 그리고, “Rust”의 owner는 alias가 되고 name은 더 이상 owner가 아닙니다. 그리고 어떤 값에 대한 owner도 아닌 변수를 읽으려고 하면 값이 이미 옮겨졌다는 에러를 발생합니다.

 

당황스럽지 않나요? 저희는 무척 당황스러웠습니다. 이 패턴에서 name은 scope을 나갈 때 메모리 해제의 대상에서 제외됩니다. 이미 참조하고 있는 값이 없기 때문이죠. 이를 통해서 이른바 Dangling Pointer 나 이중 해제 같은 문제는 구조적으로 발생하지 않습니다. 이 패턴은 다음과 같은 예제에서 당황스러움을 더해줍니다.

fn main() {
  let name = String::from("Rust");
  greeting(name);
  println!("bye, {}", name);
  // error[E0382]: use of moved value: `name`
}

fn greeting(alias: String) {
  println!("hello, {}", alias);
}

함수에 전달하고 나면 ownership 또한 넘어가 버립니다. 물론 다음과 같은 코드들은 잘 실행됩니다.

let name = "Rust";
let alias = name;

println!("hello, {}", name);
fn main() {
  let times = 5;
  greeting(times);
  println!("bye, {} times", times);
}

fn greeting(count: i32) {
  println!("hello, {} times", count);
}

이는 예제의 변수에 할당이 reference가 아닌 value이기 때문입니다.

 

여기까지 보면 참 코딩하기 불편할 것 같습니다. 물론 임시 변수는 필요한 만큼으로 최소화하는 것이라곤 하지만요. 그래서 이른바 ‘빌려주기’ 기능을 만들었나 봅니다.

fn main() {
  let name = String::from("Rust");
  greeting(&name);
  println!("bye, {}", name);
}

fn greeting(alias: &String) {
  println!("hello, {}", alias);
}

Borrowing은 & 연산자를 통해 표현됩니다. 위 코드는 잘 동작합니다. 이유는 바로 Ownership을 양도하지 않고 빌려주기만 했기 때문입니다. greeting 함수가 수행을 마치고 돌아오면 “Rust”에 대한 Ownership 은 다시 name으로 돌아옵니다. Borrowing 의 핵심은 “함수 호출”을 매개로만 허용한다는 것입니다. 함수 호출은 scope을 만들게 되고 함수가 종료될 때 scope을 벗어나면서 메모리에 관한 필요한 행위들을 수반할 수 있다는 점입니다. 이를 통해서 호출 스택이 회수되면서 Ownership이 회수되거나 owner 였다면 이제 메모리를 회수하는 두 행위 중 하나를 반드시 수행하게 됩니다. 그리고 여전히 한순간에는 하나의 참조만이 유지됩니다.

정리

이른바 참조 카운트나 참조 트리 등 참조가 모두 사라졌는지 아니면 아직 참조가 살아있는지를 확인하는 것은 Garbage 인지 확인하는 기본적인 방법이었습니다. 이는 하나의 값을 다수의 참조로 공유하는 방법에 기반된 해결법이었습니다. Rust의 Ownership 모델은 이 기반을 바꿔보는 시도를 하였습니다. 하나의 값에 대한 참조는 한 순간에는 하나뿐이다. 이를 기반으로 한 메모리 관리는 Garbage Collection을 불필요하게 만들 수 있는 메모리 관리 방법을 가능케 하였습니다. 간단한 제약 하나, 참조를 동시에 공유하지 못한다. 가 가져올 이로움에 대해서는 사실 Rust 의 인기 상승의 비결로 어느 정도 입증되고 있다고 볼 수 있습니다. 단지 Rust의 시도를 함께 따라가는 여정에 불과했지만 그들의 여정에서 배울 수 있는 점이 많았습니다. 여기까지 읽어주신 수고에 감사드립니다.

'Backend' 카테고리의 다른 글

Linger 로 오버헤드 줄이기  (0) 2021.07.14
try-catch 지옥 벗어나기  (0) 2021.05.21
Middleware 패턴과 코드 수준에서의 활용  (0) 2021.04.30
Monad 를 이해하는 방법  (2) 2021.04.16
Component 와 Transclude  (1) 2021.04.12
댓글