소유권이란 무엇인가
앞 챕터 끝에서 던진 질문부터 풀겠습니다. 다음 코드를 봅시다.
fn main() {
let numbers = vec![4, 8, 15, 16, 23, 42];
for n in numbers {
print!("{n} ");
}
println!("\n합계 확인: {:?}", numbers); // 여기서 컴파일 에러
}이 코드는 컴파일되지 않습니다. 에러 메시지는 대략 이렇습니다.
error[E0382]: borrow of moved value: `numbers`
--> src/main.rs:7:33
|
4 | for n in numbers {
| ------- value moved here
...
7 | println!("\n합계 확인: {:?}", numbers);
| ^^^^^^^ value borrowed here after movenumbers를 "빌린 게 아니라 옮겼다(moved)"고 합니다. 이 문장이 Rust 전체를 이해하는 첫 번째 열쇠입니다.
값에는 항상 소유자가 하나뿐이다
Rust의 규칙은 단순합니다.
- 모든 값은 정확히 하나의 변수가 소유한다.
- 소유자가 스코프를 벗어나면 값은 즉시(그리고 자동으로) drop된다.
- 소유권은 대입이나 함수 호출을 통해 다른 변수로 넘어갈 수 있다 — 이걸 move라고 부른다.
for n in numbers는 numbers의 소유권을 반복자에게 넘깁니다. 반복이 끝나면 그 반복자가 스코프를 벗어나면서 numbers가 가지고 있던 데이터도 함께 drop됩니다. 그래서 그 아래 줄에서 numbers를 다시 쓰려고 하면, 컴파일러 입장에서는 "이미 없어진 값을 쓰려고 한다"는 뜻이 됩니다.
C++를 아는 분이라면 소멸자(destructor)를 떠올릴 텐데, 정확히 그겁니다. Rust의 Drop은 C++ RAII의 직계 후손입니다. 다른 점은 Rust가 "소멸 이후 접근"을 컴파일 타임에 강제로 막는다는 것뿐입니다.
왜 대입할 때마다 옮겨지는가
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}"); // 컴파일 에러: value borrowed here after move
}String은 힙에 텍스트 데이터를 저장하고, 스택에는 포인터·길이·용량 세 필드만 가지고 있습니다. let s2 = s1;을 실행하면 이 세 필드가 s2로 복사됩니다. 만약 s1도 계속 유효하다면, 스코프가 끝날 때 s1과 s2 둘 다 같은 힙 메모리를 해제하려고 시도하게 됩니다 — 이걸 이중 해제(double free)라고 부르고, C++에서 흔히 겪는 버그입니다. Rust는 이 상황 자체를 막기 위해 s1을 무효화합니다. 값을 "복사"한 게 아니라 "옮긴" 겁니다.
flowchart LR A["let s1 = String::from(...)"] --> B["s1이 힙 데이터 소유"] B --> C["let s2 = s1;"] C --> D["소유권이 s2로 이동"] D --> E["s1은 이후 무효 처리"] E --> F["스코프 종료 시 s2만 drop"]
그러면 정수는 왜 멀쩡할까
fn main() {
let x = 5;
let y = x;
println!("x = {x}, y = {y}"); // 정상 동작
}이건 에러가 나지 않습니다. i32 같은 정수형은 크기가 고정되어 있고 스택에만 저장되기 때문에, "옮기는" 비용과 "복사하는" 비용이 사실상 같습니다. 그래서 Rust는 이런 타입에는 Copy 트레잇을 부여해서 move 대신 복사가 일어나게 합니다. 정수, 부동소수점, bool, char, 그리고 이들로만 이루어진 튜플이 여기 해당합니다. 반대로 String, Vec<T>, Box<T>처럼 힙 데이터를 가리키는 타입은 Copy가 아닙니다.
이 구분을 처음 마주치면 "왜 어떤 건 되고 어떤 건 안 되지"라는 생각이 듭니다. 기준은 하나입니다 — 복사 비용이 저렴하고 예측 가능한가. 스택에만 있는 고정 크기 데이터는 그렇고, 힙을 가리키는 데이터는 그렇지 않습니다.
함수에 값을 넘길 때도 똑같다
fn print_and_drop(s: String) {
println!("{s}");
} // 여기서 s가 drop됨
fn main() {
let text = String::from("소유권 이동");
print_and_drop(text);
// println!("{text}"); // 컴파일 에러 — text는 이미 옮겨짐
}함수 호출도 대입과 똑같이 취급됩니다. text의 소유권은 print_and_drop의 매개변수 s로 넘어가고, 함수가 끝나면 s가 drop되면서 데이터도 사라집니다. main에서 text를 계속 쓰고 싶었다면, 함수에 값을 넘기는 게 아니라 "잠깐 빌려주는" 방법이 필요합니다. 그게 다음 챕터의 주제인 참조(reference)입니다.
지금 단계에서 소유권 규칙이 과하게 엄격해 보인다면 정상적인 반응입니다. 대부분의 Rust 입문자가 여기서 처음 멈춥니다. 하지만 이 규칙이 있기 때문에 Rust는 가비지 컬렉터 없이도 이중 해제와 use-after-free가 원천적으로 불가능합니다. 다음 챕터에서 빌림을 배우고 나면, 매번 소유권을 옮기지 않고도 값을 여러 곳에서 쓰는 법이 보일 겁니다.