새벽 두 시, 운영 중인 서버 프로그램이 이유 없이 죽었다. 로그에는 Segmentation fault 한 줄뿐. 원인을 찾는 데 이틀이 걸렸고, 범인은 이미 해제된 메모리를 다른 스레드가 한 번 더 읽은 것이었다. C/C++로 서버를 짜 본 사람이라면 한 번쯤 겪어 봤을 장면이다.
Rust는 바로 이런 밤을 없애려고 만들어진 언어다. 그리고 그 핵심에 소유권(ownership) 이라는 규칙이 있다. 처음 Rust를 배우는 사람이 가장 많이 부딪히는 벽이기도 하다. 오늘은 이 벽을 "외울 규칙"이 아니라 "왜 필요한지 납득되는 규칙"으로 풀어 본다.
메모리를 치우는 세 가지 방법
프로그램이 쓴 메모리는 누군가 치워야 한다. 지금까지 언어들은 크게 두 길을 택했다.
- 개발자가 직접 치우기 (C, C++):
malloc/free,new/delete. 빠르지만, 두 번 치우거나(double free) 치운 뒤에 또 쓰면(use-after-free) 프로그램이 터진다. - 청소부에게 맡기기 (Java, Python, Go, JavaScript): 가비지 컬렉터(GC)가 주기적으로 안 쓰는 메모리를 찾아 회수한다. 안전하지만, 청소하는 동안 잠깐씩 멈추거나 메모리를 넉넉히 써야 한다.
Rust는 세 번째 길을 냈다. 컴파일할 때 "이 값은 언제 치워도 되는지"를 규칙으로 미리 확정해 버리는 것이다. 실행 중 청소부도 없고, 개발자가 free를 직접 부르지도 않는다. 규칙을 어기면 아예 컴파일이 안 된다.
Rust의 소유권은 "런타임에 터질 버그"를 "컴파일 에러"로 앞당기는 장치다.
소유권의 세 가지 규칙
규칙 자체는 짧다.
- 모든 값에는 주인(owner) 변수가 딱 하나 있다.
- 주인은 한 번에 하나뿐이다.
- 주인이 스코프(
{ })를 벗어나면 값은 자동으로 해제된다.
fn main() {
{
let s = String::from("안녕"); // s가 주인
println!("{}", s);
} // 여기서 s가 스코프를 벗어나며 메모리 자동 해제
}세 번째 규칙 덕분에 free를 부를 필요가 없다. 문제는 두 번째 규칙에서 생긴다.
"value borrowed here after move" — 입문자의 첫 번째 에러
다른 언어에서는 아무 문제 없는 코드가 Rust에서는 막힌다.
let a = String::from("hello");
let b = a; // 소유권이 a → b로 '이동(move)'
println!("{}", a); // 컴파일 에러! a는 더 이상 주인이 아님처음 보면 황당하다. 하지만 이유를 따져 보면 합리적이다. 만약 a와 b가 같은 메모리를 동시에 소유한다면, 둘 다 스코프를 벗어날 때 같은 메모리를 두 번 해제하게 된다. 서두의 새벽 두 시 사고가 바로 이런 종류다. Rust는 "주인은 하나"라는 규칙으로 이 가능성 자체를 없앴다.
정말 복사본이 필요하면 명시적으로 clone()을 쓴다.
let a = String::from("hello");
let b = a.clone(); // 힙 데이터까지 복사 — 비용이 드는 걸 코드에 드러냄
println!("{} {}", a, b);참고로 i32, f64, bool 같은 작은 고정 크기 값은 Copy 특성이 있어 이동 대신 그냥 복사된다. let x = 5; let y = x; 뒤에 x를 써도 괜찮은 이유다.
빌려 쓰기: 참조(&)와 빌림 규칙
매번 소유권을 넘기거나 복사하면 불편하다. 그래서 Rust에는 빌림(borrowing) 이 있다. 값을 넘기지 않고 &로 잠깐 빌려주는 것이다.
fn len_of(s: &String) -> usize {
s.len() // 읽기만 함, 소유권은 그대로
}
fn main() {
let name = String::from("ojjda");
let n = len_of(&name);
println!("{} 의 길이는 {}", name, n); // name 여전히 사용 가능
}빌림에는 딱 하나의 큰 원칙이 있다.
| 동시에 허용되는 것 | 가능 여부 |
|---|---|
읽기 전용 참조 &T 여러 개 | ✅ |
수정 가능 참조 &mut T 하나 | ✅ |
&mut T와 &T를 동시에 | ❌ |
&mut T 두 개 동시에 | ❌ |
"여럿이 읽거나, 한 명만 쓰거나." 도서관 책에 비유하면 이해가 쉽다. 여러 사람이 같은 책을 돌려 읽을 수는 있지만, 누군가 책에 밑줄을 긋는 동안에는 다른 사람이 동시에 읽거나 고칠 수 없다.
이 규칙이 막아 주는 것이 바로 데이터 레이스(data race) 다. 멀티스레드 프로그램에서 한쪽이 쓰는 도중 다른 쪽이 읽어서 값이 꼬이는 버그 말이다. 다른 언어에서는 테스트로도 잘 안 잡히는 이 버그를, Rust는 컴파일 단계에서 거절한다.
댕글링 참조와 라이프타임 맛보기
하나만 더 보자. 이미 사라진 값을 가리키는 참조, 이른바 댕글링(dangling) 참조다.
fn dangle() -> &String {
let s = String::from("잠깐");
&s // s는 함수 끝에서 해제되는데, 그 참조를 반환하려 함
} // 컴파일 에러: missing lifetime specifierRust 컴파일러는 "참조가 가리키는 값이 참조보다 먼저 죽으면 안 된다"는 걸 추적한다. 이를 라이프타임(lifetime) 이라 부른다. 대부분은 컴파일러가 알아서 추론하므로 입문 단계에서는 "참조는 원본보다 오래 살 수 없다" 정도만 기억해도 충분하다. 위 코드는 참조 대신 String 자체를 반환(소유권을 넘기기)하면 해결된다.
컴파일러와 싸우지 않는 요령
처음 몇 주는 컴파일러와 씨름하는 기분이 든다. 이때 도움이 되는 습관 몇 가지.
- 에러 메시지를 끝까지 읽는다. Rust 컴파일러는 친절하기로 유명하다. 어디서 이동됐고, 어디서 빌렸는지 화살표로 짚어 주고 해결책(
help:)까지 제안한다. - 함수 인자는 기본을
&로. 읽기만 한다면&String보다&str을 받는 게 더 유연하다. - 막히면 일단
clone(). 성능 최적화는 나중 일이다. 동작하는 코드를 먼저 만들고, 병목일 때만 줄이면 된다. cargo check를 자주. 빌드 전체보다 훨씬 빠르게 소유권 에러만 확인할 수 있다.
cargo new hello_rust && cd hello_rust
cargo check # 빠른 문법·소유권 검사
cargo run # 빌드 후 실행정리하며
소유권은 결국 한 문장으로 요약된다. 값에는 주인이 하나, 빌려줄 땐 "여럿이 읽거나 한 명만 쓰거나", 주인이 떠나면 값도 사라진다. 이 규칙 덕분에 Rust는 가비지 컬렉터 없이도 메모리 안전성을 얻었고, 그래서 운영체제·브라우저 엔진·클라우드 인프라처럼 속도와 안정성이 모두 중요한 곳에서 점점 더 많이 쓰이고 있다.
처음엔 컴파일러가 잔소리꾼처럼 느껴질 것이다. 하지만 그 잔소리 하나하나가, 언젠가 새벽 두 시에 받았을 장애 전화를 미리 막아 준 것이라고 생각하면 조금은 고마워진다. 오늘 cargo new 한 줄로 첫 에러를 만나 보시길. 그 에러가 Rust와 친해지는 첫걸음이다.


