러스트(Rust)라는 언어의 유명세는 그 특유의 메모리 관리 시스템 덕분에 널리 알려진 것 같습니다. 독창적이면서도 릭(leak)이나 레퍼런스 공유로 발생하는 여러 문제를 컴파일 때 미리 대비할 수 있기도 하기에 러스트의 특징과 장단점이 잘 드러나는 기능인 것 같네요.
이번 글은 러스트의 소유권 개념과 메모리 관리 시스템을 간단히 살펴보는 글입니다.
메모리의 라이프사이클(Life Cycle)
대부분의 언어들이 그렇지만, 변수가 인스턴스화 되는 시점에 메모리가 할당(allocate)됩니다. 상식적이라면 상식적인 이야기지요. 그렇다면 이 메모리는 언제 해제(deallocate) 될까요?
러스트의 메모리 관리의 기본은 바로 스코프(Scope)입니다. 변수는 자신이 생성된 스코프를 벗어나면 사라지는데 이걸 메모리 관리에 이용합니다.
fn some_function(condition: bool) {
let a = "A".to_string();
if condition {
let b = "B".to_string();
}
}
위 예제에서 a
변수가 가리키는 인스턴스는 some_function()
함수가 종료되면 같이 해제됩니다. 그리고 b
는 if 블록을 벗어나면 삭제됩니다. 즉 코드의 블록(block)에 해당하는 중괄호가 변수와 변수가 가리키는 인스턴스의 삶의 한 주기, 즉 라이프사이클과 동일합니다. 스코프를 벗어나면 어차피 변수가 사라져서 접근도 못 하는데 메모리 해제도 이때 하면 효율적이기도 하겠네요.
하지만 상황은 쉽게만 흘러가지는 않겠지요. 변수가 다른 용도로 여러 방법으로 사용되면서 좀 더 다른 상황을 만나게 됩니다. 이를 위해서 소유권이라는 개념을 알아야 합니다.
소유권(Ownership)
러스트의 메모리 관리 시스템은 이 소유권 개념과 크게 연관되어 있습니다. 이 소유권이란 특정 인스턴스를 어떤 변수가 소유하고 있는지를 표현하는 개념입니다.
fn main() {
let name = "Cheolsoo".to_string();
println!("name is {}", name);
}
위 코드에서 name
변수는 초기 값으로 "Cheolsoo" 문자열(String) 인스턴스의 레퍼런스를 가지게 되는데 러스트에서는 추가로 해당 인스턴스를 소유한다고 표현합니다. 즉 name
변수가 생성되면서 해당 문자열을 소유합니다. 그리고 코드 상 name
변수는 main()
함수 스코프 안에서만 존재할 수 있습니다. 그래서 name
변수는 main()
함수를 벗어날 때 사라지며 같이 소유하고 있던 문자열 인스턴스도 이때 메모리에서 해제됩니다.
앞서 했던 스코프 이야기와 아직은 별 차이가 없네요.
소유권의 이전
문제는 한 인스턴스는 하나의 변수만 소유할 수 있다는 규칙에 있습니다. 즉 러스트에서 소유권은 하나의 변수만 가질 수 있으며 다른 변수와는 공유할 수 없습니다.
let name = "Cheolsoo".to_string();
let nick = name;
println!("name is {}", name); // ERROR
println!("nick is {}", nick);
위 예제는 컴파일 오류가 발생합니다. 세 번째 라인에서 name
변수가 뭔가를 빌리려(borrow)하는데 안 된다는 식으로 말이지요.
이유는 소유권의 양도가 발생했기 때문입니다. 위 예제의 두 번째 라인에서 name
변수의 내용을 nick
변수에 대입하는데, 이때 소유하던 문자열 인스턴스의 소유권이 nick
으로 넘어갑니다. 그래서 name
은 이후 아무런 내용도 소유하지 못하는 껍데기뿐인 변수가 됩니다. 그래서 3번째 라인에서 아무것도 가지지 못한 불쌍한 변수를 사용하려 해서 문제가 발생합니다.
결과적으로 러스트에서 대입은 소유권까지 넘기는 무시무시한 행위라는 말이 됩니다.
이런 소유권의 양도는 함수 호출의 경우도 비슷합니다.
fn do_what(stuff: String) {
println!("do {}, what?!", stuff);
}
fn main() {
let name = "Cheolsoo".to_string();
do_what(name);
println!("name is {}", name); // ERROR
}
이 예제에서도 main 함수의 println!()
코드에서 잃어버린 소유권으로 인한 분쟁(?)이 발생합니다.
아까는 name
변수를 다른 변수에 대입한 것이 문제였지만, 이번에는 do_what()
이라는 함수의 매개변수로 name
변수를 넘기면서 소유권의 양도가 발생합니다. 즉 name
변수가 가리키던 문자열 인스턴스의 소유권이 do_what()
함수의 stuff
라는 변수가 가져가게 됩니다.
그리고 stuff
변수는 do_what()
함수의 스코프에 묶여 있습니다. 따라서 제일 처음 name
변수가 가지고 있던 문자열 인스턴스는 이 do_what()
함수가 종료되는 시점에 메모리에서 해제됩니다. name
은 소유하고 있는 게 없으므로 해제되든 말든 그저 그런 껍데기일 뿐이고요. 그리고 이 빈 껍데기를 사용하려는 코드는 컴파일러가 알아서 미리 잡아줍니다.
결과적으로 컴파일러의 지시에 잘 따르면(?) 메모리는 아주 깔끔하게 해제되어 릭도 없고 레퍼런스 공유 문제도 전혀 발생할 수 없게 됩니다. 이것이 바로 러스트 특유의 소유권을 이용한 메모리 관리입니다.
소유권의 반환
여기서 잠깐 다른 생각을 해 봅시다. 함수는 반환(return)을 할 수 있습니다. 이때 소유권도 반환하는 것이 가능하지 않을까요?
fn do_what(stuff: String) -> String {
println!("do {}, what?!", stuff);
stuff
}
fn main() {
let name = "Cheolsoo".to_string();
let nick = do_what(name);
println!("nick is {}", nick);
}
위 코드는 별문제 없이 컴파일되고 별문제 없이 의도대로 잘 실행됩니다. 결과적으로 리턴을 이용해 소유권을 양도하는 것이 가능하다는 말입니다. 다만 이 소유권을 nick
이라는 다른 변수가 가져가게 되어서 name
은 여전히 빈 껍데기 신세이지만 뭐 어때요. 여전히 메모리는 깔끔하게 관리되고 있으니까요.
소유권의 임대
앞서 살펴봤듯이, 하나의 인스턴스에 대한 소유 권한은 단 하나의 변수만이 가질 수 있습니다. 이 규칙은 매우 강력한 메모리 관리를 가능하게 하지만 솔직히 많은 경우에서 불편함을 유발할 수도 있습니다. 만약 특정 함수에 해당 변수를 매개변수로 넘기기는 했지만, 해당 함수가 종료된 이후에도 그 변수를 계속 쓰고 싶을 수도 있습니다.
뭐 이런저런 사유로 소유권에 임대라는 개념이 등장합니다.
fn do_what(stuff: &String) {
println!("do {}, what?!", stuff);
}
fn main() {
let name = "Cheolsoo".to_string();
do_what(&name);
println!("name is {}", name);
}
위 코드는 문제없이 컴파일되고 실행됩니다. 그런데 코드에서 &
라는 잘 안 보이던 오퍼레이터가 등장하네요. &str
등에서나 보던 포인터를 받는 오퍼레이터가 아니었던가요? 그렇다면 포인터를 받으면 해결되는 걸까요?
실제로 포인터를 받는지 아닌지는 모르겠지만, &
오퍼레이터는 소유권을 임시로 빌린다는 의미로 사용하는 임대 오퍼레이터입니다. do_what()
함수에서 stuff
매개변수의 타입 앞에 &
가 붙어서 소유권을 임시로 빌린다는 표시를 하였고, 호출하는 코드인 do_what(&name)
에서도 넘겨주는 변수 앞에 &
를 붙여서 이 인스턴스를 임대한다고 표현할 수 있습니다.
이 임대 개념이 러스트의 메모리 관리가 최악이 될 뻔한 상황을 많이 없애주게 되었습니다. 위 코드가 잘 돌아갔다는 말은 즉 임시로 빌린 인스턴스는 스코프를 벗어나도 해제시키지 않는다는 말입니다. 결과적으로 이번에도 메모리가 아주 깔끔하게 관리된 셈이네요.
원시 타입의 경우
이번에는 살짝 핀트가 어긋난(?) 예제를 하나 살펴봅시다.
let a = 10;
let b = a;
println!("a = {}", a);
println!("b = {}", b);
앞서 살펴봤던 예제들에서는 위 코드의 두 번째 라인처럼 대입으로 소유권이 넘어가서 출력하는 코드에서 에러가 발생하는 게 뻔한 코드입니다. 하지만 위 코드는 문제없이 컴파일되고 문제없이 잘 실행됩니다. 즉 원시 타입은 별도의 소유권이 작동하지 않는다고 추측할 수 있습니다.
다르게 표현하자면 소유권은 힙(Heap)에 할당되는 인스턴스에만 사용되는 개념이라고 추측할 수도 있겠네요.
다른 예를 하나 더 살펴봅시다.
fn do_what(stuff: &str) {
println!("do {}, what?!", stuff);
}
fn main() {
let name = "Gildong";
do_what(name);
println!("name = {}", name);
}
이번에도 do_what(name)
코드에서 볼 수 있듯이 소유권 임대를 표시하지 않았습니다. 그럼에도 역시 이 코드는 문제가 없습니다.
다만 재미있는 점이 있다면 여기서 사용하는 문자열의 타입이 &str
이라는 점입니다. 이미 포인터를 사용하고 있지요. 그렇다면 임대 개념은 포인터를 사용하면 알아서 발동된다는 것일까요? 아니 어쩌면 포인터 자체가 임대 개념을 포함하고 있을까요?
아직 명확하지는 않습니다만 이런 경우도 딱히 소유권 때문에 걱정할 일은 없을 것 같다는 생각이 드네요. &str
문자열은 무조건 포인터 형태로만 넘기고 받을 테니 말이죠.
어쨌거나 대충 뭉뚱그리면 원시 타입은 딱히 소유권으로 걱정할 필요는 없다는 것 같습니다.
println!
은 왜 아무 일 없나?
앞에서 함수에 변수를 넘겨줄 때 소유권의 양도가 발생한다는 점과 소유권을 임대할 때 &
를 표시해야 한다는 것을 알 수 있었습니다.
그런데 유독 println!()
만큼은 이런 소유권을 가져가거나 빌리는 짓(?)을 안 하는 것처럼 보입니다.
이는 바로 println!
이 함수가 아닌 매크로(Macros)라는 차이에서 발생합니다. 매크로는 C의 전처리기(Preprocessor) 매크로와 비슷하게 컴파일 전에 인라인 코드로 풀어지는 형태의 특수(?) 기능이기 때문에 함수와는 다르게 동작할 수밖에 없습니다.
물론 정확한 것은 매크로에 대해 정리할 때 추가로 다뤄봐야 할 것 같습니다.
맺음말
일단은 복잡한 예제도 없고 변조가능(mutable) 매개변수 예제도 없는 만큼 적당히 짧은 글로 끝맺음을 하게 되었습니다. 하지만 이보다 많은 내용을 다루기엔 언급하지 않은 많은 기능들을 등장시켜야 하기에 글의 순서가 어긋나는 것 같은 느낌이 들어서 기본적인 개념 수준에서 글을 억지로 마무리할 수밖에 없네요. 필요하다면 나중에 좀 더 복잡한 이야기를 정리할 기회가 있을지도 모르겠습니다.
어쨌든 이런 러스트의 메모리 관리 시스템의 가장 큰 장점은 컴파일 때 대부분의 메모리 관리 문제점을 미연에 방지할 수 있다는 점이 아닐까 싶네요. 정적 타이핑(Static Typing) 언어들의 장점도 이런 빌드 타임 문제 예방이었는데 소유권도 비슷한 장점 같네요.
다음 글은 구조체에 관한 내용으로 이어집니다.
관련글
'기술적인 이야기 > 기타 개발' 카테고리의 다른 글
러스트의 미래는 어떻게 될까? (1053) | 2020.08.19 |
---|---|
러스트의 구조체 살펴보기 (896) | 2020.08.19 |
러스트의 함수와 클로저 (1050) | 2020.08.10 |
러스트의 제어 구조 (1043) | 2020.08.05 |
러스트의 기본 타입들 (881) | 2020.07.31 |
댓글