Rust 기초 강좌 #3 소유권: move가 기본인 언어, GC 없이 메모리가 정리되는 원리
1편에서 Rust는 메모리 해제 시점을 컴파일 타임에 확정한다고 했습니다. 이번 편이 그 실체인 소유권(ownership)입니다. Rust 학습에서 가장 자주 막히는 고비지만, 규칙 자체는 세 줄이고 나머지는 그 규칙이 대입, 함수 호출, 스코프에서 어떻게 작동하는지의 문제입니다. 여기를 넘으면 이후 편은 오히려 평탄해집니다.
세 규칙 #
- 모든 값은 소유자(owner)라는 변수를 갖습니다.
- 소유자는 한 시점에 하나뿐입니다.
- 소유자가 스코프를 벗어나면 값은 해제(drop)됩니다.
규칙 3부터 보겠습니다. GC 언어는 “이 값을 아직 누가 쓰나"를 런타임에 추적해서 안 쓰는 값을 회수합니다. Rust는 추적할 필요가 없습니다. 소유자가 하나뿐이므로, 소유자가 스코프를 벗어나는 지점이 곧 해제 지점입니다. 컴파일러는 그 지점에 해제 코드를 미리 심어 둡니다. 런타임 추적 비용이 없고, 해제 시점이 코드만 보고 예측됩니다.
{
let log = String::from("요청 처리 시작");
// log 사용
} // 스코프 끝. 여기서 log의 힙 메모리가 해제된다
그렇다면 값을 대입하거나 함수에 넘기면 어떻게 될까요? 소유자가 둘이 되면 규칙 2가 깨지고, 같은 메모리를 두 번 해제하게 됩니다. 여기서 Rust의 기본 동작인 이동(move)이 나옵니다.
move: 대입은 복사가 아니라 이동 #
let s1 = String::from("hello");
let s2 = s1; // 소유권이 s2로 이동
println!("{s1}"); // 컴파일 에러
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:4:15
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`,
| which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 | println!("{s1}");
| ^^^^ value borrowed here after moveString은 실제 문자 데이터를 힙에 두고, 변수에는 그 위치를 가리키는 정보만 담습니다. let s2 = s1에서 복사되는 것은 이 가리키는 정보뿐이고, 힙 데이터는 하나 그대로입니다. 파이썬이나 자바스크립트라면 여기서 두 변수가 같은 객체를 공유하고 GC가 뒤처리를 맡습니다. Rust는 대신 소유권을 s2로 옮기고 s1을 무효화합니다. 소유자는 여전히 하나이므로 이중 해제도, 해제된 값 사용도 원천적으로 불가능해집니다. C++에서 수십 년간 사고를 낸 use-after-free가 Rust에서는 위와 같은 컴파일 에러 한 장으로 끝나는 것입니다.
에러 메시지의 value borrowed here after move는 Rust 입문자가 가장 많이 만나는 문장입니다. “이동한 뒤에 썼다"는 뜻이고, 해결 방법은 이 편과 다음 편에 걸쳐 나옵니다.
Copy: 스택 고정 크기 타입의 예외 #
let a = 5;
let b = a;
println!("{a} {b}"); // 문제없음. 5 5
정수, 부동소수점, bool, char, 그리고 이런 타입만 담은 튜플은 이동하지 않고 복사됩니다. 크기가 작고 컴파일 타임에 확정된 스택 값이라서 복사 비용이 사실상 0이고, 힙 소유 문제가 없기 때문입니다. 이런 타입은 Copy 트레이트를 구현하고 있다고 말하는데, 위 에러 메시지에도 which does not implement the Copy trait라는 단서로 등장했습니다. 트레이트 자체는 8편에서 다루고, 지금은 판단 기준만 기억하면 됩니다. 힙을 소유하면 move, 스택에 다 들어가면 Copy입니다.
clone: 비용이 보이는 복사 #
힙 데이터까지 통째로 복사하고 싶으면 명시적으로 요청합니다.
let s1 = String::from("hello");
let s2 = s1.clone(); // 힙 데이터까지 복사
println!("{s1} {s2}"); // 둘 다 유효
GC 언어에서는 복사인지 공유인지가 코드에 드러나지 않지만, Rust에서는 힙 복사가 일어나는 모든 지점에 .clone()이 찍혀 있습니다. 성능을 추적할 때 복사 비용을 grep 한 번으로 찾을 수 있다는 뜻입니다. 입문 단계에서는 컴파일 에러를 .clone()으로 넘기는 것도 정당한 전략입니다. 일단 동작하게 만들고, 다음 편의 빌림으로 줄여 가면 됩니다.
함수 경계에서도 같은 규칙 #
fn print_report(report: String) {
println!("{report}");
} // report가 스코프를 벗어나며 해제
let summary = String::from("월간 요약");
print_report(summary); // 소유권이 함수로 이동
println!("{summary}"); // 컴파일 에러: value borrowed here after move
함수에 값을 넘기는 것도 대입과 똑같이 move입니다. summary의 소유권은 print_report로 넘어갔고, 함수가 끝나는 순간 값도 해제됐습니다. 그런데 이대로면 “값을 읽기만 하는 함수"를 부를 때마다 소유권을 뺏기고, 계속 쓰려면 반환으로 돌려받는 번거로운 코드가 됩니다.
fn print_report(report: String) -> String {
println!("{report}");
report // 소유권을 도로 반환
}
let summary = print_report(summary); // 돌려받기
동작은 하지만 매번 이럴 수는 없습니다. “소유권은 그대로 두고 잠깐 읽기만 빌리는” 방법이 필요한데, 그것이 다음 편의 참조와 빌림(borrowing)입니다.
정리 #
- 소유권 규칙은 세 줄입니다. 값마다 소유자는 하나, 소유자가 스코프를 벗어나면 해제. 이것으로 GC 없이 해제 시점이 컴파일 타임에 확정됩니다.
- 힙을 소유한 타입의 대입과 함수 전달은 move입니다. 원래 변수는 무효화되고, 이후 사용은
value borrowed here after move컴파일 에러가 됩니다. - 정수 같은 스택 고정 크기 타입은
Copy라서 자유롭게 복사됩니다. 힙이면 move, 스택이면 Copy가 판단 기준입니다. - 힙 복사는
.clone()으로만 일어나므로 복사 비용이 코드에 드러납니다. 입문 단계에서는 clone으로 넘기고 나중에 줄이는 것도 전략입니다. - 다음 편은 소유권을 옮기지 않고 값을 사용하는 참조와 빌림입니다. 입문자가 가장 많이 부딪히는 빌림 규칙 에러를 정면으로 다루겠습니다.