핵심 ② 소유권·빌림·수명 — borrow checker와 싸우는 구간
Rust의 전부. C++의 이동 시맨틱·RAII·스마트 포인터가 '조심해서 지켜야 했던 규칙'이었다면 Rust는 안 지키면 컴파일이 안 된다. 소유권(move가 기본), 빌림(&·&mut과 한 번에 하나 규칙), 수명(댕글링을 컴파일 타임에 거절)을 C++ 대응으로 잡고, borrow checker 에러를 '적이 아니라 선생'으로 읽는 법.
Rust 학습 로드맵의 ② 핵심(뼈대, 최우선) 단계입니다. 앞 글: ① 문법·불변성·cargo
Rust의 전부입니다. 학습 시간의 절반을 여기 쓸 각오로 들어갑니다. 여기서 막히면 이후 모든 게 막히고, 여기를 넘으면 나머지는 빠릅니다. 다행히 C++ 배경이 가장 크게 빛나는 구간이기도 합니다 — 소유권·빌림은 결국 C++의 이동 시맨틱·RAII·unique_ptr/shared_ptr·댕글링 포인터 문제를 컴파일러가 강제하는 버전이거든요. 소유권이 하나의 메모리 관리 모델로서 GC·수동 관리와 어디에 서 있는지는 메모리 관리 모델을 함께 보세요.
소유권 — move가 기본 동작
Rust의 규칙은 세 문장입니다.
- 모든 값에는 소유자(owner)가 하나 있다.
- 소유자가 스코프를 벗어나면 값은 자동 해제된다(RAII와 동일).
- 소유권은 이동(move)한다 — 대입·전달하면 원본은 무효화된다.
3번이 C++과 갈리는 핵심입니다. C++에서 std::move는 수동·선택이지만, Rust에서는 이동이 기본입니다.
1
2
3
4
let s1 = String::from("hello");
let s2 = s1; // 이동 — s1의 소유권이 s2로 넘어감
println!("{}", s1); // 컴파일 에러! s1은 이미 무효
// → borrow of moved value: `s1`
C++이라면 s2 = s1은 복사(깊은 복사)입니다. Rust는 기본이 이동이라 s1이 무효화됩니다. 힙 데이터를 조용히 복제하는 비용을 원천 차단하려는 설계입니다. 정말 복제가 필요하면 명시적으로 s1.clone()을 부릅니다.
i32처럼 스택에만 있는 작은 타입은Copy트레이트가 붙어 있어 이동 대신 복사됩니다. 그래서let a = 5; let b = a;뒤에a를 써도 됩니다. “힙을 가진 타입은 move, 값 타입은 copy”로 기억하면 됩니다.
빌림 — &와 &mut, 그리고 “한 번에 하나”
소유권을 넘기지 않고 잠깐 쓰고 싶을 때 빌립니다(borrow). &는 불변 참조, &mut은 가변 참조입니다. C++의 const T& / T&에 대응하지만, Rust는 규칙을 컴파일러가 강제합니다.
1
2
3
4
5
fn len(s: &String) -> usize { s.len() } // 빌리기만 — 소유권 안 가져감
let s = String::from("hi");
let n = len(&s); // &s로 빌려줌
println!("{} {}", s, n); // s는 여전히 유효 — 소유권을 안 뺏겼으니까
핵심 규칙 하나만 기억하면 됩니다.
불변 참조(
&)는 여러 개 동시에 가능하지만, 가변 참조(&mut)는 동시에 딱 하나. 그리고 가변 참조가 살아 있는 동안 불변 참조도 못 만든다.
1
2
3
4
5
let mut s = String::from("hi");
let r1 = &s; // 불변 빌림 OK
let r2 = &s; // 불변 빌림 여러 개 OK
let w = &mut s; // 컴파일 에러! 불변 참조들이 살아있는데 가변으로 빌림
// → cannot borrow `s` as mutable because it is also borrowed as immutable
이 규칙이 데이터 레이스를 컴파일 타임에 원천 차단합니다. “쓰는 쪽이 하나뿐이거나, 읽기만 여럿”이면 경합이 없다는 것이 데이터 레이스의 정의인데, Rust는 그 조건을 타입 시스템으로 못박아 둔 겁니다. C++에서 뮤텍스로 런타임에 지키던 걸 컴파일러가 대신 검사합니다.
수명(lifetime) — 댕글링을 컴파일 타임에 거절
참조는 원본보다 오래 살 수 없습니다. C++의 댕글링 포인터(해제된 메모리를 가리키는 참조, 런타임 UB)를 Rust는 컴파일 단계에서 거절합니다.
1
2
3
4
fn dangle() -> &String { // 컴파일 에러
let s = String::from("hi");
&s // s는 함수 끝에서 해제되는데 그 참조를 반환?
} // → missing lifetime specifier / returns a reference to local
대부분의 수명은 컴파일러가 자동 추론하므로 'a 문법을 직접 쓸 일은 초반엔 드뭅니다. 다만 참조를 여럿 받아 참조를 반환하는 함수처럼 컴파일러가 관계를 확신 못 할 때, fn longest<'a>(x: &'a str, y: &'a str) -> &'a str처럼 “반환값은 이 입력들만큼 산다”고 이름표를 달아 알려줍니다. 'a는 수명을 만드는 게 아니라 관계를 서술하는 주석이라는 점을 기억하세요.
borrow checker는 적이 아니라 선생이다
“borrow checker와 싸운다”는 바로 이 구간입니다. 가장 흔한 두 에러:
- use after move — 값을 옮긴 뒤 원본을 또 씀. → 정말 두 곳에서 써야 하면
.clone(), 아니면 빌림(&)으로 바꾸기. - mutable borrow 충돌 — 불변 참조가 살아 있는데 가변으로 빌림. → 참조의 스코프를 줄이거나(먼저 다 쓰고 나서 가변 빌림), 구조를 다시 보기.
여기서 태도가 학습 속도를 가릅니다. Rust 컴파일러 에러는 원인과 해결책을 함께 알려줍니다. moved here / borrow later used here로 문제가 생긴 두 지점을 짚어 주고, 종종 help: 줄에 고치는 법까지 제시합니다. 에러를 밀어내려 하지 말고 읽으면서 배우는 게 이 구간을 가장 빨리 넘는 길입니다. rustlings의 move_semantics·borrowing 연습을 반복하면 손에 붙습니다.
C++ 대응으로 정리
| C++ | Rust | 핵심 차이 |
|---|---|---|
std::move (수동·선택) | 이동(기본 동작) | 대입/전달이 곧 이동. 원본 무효화 |
| RAII 소멸자 | 소유권 drop | 같은 개념을 컴파일러가 강제 |
const T& | &T (불변 빌림) | 기본이 불변. 규칙을 컴파일러가 검사 |
T& (가변) | &mut T | “가변 참조는 동시에 하나” 강제 |
| 댕글링 포인터 (런타임 UB) | 수명(lifetime) | 컴파일 타임에 거절 |
| 복사 생성자 | .clone() (명시) | 복제는 반드시 눈에 보이게 |
자주 막히는 지점
- 이동을 복사로 착각 — C++ 습관으로
let b = a;뒤에a를 씀. 힙 타입이면a는 무효.Copy타입(정수 등)만 예외. .clone()남발 — 에러를 없애려 아무 데나clone()을 박으면 컴파일은 되지만 Rust를 안 쓰는 것과 같습니다. 먼저 빌림(&)으로 풀 수 있는지 보세요. 대부분 됩니다.- 참조를 저장해 두려는 설계 — struct 필드에 참조를 담으면 수명 주석이 번져 복잡해집니다. 초반엔 소유하는 값(
String,Vec)을 담는 게 훨씬 편합니다.
통과 기준
- use-after-move와 mutable borrow 충돌 에러를 스스로 진단하고 고칠 수 있다.
&(빌림)와 이동,.clone()(복제)을 언제 쓰는지 구분할 수 있다.- 여기까지 왔으면 Rust에서 가장 큰 산을 넘은 것입니다.
다음은 ③ struct·enum·match입니다. Rust의 타입 시스템, 특히 데이터를 품는 enum과 완전성을 강제하는 패턴 매칭으로 들어갑니다 — Option/Result가 여기서 나옵니다.
Reference
- The Rust Book Ch.4 — Understanding Ownership — 이 단계의 본체.
- rustlings — move_semantics / borrowing — 손으로 borrow checker 넘기기. 병행 필수.
- Rust by Example — Ownership and moves
- 메모리 관리 모델 — 소유권이 GC·수동 관리와 어디에 서 있는지