Rust 빌림 규칙과 Borrow Checker
소유권만으로는 부족하다. *"잠깐 빌려서 보기만 하고 싶다"*거나 *"잠깐 빌려서 수정만 하고 싶다"*는 패턴이 필요하다. 그게 **참조(reference)와 빌림(borrow)**이다. 그리고 이 빌림이 안전하도록 보장하는 게 빌림 규칙이다.
빌림의 두 종류
let s = String::from("hello");
let r1 = &s; // 불변 참조자 (immutable reference)
let r2 = &mut s; // 가변 참조자 (mutable reference)
&s: 읽기 전용으로 빌림. 데이터를 보기만 함.&mut s: 읽기/쓰기로 빌림. 데이터를 수정할 수 있음.
C++의 const T& vs T&와 비슷하다.
진짜 규칙
Rust borrow checker의 규칙을 한 줄로 요약하면:
어느 한 시점에, 한 데이터에 대해서:
- 불변 참조자는 여러 개 있어도 됨
- 가변 참조자는 정확히 하나만 있어야 하고, 다른 어떤 참조자도 동시에 있으면 안 됨
| 상황 | 허용? |
|---|---|
&s 여러 개 | ✅ |
&mut s 한 개만 | ✅ |
&mut s 두 개 이상 | ❌ |
&s와 &mut s 동시에 | ❌ |
핵심은 "동시에"
let mut s = String::from("hello");
{
let r1 = &mut s;
} // ← 여기서 r1이 스코프를 벗어남. 가변 참조 끝.
let r2 = &mut s; // ✅ 새로운 가변 참조. OK.
규칙은 *"동시에 두 개의 가변 참조가 있으면 안 됨"*이다. 핵심 단어는 동시에.
시간 →
s 생성 ●─────────────────────────────●
│
r1 만듦 ●────●
│
r1 소멸
r2 만듦 ●──────●
r1이 살아있는 구간과 r2가 살아있는 구간이 겹치지 않는다. 그래서 *"동시에 둘"*이 아니라 *"순차적으로 하나씩"*이라 OK다.
불변과 가변을 동시에 쓰면 안 되는 이유
let mut s = String::from("hello");
let r1 = &s; // 불변 참조 1
let r2 = &s; // 불변 참조 2
let r3 = &mut s; // ❌ 위의 불변 참조들이 아직 살아있음
직관적으로 왜 위험한지:
r1과r2는 *"이 데이터는 안 바뀐다고 약속받고 빌렸다"*는 의미r3은 *"이 데이터를 내가 바꿀 수 있다"*는 의미- 둘이 동시에 존재하면 →
r1이 *"안 바뀜"*이라고 믿는 동안r3이 데이터를 바꿔버릴 수 있음 → 약속 위반
이런 규칙이 막는 진짜 위험
이 규칙은 임의로 만든 제약이 아니다. 데이터 경합과 무효한 참조를 컴파일 타임에 막기 위한 핵심 메커니즘이다.
Iterator 무효화 (단일 스레드에서도 발생)
C++에서 자주 일어나는 버그:
std::vector<int> v = {1, 2, 3, 4, 5};
int& first = v[0]; // ← 첫 번째 원소 참조
v.push_back(6); // ← vector가 재할당될 수 있음!
// 재할당되면 first는 사라진 메모리를 가리킴
std::cout << first; // ← undefined behavior
vector는 capacity를 넘어가면 새 메모리를 할당하고 데이터를 옮긴다. 이전 메모리는 해제되어 first는 dangling reference가 된다.
Rust에서 같은 코드:
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0]; // 불변 참조
v.push(6); // ❌ cannot borrow `v` as mutable
// because it is also borrowed as immutable
println!("{}", first);
push는 내부적으로 &mut self를 받아 가변 참조를 만들려 한다. first라는 불변 참조가 살아있으니 규칙 위반이다. 컴파일러가 막아준다.
이건 단일 스레드에서도 일어나는 문제다. 멀티스레드와 무관하다.
데이터 경합 (멀티스레드)
// Thread 1 // Thread 2
shared_data.push_back(x); shared_data.push_back(y);
// 동시에 같은 메모리를 수정 → race condition
Rust의 빌림 규칙은 컴파일 타임에 *"동시에 mutable 접근이 일어날 수 없다"*를 보장한다. mutable 접근은 한 번에 하나뿐이거나, 모든 접근이 read-only이거나 둘 중 하나이므로 데이터 경합이 원천 차단된다.
한 문장으로
**불변 참조는 *"이 데이터는 변하지 않는다"*를 보장하고, 가변 참조는 "이 데이터를 내가 독점적으로 다룬다"를 보장한다. 두 보장이 충돌하지 않으려면, 둘은 동시에 존재할 수 없다.
Non-Lexical Lifetimes (NLL)
빌림 규칙에서 가장 미묘하지만 가장 중요한 부분이다. 현재 Rust 컴파일러는 참조의 라이프타임을 마지막으로 사용된 지점까지만으로 본다.
책에서 보는 옛날 모델
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s; // ❌ 책의 설명대로라면 여기서 에러
println!("{} {} {}", r1, r2, r3);
옛 모델에서는 *"라이프타임은 어휘적 스코프(중괄호 끝)까지"*였다. 그래서 r1, r2가 함수 끝까지 살아있고, 그 사이에 r3을 만들면 충돌이라고 판정했다.
현대 Rust는 더 똑똑하다
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2); // ← r1, r2의 마지막 사용 지점
let r3 = &mut s; // ✅ 사실 OK! 현대 Rust에서는 컴파일됨
println!("{}", r3);
r1과 r2는 println! 이후로 더 이상 안 쓰인다. 그러면 borrow checker는 *"여기서 그것들의 빌림은 끝났다"*고 본다. 그래서 그 다음 줄에서 r3을 만드는 건 충돌하지 않는다.
이걸 **Non-Lexical Lifetimes (NLL)**이라고 한다. *"라이프타임이 어휘적 스코프가 아니라, 실제 마지막 사용 지점까지"*라는 뜻이다. 2018 edition부터 안정화되었다.
NLL이 의미하는 바
책이 *"세 변수를 만든 그 자체로 에러"*처럼 보여주는 건 단순화한 설명이다. 실제로는 *"불변 참조가 살아있는 동안 가변 참조를 만들면 에러"*가 더 정확하다. 그리고 *"살아있다"*의 정의가 NLL에서는 *"마지막 사용 지점까지"*다.
이 차이가 실제 코드 작성에서 큰 의미를 가진다. NLL 덕분에 다음 같은 패턴이 자연스럽게 동작한다.
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{}", first); // first의 마지막 사용
v.push(4); // ✅ 이제 가변 빌림 가능
빌림 규칙 시각화
허용되는 상황 1: 불변 참조 여러 개
─────────────────────────────────
s ●━━━━━━━━━━━━━━━━━━━━━━━●
&s ──●━━━●
&s ──●━━━●
&s ──●━━━● ← 모두 read-only라서 충돌 없음
허용되는 상황 2: 가변 참조 하나
─────────────────────────────────
s ●━━━━━━━━━━━━━━━━━━━━━━━●
&mut s ──●━━━━━━━━━━━● ← 독점적 접근
허용되는 상황 3: 시간차로 분리
─────────────────────────────────
s ●━━━━━━━━━━━━━━━━━━━━━━━●
&mut s ──●━━●
&mut s ──●━━● ← 겹치지 않음
금지되는 상황 1: 가변 참조 두 개 동시
─────────────────────────────────
s ●━━━━━━━━━━━━━━━━━━━━━━━●
&mut s ──●━━━━━━━━●
&mut s ──●━━━━━━━━● ← ❌ 충돌
금지되는 상황 2: 불변과 가변 동시
─────────────────────────────────
s ●━━━━━━━━━━━━━━━━━━━━━━━●
&s ──●━━━━━━━━●
&mut s ──●━━━━━━━━● ← ❌ 충돌
핵심은 시간선에서 겹치는지다.
빌림 규칙이 막힐 때 풀어가는 패턴
이 규칙이 처음엔 빡빡하게 느껴지는데, 익숙해지면 합리적이다. 막히면 보통 이렇게 푼다:
- 스코프를 좁힌다 —
{ ... }로 참조를 일찍 죽인다 - 사용 순서를 정리한다 — 불변 참조를 다 쓰고 나서 가변 참조를 만든다
- clone을 한다 — 어차피 데이터가 작거나 성능이 안 중요하면 그냥 복사한다
- 구조를 다시 본다 — 보통 borrow checker가 막는 코드는 *"진짜로 위험한 패턴"*이다. 다시 짤 가치가 있음
더 깊이 들어가면
빌림 규칙의 자연스러운 확장으로 **라이프타임 표시('a)**가 있다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... }
함수가 참조를 반환할 때 *"이 참조는 어떤 입력에서 왔는가"*를 표시하는 게 라이프타임이다. *"빌린 데이터는 빌림 출처보다 오래 살 수 없다"*는 규칙을 함수 시그니처로 표현하는 방법이다. 책 10장에 본격적으로 나온다.
또한 slice(&s[0..5] 같은 부분 참조)도 같은 빌림 규칙을 따른다. slice가 살아있는 동안 원본을 수정할 수 없다.
같이 보면 좋은 노트
- why-ownership-exists — 소유권의 동기
- stack-vs-heap-in-ownership — 빌림이 dangling reference를 막는 방식
- copy-vs-move —
&mut T가 왜Copy가 아닌지
한 줄 요약
Rust의 빌림 규칙은 *"한 시점에 가변 참조 하나, 또는 불변 참조 여럿. 둘이 섞이면 안 됨"*이다. 이 규칙은 데이터 경합과 무효한 참조를 컴파일 타임에 차단하기 위해 존재한다. 현대 Rust는 **NLL(Non-Lexical Lifetimes)**로 *"라이프타임이 어휘적 스코프가 아니라 마지막 사용 지점까지"*로 보아, 책의 설명보다 더 유연하게 동작한다.