Search for a command to run...
아마 프로그래밍 쪽에 관심이 있다면 Rust를 한번 즈음 들어봤을 것이다. 그러면 두가지의 견해를 들어봤을 것이다.
사실 1번과 2번은 모두 이어진 말이다. 소유권 덕분에 안전한 코드를 작성할 수 있고, 소유권이라는 제약 때문에 일반적인 프로그래밍과 다른 방향으로 아키텍쳐를 짜야한다.
그러면 소유권이란 무엇일까? 복잡하게 가면 (우리 교수님이 말하길) affine logic이 나오고, 간단하게 가면 Read-Write Lock (mutex) 라는 개념이 된다. 하나의 변수는 한 곳에서만 점유(=lock 걸기) 할 수 있다는 뜻이다. 딱 한 곳에서만 관리를 한다는게 중요하다
많은 분들이 변수에 RW Lock을 만들었다고 하면 이해를 완료 했다.
test 라는 변수를 만들어보자. 이 변수는 function 이라는 함수에서 관리된다. (= function이 lock을 가지고 있다)
fn function() {
let mut test = ~~~;
}
이것을 새로운 함수에 전달해 보자. 이 경우 문제가 생긴다. test 라는 변수가 subfunc에 이동했다고 생각하기 때문이다. (= lock이 subfunc으로 넘어감. function은 lock을 가지고 있지 않음)
fn function() {
...
subfunc(test);
...
println!("test 값: {test}"); // test에 접근할 수 없음
}
다른 여타 프로그램이라면 맨 마지막에 test를 출력할 수 있었을 것이다. 그러나 Rust에서는 오류가 발생한다. 그 이유는 test 변수의 관리 책임이 subfunc으로 넘어갔기 때문이다. subfunc(test)을 실행한 시점에 test의 관리 권한은 subfunc에 넘어간다. 그래서 subfunc 함수가 끝나는 시점에 test도 같이 사라진다.
조금 이해하기 쉽게 코틀린으로 코드를 대치해 보자.
fun method1() {
// method1에서 리스트 생성
val myList = listOf("Apple", "Banana", "Cherry", "Date")
methodConsume(myList)
println("Received list: $myList")
}
fun method2() {
// 이번에는 뒤에 더 사용 안함.
val myList = listOf("Apple", "Banana", "Cherry", "Date")
methodConsume(myList)
}
fun methodConsume(list: List<String>) {
// method2에서 리스트 출력
println("Received list: $list")
}
코틀린(JVM) 뒤에서 이루어질 메모리의 할당과 생성을 집중해서 살펴보자.
method1에서 myList를 생성하며 heap에 메모리를 할당한다.methodConsume로 myList의 포인터(=Reference)를 넘긴다methodConsume에서 myList를 출력한다MethodConsume()은 list 사용이 끝났는지를 알 수 없다.여기 3번이 끝난 직후 컴파일 타임에는 "myList가 사용이 끝났는지"를 판단할 수 없다. 이 함수 바깥에서 myList가 쓰이는지 확정할 수 없기 때문이다.
그래서, 런타임(JVM)에서 객체의 참조 갯수 (Reference Count)를 이용하여 객체의 사용이 끝났는지를 판별하게 된다.
물론 지금 예시에서는 객체의 사용이 끝났는지 알 수 있다. 하지만 조금 복잡한 예시로 간다면 불가하다. (예: HashMap 사용)
만약 method2()처럼 myList 사용이 끝났다면 methodConsume(list)이 끝나자마자 객체를 제거하면 된다. 이렇게만 된다면 우리를 괴롭히는 Garbage Collecter는 불필요 할 것이다.
누군가는 변수를 관리해야 한다. 그리고 관리하는 사람이 없어진다면 메모리를 해제한다. 소유권은 코틀린처럼 list(Vector)가 할 수도 있고, map(HashMap)에서 할 수도 있다.
아래 코드를 다시 보자.
let mut list = vec![ABCD];
fn function() {
list.pop();
}
list가 관리한다.list.pop()을 통해서 ABCD의 소유권(관리자)이 function으로 넘어간다function이 끝나며 관리자가 더는 없어진다라는 식이 된다.
이 방법을 사용하면 성능 저하 없이, 안전하게, 컴파일 타임에 메모리를 관리할 수 있다. Garbage Collecter를 비롯한 잡다한 부스러기들이 사라지므로 불필요한 성능 저하가 사라진다.
만약 소유권 전환만 있으면 pure-functional 스타일로만 코딩을 해야할 것이다. 그러면 현실 문제를 푸는데 도움이 될 수가 없다. 만약 소유권 전환만 있었다면 대학원 연구실에서나 쓰이는 언어로 남았을 것이다.
그래서 나온것이 빌리기(borrow) 이다. 빌리기(borrow)를 사용하면 "소유권"은 건들지 않는다. (관리자는 그대로이다). 하지만 다른 함수에게 변수를 접근할 수 있게 해 준다.
쉽게 말해서, 소유권은 그대로 둔 체 접근할 수 있는 포인터를 넘겨주는 것이다.
Rust에서는 &를 이용해서 레퍼런스(=포인터)를 생성할 수 있다. 이렇게 생긴 레퍼런스는 원래 객체를 가르키는게 되고, 이것으로 변수에 접근 할 수 있다.
예를 들어 아래와 같은 코드를 보자
fn parent() {
let mut list = vec![ABCD];
sub_function(&list);
println!("list의 값: {list:?}");
}
여기서는 &list 라는것을 사용했다. 이러면 소유권(관리 권한 = 메모리 해제의 책임)은 parent()에 그대로 둔 채, sub_function()에서 값을 읽을 수 있는 포인터를 받게 된다.
소유권은 여전히 parent()에 있으므로, list는 parent() 함수가 끝난 직후 heap의 메모리가 해제 될 것이다.
라는 이야기를 누군가 했었다.
결과만 보면 다르다. borrow rule에는 빌려준 객체는 원래 데이터보다 더 오래 존재할 수 없다 라는 제약이 존재한다. 다음 코드를 보자:
let mut global_list = Vec::new();
fn parent() {
let mut list = vec![ABCD];
sub_function(&list);
println!("list의 값: {list:?}");
}
fn sub_function(list: &Vec<_>) {
global_list.push(list);
}
list는 parent()가 소유(관리)한다.[parent]: list의 포인터를 sub_function에 넘겨준다[sub_function]: 전역 변수 global_list에 포인터가 저장된다[parent]: 소유권을 가진 parent()가 끝나면 메모리가 해제된다global_list의 포인터는 무엇을 참조하나...? (= dangling pointer 문제 발생)Java, Python과 같은 여타 언어에서는 이러한 행위가 용납된다. (비록 들어나는 포인터는 없지만 실제로 reference가 넘어간다.) 객체를 이리저리 옮기고 Dict에 넣고 List에 넣을 수 있지 않은가? 그리고 잘 생각해보면 이것들이 같은 객체를 가르킨다는 점에서 모두 포인터이다.
이것들을 차례차례 생각해 보면, Java, Python 같은 언어들은 메모리를 언제 해제해야 할지 컴파일 타임에 결정할 수 없다. 이 문제가 Rust와 다른 언어들간의 본질적인 차이를 만드는 지점이다.
모든 상황을 고려하는 슈퍼-킹-갓 컴파일러가 나온다고 쳐도, rand() 같은게 나온다면 손 쓸수 없을 것이다.
Rust는 언어에 제약을 추가해서 메모리 해제 시점을 컴파일 타임에 계산할 수 있고, 이것이 모든것의 원인이자 원흉이다.
Rust에는 소유권 개념과 빌려주기 라는 개념이 있다. 이것들은 사실 메모리를 언제 해제 하는지를 결정하는데 쓰인다. 소유권이라는 제약 덕분에 컴파일러는 관리자가 사라졌네? 그러면 다른데서 이 변수를 쓰는곳이 없겠네? 그러면 메모리를 해제해도 되겠네? 라고 판단할 수 있다.
이 제약 덕에 C, C++하고 동일한 수준으로 실행 파일을 만들되, 메모리 해제 시점만 컴파일러가 추가해 주는 방식이 된다. 그 덕에 안전하면서도 C, C++과 같은 속도를 누릴 수 있다.
Java나 Python 같은 언어에서는 이 변수를 몇군데에서 쓰고있는지를 확인해서(Reference Count) 이 문제를 해결한다. 해당 변수를 쓰고있는 메서드나 컨테이너(list, map)이 0개라면 비로소 메모리를 해제한다.
C, C++에서는 개발자가 직접 메모리 해제 시점을 정해야 한다. 만약, 메모리 해제 시점이 누락되면 memory leak이 되는것이고, 두번 해제하면 double-free가 발생하게 된다.
이 글을 이해했으면 큰 개념 이해는 끝난다. 그 뒤는 패턴을 보면서 이해하면 충분히 Rust를 활용 할 수 있다.