소유권 - 값의 주인은 하나
이 장에서 배우는 것
앞 장에서는 if와 loop가 값을 돌려주는 식이라는 점을 확인했다. 이번 장에서는 Rust를 다른 언어와 가장 크게 갈라놓는 규칙인 소유권(ownership)을 다룬다. 가계부 도구가 지출 항목의 분류 이름이나 메모 같은 문자열을 여러 함수에 넘기기 시작하면, 값을 넘긴 뒤에 그 값을 다시 쓸 수 있는지가 곧바로 문제가 된다. 이 장은 그 문제를 메모리 배치부터 차근차근 풀어 간다.
- 스택(stack)과 힙(heap)에 값이 어떻게 놓이는지 설명하고,
String한 개가 두 곳에 걸쳐 있음을 그림으로 말할 수 있다. - 대입과 함수 호출에서 값이 이동(move)하는 경우와 복사(copy)되는 경우를 타입만 보고 구분할 수 있다.
clone으로 값을 복제해야 하는 때와 그러지 않아도 되는 때를 가릴 수 있다.- 값이 정리(drop)되는 시점을 코드 위에서 짚고, 여러 값이 정리되는 순서를 예측할 수 있다.
- 함수 인자와 반환값을 통해 소유권이 오가는 흐름을 읽고 컴파일 오류의 원인을 찾을 수 있다.
문제 상황
가계부 도구를 만드는 중이라고 하자. 사용자가 "식비"라는 분류로 지출을 입력하면, 프로그램은 그 분류 이름을 문자열로 들고 있다가 화면에 찍고, 집계 함수에 넘기고, 나중에 다시 화면에 찍는다. 가비지 컬렉터가 있는 언어에서는 이 흐름이 아무 문제 없이 돌아간다. 문자열을 가리키는 이름이 몇 개든 런타임이 알아서 마지막 사용을 추적하기 때문이다.
Rust에는 그런 런타임 추적이 없다. 대신 컴파일러가 "이 문자열을 정리할 책임이 누구에게 있는가"를 코드만 보고 결정한다. 그래서 다음처럼 다른 언어에서는 자연스러운 코드가 컴파일되지 않는다.
fn main() {
let category = String::from("식비");
let saved = category;
println!("{category}");
}
컴파일러는 error[E0382]: borrow of moved value: `category`라고 답한다. 요지는 "category의 값은 let saved = category;에서 이미 옮겨졌으니 그 뒤에 쓸 수 없다"는 것이다. 이 오류가 왜 생기는지 이해하면 이 장의 내용을 거의 다 이해한 셈이다. 그 이유는 값이 메모리 어디에 있는지에서 출발한다.
스택과 힙, 그리고 String
스택: 크기가 정해진 값의 자리
함수가 호출되면 그 함수의 지역 변수들이 놓일 자리가 스택에 한꺼번에 잡힌다. 스택은 함수가 끝나면 그 자리를 통째로 거둔다. 자리를 잡고 거두는 일이 매우 단순해서 빠르지만, 대신 컴파일할 때 크기가 정해진 값만 둘 수 있다. i64는 항상 8바이트이고 bool은 1바이트이므로 스택에 놓인다.
힙: 실행 중에 크기가 정해지는 값의 자리
사용자가 입력하는 메모는 길이를 미리 알 수 없다. 이런 값은 실행 중에 운영체제와 할당기에서 원하는 크기만큼 빌려 온 영역, 곧 힙에 둔다. 힙의 영역은 얼마나 오래 쓸지 정해져 있지 않으므로 다 쓴 뒤 반드시 돌려주어야 한다. 돌려주는 일을 잊으면 메모리가 새고, 두 번 돌려주면 프로그램이 망가진다. Rust의 소유권 규칙은 이 "정확히 한 번 돌려주기"를 컴파일러가 보장하게 만드는 장치다.
String 한 개는 두 곳에 걸쳐 있다
String::from("식비")로 만든 문자열은 두 부분으로 이루어진다. 스택에는 세 칸짜리 묶음이 있다. 힙 데이터의 주소를 담은 포인터(ptr), 현재 채워진 바이트 수(len), 확보한 바이트 수인 용량(capacity, cap)이다. 실제 글자 바이트는 힙에 있다. "식"과 "비"는 UTF-8에서 각각 3바이트이므로 len은 글자 수 2가 아니라 6이다.
이 구조에서 중요한 점은 스택의 세 칸이 힙 영역을 "가리키기만" 한다는 것이다. 이 묶음을 그대로 복사해서 변수 두 개가 같은 주소를 갖게 되면 어떻게 될까. 두 변수가 각자 범위를 벗어날 때 둘 다 힙 영역을 돌려주려 할 것이다. 같은 영역을 두 번 돌려주는 이중 해제가 생기는 것이다. Rust는 이 상황을 애초에 만들지 않는다.
이동, 복사, 복제
소유권의 세 가지 규칙
Rust의 소유권은 다음 세 문장으로 줄일 수 있다.
- 모든 값에는 그 값을 소유하는 변수가 하나 있다.
- 소유자는 한 시점에 하나뿐이다.
- 소유자가 범위를 벗어나면 값은 정리된다.
제목의 "값의 주인은 하나"는 두 번째 규칙을 가리킨다. 나머지 내용은 이 규칙에서 따라 나온다.
이동: 소유자가 바뀐다
let saved = category;가 실행되면 스택의 ptr·len·cap 세 칸이 saved로 복사된다. 힙의 글자 바이트는 건드리지 않는다. 그 대신 컴파일러는 category를 "값을 잃은 변수"로 취급하고 이후의 사용을 막는다. 소유자가 category에서 saved로 옮겨 갔으므로 이를 이동이라고 부른다.
이동은 힙 데이터를 복사하지 않으므로 값이 아무리 커도 비용이 스택 세 칸 분량으로 일정하다. 그리고 소유자가 항상 하나이므로 범위가 끝날 때 정리할 대상도 하나다.
복사: 스택만으로 끝나는 값
정수는 사정이 다르다. let copied = income;에서 income의 8바이트를 복사해도 힙을 가리키는 주소가 없으므로 이중 해제 문제가 없다. 이런 타입은 Copy라는 표지를 달고 있다. 표지의 정체인 트레이트(trait)는 뒤에서 자세히 다루고, 지금은 "이 타입은 대입해도 원본이 그대로 유효하다는 약속"이라고만 이해하면 된다.
| 타입 | 대입 방식 | 대입 뒤 원본 | 힙 사용 |
|---|---|---|---|
i64, u32, f64 | 복사 | 사용 가능 | 없음 |
bool, char | 복사 | 사용 가능 | 없음 |
Copy 타입만 담은 튜플 (i64, bool) | 복사 | 사용 가능 | 없음 |
String | 이동 | 사용 불가 | 있음 |
튜플은 안에 든 값이 모두 Copy일 때만 Copy다. (i64, String)은 String 때문에 이동한다.
복제: 힙까지 새로 만든다
이동 뒤에도 원본이 필요하면 clone 메서드로 힙 데이터까지 깊게 복제한다. let backup = original.clone();이 실행되면 힙에 같은 글자를 가진 새 영역이 생기고 backup이 그 영역의 소유자가 된다. 두 변수는 서로 다른 영역을 소유하므로 둘 다 계속 쓸 수 있다. 이동과 달리 복제는 메모리 할당과 바이트 복사가 일어나는 비용이 드는 연산이다. 그래서 Rust는 복제를 암묵적으로 하지 않고 clone()이라는 눈에 띄는 호출로 쓰게 한다.
정리 시점과 함수 경계
범위가 끝나면 정리된다
소유자가 중괄호 { }로 만든 범위를 벗어나면 Rust가 값을 정리(drop)하고 힙 영역을 돌려준다. 코드에 정리 호출을 쓸 필요가 없다. 한 범위 안에 소유자가 여러 개면 나중에 선언한 변수부터 거꾸로 정리된다. 이동으로 소유권을 잃은 변수는 정리 대상에서 빠진다. 값이 이미 다른 소유자에게 갔기 때문이다.
문자열의 정리는 눈에 보이지 않으므로, 이 장의 예제는 정리되는 순간에 메시지를 찍는 관찰용 타입 Note를 하나 쓴다. Drop이라는 트레이트를 구현하면 정리 직전에 우리가 쓴 코드가 실행된다. 구조체 문법과 트레이트 구현의 자세한 내용은 뒤에서 다루므로, 여기서는 "정리되는 순간을 볼 수 있게 해 주는 도구" 정도로 읽으면 된다.
함수 인자는 이동이다
함수를 호출하며 값을 넘기는 것은 그 인자 변수에 대입하는 것과 같다. String을 넘기면 소유권이 함수의 매개변수로 이동한다. 그러면 그 값의 정리 책임도 함수로 넘어가서, 함수가 끝나는 시점에 값이 정리된다. 호출한 쪽에서는 넘긴 변수를 더 쓸 수 없다. 반면 i64를 넘기면 복사이므로 호출한 뒤에도 원본이 유효하다.
반환은 소유권을 돌려주는 길이다
함수가 값을 반환하면 소유권이 호출한 쪽으로 이동한다. 그래서 "받은 값을 잠깐 쓰고 다시 돌려주는" 함수를 만들 수 있다. 이 장의 describe 함수는 분류 이름의 길이를 계산한 뒤 문자열 자체를 길이와 함께 튜플로 돌려준다. 호출한 쪽은 이 튜플을 풀어 문자열의 소유권을 되찾는다. 매번 값을 돌려받는 방식은 번거롭다. 이 번거로움을 없애는 빌림은 다음 장의 주제다.
| 코드 모양 | 소유권의 움직임 | 원본 변수 |
|---|---|---|
let b = a; (String) | a에서 b로 이동 | 사용 불가 |
let b = a; (i64) | 복사, 소유자 둘 | 사용 가능 |
let b = a.clone(); | 새 힙 영역의 소유자가 b | 사용 가능 |
f(a) (String) | a에서 매개변수로 이동 | 사용 불가 |
let b = f(); | 함수에서 b로 이동 | 해당 없음 |
완성 코드
아래는 지금까지의 내용을 한 프로그램에 모은 것이다. cargo new ledger_ownership으로 프로젝트를 만들고 src/main.rs를 다음 내용으로 바꾼다. 외부 크레이트는 쓰지 않으며 경고 없이 컴파일된다.
struct Note {
label: String,
}
impl Drop for Note {
fn drop(&mut self) {
println!(" [drop] {} 해제", self.label);
}
}
fn make_note(label: String) -> Note {
Note { label }
}
fn consume(note: Note) {
println!(" consume 안: {}", note.label);
}
fn total_with_fee(amount: i64) -> i64 {
amount + 150
}
fn describe(category: String) -> (String, usize) {
let len = category.len();
(category, len)
}
fn main() {
let income: i64 = 3_000_000;
let copied = income;
println!("1) 수입 {income}, 복사본 {copied}");
println!(" 수수료 포함 {}", total_with_fee(income));
println!(" 호출 뒤에도 income = {income}");
let category = String::from("식비");
let moved = category;
println!("2) moved = {moved}");
let original = String::from("교통");
let backup = original.clone();
println!("3) original = {original}, backup = {backup}");
let (returned, len) = describe(moved);
println!("4) {returned}의 바이트 길이 = {len}");
println!("5) drop 시점");
let first = make_note(String::from("첫째"));
{
let inner = make_note(String::from("안쪽"));
println!(" 안쪽 블록 안: {}", inner.label);
}
println!(" 안쪽 블록 뒤");
let second = make_note(String::from("둘째"));
consume(first);
println!(" consume 호출 뒤");
drop(second);
println!(" drop(second) 호출 뒤");
let third = make_note(String::from("셋째"));
let fourth = make_note(String::from("넷째"));
println!("6) main 끝, third={}, fourth={}", third.label, fourth.label);
}
줄별 해설
맨 위의 Note와 impl Drop은 관찰용 도구다. Note는 문자열 하나를 소유하고, 정리될 때 drop 메서드가 호출되어 라벨을 찍는다. 이 메서드를 우리가 직접 부르는 일은 없다. 정리 시점이 오면 Rust가 대신 부른다. make_note는 String을 받아 Note 안에 넣어 돌려주므로 인자로는 이동이, 반환으로는 소유권 전달이 일어난다.
consume은 Note를 값으로 받는다. 본문에서 라벨을 찍기만 하고 아무것도 돌려주지 않으므로, 함수가 끝나는 닫는 중괄호에서 note가 정리된다.
total_with_fee는 i64를 받는다. 정수는 복사되므로 호출한 쪽의 income은 영향을 받지 않는다. 출력 1)의 세 줄이 이를 확인한다.
describe는 category.len()으로 바이트 길이를 구한 뒤 (category, len)을 반환한다. 튜플을 만들 때 category가 튜플 안으로 이동하고, 그 튜플이 호출한 쪽으로 이동한다. "식비"는 6바이트이므로 길이는 6이다.
main의 1): let copied = income;은 복사다. 이후 income을 계속 써도 오류가 없다.
main의 2): let moved = category;는 이동이다. 이 뒤에서는 category를 쓰지 않는다. 쓰면 앞의 문제 상황에서 본 오류가 난다.
main의 3): original.clone()이 힙에 새 영역을 만들어 backup에 준다. 두 변수를 모두 출력할 수 있다.
main의 4): describe(moved)로 소유권이 함수에 넘어갔다가 튜플로 돌아온다. let (returned, len) = ...은 튜플을 두 변수로 풀어 받는 문법이다. 이제 문자열의 소유자는 returned다.
main의 5): 정리 시점 실험이다. first를 만들고, 안쪽 블록에서 inner를 만든다. 닫는 중괄호를 지나는 순간 inner가 정리되므로 "안쪽 블록 뒤"보다 먼저 [drop] 안쪽 해제가 찍힌다. 이어서 second를 만들고, consume(first)로 first를 넘긴다. 정리는 consume이 끝나는 곳에서 일어나므로 "consume 호출 뒤"보다 앞에 찍힌다. drop(second)는 표준 라이브러리 함수로, 값을 인자로 받아 이동시킨 뒤 곧바로 함수를 끝내 정리한다. 범위가 끝나기 전에 값을 정리하고 싶을 때 쓴다.
main의 6): third와 fourth는 main이 끝날 때까지 산다. 함수가 끝나면 나중에 선언한 fourth가 먼저, third가 다음으로 정리된다. first와 second는 이미 소유권을 잃었으므로 다시 정리되지 않는다. 문자열 변수들도 함께 정리되지만 메시지가 없어서 보이지 않는다.
실행 결과
프로젝트 폴더에서 다음 명령을 실행한다. 빌드 진행 메시지를 빼기 위해 --quiet를 붙였다.
$ cargo run --quiet
1) 수입 3000000, 복사본 3000000
수수료 포함 3000150
호출 뒤에도 income = 3000000
2) moved = 식비
3) original = 교통, backup = 교통
4) 식비의 바이트 길이 = 6
5) drop 시점
안쪽 블록 안: 안쪽
[drop] 안쪽 해제
안쪽 블록 뒤
consume 안: 첫째
[drop] 첫째 해제
consume 호출 뒤
[drop] 둘째 해제
drop(second) 호출 뒤
6) main 끝, third=셋째, fourth=넷째
[drop] 넷째 해제
[drop] 셋째 해제
실무에서 자주 틀리는 것
이동한 변수를 다시 쓴다
가장 흔한 실수다. 다른 변수에 대입하거나 함수에 넘긴 문자열을 그 뒤에 또 쓴다.
fn main() {
let memo = String::from("정기 구독");
let archived = memo;
println!("{memo} / {archived}");
}
오류 요지는 error[E0382]: borrow of moved value: `memo`이다. 컴파일러는 memo가 String 타입이라 Copy를 구현하지 않으므로 대입에서 이동했다고 설명하고, 복제를 고려하라고 제안한다. 두 변수를 모두 써야 한다면 복제한다.
fn main() {
let memo = String::from("정기 구독");
let archived = memo.clone();
println!("{memo} / {archived}");
}
이후 어느 쪽도 쓰지 않는다면 복제할 필요가 없으므로, 먼저 "정말 둘 다 필요한가"를 따져 보는 것이 좋다.
함수에 넘긴 값을 호출 뒤에 또 쓴다
함수 인자도 이동이라는 사실을 놓치면 같은 오류를 만난다.
fn show(text: String) {
println!("{text}");
}
fn main() {
let memo = String::from("월세");
show(memo);
show(memo);
}
두 번째 호출에서 error[E0382]: use of moved value: `memo`가 난다. 첫 호출에서 값이 이동했다는 표시가 함께 나온다. 지금 배운 도구로 고치려면 함수가 값을 돌려주게 만든다.
fn show(text: String) -> String {
println!("{text}");
text
}
fn main() {
let memo = String::from("월세");
let memo = show(memo);
let memo = show(memo);
println!("끝: {memo}");
}
같은 이름으로 다시 선언하는 것은 앞 장에서 본 가려짐(shadowing)이다. 이 방식은 동작하지만 번거롭다. 다음 장의 빌림을 배우면 값을 넘기지 않고 읽기만 하는 함수를 훨씬 간단하게 쓸 수 있다.
반복문 안에서 값을 넘긴다
반복문은 같은 코드를 여러 번 실행하므로, 안에서 이동시키면 두 번째 반복에서 이미 없는 값을 쓰게 된다.
fn show(text: String) {
println!("{text}");
}
fn main() {
let memo = String::from("교통비");
for _ in 0..3 {
show(memo);
}
}
오류는 error[E0382]: use of moved value: `memo`이고, "value moved here, in previous iteration of loop"라는 설명이 붙는다. 반복마다 새 복제본을 넘기면 해결된다.
fn show(text: String) {
println!("{text}");
}
fn main() {
let memo = String::from("교통비");
for _ in 0..3 {
show(memo.clone());
}
}
이 경우 복제는 반복마다 힙 할당을 일으키므로, 성능이 중요한 곳에서는 역시 다음 장의 빌림이 더 알맞은 답이다.
한눈에 보기
| 개념 | 한 줄 설명 | 기억할 점 |
|---|---|---|
| 스택 | 크기가 정해진 값이 놓이는 자리 | 함수가 끝나면 통째로 거둔다 |
| 힙 | 실행 중 크기가 정해지는 값의 자리 | 돌려주는 책임이 소유자에게 있다 |
| 소유권 | 값마다 소유자 변수가 하나 | 소유자가 범위를 벗어나면 정리된다 |
| 이동 | 대입·인자 전달로 소유자가 바뀜 | 원본 변수는 이후 사용 불가 |
| Copy | 스택만으로 끝나는 타입의 복사 | 정수·bool·char 등은 원본이 유효 |
| clone | 힙까지 새로 만드는 복제 | 비용이 드니 필요할 때만 쓴다 |
| drop | 범위 끝에서 자동 정리 | 선언의 역순, 이동된 변수는 제외 |
연습 문제
- 다음 코드에서 컴파일 오류가 나는 곳은 어디인가. 오류가 나지 않는 변수가 있다면 그 이유도 설명하라.
let price = 4_500; let copy_price = price; let name = String::from("커피"); let other = name; println!("{price} {copy_price} {name} {other}"); - 두 분류 이름
String두 개를 받아 바이트 길이가 더 긴 쪽을 돌려주는 함수longer를 작성하라. 길이가 같으면 첫 번째를 돌려준다. 돌려주지 않은 쪽 문자열은 어떻게 되는지도 설명하라. - 이 장의
Note와make_note가 있다고 할 때, 다음 코드가 출력하는 내용을 순서대로 적어라.let a = make_note(String::from("A")); let b = make_note(String::from("B")); let c = a; println!("끝"); - 다음 코드가 컴파일되도록 두 가지 방법으로 고쳐라. 첫째는
clone을 쓰는 방법이고, 둘째는 함수가 값을 돌려주게 하는 방법이다.fn print_memo(text: String) { println!("{text}"); } fn main() { let memo = String::from("정기 구독"); for _ in 0..3 { print_memo(memo); } }
정답과 해설
1번. 오류는 println!에서 {name}을 쓰는 곳에서 난다. name의 값이 let other = name;에서 other로 이동했기 때문이다. price는 i64로 추론되는 정수이며 Copy 타입이라 copy_price에 복사될 뿐 원본이 유효하다. {other}는 현재 소유자이므로 문제없다. {name}만 지우면 컴파일된다.
2번. 다음처럼 쓸 수 있다.
fn longer(a: String, b: String) -> String {
if a.len() >= b.len() { a } else { b }
}
if는 값을 돌려주는 식이므로 각 갈래가 a 또는 b의 소유권을 그대로 함수의 반환값으로 내보낸다. 반환되지 않은 쪽 문자열은 소유자가 함수 안의 매개변수로 남으므로 함수가 끝날 때 정리된다. 호출한 쪽에서 넘긴 두 변수는 모두 이동했으니 쓸 수 없고, 반환값만 새 소유자가 된다.
3번. 출력은 다음 세 줄이다.
끝
[drop] A 해제
[drop] B 해제
let c = a;로 A 노트의 소유자가 c가 되고 a는 정리 대상에서 빠진다. 범위가 끝날 때는 나중에 선언한 변수부터 정리되므로 c(값 A), b(값 B), a(이미 이동, 없음) 순이 된다. 변수 이름 순서가 아니라 값의 소유자를 따라가야 정답이 나온다.
4번. 첫째 방법은 호출을 print_memo(memo.clone());으로 바꾸는 것이다. 반복마다 복제본이 만들어져 함수로 이동하고 거기서 정리되며, 원본 memo는 계속 유효하다. 둘째 방법은 함수와 호출부를 다음처럼 바꾸는 것이다.
fn print_memo(text: String) -> String {
println!("{text}");
text
}
fn main() {
let mut memo = String::from("정기 구독");
for _ in 0..3 {
memo = print_memo(memo);
}
}
여기서는 반복마다 값이 함수로 들어갔다가 반환되어 memo에 다시 대입된다. 이동한 값을 같은 변수에 다시 채우므로 다음 반복에서도 유효하다. 다시 대입하려면 변수가 mut여야 한다. 복제는 할당 비용이 들고, 반환 방식은 코드가 길어진다는 차이가 있다. 다음 장에서 배울 빌림을 쓰면 두 문제가 모두 사라진다.