이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.
우리는 일반적인 다른 프로그래밍 언어에서도 쉽게 접할 수 있는
변수, 자료형, 함수 등의 개념에 대해 살펴보았다.
이번에는 Rust의 고유한 기능 중 하나인 소유권에 대해 알아보도록 하겠다.
프로그래밍에서 메모리를 잘 관리하는 것은 중요한 일이다.
이것을 잘 관리하지 못한다면 프로그램은 필요 이상의 메모리를 사용하거나 메모리 누수를 일으키고
아직 사용 중인 메모리를 해제하여 문제가 발생할 수도 있다.
C 언어 같은 언어들은 이러한 메모리 관리를 개발자가 직접 해주어야 한다.
개발자의 사소한 실수는 프로그램 전체에 치명적인 오류를 야기할 수 있으며
때로는 그 원인을 찾아내기 힘들어 개발자에게 큰 고통을 준다.
이러한 불편을 덜어주기 위해 Java와 같은 언어들은 언어 수준에서 메모리 관리를 해준다.
가비지 컬렉터를 통해 그 누구도 참조하지 않아 더이상 접근할 수 없는 메모리의 할당을 해제한다.
그런데 백그라운드에서 계속 가비지 컬렉터가 돌아가고 있으면 그 만큼의 오버헤드가 발생한다.
오버헤드가 없지만 개발자가 모든 걸 신경써야 하는 방법과
개발자는 신경 안써도 되지만 오버헤드가 존재하는 방법.
그 동안 프로그래밍 언어들은 이 둘 사이의 고민을 해왔다.
그러던 중! Rust는 제 3의 방법을 제시했다. 그것이 바로 소유권이다.
소유권 개념을 사용하면 컴파일 시점에 메모리에 대한 검사가 이루어지며
개발자가 직접 할당 해제를 할 필요도 없으면서 실행 시간에 이를 검사하지 않는다.
여담: 힙heap 과 스택stack
Rust와 같은 시스템 프로그래밍 언어에서는 힙 메모리와 스택 메모리에 대해 알 필요가 있다.
자료를 그 중 어디에 저장하는가에 따라 프로그램의 성능이 달라질 수 있으며
이는 언어의 동작과 의사결정에 큰 영향을 주는 부분이다.스택 메모리는 이름에서 알 수 있듯이 자료구조 스택과 유사하다.
이것은 후입선출後入先出 구조로 이루어져 나중에 들어온 자료가 먼저 사라진다.
스택 메모리에 저장되는 자료는 반드시 고정적인 크기를 가져야 하며
컴파일 시점에 그 크기를 알 수 있어야 한다.스택 메모리에 저장할 수 없는,
그러니까 동적으로 크기가 변하거나 컴파일 시점에 크기를 알 수 없는 자료는
힙 메모리에 저장하여 사용한다.
힙 메모리는 스택보다 복잡하며, 유동적으로 공간을 할당하거나 해제할 수 있다.
우리는 힙 메모리에 적절한 크기의 공간을 할당하고 그 주소를 스택 메모리에 저장할 수 있다.
주소값을 저장하는 포인터는 모두 동일한 고정 크기를 가지고 이썽 스택 메모리에 담을 수 있다.스택 메모리에 자료를 넣을 땐 항상 맨 위에 추가하는 반면
힙 메모리에 자료를 넣을 땐 그것이 들어가는 크기의 공간을 찾아 넣어야 하고
접근할 때도 그 주소를 찾아 접근해야 하므로 상대적으로 오래 걸린다.어떤 함수가 호출되면 인자로 전달된 값과 함수 내부에서 사용되는 값이 스택 메모리에 쌓이고
함수에서 반환될 때 그 자료들은 스택 메모리에서 제거된다.
힙 메모리는 좀 더 동적으로 관리되는데 힙 메모리에 저장된 자료의 사용을 추적하여
보다 효율적인 힙 메모리 관리를 하기 위해 제시된 게 소유권 개념이다.
Rust에는 다음과 같은 소유권 규칙이 있다.
- Rust의 모든 값은 소유자owner라고 불리는 변수를 가진다.
Each value in Rust has a variable that’s called its owner.- 한 순간에 특정 값의 소유자는 단 하나뿐이다.
There can only be one owner at a time.- 소유자가 범위 밖으로 나가면 그 값은 제거된다.
When the owner goes out of scope, the value will be dropped.
우리가 자료형에 대해 배울 때 살펴본 자료형들은 스택 메모리에 할당되는 녀석들이다.
소유권 규칙에 대해 공부하기 위해 힙 메모리에 저장되는 문자열 자료형String을 사용해보자.
문자열 자료형에 대한 건 추후에 더 자세히 살펴보겠지만 말이다.
큰 따옴표로 묶어 하드코딩 한 문자열은 불변성을 가지며 중복을 피해 관리하기 어려워
어떤 프로그램에서 사용하기에 적합하지 않을 수 있다.
따라서 Rust는 컴파일 시점에 크기를 알 수 없는 문자열을 저장하기 위한 String 자료형을 제공한다.
컴파일 시점에 크기를 알 수 없기에 이 녀석은 힙 메모리에 저장된다.
문자열 리터럴을 통해 String 인스턴스를 생성하고자 한다면 다음과 같이 작성할 수 있다.
let s = String::from("hello");
이렇게 생성한 String 인스턴스는 문자열 리터럴과 달리 가변성을 가질 수 있다.
물론 이를 위해서는 mut
키워드를 사용해야 한다.
let mut s = String::from("hello");
s.push_str(", world!");
문자열 리터럴은 불변성을 가지고 있어 컴파일 시점에 바이너리 형태로 변환되며 빠르고 효율적인 반면,
String 인스턴스는 오버헤드를 가지고 있지만 힙 메모리에서 가변성을 띌 수 있는 것이다.
힙 메모리에 공간을 할당 하기 위해서는 다음과 같은 과정이 필요하다.
- 실행 시점에 운영체제의 메모리 할당자에게 메모리를 요청해야 한다.
The memory must be requested from the memory allocator at runtime.- 사용이 완료되었을 때 운영체제의 메모리 할당자에게 메모리를 반환할 방법이 필요하다.
We need a way of returning this memory to the allocator when we’re done with our String.
우리는 String::from
함수를 호출함으로써 첫번째 과정을 수행할 수 있다.
그리고 두번째 과정의 방법은 언어마다 차이가 있는데,
사용자가 직접 할당 해제를 요청하거나 가비지 컬렉터가 백그라운드에서 처리해주는 게 일반적이다.
Rust의 경우 제3의 방법을 사용하는데, 여기서 소유권 개념이 사용된다.
힙 메모리에 저장된 어떤 값의 소유자가 범위 밖으로 나가면 그 값은 자동으로 할당 해제된다.
다음과 같은 경우에 중괄호를 벗어나면 더이상 s
에 접근할 수 없으므로
Rust는 그것이 소유하고 있는 힙 메모리의 값을 필요 없다고 판단하고 할당 해제하는 것이다.
{
let s = String::from("hello");
}
메모리 할당을 해제할 땐 drop
이라는 이름의 특별한 함수가 사용되는데
위 코드의 중괄호를 벗어난 시점과 같이 메모리 할당 해제가 필요한 시점에 자동으로 호출된다.
스택 메모리에 있는 값을 다른 변수에 bind하면 값의 복사가 이루어진다.
let x = 5;
let y = x;
위와 같은 코드를 작성하면 x
와 y
가 각각 별개의 5
라는 값을 소유하게 된다.
정수와 같은 자료형은 크기가 고정된 단순한 값이기 때문에
그것을 복사해서 사용해도 크기와 시간 측면에서 부담되지 않기 때문에 복사를 한다.
하지만 String 인스턴스의 경우 말이 다르다.
String 인스턴스의 스택 자료는 포인터, 용량, 길이로 이루어져 있으며
포인터는 힙 메모리에 존재하는 실질적인 문자열 값의 주소를 가진다.
용량은 이 인스턴스가 운영체제로부터 할당받은 메모리를 byte 단위로 나타내며
길이는 그 중 실제로 사용 중인 메모리를 byte 단위로 나타낸다.
String 인스턴스를 다른 다른 변수에 bind한다고 하자.
let s1 = String::from("hello");
let s2 = s1;
s2
가 s1
의 값을 복사한다고 할 때, 우리가 생각할 수 있는 것은 두 가지다.
스택 자료만 복사하거나, 힙 메모리의 실질적인 문자열까지 복사하거나.
힙 메모리의 값까지 복사할 경우 그만큼 오버헤드가 발생한다.
큰 용량을 가진 값일 경우 오버헤드가 크다.
그렇다면 스택 자료만 복사한다면 어떨까?
s1
과 s2
가 범위를 벗어날 때 둘은 각각 drop
함수로 할당 해제를 요청할 것이다.
그런데 둘은 같은 메모리를 참조하고 있어 이중 해제 에러가 발생한다.
C 언어에서 이미 free
한 값을 다시 free
하려 할 때 볼 수 있던 에러다.
따라서 Rust는 bind 시 값이 복사되는 스택 자료와 달리
힙 메모리에 할당되는 녀석은 bind할 때 소유권을 이전하며 기존 변수는 이에 접근하지 못하게 한다.
위 예제에서는 let s2 = s1;
을 수행한 후 s1
는 유효하지 않은 변수가 된다.
정수 자료형을 비롯하여 복사가 가능한 자료형들은 Copy
트레이트라는 특별한 특성을 갖는데
이 특성을 가진 녀석들은 bind 시 값이 복사된다.
트레이트에 대한 건 추후에 자세히 살펴보도록 하겠다.
여담으로, Drop
트레이트가 적용된 자료형은 Copy
트레이트를 가지지 못한다.
아무튼 복사 불가능한 값은 bind 시 소유권이 이전된다는 것을 기억하자.
때로는 힙 메모리에 저장되어 있는 값도 이동이 아닌 복사를 하고 싶을 때가 있을 수 있다.
이 때는 복사가 아니라 복제라는 기능을 사용하는데, 그러면 힙 메모리의 값까지 복사된다.
물론 그만큼 오버헤드가 존재하는 작업이다.
let s1 = String::from("hello");
let s2 = s1.clone();
복제를 하면 복사와 마찬가지로 두 변수가 각각 별개의 값을 소유하게 되므로
소유권이 이전되지 않는다.
이 포스트의 내용은 공식문서의 4장 1절 What Is Ownership?에 해당합니다.