[CS300 #040] Rust 의 소유권과 빌림 — GC 없이 메모리 안전을 얻는 법
컴퓨터공학 300 주제 시리즈의 040번째 글이다. 전체 지도는 여기.
한 줄 요약
Rust 는 모든 값에 주인을 하나씩 정하고(소유권), 주인이 범위를 벗어나면 즉시 해제하며, 남이 값을 쓸 때는 “여럿이 읽기 또는 하나만 쓰기” 규칙의 참조(빌림)만 허용한다. 이 규칙을 컴파일러가 검사해 GC 없이 해제 후 사용·이중 해제·데이터 경쟁을 막는다.
왜 필요한가
#027 과 #028 에서 메모리 관리의 두 길을 봤다. C 처럼 사람이 직접 해제하면 빠르지만 해제 후 사용과 이중 해제가 생긴다. GC 를 쓰면 안전하지만 런타임 비용과 멈춤이 생긴다.
Rust 는 세 번째 길을 택했다. 해제 시점을 컴파일러가 코드의 구조로부터 정한다. 실행 중에 추적할 것이 없으니 GC 가 필요 없고, 규칙을 어기는 코드는 컴파일되지 않으니 안전하다. 그 대가로 프로그래머는 “누가 이 값의 주인인가”를 늘 생각해야 한다. 처음 Rust 를 배우는 사람이 가장 많이 부딪히는 벽이 이 빌림 검사기다. 규칙의 이유를 알면 벽이 지도로 바뀐다.
핵심 개념
소유권 규칙 세 줄
Rust 공식 책은 소유권 규칙을 이렇게 정리한다(What Is Ownership?).
- Rust 의 모든 값에는 소유자가 있다.
- 한 번에 소유자는 하나뿐이다.
- 소유자가 범위를 벗어나면 값은 버려진다(drop).
3번이 핵심이다. { let s = String::from("hi"); } 의 닫는 중괄호에서 컴파일러가 drop(s) 를 넣는다. 힙 메모리는 이때 해제된다. C++ 의 RAII 와 같은 아이디어이며, 파일 핸들·락·소켓 같은 자원에도 똑같이 적용된다.
이동
2번 규칙 때문에 대입이나 함수 인자 전달은 기본적으로 이동(move) 이다.
let a = String::from("hello");
let b = a;
스택 힙
a: [ptr|len|cap] ──┐
├──> "hello" a 는 무효가 되고 b 만 주인이다
b: [ptr|len|cap] ──┘
String 은 스택에 포인터·길이·용량을 두고 내용은 힙에 둔다. let b = a; 는 스택의 세 값만 복사한다. 이때 a 와 b 가 같은 힙을 가리키는 채로 둘 다 유효하면, 범위를 벗어날 때 두 번 해제된다. 그래서 Rust 는 a 를 무효로 만든다. 이후 a 를 쓰면 컴파일 오류 E0382 다(E0382).
깊은 복사가 필요하면 .clone() 을 명시적으로 부른다. 비용이 드는 일이 코드에 드러난다.
정수, 불리언, 부동소수점처럼 스택에만 있고 복사가 싼 타입은 Copy 트레이트를 구현한다. 이런 타입은 대입해도 원본이 유효하다.
빌림: 참조
값을 넘길 때마다 소유권을 주고받으면 불편하다. 그래서 참조로 잠시 빌려준다.
| 참조 | 문법 | 할 수 있는 일 | 동시에 몇 개 |
|---|---|---|---|
| 공유 참조 | &T |
읽기 | 여러 개 |
| 가변 참조 | &mut T |
읽기·쓰기 | 하나만, 그동안 공유 참조도 없어야 함 |
공식 책의 빌림 규칙은 두 줄이다(References and Borrowing).
- 어느 시점에든 가변 참조 하나 또는 불변 참조 여러 개 중 하나만 가질 수 있다.
- 참조는 항상 유효해야 한다.
이것은 읽기-쓰기 락(readers-writer lock)의 규칙과 같다. 다만 실행 중 락이 아니라 컴파일 시점의 증명이다. 그래서 두 가지 큰 버그 부류가 사라진다.
- 반복자 무효화: 벡터의 원소를 참조하는 동안
push하면, 재할당으로 원소가 다른 곳으로 옮겨져 참조가 허공을 가리킨다. Rust 에서는 공유 참조가 살아 있는 동안 가변 빌림이 필요한push를 막는다(E0502). - 데이터 경쟁: 두 스레드가 같은 데이터에 동시에 쓰거나, 하나는 쓰고 하나는 읽는 상황. 가변 참조가 하나뿐이므로 구조적으로 생길 수 없다.
수명
“참조는 항상 유효해야 한다”를 검사하려면 컴파일러가 참조가 얼마나 오래 쓰이는지와 원본이 얼마나 오래 사는지를 비교해야 한다. 이 범위를 수명(lifetime) 이라 한다.
대부분은 컴파일러가 알아서 추론한다(수명 생략 규칙). 함수가 여러 참조를 받아 참조를 돌려줄 때처럼 관계가 모호하면 'a 같은 수명 매개변수로 관계를 적어 준다. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 은 “반환 참조는 두 인자 중 더 짧게 사는 것만큼만 유효하다”는 뜻이다(Validating References with Lifetimes). 수명 표기는 수명을 바꾸지 않는다. 관계를 설명할 뿐이다.
NLL: 참조는 마지막 사용까지만 산다
초기 Rust 는 참조가 선언된 블록 끝까지 살아 있다고 보았다. 그래서 이미 다 쓴 참조 때문에 거부되는 코드가 많았다. RFC 2094 의 비어휘적 수명(Non-Lexical Lifetimes) 은 참조의 수명을 마지막으로 쓰인 지점까지로 줄였다(RFC 2094). 아래 예에서 &mut e 를 쓴 뒤 e 를 다시 읽을 수 있는 이유다.
규칙이 너무 엄격할 때
모든 데이터 구조가 “주인 하나” 모양은 아니다. 그래프, 양방향 연결 리스트, 여러 곳에서 공유하는 캐시가 그렇다. 표준 라이브러리는 이런 경우를 위한 도구를 제공한다.
Rc<T>/Arc<T>: 참조 계수로 공유 소유(#028).Arc는 스레드 간 공유용.RefCell<T>/Mutex<T>: 빌림 규칙 검사를 실행 시점으로 미룬다. 어기면 패닉하거나(RefCell) 기다린다(Mutex).unsafe: 컴파일러가 증명하지 못하는 것을 프로그래머가 책임지는 구역. 표준 라이브러리 내부 구현에 쓰이고, 안전한 API 로 감싸서 내놓는다.
직접 해 보기
아래 코드는 Rust Playground(stable, rustc 1.99.0, 2021 edition)에서 컴파일·실행했다.
fn take(s: String) -> usize { s.len() } // 소유권을 받는다
fn borrow(s: &String) -> usize { s.len() } // 빌린다
fn push_bang(s: &mut String) { s.push('!'); } // 가변으로 빌린다
fn main() {
let a = String::from("hello");
let b = a; // 이동: a 는 이제 무효
let c = b.clone(); // 명시적 깊은 복사
println!("{} {}", b, c); // hello hello
let n = borrow(&c);
println!("{} {}", c, n); // hello 5 빌려줬을 뿐, c 는 그대로
let mut d = String::from("hi");
push_bang(&mut d);
println!("{}", d); // hi!
let x = 5; let y = x; // i32 는 Copy
println!("{} {}", x, y); // 5 5
println!("{}", take(d)); // 3 d 는 take 로 이동, 이후 사용 불가
let mut e = String::from("e");
let m = &mut e;
m.push('x');
println!("{}", e); // ex NLL: m 의 수명은 위 줄에서 끝났다
}
규칙을 어기면 컴파일러가 이유를 정확히 짚는다. 같은 환경의 실제 메시지다.
let a = String::from("hello");
let b = a;
println!("{} {}", a, b);
error[E0382]: borrow of moved value: `a`
2 | let a = String::from("hello");
| - move occurs because `a` has type `String`, which does not implement the `Copy` trait
3 | let b = a;
| - value moved here
4 | println!("{} {}", a, b);
| ^ value borrowed here after move
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
3 | let first = &v[0];
| - immutable borrow occurs here
4 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{}", first);
| ----- immutable borrow later used here
C++ 에서 같은 코드는 컴파일되고, push 가 재할당을 일으키면 first 는 해제된 메모리를 가리키게 된다. Rust 는 이 상황을 빌드 단계에서 거부한다.
해제 시점은 Drop 트레이트로 직접 볼 수 있다.
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) { println!("drop {}", self.0); }
}
fn consume(g: Guard) { println!("consume {}", g.0); }
fn main() {
let _a = Guard("a");
{
let _b = Guard("b");
println!("inner scope end");
} // drop b
let c = Guard("c");
consume(c); // c 는 consume 안에서 끝난다
println!("main end");
} // drop a
출력:
inner scope end
drop b
consume c
drop c
main end
drop a
c 는 소유권이 consume 으로 넘어갔으므로 main 이 아니라 consume 이 끝날 때 해제된다.
현업에서는
- 메모리 안전 언어로의 이동: 시스템 소프트웨어에서 메모리 안전성 취약점을 줄이려는 흐름 속에 Rust 가 채택되고 있다. 리눅스 커널에도 Rust 로 커널 코드를 작성할 수 있는 지원이 들어갔다(Rust — The Linux Kernel documentation).
- 컨테이너 생태계의 Rust 도구: 컨테이너 런타임, 프록시, CLI 도구 중 Rust 로 작성된 것이 늘고 있다. GC 멈춤이 없고 정적 바이너리 하나로 배포되어 작은 컨테이너 이미지에 넣기 좋다.
- API 설계에서의 소유권: Rust 함수 시그니처는
String(소유권을 가져간다),&str(읽기만 빌린다),&mut String(수정하려 빌린다)로 의도가 드러난다. 다른 언어로 API 를 설계할 때도 “이 함수는 인자를 보관하는가, 잠깐 읽는가, 바꾸는가”를 명시하는 습관으로 옮겨 쓸 수 있다(#025). - 빌림 검사기와 싸우지 않기: 컴파일 오류를
clone()과Rc<RefCell<...>>로 덮으면 컴파일은 되지만 설계가 흐려진다. 오류가 가리키는 “누가 주인인가”를 다시 정하는 쪽이 대개 더 좋은 구조로 이어진다.
확인 문제
- Rust 소유권 규칙 세 가지를 말하라.
let b = a;후a를 쓸 수 없는 이유를 이중 해제와 연결해 설명하라.i32변수는 대입 후에도 원본을 쓸 수 있는데String은 안 되는 이유는?- 벡터 원소에 대한 참조를 쥔 채
push를 막는 규칙은 어떤 실제 버그를 예방하는가? - 수명 매개변수
'a를 적으면 참조의 수명이 늘어나는가?
풀이
- 모든 값에는 소유자가 있다. 소유자는 한 번에 하나다. 소유자가 범위를 벗어나면 값은 버려진다.
- 대입은 스택의 포인터만 복사하므로 둘 다 유효하면 같은 힙 메모리를 두 주인이 범위 끝에서 각각 해제하게 된다. 그래서
a를 무효로 해 주인을 하나로 유지한다. i32는Copy트레이트를 구현해 대입이 비트 복사이고 해제할 힙 자원이 없다.String은 힙 버퍼를 소유하므로Copy가 아니고 대입이 이동이다.- 재할당으로 원소가 옮겨지면서 기존 참조가 해제된 메모리를 가리키는 반복자 무효화(해제 후 사용) 버그.
- 아니다. 수명 표기는 여러 참조 사이의 관계를 컴파일러에게 설명할 뿐, 실제로 값이 사는 기간을 바꾸지 않는다.
더 읽을거리 (References)
- The Rust Programming Language, What Is Ownership?
- The Rust Programming Language, References and Borrowing
- The Rust Programming Language, Validating References with Lifetimes
- RFC 2094 — Non-lexical lifetimes
- Rust Error Codes Index, E0382, E0502