오늘은 Rust의 소유권에 대해 알아보고자 합니다.
소유권
소유권(ownershipt)는 rust에서 가장 독특한 기능이며, 언어 전반에 깊은 영향을 끼칩니다.
소유권은 가비지 컬렉터(GC : Garbage Collector)없이 메모리 안전성을 보장하도록 해주므로, 소유권이 어떻게 동작하는지를 이해하는 것은 중요합니다.
몇몇 언어(JAVA, Golang 등)는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리르 정기적으로 찾는 방법을 채택했고, 다른 언어(C, C++ 등)는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 채택했습니다.
rust는 소유권(ownership)이라는 시스템을 만들고, 컴파일러가 컴파일 중에 검사할 여러 규칙을 정해 메모릴 관리하는 방식을 채택했습니다.
이 규칙 중 하나라도 위반한다면 프로그램은 컴파일되지 않습니다.
소유권은 프로그램 실행 속도를 느리게 하지 않습니다.
소유권 규칙
- rust에서 각각의 값은 소유자(owner)가 정해져 있습니다.
- 한 값의 소유자는 동시에 여럿 존재할 수 없습니다.
- 소유자가 scope 밖으로 벗어날 때, 값은 버려집니다. (dropped)
변수의 스코프
{
let s = "hello world";
}
변수 s는 문자열 리터럴(string literal)을 나타내며, 값은 코드 내에 하드코딩 되어 있습니다. 이 변수는 선언된 시점부터 현재의 스코프를 벗어날 때까지 유효합니다.
String 타입
기존 정수형, 부동소수점형, bool 타입 등은 명확한 크기를 가지고 있어서, 전부 스택에 저장되고 스코프({})를 벗어날 때 제거되며, 코드의 다른 쪽에서 별도의 스코프 내에서 같은 값을 사용할 때 새 독립적인 인스턴스를 빠르고 간단하게 만들 수 있습니다.
그러나 String타입은 힙에 저장되면서 rust의 정리 과정을 알아보는데 적합한 타입입니다.
문자열 리터럴은 쓰기 편리하지만 만능이 아닙니다.
그 이유는, 문자열 리터럴이 불변성(immutable)을 지니기에 변경할 수 없다는 점과, 프로그램에 필요한 모든 문자열을 우리가 프로그래밍하는 시점에 알 수 없다는 점 때문입니다.
이중 콜론(::)은 우리가 함수를 사용할 때, string_from같은 함수명을 사용하지 않고, String타입에 있는 특정된 from함수라는 것을 지정할 수 있게 해주는 네임스페이스 연산자입니다.
String::from()으로 선언된 문자열은 변경이 가능합니다.
let mut s = String::from("hello");
s.push_str(", world!"); // push_str()이 문자열에 리터럴을 추가합니다
println!("{}", s); // 이 줄이 `hello, world!`를 출력합니다
문자열 리터럴과 String은 무슨 차이가 있길래 어떤 것은 변경이 가능하고, 어떤 것은 변경할 수 없을까요?
차이점은 각 타입의 메모리 사용 방식에 있습니다.
메모리와 할당
문자열 리터럴은 컴파일 타임에 내용을 알 수 있으므로, 텍스트가 최종 실행파일에 하드코딩되어, 빠르고 효율적이지만, 크기를 알 수 없고 실행중 크기가 변할 수도 있는 텍스트를 바이너리 파일에 넣을 수 없어, 문자열이 변하지 않는 경우에만 사용할 수 있습니다.
반면, String타입은 힙에 메모리를 할당하는 방식을 사용하기 때문에 텍스트 내용 및 크기를 변경할 수 있습니다.
- 실행 중 메모리 할당자로부터 메모리를 요청해야 합니다.
- String사용을 마쳤을 때 메모리를 해제할 (즉, 할당자에게 메모리를 반납할) 방법이 필요합니다.
첫 번째의 경우 String::from 호출 시, 필요한 만큼 메모리를 요청하도록 구현되어져 있습니다.
두 번째의 경우 가비지 컬렉터(GC : Garbage Collector)를 갖는 언어에서는 GC가 사용하지 않는 메모리를 찾아 없애줘서 프로그래머가 신경쓸 필요가 없습니다.
GC가 없는 언어에서는 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 합니다.
이 부분의 경우, 프로그래머가 놓친 부분이 있으면 메모리 낭비가 발생하고, 메모리 해제 시점을 너무 일찍 잡으면 유효하지 않은 변수가 생깁니다.
두 번 해제할 경우에도 마찬가지로 버그가 발생합니다.
따라서, allocate(할당)과 free(해제)가 하나씩 짝짓도록 만들어야 합니다.
rust에서는 이러한 문제를 변수가 자신이 소속된 스코프를 벗어나느 순간 자동으로 메모리를 해제하는 방식으로 해결했습니다.
{
let s = String::from("hello"); // s는 이 지점부터 유효합니다
// s를 가지고 무언가 합니다
} // 이 스코프가 종료되었고, s는 더 이상
// 유효하지 않습니다.
s가 스코프 밖으로 벗어날 때, rust는 drop이라는 특별한 함수를 호출하여 메모리를 자연스럽게 해제합니다.
이 패턴은 rust 코드 작성하는 데 깊은 영향을 미칩니다.
힙 영역을 사용하는 변수가 많아져서 상황이 복잡해지면 코드가 예기치 못한 방향으로 동작할 수 있습니다.
다음은 그런 복잡한 상황들에 대하여 설명하도록 하겠습니다.
변수와 데이터 간 상화작용 방식 : 이동
fn main() {
let num1 = 5;
let num2 = num1;
println!("{} {}", num1, num2);
}
rust에서 다음과 같이 작성된 경우, 다른 언어들과 마찬가지로 num1에 5, num2에 5가 주어지고, 두 값 모두 스택에 푸시됩니다.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{} World", s1);
}
다음과 같은 경우에는 어떻게 될까요?
이전 방식과 매우 비슷해서 다른 언어들처럼 s1, s2 모두 같은 값을 가지고 있을 것이라고 생각할 수 있습니다.
그러나, 해당 코드는 다르게 동작합니다.
String은 그림의 좌측에 표시된 것처럼 문자열 내용이 들어 있는 메모리를 가리키는 포인터, 문자열 길이, 메모리 용량 으로 이루어져 있습니다.
여기서 s2에 s1을 대입하면 String데이터가 복사됩니다.
이 떄, 데이터는 스택에 있는 데이터(포인터, 길이, 용량)를 말하며, 포인터가 가리키는 힙 영역의 데이터는 복사되지 않습니다.
rust가 다음과 같이 힙 메모리의 데이터까지 복사하여 동작한다면, 힙 내 데이터가 커질수록 s2 = s1연산은 매우 느려질 겁니다.
이전 내용중에, rust는 스코프 밖으로 벗어날 때, 자동으로 drop함수를 호출하여 해당 변수가 사용하는 힙 메모리르 제거한다는 내용이 있었습니다.
s1, s2이 스코프 밖으로 벗어날 경우, 메모리 해제를 진행하게 된다면 중복 해제(double free) 에러가 발생할 겁니다.
이는 메모리 안정성 버그 중 하나이며, 보안을 취약하게 만드는 메모리 손상의 원인입니다.
메모리 안전성을 보장하기 위하여, rust는 let s2 = s1; 라인 뒤로는 s1이 더 이상 유효하지 않다고 판단합니다.
위의 코드를 실행시킬 경우, 다음과 같은 에러가 발생합니다.
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` due to previous error
기존에 얕은 복사(shallow copy), 깊은 복사(deep copy)라는 용어를 들어보셨지만, rust에서는 기존의 변수를 무효화하기 때문에 이를 얕은 복사가 아닌 이동(move)이라고 하며, 앞선 코드에서는 s1이 s2로 이동되었다고 표현합니다.
이로써, 스코프를 벗어날 때, s2만 유효하여 본인만 메모리를 해제하고, 중복 해제문제는 해결이 됩니다.
변수와 데이터 간 상호작용 방식 : 클론
그러면 s1의 값으 s2에 복사하고 싶은 경우에는 어떻게 해야할 까요?
.clone() 메소드를 사용하여 가능합니다.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
이 코드의 실행 결과는 힙 데이터까지 복사됐을 때의 메모리 구조는 다음과 같습니다.
스택에만 저장되는 데이터 : 복사
fn main() {
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
}
해당 코드에서 x는 계속 유효함며 y로 이동되지도 않았습니다.
이유는 정수형 등 컴파일 타임에 크기가 고정되는 타입은 모두 스택에 저장되기 때문입니다.
스택에 저장되니, 복사본을 빠르게 만들 수 있어 y를 생성하고 나면 x를 무효화할 필요가 없습니다.
rust에는 정수형처럼 스택에 저장되는 타입에 달아 놓을 수 있는 Copy 트레이트가 있습니다.
만약, 어떤 타입에 이 Copy 트레이트가 구현되어 있다면, 이 타입의 변수는 사용되어도 이동되지 않고 복사되어 대입 연산 후에도 사용할 수 있습니다.
하지만, 구현하려는 타입이나, 구현하려는 타입 중 일부분에 Drop 트레이트가 구현된 경우에는 Copy 트레이트를 어노테이션(annotation) 할 수 없습니다.
스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 어노테이션을 추가하면 컴파일 에러가 발생합니다.
Copy가 가능한 타입은 다음과 같습니다.
- 모든 정수형 타입 (예 : u32)
- true, false 값을 갖는 논리 자료형 bool
- 모든 부동 소수점 타입 (예 : f64)
- 문자 타입 char
- Copy 가능한 타입만으로 구성된 튜플 (예 : (i32, i32))
소유권과 함수
fn main() {
let s = String::from("hello");
takes_ownership(s);
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
}
다음과 같은 경우에서, rust는 함수를 호출한 다음에, 함수에 인자로 전달한 값을 사용하려는 경우, 컴파일 타임 에러를 발생시킵니다.
반환 값과 스코프
함수에 넘겨줄 값을 함수 호출이후에도 쓰고 싶은 경우에는 어떻게 해야할 까요?
튜플을 사용하여 여러 값을 반환하는 방법으로 가능합니다.
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
하지만, 이런 식으로 하는 경우, 너무 거추장스럽고 많은 작업량이 수반됩니다.
다행히, rust에는 소유권 이동 없이 값을 사용할 수 있는 참조자(reference)라는 기능을 가지고 있습니다.
정리
- 스택에 저장되는 값의 경우, 다른 언어들과 마찬가지로 대입 연산이 가능하다.
- 힙에 저장되는 객체형태의 타입의 경우, 기본적으로 대입이 아닌 복사의 형태로 이루어진다.
- 힙에 저장되는 타입을 함수에 값을 전달한 이후의 변수는 사용이 불가능하다.
- 함수에 전달한 값을 다시 사용하고 싶은 경우, tuple을 활용하여 다시 전달받는 방식으로도 가능하다.
'Rust' 카테고리의 다른 글
Rust 설치부터 실행까지 (슬라이스) - 10 (0) | 2025.01.06 |
---|---|
Rust 설치부터 실행까지 (참조자) - 9 (0) | 2025.01.05 |
Rust 설치부터 실행까지 (주석, 조건 반복문) - 7 (0) | 2024.12.30 |
Rust 설치부터 실행까지 (함수, 구문, 표현식) - 6 (0) | 2024.12.28 |
Rust 설치부터 실행까지 (데이터 타입, 튜플, 어레이) - 5 (0) | 2024.12.28 |
댓글