- 들어가며 — GC 없이 안전한 메모리
- 소유권의 세 가지 규칙
- 이동(move) 시맨틱 — 복사가 아니라 이동
- E0382 — 이동 후 사용 에러 읽기
- 빌림 — 소유권을 넘기지 않고 접근하기
- 빌림 검사기 — &mut XOR & 규칙
- E0502 — 불변·가변 대여 충돌
- 라이프타임 — 참조가 원본보다 오래 살지 못하게
- 라이프타임 생략 — 대부분은 안 써도 된다
- Copy vs Clone — 이동이 안 일어나는 타입
- 손으로 익히기
- 마치며
- 참고 자료
들어가며 — GC 없이 안전한 메모리
대부분의 언어는 메모리 안전을 두 가지 방식 중 하나로 얻습니다. C·C++처럼 프로그래머에게 전부 맡기거나(그리고 use-after-free, 이중 해제, 데이터 경쟁을 얻거나), Java·Go·파이썬처럼 가비지 컬렉터에게 맡기거나(그리고 런타임 오버헤드와 예측 불가능한 멈춤을 얻거나) 말이죠.
Rust는 제3의 길을 갑니다. 컴파일 타임에 메모리와 자원의 수명을 추적해서, GC 없이도 use-after-free와 데이터 경쟁이 아예 컴파일되지 않게 만듭니다. 이 마법의 정체는 세 가지 규칙 집합입니다. 소유권(ownership), 빌림(borrowing), 라이프타임(lifetimes). 이 셋을 이해하면 Rust의 나머지는 대부분 자연스럽게 따라옵니다. 반대로 이 셋과 씨름하는 기간이 바로 그 악명 높은 "빌림 검사기와의 싸움"입니다.
이 글의 목표는 그 싸움을 빨리 끝내는 것입니다. 규칙을 외우는 대신 왜 그런 규칙이 있는지 이해하면, 검사기가 왜 화를 내는지 보이기 시작합니다.
소유권의 세 가지 규칙
Rust 공식 책은 소유권을 세 문장으로 요약합니다.
- Rust의 모든 값에는 **소유자(owner)**가 있다.
- 소유자는 한 번에 하나뿐이다.
- 소유자가 스코프를 벗어나면 값은 버려진다(dropped).
세 번째 규칙이 GC를 대체하는 부분입니다. 스코프가 끝나는 }에서 Rust는 그 스코프가 소유한 값들의 drop을 자동으로 호출합니다. 힙 메모리라면 해제되고, 파일이라면 닫히고, 락이라면 풀립니다. 이걸 RAII(Resource Acquisition Is Initialization)라고 부르며, C++에서 온 개념이지만 Rust는 이걸 언어 차원에서 강제합니다.
fn main() {
let s = String::from("hello"); // s가 힙 문자열을 소유
println!("{s}");
} // 여기서 s가 스코프를 벗어남 → drop(s) 자동 호출 → 힙 메모리 해제
String은 힙에 데이터를 담고, 스택에는 (포인터, 길이, 용량) 세 값을 둡니다. 스코프가 끝나면 스택의 세 값이 사라지고, 그 직전에 drop이 힙 버퍼를 반환합니다. 개발자가 free를 호출할 필요도, GC가 나중에 청소할 필요도 없습니다. 해제 시점이 코드에 정적으로 박혀 있습니다.
이동(move) 시맨틱 — 복사가 아니라 이동
여기서 "소유자는 하나뿐"이라는 규칙이 흥미로워집니다. 값을 다른 변수에 대입하면 어떻게 될까요?
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1의 소유권이 s2로 "이동(move)"
println!("{s2}"); // OK
// println!("{s1}"); // 컴파일 에러! s1은 더 이상 유효하지 않음
}
다른 언어에 익숙하다면 s1과 s2가 같은 문자열을 가리키는 두 이름일 거라 예상합니다. 하지만 그러면 규칙 2를 어깁니다. 소유자가 둘이 되니까요. 만약 둘 다 유효하다면, 스코프가 끝날 때 drop이 두 번 호출되어 같은 힙 버퍼를 이중 해제하게 됩니다. 이게 바로 C++에서 악명 높은 버그죠.
Rust의 해법은 대입을 이동으로 정의하는 것입니다. let s2 = s1; 이후 s1은 "이동됨(moved-out)" 상태가 되어 더 이상 쓸 수 없습니다. 소유권은 정확히 하나의 변수(s2)에 있고, drop도 한 번만 호출됩니다. 이동 자체는 스택에 있는 세 값(포인터, 길이, 용량)만 복사하는 얕은 연산이라 힙 데이터는 건드리지 않습니다. 빠르고 안전합니다.
함수 호출도 마찬가지입니다. 값을 함수에 넘기면 소유권이 함수로 이동합니다.
fn consume(s: String) {
println!("{s}");
} // 여기서 s가 drop됨
fn main() {
let s = String::from("hello");
consume(s); // 소유권이 consume으로 이동
// println!("{s}"); // 에러! s는 이미 이동됨
}
E0382 — 이동 후 사용 에러 읽기
위 코드의 주석을 풀면 Rust는 이렇게 말합니다. Rust 에러 메시지는 세계에서 가장 친절한 축에 들기 때문에, 실제로 읽는 것만으로 대부분 해결됩니다.
error[E0382]: borrow of moved value: `s`
--> src/main.rs:9:22
|
7 | let s = String::from("hello");
| - move occurs because `s` has type `String`,
| which does not implement the `Copy` trait
8 | consume(s);
| - value moved here
9 | // println!("{s}");
| ^ value borrowed here after move
이 메시지를 뜯어봅시다. error[E0382]는 에러 코드입니다. rustc --explain E0382로 상세 설명을 볼 수 있습니다. 본문은 세 지점을 정확히 짚습니다. (1) s가 이동된 이유는 String이 Copy를 구현하지 않아서, (2) 값이 이동된 지점은 consume(s), (3) 이동 후에 값을 다시 쓰려 한 지점. 원인, 이동 지점, 재사용 지점이 한눈에 들어옵니다.
해결책은 상황에 따라 다릅니다. 값을 계속 써야 한다면 (a) consume이 소유권을 가져가는 대신 빌리게 하거나(다음 절), (b) s.clone()으로 깊은 복사본을 넘기거나, (c) consume이 값을 돌려주게 만들면 됩니다. 대개 정답은 (a)입니다.
빌림 — 소유권을 넘기지 않고 접근하기
모든 함수가 값의 소유권을 가져간다면 프로그래밍이 지옥이 됩니다. 문자열 길이 하나 재려고 소유권을 넘겼다가 돌려받아야 하니까요. 그래서 **빌림(borrowing)**이 있습니다. 값을 소유하지 않고 잠깐 참조만 빌리는 것입니다. 문법은 앰퍼샌드(&)입니다.
fn length(s: &String) -> usize { // &String: 문자열을 "빌림"
s.len()
} // s는 참조일 뿐이므로 여기서 drop되지 않음 (원본은 그대로)
fn main() {
let s = String::from("hello");
let n = length(&s); // &s: s를 빌려줌
println!("{s} has length {n}"); // s는 여전히 유효!
}
참조는 값을 소유하지 않으므로, 참조가 스코프를 벗어나도 원본은 drop되지 않습니다. length가 끝나도 main의 s는 멀쩡합니다. 이게 빌림의 핵심입니다. 소유권은 그대로 두고 접근 권한만 잠시 빌려주는 것.
빌림에는 두 종류가 있습니다.
- 불변 참조(
&T): 읽기 전용. 값을 볼 수만 있고 바꿀 수 없다. - 가변 참조(
&mut T): 읽기·쓰기. 빌린 값을 수정할 수 있다.
fn push_world(s: &mut String) { // 가변으로 빌림
s.push_str(" world"); // 원본을 수정
}
fn main() {
let mut s = String::from("hello"); // mut로 선언해야 가변 대여 가능
push_world(&mut s); // 가변 참조를 넘김
println!("{s}"); // "hello world"
}
빌림 검사기 — &mut XOR & 규칙
이제 Rust의 심장입니다. 빌림 검사기는 참조에 대해 딱 하나의 규칙을 강제합니다. 이걸 이해하면 검사기 에러의 90%가 설명됩니다.
임의의 순간, 특정 값에 대해 가변 참조(
&mut) 하나를 갖거나, 불변 참조(&) 여럿을 갖거나 둘 중 하나만 가능하다. 둘을 동시에 가질 수는 없다.
이걸 종종 "공유 XOR 가변(shared XOR mutable)" 또는 **"aliasing XOR mutation"**이라고 부릅니다. 배타적 논리합(XOR)이라는 이름 그대로, 공유(여러 &)와 변경(&mut)은 상호 배타적입니다. 여럿이 읽는 동안엔 아무도 못 쓰고, 누군가 쓰는 동안엔 아무도 못 읽습니다(그 자신 포함).
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 불변 참조 1
let r2 = &s; // 불변 참조 2 — OK, 여럿의 불변 참조는 허용
println!("{r1} and {r2}");
let r3 = &mut s; // 가변 참조 — 위 불변 참조들이 더는 안 쓰이면 OK
r3.push_str(" world");
println!("{r3}");
}
만약 불변 참조가 살아 있는 동안 가변 참조를 만들려 하면 에러입니다.
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 불변 대여 시작
let r2 = &mut s; // 에러! 불변 대여 중에 가변 대여 불가
println!("{r1} {r2}");
}
이 규칙이 왜 필요할까요? 데이터 경쟁과 반복자 무효화(iterator invalidation)를 컴파일 타임에 막기 위해서입니다. 만약 어떤 스레드가 벡터를 읽는 동안 다른 스레드가(혹은 심지어 같은 스레드가) 그 벡터에 원소를 추가하면, 재할당이 일어나 읽던 참조가 허공을 가리키게 됩니다. C++에서는 이게 런타임 크래시나 조용한 손상으로 나타나지만, Rust에서는 애초에 컴파일이 안 됩니다. "가변 참조가 하나면 그 참조를 통하지 않고는 아무도 값을 바꿀 수 없다"는 보장이, 데이터 경쟁의 정의 자체("동기화 없는 동시 접근 중 최소 하나가 쓰기")를 컴파일 타임에 불가능하게 만듭니다.
E0502 — 불변·가변 대여 충돌
위의 충돌을 실제 컬렉션 코드에서 보면 이렇습니다. 가장 흔히 만나는 에러 중 하나입니다.
fn main() {
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
--> src/main.rs:4:5
|
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
이 에러는 성가셔 보이지만, 실은 진짜 버그를 막고 있습니다. v.push(4)는 벡터 용량이 부족하면 힙에 더 큰 버퍼를 새로 할당하고 기존 원소들을 복사한 뒤 옛 버퍼를 해제합니다. 그러면 first가 가리키던 주소는 이미 해제된 메모리입니다. C++의 std::vector에서 이건 그 유명한 반복자 무효화 버그이고, 대개 조용히 잘못된 값을 읽거나 세그폴트가 납니다. Rust는 first의 불변 대여가 println!까지 살아 있음을 알기에 그 사이의 push를 거부합니다. 해결책은 first를 다 쓴 뒤에 push하거나, 인덱스로 그때그때 접근하는 것입니다.
참고로 Rust 2018부터 도입된 NLL(Non-Lexical Lifetimes) 덕분에, 참조의 수명은 스코프의 }가 아니라 마지막으로 사용된 지점까지입니다. 그래서 위에서 println!("{first}")를 지우면 컴파일이 통과합니다. first가 더는 안 쓰이므로 대여가 이미 끝난 것으로 간주되기 때문입니다.
라이프타임 — 참조가 원본보다 오래 살지 못하게
빌림 검사기의 두 번째 임무는 **댕글링 참조(dangling reference)**를 막는 것입니다. 참조가 자기가 가리키는 값보다 오래 살아남으면 안 됩니다. 그러면 이미 해제된 메모리를 가리키게 되니까요.
fn main() {
let r;
{
let x = 5;
r = &x; // x를 빌림
} // x가 여기서 drop됨
// println!("{r}"); // 에러! r은 죽은 x를 가리킴
}
Rust는 r이 x보다 오래 살아남으려 한다는 걸 알고 거부합니다(에러 코드 E0597, "x does not live long enough"). 여기까지는 컴파일러가 알아서 추론합니다. 그런데 함수 경계를 넘으면 컴파일러가 혼자 알 수 없는 경우가 생깁니다. 그때 우리가 라이프타임 애너테이션으로 관계를 명시해 줘야 합니다.
// 두 문자열 슬라이스 중 긴 쪽을 반환
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
여기 붙은 'a(라이프타임 파라미터, "틱 에이"라고 읽습니다)가 라이프타임 애너테이션입니다. 이게 말하는 바는 이렇습니다. "반환되는 참조는 x와 y 중 더 짧게 사는 쪽만큼은 유효하다." 애너테이션은 참조의 실제 수명을 바꾸지 않습니다. 다만 여러 참조의 수명 사이의 관계를 컴파일러에게 알려줄 뿐입니다. 컴파일러는 이 관계를 이용해, 반환값이 원본들보다 오래 쓰이지 않는지 호출 지점에서 검사합니다.
왜 필요할까요? 컴파일러 입장에서 longest의 반환값이 x에서 왔는지 y에서 왔는지 함수 본문을 보지 않고는 알 수 없습니다(그리고 검사는 시그니처만 보고 합니다). 두 인자의 수명이 다를 수 있으니, 반환된 참조가 얼마나 유효한지 표현할 방법이 필요합니다. 'a가 바로 그 표현입니다.
라이프타임 생략 — 대부분은 안 써도 된다
여기까지 읽고 "그럼 모든 참조에 'a를 붙여야 하나" 걱정된다면, 다행히 아닙니다. 컴파일러에는 **라이프타임 생략 규칙(elision rules)**이 있어서, 흔한 패턴에서는 애너테이션을 자동으로 추론합니다. 그래서 실전에서 명시적 라이프타임을 쓰는 일은 생각보다 드뭅니다.
// 명시적으로 쓰면:
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
// 생략 규칙 덕분에 이렇게 써도 동일:
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
생략 규칙은 대략 이렇습니다. (1) 각 입력 참조는 저마다 라이프타임을 얻는다. (2) 입력 참조가 정확히 하나면, 그 라이프타임이 모든 출력 참조에 부여된다. (3) 메서드에서 &self나 &mut self가 있으면, self의 라이프타임이 모든 출력에 부여된다. 이 규칙으로 모호함이 없으면 우리가 안 써도 됩니다. 규칙으로 결정되지 않는 경우(예: longest처럼 입력 참조가 둘)에만 직접 명시하면 됩니다.
'static 라이프타임도 알아둘 만합니다. &'static str은 프로그램 전체 기간 동안 사는 참조로, 문자열 리터럴("hello")이 대표적입니다. 리터럴은 바이너리에 박혀 있어 항상 유효하기 때문입니다.
Copy vs Clone — 이동이 안 일어나는 타입
앞에서 String 대입은 이동이었습니다. 그런데 정수는 다릅니다.
fn main() {
let x = 5;
let y = x; // 이동이 아니라 복사(copy)
println!("{x} {y}"); // 둘 다 유효! x가 이동되지 않음
}
i32 같은 타입은 Copy 트레잇을 구현합니다. Copy인 타입은 대입 시 이동 대신 비트 단위 복사가 일어나고, 원본이 여전히 유효합니다. 왜 정수는 괜찮고 String은 안 될까요? 정수는 스택에 통째로 사는 고정 크기 값이라, 비트를 복사해도 힙 자원의 공유가 없어 이중 해제 위험이 없습니다. 반면 String은 힙 포인터를 품고 있어서 비트 복사하면 두 소유자가 같은 힙을 가리키게 됩니다. 그래서 Copy가 될 수 없습니다.
Copy인 타입들: 모든 정수·부동소수(i32, u64, f64), bool, char, 그리고 Copy인 것들로만 이루어진 튜플·배열((i32, i32), [u8; 4]). 규칙은 "힙 자원을 소유하지 않고 Drop도 구현하지 않는 순수 스택 값"입니다.
Clone은 명시적인 깊은 복사입니다. Copy가 자동·암묵·저렴한 복사라면, Clone은 수동·명시적이고 대개 비쌉니다(힙 할당 포함).
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 힙 데이터까지 통째로 복제 (새 할당)
println!("{s1} {s2}"); // 둘 다 유효 — s1이 이동되지 않았음
}
.clone()은 이동 에러를 회피하는 손쉬운 탈출구지만, 남용은 성능 냄새입니다. 힙 문자열을 매 호출마다 복제하는 코드는 대개 빌림(&str)으로 바꿀 수 있습니다. 초심자 시절 .clone()으로 검사기를 달랬다면, 익숙해진 뒤 그 자리들을 참조로 리팩터링하는 게 좋은 다음 단계입니다.
정리하면 대입 시 동작은 세 갈래입니다. Copy면 자동 복사(원본 유효), Clone을 명시 호출하면 깊은 복사(원본 유효), 둘 다 아니면 이동(원본 무효화).
손으로 익히기
소유권·빌림·라이프타임은 읽어서 아는 것과 손으로 아는 것의 간극이 특히 큰 주제입니다. 검사기가 왜 거절하는지는 결국 여러 번 거절당해 보며 몸에 익습니다. Rust 학습 랩에서 이동·빌림·라이프타임 시나리오를 직접 컴파일해 보며 에러 메시지를 읽는 연습을 하면, 이 글의 규칙들이 훨씬 빨리 손에 붙습니다.
한 가지 실전 팁으로 마무리합니다. 검사기와 싸우게 되면, 코드를 마구 바꾸기 전에 에러 코드로 원인을 먼저 읽으세요. E0382(이동 후 사용)인지 E0502(대여 충돌)인지 E0597(수명 부족)인지에 따라 처방이 완전히 다릅니다. Rust의 에러는 대부분 무엇을, 어디서, 왜 어겼는지를 정확히 알려줍니다. 그 자백을 읽는 것이 추측보다 백배 빠릅니다.
마치며
소유권·빌림·라이프타임은 처음엔 장애물처럼 느껴지지만, 실은 다른 언어에서 런타임에(또는 프로덕션 새벽 3시에) 터졌을 버그들을 컴파일러가 대신 잡아 주는 것입니다. use-after-free, 이중 해제, 데이터 경쟁, 반복자 무효화 — 이 모두가 "소유자는 하나, 공유 XOR 가변, 참조는 원본보다 오래 못 산다"는 세 규칙에서 정적으로 배제됩니다.
빌림 검사기와의 싸움은 끝이 있습니다. 규칙의 이유를 이해하는 순간, 검사기는 적이 아니라 페어 프로그래밍 파트너가 됩니다. 그 시점부터 Rust는 "까다로운 언어"가 아니라 "내 실수를 컴파일 타임에 짚어 주는 언어"가 됩니다.
참고 자료
- The Rust Programming Language, 4장 "Understanding Ownership": https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- The Rust Programming Language, 10.3장 "Validating References with Lifetimes": https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html
- Rust Reference — Ownership and destructors: https://doc.rust-lang.org/reference/destructors.html
- rustc error index (E0382, E0502, E0597): https://doc.rust-lang.org/error_codes/error-index.html
- Rust by Example — Ownership and moves: https://doc.rust-lang.org/rust-by-example/scope/move.html
현재 단락 (1/156)
대부분의 언어는 메모리 안전을 두 가지 방식 중 하나로 얻습니다. C·C++처럼 프로그래머에게 전부 맡기거나(그리고 use-after-free, 이중 해제, 데이터 경쟁을 얻거나)...