빌림과 참조 — & 와 &mut
앞 챕터의 마지막 문제를 다시 봅시다. print_and_drop(text)를 호출하고 나서도 text를 계속 쓰고 싶다면 어떻게 해야 할까요. 정답은 소유권을 넘기지 않고 참조를 넘기는 겁니다.
fn print_length(s: &String) {
println!("길이: {}", s.len());
}
fn main() {
let text = String::from("소유권 이동");
print_length(&text);
println!("여전히 쓸 수 있다: {text}"); // 정상 동작
}&text는 "이 값을 잠깐 빌려줄게, 소유권은 안 넘어가"라는 뜻입니다. 함수 시그니처의 s: &String도 "나는 이 값을 빌려서 쓸 뿐, 함수가 끝나도 원본은 건드리지 않는다"는 약속입니다. 이 방식을 빌림(borrowing) 이라고 부릅니다. Rust 공식 문서는 이를 "참조와 빌림(references and borrowing)"이라는 이름으로 다루는데, 사실 같은 현상을 가리키는 두 단어입니다 — &로 값을 가리키는 게 참조이고, 그 행위를 소유권 관점에서 부르면 빌림입니다.
불변 참조는 여러 개 가능하다
fn main() {
let text = String::from("hello");
let r1 = &text;
let r2 = &text;
println!("{r1}, {r2}, {text}");
}&text를 여러 번 만들어도 문제없습니다. 아무도 값을 바꾸지 않으니, 몇 명이 동시에 읽든 안전합니다. 도서관에서 책을 대출하지 않고 그 자리에서 여러 명이 같이 읽는 것과 비슷합니다.
값을 바꾸려면 가변 참조가 필요하다
fn add_exclamation(s: &mut String) {
s.push_str("!");
}
fn main() {
let mut text = String::from("hello");
add_exclamation(&mut text);
println!("{text}"); // hello!
}여기서 눈여겨볼 게 세 군데입니다. text 선언에 mut, 호출부의 &mut text, 함수 시그니처의 &mut String. 셋 중 하나라도 빠지면 컴파일이 안 됩니다. Rust는 "이 변수가 바뀔 수 있다"는 사실을 선언, 호출, 함수 시그니처 세 군데 모두에서 명시적으로 요구합니다. 귀찮아 보이지만, 코드를 읽을 때 &mut가 보이는 순간 "이 호출이 값을 바꿀 수 있다"는 걸 다른 곳을 보지 않고도 알 수 있다는 뜻이기도 합니다.
가변 참조는 동시에 하나뿐이다
여기서부터가 진짜 규칙입니다.
fn main() {
let mut text = String::from("hello");
let r1 = &mut text;
let r2 = &mut text; // 컴파일 에러
println!("{r1}, {r2}");
}error[E0499]: cannot borrow `text` as mutable more than once at a time같은 값에 대해 가변 참조를 동시에 두 개 이상 만들 수 없습니다. 그리고 불변 참조와 가변 참조도 같은 스코프에서 섞을 수 없습니다 — 누군가 값을 읽고 있는 동안 다른 누군가 값을 바꾸면 읽던 쪽이 이상한 값을 보게 되기 때문입니다. 정리하면 규칙은 다음 중 하나입니다.
- 불변 참조(
&T) 여러 개, 동시에 존재 가능 - 가변 참조(
&mut T) 딱 하나만, 동시에 존재 가능 - 이 둘은 섞일 수 없음
stateDiagram-v2 [*] --> 읽기전용상태 읽기전용상태 --> 읽기전용상태: &T 추가 획득 가능 읽기전용상태 --> 배타적쓰기상태: 모든 &T 해제 후 &mut T 획득 배타적쓰기상태 --> 읽기전용상태: &mut T 해제 배타적쓰기상태 --> [*]
이 규칙이 낯설게 느껴진다면, 사실 여러분은 이미 이 문제를 다른 언어에서 겪어봤을 가능성이 높습니다. 자바나 파이썬에서 리스트를 순회하면서 동시에 그 리스트에 원소를 추가하면 ConcurrentModificationException이나 예측 불가능한 동작이 납니다. 그 언어들은 이 문제를 런타임에 예외로 알려줍니다. Rust는 같은 문제를 컴파일 타임에 원천 차단합니다 — 실행해보기 전에, 애초에 그런 코드를 짤 수 없게 만드는 겁니다.
스코프가 끝나야 빌림도 끝난다는 착각
초보자가 흔히 하는 오해 하나. "가변 참조는 변수가 스코프를 벗어나야 끝난다"고 생각하기 쉽지만, 실제로는 마지막으로 쓰인 지점까지만 유효합니다.
fn main() {
let mut text = String::from("hello");
let r1 = &mut text;
r1.push_str(" world");
// r1은 여기서 마지막으로 쓰였다 — 여기서 사실상 끝
let r2 = &mut text; // 문제없음
r2.push_str("!");
println!("{text}");
}이 동작은 비교적 최근(2018 에디션)에 도입된 "Non-Lexical Lifetimes(NLL)"라는 개선 덕분입니다. 그 전에는 변수 선언 스코프가 끝나야만 빌림도 끝났고, 그래서 지금이라면 아무 문제 없을 코드도 거절당하는 경우가 많았습니다. Rust 초창기 자료를 보다가 지금은 통과되는 코드가 에러 예시로 나와 있다면, 대부분 이 NLL 이전 버전 이야기입니다.