- Published on
Rust 소유권과 빌림 체커 완벽 가이드 — Move, Borrow, Lifetime, Drop, Send/Sync, Pin, NLL, Polonius (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 메모리 안전을 위한 세 번째 길
지금까지 두 가지 메모리 관리 모델을 봤다:
- 가비지 컬렉션: 런타임이 자동으로 수집. 안전하지만 GC pause와 메모리 오버헤드.
- 수동 메모리 관리와 할당자: 사용자가 직접
malloc/free. 빠르지만 use-after-free, double-free, 누수의 위험.
Rust는 세 번째 길을 제시한다: 컴파일 타임에 메모리 안전을 증명한다. 런타임 GC도 없고, 수동 free의 위험도 없다. 컴파일러가 모든 메모리 액세스가 안전함을 정적으로 증명하고, 그렇지 않으면 컴파일 자체가 안 된다.
이는 마법이 아니다. 소유권(ownership), 빌림(borrowing), **수명(lifetime)**이라는 세 개념의 정교한 조합이다. 이 세 개념은 1999년 Cyclone 언어, 그 이전의 linear logic과 affine type 이론에 뿌리를 두고 있고, Rust가 이를 처음으로 산업용 언어에 가져왔다.
이 글은 Rust 소유권 시스템의 모든 것을 다룬다. 이론적 배경부터 일상적 사용 패턴, 그리고 차세대 borrow checker인 Polonius까지. 1,300줄에 달하지만 모든 절은 독립적으로 읽을 수 있다.
이 글로 메모리 관리 트릴로지가 완성된다:
세 글이 모이면 "프로그래밍 언어는 메모리를 어떻게 안전하게 다루나"라는 질문 전체에 답할 수 있다.
1. 메모리 안전이 왜 어려운가
1.1 C/C++의 함정
C 코드의 잘 알려진 위험들:
// Use-after-free
char *p = malloc(100);
free(p);
strcpy(p, "hello"); // UB
// Double-free
char *p = malloc(100);
free(p);
free(p); // UB
// Buffer overflow
char buf[10];
strcpy(buf, "this is way too long"); // UB
// Dangling pointer
char *get_string() {
char buf[100];
strcpy(buf, "hello");
return buf; // 스택 변수의 주소를 반환! UB
}
// Iterator invalidation (C++)
std::vector<int> v = {1, 2, 3};
auto it = v.begin();
v.push_back(4); // 재할당으로 it 무효화
*it; // UB
이 모든 버그는 컴파일러가 잡아주지 못한다. 런타임에서 segfault로 운 좋게 잡히거나, 더 흔히는 조용히 데이터를 손상시키거나, 심각하게는 보안 취약점이 된다.
1.2 Microsoft, Google, Mozilla의 통계
- Microsoft (2019): 자기 제품의 보안 버그 중 약 70%가 메모리 안전성 결함.
- Google (2022): Chrome의 high-severity 버그 중 약 70%가 메모리 안전성.
- Linux Kernel: 매월 수십 개의 use-after-free 관련 패치.
이 통계는 "메모리 안전성은 보안 그 자체"라는 결론을 강하게 시사한다.
1.3 두 가지 전통적 해결책
해결책 1: GC — 사용자가 free를 호출하지 않게 한다. 런타임이 알아서 한다. 안전하지만 비용이 있다 (pause, RSS, latency 변동성).
해결책 2: 수동 + 도구 — C/C++을 그대로 쓰되 ASAN, valgrind 같은 도구로 잡는다. 효과적이지만 100%는 아니다 (런타임에 안 일어난 버그는 못 잡음).
Rust는 다른 길을 제시한다: 컴파일러가 모든 메모리 액세스가 안전함을 증명하게 한다. 런타임 비용 없이, 100% 정적 보장.
2. 역사 — Cyclone에서 Rust까지
2.1 Linear Logic (1987)
수학자 Jean-Yves Girard가 발표한 linear logic. 핵심 통찰: "자원은 한 번 쓰면 사라진다"는 개념을 형식적으로 다룬다. 한 번 쓴 가설은 다시 쓸 수 없다.
이는 후에 linear types (일회용 타입)와 affine types (최대 한 번 쓰기 가능한 타입)의 토대가 된다.
2.2 Cyclone (2002)
Cornell과 AT&T가 개발한 "안전한 C". C와 거의 같은 구문이지만 region (수명 영역), nullable pointer 분리, dangling pointer 회피 등을 가졌다. 산업적으로 성공하지 못했지만 Rust에 큰 영향을 주었다.
핵심 아이디어:
- Region inference: 메모리 영역의 수명을 컴파일러가 자동 추론.
- Nullable vs non-null: 포인터 타입을 둘로 분리.
- Tagged unions: 안전한 union 타입.
2.3 Rust의 시작 (2006-2010)
Mozilla의 Graydon Hoare가 개인 프로젝트로 시작. 처음에는 GC 기반 함수형 언어였다. 2010년쯤 GC를 빼고 ownership 모델로 전환. Cyclone과 ML 계열 언어들의 영향.
2.4 Rust 1.0 (2015)
5년의 알파/베타 끝에 1.0 릴리스. 핵심 약속: "stable한 컴파일러는 깨진 코드를 만들지 않는다." 이후 6주마다 새 버전이 나오지만 backward compatible.
2.5 NLL (Non-Lexical Lifetimes, 2018)
처음 borrow checker는 lexical scope 기반이었다. 너무 보수적이었고 사용자가 자주 좌절했다. Niko Matsakis가 주도한 NLL 작업이 이를 MIR 기반의 더 정교한 분석으로 교체. 2018 edition에 포함.
2.6 Polonius (진행 중)
NLL을 더 발전시킨 차세대 borrow checker. Datalog 기반의 fact-based 분석. NLL이 거부하는 일부 안전한 코드를 받아들인다. 아직 실험적, stable에 들어가는 중.
2.7 산업 채택
- 2020: AWS가 Rust 팀에 투자. Bottlerocket(컨테이너 OS), Firecracker(VM), 일부 Lambda 런타임.
- 2022: Linux 6.1에 Rust 코드 첫 머지. Linus Torvalds가 직접 승인.
- 2023: Cloudflare Pingora (Rust 프록시) 발표. Nginx 대체 시도.
- 2024: Microsoft가 Windows 일부 컴포넌트를 Rust로 다시 씀.
- 2025: Linux drm/asahi (Apple Silicon GPU 드라이버)가 Rust로 작성됨.
★ Insight ─────────────────────────────────────
- Rust는 학술 이론의 산업 응용: linear/affine types, region inference, separation logic — 이 모든 학술적 아이디어가 Rust 안에 살아있다. Rust 팀은 새 기능을 도입할 때 학술 논문을 인용하고 형식적 모델을 검토한다. RustBelt 프로젝트는 Rust의 unsafe 안전성을 Coq에서 형식 증명했다.
- Rust의 진짜 혁신은 "산업적 받아들임"이다: 이전에도 Cyclone 같은 안전한 시스템 언어가 있었다. Rust가 다른 점은 (1) cargo 같은 좋은 도구, (2) 점진적 채택 가능성, (3) 활발한 커뮤니티, (4) 큰 회사들의 지원. 좋은 이론 + 좋은 엔지니어링 + 좋은 정치의 조합.
- Rust는 "C/C++ 대체"가 아니라 "새 영역의 가능성": 모든 C/C++ 코드를 Rust로 다시 쓰는 것은 비현실적이다. Rust의 진짜 기여는 "이전에는 GC 언어로만 쓸 수 있던 곳에 메모리 안전한 시스템 언어를 가져온 것" — 예: 브라우저 엔진, OS 컴포넌트, 임베디드.
─────────────────────────────────────────────────
3. 소유권의 기본 — 세 가지 규칙
Rust 소유권 시스템은 단 세 가지 규칙으로 표현 가능하다:
3.1 규칙 1 — 모든 값은 정확히 한 명의 owner를 가진다
let s1 = String::from("hello");
// s1이 String의 owner
3.2 규칙 2 — 한 시점에 한 명의 owner만
owner는 양도(transfer)될 수 있지만 동시에 둘일 수는 없다.
let s1 = String::from("hello");
let s2 = s1; // 소유권이 s1 -> s2로 이전
println!("{}", s1); // 컴파일 에러: s1은 더 이상 유효하지 않음
이 동작을 move라 한다. C++의 move semantics와 비슷하지만 컴파일러가 강제한다.
3.3 규칙 3 — owner가 scope를 벗어나면 값이 drop된다
{
let s = String::from("hello");
// s 사용 가능
}
// 여기서 s가 drop. String의 메모리가 자동 해제.
}가 곧 free를 호출한다. 컴파일러가 정확한 위치를 안다. RAII (Resource Acquisition Is Initialization)의 컴파일 타임 강제.
3.4 왜 한 명의 owner인가
여러 명이 같은 자원을 소유하면 누가 정리할지 모호해진다. 한 명만 소유하면 그 한 명의 scope 끝이 곧 정리 시점이다 — 결정적이고 모호함이 없다.
C++의 unique_ptr가 같은 모델을 가진다. Rust는 이를 모든 값에 강제한다.
3.5 Move의 의미
let v1 = vec![1, 2, 3];
let v2 = v1; // move
// v1은 더 이상 유효하지 않음
내부적으로 무엇이 일어나는가? Vec은 (포인터, 길이, 용량) 세 워드. move는 이 세 워드를 그대로 복사한다 (memcpy 같은 것). v1의 메모리는 건드리지 않는다. v2가 같은 힙 할당을 가리킨다.
핵심: 컴파일러가 v1을 더 이상 사용 불가로 표시한다. 그래서 같은 힙 할당을 두 곳에서 가리키는 일이 없다. v2가 drop되면 힙 할당이 한 번만 free된다.
3.6 Copy vs Move
원시 타입 (i32, f64, bool, char)은 move 대신 copy된다:
let x = 42;
let y = x; // copy
println!("{} {}", x, y); // OK: 둘 다 유효
이는 Copy trait을 구현한 타입. 작고, 힙 할당이 없고, 비트 단위로 안전하게 복사 가능한 타입.
#[derive(Copy, Clone)]
struct Point { x: i32, y: i32 }
Copy는 명시적으로 derive해야 한다. String이나 Vec은 Copy가 아니다 (힙 할당 때문).
3.7 함수 인자와 move
함수에 값을 넘기는 것도 move:
fn take(s: String) {
// s 사용
}
let s = String::from("hello");
take(s);
println!("{}", s); // 컴파일 에러: s는 take()로 move됨
이를 회피하려면 borrow (다음 절).
4. Borrow와 Reference
4.1 빌리기 (Borrowing)
매번 소유권을 옮기지 않고 임시로 빌릴 수 있다. 이를 reference 또는 borrow라 한다.
fn print_string(s: &String) {
println!("{}", s);
}
let s = String::from("hello");
print_string(&s); // s를 빌려줌
println!("{}", s); // OK: s는 여전히 owner
&s가 reference. &String은 reference의 타입. 함수가 끝나면 borrow가 끝나고 owner가 다시 자유롭게 사용 가능.
4.2 Shared Borrow (&T)
읽기 전용. 여러 개를 동시에 가질 수 있다.
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &s;
println!("{} {} {}", r1, r2, r3); // OK
여러 명이 동시에 읽는 것은 안전하다.
4.3 Mutable Borrow (&mut T)
쓰기 가능. 한 번에 한 개만 가질 수 있다.
let mut s = String::from("hello");
let r1 = &mut s;
r1.push_str(" world");
4.4 빌림 규칙 — XOR Rule
핵심 규칙:
- 공유 빌림 N개 OR 가변 빌림 1개, 둘 중 하나만.
같은 데이터를 동시에 두 가지 borrow로 가질 수 없다.
let mut s = String::from("hello");
let r1 = &s; // 공유
let r2 = &mut s; // 컴파일 에러: 공유와 가변 동시 불가
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 컴파일 에러: 가변 둘 동시 불가
4.5 왜 XOR 규칙인가
이 규칙은 두 가지 race condition을 원천 차단한다:
- Iterator invalidation: 한 reference가 iter 중인데 다른 reference가 컬렉션을 수정하면 invalidate. C++의 흔한 버그. Rust는 컴파일 타임에 막는다.
- 데이터 race: 멀티스레드에서 한 곳이 쓰는 동안 다른 곳이 읽으면 정의되지 않은 동작. Rust는 single-threaded 코드에서도 같은 규칙을 적용한다.
이 두 종류의 버그가 모두 "공유와 가변이 동시 존재"에서 나온다. Rust는 그 가능성 자체를 제거한다.
4.6 Borrow의 수명
빌림은 시간적으로 한정된다. 시작과 끝이 명확하다.
let mut s = String::from("hello");
{
let r = &s;
println!("{}", r);
// r이 여기서 끝남
}
let r2 = &mut s; // OK: r은 이미 끝났음
옛날 borrow checker는 lexical scope를 봤다. 모던 borrow checker (NLL)는 마지막 사용을 본다 — 더 똑똑하다.
4.7 Dangling References의 회피
fn dangling() -> &String {
let s = String::from("hello");
&s
} // s가 drop. &s는 dangling.
위 코드는 컴파일 에러. 컴파일러가 "반환 reference가 함수 안에서 만든 값을 가리킨다"를 잡는다.
C에서는 이런 코드가 흔한 버그. Rust는 컴파일 자체를 거부.
5. Lifetime — 시간이 있는 타입
5.1 Lifetime의 정체
reference는 어딘가의 데이터를 가리킨다. 그 데이터가 살아있는 동안만 reference도 유효하다. lifetime은 "이 reference가 얼마나 오래 살 수 있는가"의 형식적 표현이다.
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() { s1 } else { s2 }
}
'a가 lifetime parameter. "두 입력 reference의 lifetime을 'a라고 부르자. 출력 reference도 같은 lifetime을 가진다"라는 뜻.
이 타입 시그니처가 호출자에게 약속한다: "출력 reference는 두 입력 reference 중 더 짧은 것만큼 산다." 호출자는 이 약속을 지킬 수 있을 때만 함수를 호출 가능.
5.2 왜 lifetime이 필요한가
fn longest(s1: &str, s2: &str) -> &str {
// ...
}
let r;
{
let s2 = String::from("short");
r = longest("hello", &s2);
}
println!("{}", r); // s2는 이미 drop됨!
lifetime이 없다면 위 코드가 컴파일 가능하고 dangling reference가 된다. Lifetime은 "출력이 입력에 종속된다"를 표현해서, 컴파일러가 이를 잡을 수 있게 한다.
5.3 Lifetime Elision
대부분의 경우 lifetime을 명시적으로 쓸 필요가 없다. 컴파일러가 추론한다.
fn first_word(s: &str) -> &str {
s.split_whitespace().next().unwrap()
}
이는 실제로 fn first_word<'a>(s: &'a str) -> &'a str. 컴파일러의 elision rule 덕분에 명시적 annotation 불필요.
Elision rules:
- 각 입력 reference에 별도 lifetime parameter
- 입력 lifetime이 정확히 하나면, 모든 출력은 그 lifetime
- 입력에
&self또는&mut self가 있으면, 출력은self의 lifetime
이 세 rule로 대부분의 함수가 lifetime annotation 없이 작동한다.
5.4 'static lifetime
특수한 lifetime: 'static. 프로그램이 끝날 때까지 살아있다는 의미.
let s: &'static str = "hello"; // 문자열 리터럴은 'static
대부분의 string literal이 'static. 그러나 'static을 남발하면 안 된다 — 정말 필요한 경우만.
5.5 Struct에서의 lifetime
reference를 필드로 가진 struct는 lifetime annotation이 필요하다.
struct Wrapper<'a> {
name: &'a str,
}
impl<'a> Wrapper<'a> {
fn name(&self) -> &str {
self.name
}
}
이는 "Wrapper는 name reference만큼만 산다"는 의미.
5.6 Lifetime의 직관
처음에는 lifetime이 어렵게 느껴진다. 직관:
- Lifetime은 "이 reference가 가리키는 데이터의 수명"이다.
- Annotation은 "여러 reference 사이의 수명 관계"를 표현한다.
- 컴파일러가 안 잡는 거의 모든 dangling reference를 lifetime이 잡는다.
6. NLL — Non-Lexical Lifetimes
6.1 옛날의 문제
Rust 2018 이전에는 borrow checker가 lexical scope를 봤다. 그래서 다음 코드가 거부되었다:
let mut v = vec![1, 2, 3];
let r = &v[0];
println!("{}", r); // r 마지막 사용
v.push(4); // 컴파일 에러 (옛날)
옛날 borrow checker는 r이 }까지 산다고 가정. 그러나 실제로는 println! 이후 안 쓰인다.
6.2 NLL의 도입
2018년 NLL이 도입되어 이를 해결. borrow checker가 마지막 사용을 본다.
let mut v = vec![1, 2, 3];
let r = &v[0];
println!("{}", r); // r 마지막 사용
v.push(4); // OK (NLL 이후)
이는 사용자 경험을 크게 개선했다. "왜 이 코드가 안 되지?" 좌절이 크게 줄었다.
6.3 구현 — MIR 기반
NLL은 컴파일러의 MIR (Mid-level IR) 단계에서 동작한다. 흐름 분석으로 각 reference가 진짜로 살아있는 시점을 추적.
이는 단순한 "마지막 사용까지" 이상이다. control flow를 따라가며 reference의 활성 범위를 정확히 계산한다.
6.4 NLL의 한계
NLL도 완벽하지 않다. 일부 안전한 코드가 여전히 거부된다. 가장 흔한 예: two-phase borrows가 필요한 코드.
let mut v = vec![1, 2, 3];
v.push(v.len() as i32); // 옛날: 에러. NLL: OK (two-phase borrow)
NLL은 two-phase borrow를 지원한다. 그러나 더 복잡한 case에서는 여전히 거부.
7. Polonius — 차세대 Borrow Checker
7.1 동기
NLL은 충분히 좋지만, 일부 사용자 코드를 여전히 거부한다. Polonius는 더 정교한 분석으로 더 많은 안전한 코드를 받아들이는 것을 목표.
7.2 Datalog 기반
Polonius는 Datalog (논리 프로그래밍의 한 종류) 위에서 borrow checking을 표현한다. fact를 입력하고 rule이 결론을 도출하는 모델.
Facts:
loan_issued_at(L0, 'a)
loan_killed_at(L0, p2)
...
Rules:
borrow_live_at(loan, point) :-
loan_issued_at(loan, region),
region_live_at(region, point),
!loan_killed_at(loan, point).
이 모델은 더 표현력이 풍부하다. NLL이 해결 못하는 일부 패턴을 처리할 수 있다.
7.3 진행 상황
Polonius는 2018년부터 개발 중. 2024년 시점, 일부 case에서는 NLL을 능가하지만 아직 stable에 들어가지 않았다. 점진적으로 NLL을 대체할 예정.
7.4 Polonius가 받아들이는 케이스
fn get_or_insert(map: &mut HashMap<i32, String>, key: i32) -> &String {
if let Some(v) = map.get(&key) {
return v; // NLL: 에러 (map이 여전히 borrow됨)
}
map.insert(key, String::from("default")); // NLL: 에러
map.get(&key).unwrap()
}
위 코드는 NLL이 거부한다. Polonius는 받아들인다 — map.get(&key)가 None이면 borrow가 끝났음을 추론.
해결책으로 현재 Rust는 entry API를 권장:
map.entry(key).or_insert_with(|| String::from("default"))
Polonius가 stable이 되면 위 같은 명시적 패턴이 덜 필요해진다.
8. Move Semantics 깊이
8.1 Drop Trait
값이 scope를 벗어날 때 자동 호출되는 함수.
struct File {
fd: i32,
}
impl Drop for File {
fn drop(&mut self) {
unsafe { libc::close(self.fd); }
}
}
이는 RAII의 컴파일 타임 강제. C++의 destructor와 비슷하지만 더 단순 (예외 안전성 같은 복잡함이 적음).
8.2 부분 Move
struct의 일부 필드만 move할 수 있다.
struct Pair { a: String, b: String }
let p = Pair { a: String::from("x"), b: String::from("y") };
let a = p.a; // p.a가 move됨
let b = p.b; // OK: p.b는 여전히 유효
println!("{}", p.a); // 컴파일 에러: p.a는 move됨
println!("{}", p.b); // OK
부분 move는 컴파일러가 정밀하게 추적한다.
8.3 Move와 Drop의 상호작용
Move된 값은 drop되지 않는다 — 그 값을 받은 새 owner가 책임진다.
let s1 = String::from("hello");
let s2 = s1; // s1 -> s2 move
// 함수 끝
// s2가 drop. s1은 drop 되지 않음 (이미 move됨).
컴파일러가 정확히 한 번만 drop을 보장한다. double-free 불가능.
8.4 Clone
Copy가 아닌 값을 명시적으로 복제하려면 Clone:
let s1 = String::from("hello");
let s2 = s1.clone(); // 명시적 깊은 복사
println!("{} {}", s1, s2); // 둘 다 유효
Clone은 사용자가 호출. 컴파일러가 자동으로 안 한다. 복사가 비싸므로 명시적이어야 한다는 철학.
8.5 Copy vs Clone의 차이
Copy: 비트 단위 복사로 안전. 자동. 작은 값에만.Clone: 사용자 정의 복사. 명시적 호출. 깊은 복사 가능.
Copy인 모든 타입은 Clone도 구현해야 한다 (Clone이 super-trait).
#[derive(Copy, Clone)]
struct Point { x: i32, y: i32 }
9. RAII와 Drop — 자원 관리의 일반화
9.1 RAII의 약속
RAII (Resource Acquisition Is Initialization)는 C++의 핵심 패턴. "자원을 얻으면 객체로 감싸고, 객체가 destroy될 때 자원을 해제한다."
Rust는 이를 컴파일 타임에 강제한다. 모든 값에 명시적 또는 자동 Drop이 있고, scope 끝에 호출된다.
9.2 어떤 자원에 적용할 수 있나
- 메모리:
Box,Vec,String이 자기 메모리를 free. - 파일 디스크립터:
File이 close. - 소켓:
TcpStream,TcpListener. - 락:
MutexGuard가 unlock. - 그래픽 핸들: GPU 텍스처, 셰이더.
- OS 핸들: 파일 매핑, 공유 메모리.
모든 자원이 같은 패턴으로 관리된다. 명시적 close/release/free가 거의 없다.
9.3 Drop의 호출 순서
여러 변수가 scope를 벗어날 때 drop 순서는 선언 역순이다.
let a = String::from("a"); // 1
let b = String::from("b"); // 2
let c = String::from("c"); // 3
// scope 끝: c, b, a 순서로 drop
이는 C++과 같다. 의존 관계가 있는 자원 (예: lock guard와 그 안에서 만든 객체) 관리에 중요.
9.4 ManuallyDrop과 mem::drop
let s = String::from("hello");
std::mem::drop(s); // 명시적 drop
println!("{}", s); // 컴파일 에러: s는 이미 drop됨
mem::drop은 자기 인자를 받아서 그냥 함수가 끝난다. 그러면 인자는 자동으로 drop. 이는 명시적 drop을 표현하는 표준 패턴.
ManuallyDrop<T>는 자동 drop을 비활성화. unsafe 코드에서 자주 사용.
9.5 GC와의 비교
| 기준 | RAII (Rust) | GC |
|---|---|---|
| 결정성 | 결정적 (scope 끝) | 비결정적 (GC가 결정) |
| 즉시성 | 즉시 | 지연 |
| 성능 | 예측 가능 | 가변 (pause) |
| 사용자 부담 | borrow checker 학습 | 거의 없음 |
| 자원 종류 | 모든 자원 | 메모리 위주 |
RAII의 큰 장점: 메모리 외 자원도 자동 관리. GC는 memory만 잘 다루지, 파일/소켓/락은 사용자가 신경 써야 한다 (try-with-resources, defer 등).
10. Smart Pointers — 추가적 메모리 관리 패턴
&T와 &mut T만으로 표현 못 하는 패턴들이 있다. Rust는 이를 위해 smart pointer 타입들을 제공한다.
10.1 Box - 힙 할당
let b = Box::new(42);
println!("{}", *b);
Box<T>는 단순한 힙 할당. owner가 정확히 한 명. drop 시 자동 free.
용도:
- 큰 객체를 힙에 두고 싶을 때
- 재귀 자료구조 (linked list, tree)
- trait object (
Box<dyn Trait>)
10.2 Rc - 참조 카운트
use std::rc::Rc;
let a = Rc::new(String::from("hello"));
let b = Rc::clone(&a);
let c = Rc::clone(&a);
println!("count: {}", Rc::strong_count(&a)); // 3
Rc<T>는 single-threaded reference counting. 여러 owner를 허용. 카운트가 0이 되면 drop.
순환 참조 주의: Rc<T>로 사이클을 만들면 누수. Weak<T>로 회피.
10.3 Arc - Atomic 참조 카운트
use std::sync::Arc;
use std::thread;
let a = Arc::new(String::from("hello"));
let a2 = Arc::clone(&a);
thread::spawn(move || {
println!("{}", a2);
});
println!("{}", a);
Arc<T>는 multi-threaded RC. atomic 연산 사용. Rc보다 약간 느림.
10.4 RefCell - 내부 가변성
use std::cell::RefCell;
let c = RefCell::new(5);
*c.borrow_mut() += 1;
println!("{}", c.borrow());
RefCell<T>는 컴파일 타임 borrow check를 런타임으로 옮긴다. 빌림 규칙 위반이 panic으로 나타난다.
용도: 외부 인터페이스가 &self인데 내부 상태를 수정해야 할 때.
Rc<RefCell<T>> 패턴은 single-threaded에서 흔한 "공유 가변" 표현.
10.5 Cell - Copy 타입의 내부 가변성
use std::cell::Cell;
let c = Cell::new(5);
c.set(10);
let v = c.get();
Cell<T>는 T: Copy에만 작동. RefCell보다 단순하고 빠르다.
10.6 Mutex와 RwLock
use std::sync::Mutex;
let m = Mutex::new(0);
{
let mut g = m.lock().unwrap();
*g += 1;
} // g가 drop. lock 자동 해제.
Mutex<T>는 뮤텍스 + 데이터를 한 타입에 묶는다. lock을 잡으면 데이터에 접근. lock guard가 drop되면 자동 unlock.
이는 RAII의 우아한 응용. Lock을 잊고 해제 안 하는 일이 불가능.
10.7 정리 표
| 타입 | 소유 | 멀티스레드 | 용도 |
|---|---|---|---|
Box<T> | 단일 | OK | 힙 할당, trait object |
Rc<T> | 공유 | NO | single-thread 공유 |
Arc<T> | 공유 | OK | multi-thread 공유 |
Cell<T> | 단일 | NO | 내부 가변성 (Copy 타입) |
RefCell<T> | 단일 | NO | 내부 가변성 (런타임 검사) |
Mutex<T> | 단일 | OK | lock + 데이터 |
RwLock<T> | 단일 | OK | reader-writer lock |
11. Send와 Sync — 멀티스레드 안전성을 타입으로
11.1 Send와 Sync trait
Rust는 멀티스레드 안전성을 두 marker trait으로 표현한다:
Send: 한 스레드에서 다른 스레드로 안전하게 옮길 수 있는 타입.Sync: 여러 스레드에서 동시에 reference를 안전하게 가질 수 있는 타입.
이 두 trait은 자동 derive된다. 컴파일러가 타입의 구성 요소를 보고 자동으로 결정.
11.2 무엇이 Send인가
거의 모든 일반 타입. i32, String, Vec<T> (T가 Send면), Box<T> (T가 Send면).
Send가 아닌 타입의 예:
Rc<T>: 카운터가 atomic이 아니어서 race condition.*const T,*mut T: raw pointer는 안전성 보장 없음.- 특정 OS 핸들: 다른 스레드에서 close하면 안 되는 것들.
11.3 무엇이 Sync인가
여러 스레드가 동시에 &T를 가져도 안전한 타입.
trait Sync {}
// 자동 derive 규칙 (대략):
// T: Sync iff &T: Send
Sync가 아닌 타입의 예:
Cell<T>,RefCell<T>: 내부 가변성이 단일 스레드 가정.Rc<T>: 카운터 race.
11.4 컴파일 타임 데이터 race 방지
use std::thread;
let v = vec![1, 2, 3];
thread::spawn(move || {
println!("{:?}", v);
}); // OK: Vec<i32>는 Send
let r = Rc::new(42);
thread::spawn(move || {
println!("{}", r);
}); // 컴파일 에러: Rc는 Send 아님
thread::spawn의 시그니처가 F: Send + 'static을 요구. 컴파일러가 검사. 컴파일 타임에 데이터 race 불가능.
11.5 자동 derive의 우아함
사용자가 거의 신경 쓸 필요 없다. Send/Sync는 구성 요소의 trait을 자동으로 받는다. 새 struct를 만들면 자동으로 적절한 trait이 결정.
struct MyStruct {
a: String,
b: Vec<i32>,
}
// 자동: MyStruct: Send + Sync (모든 필드가 Send + Sync)
struct WithRc {
r: Rc<i32>,
}
// 자동: WithRc: !Send (Rc 때문)
11.6 unsafe impl
특수 케이스에서 사용자가 명시적으로 Send/Sync를 구현할 수 있다. 그러나 unsafe — 사용자가 안전성을 보장해야 한다.
unsafe impl Send for MyStruct {}
unsafe impl Sync for MyStruct {}
이는 컴파일러가 자동 derive하지 않는 경우 (예: raw pointer 사용)에 필요. 매우 신중해야 한다.
12. Pin — Self-Referential 자료구조
12.1 문제
대부분의 Rust 타입은 자유롭게 move 가능. 그러나 일부 자료구조는 자기 자신을 가리키는 reference를 가진다 (self-referential).
struct SelfRef {
data: String,
pointer: *const String, // self.data를 가리킴
}
이런 객체를 move하면 pointer가 옛 위치를 가리키게 된다 — dangling. 이를 막아야 한다.
12.2 Pin의 약속
Pin<P>는 "이 값은 옮겨지지 않는다"는 보장. P는 보통 Box, &mut, Arc.
let pinned: Pin<Box<MyType>> = Box::pin(MyType::new());
한 번 pin되면 메모리 안에서 위치가 고정. drop될 때까지.
12.3 왜 어려운가
Pin은 Rust의 가장 어려운 부분 중 하나로 악명 높다. 이유:
- 일반적인 move semantics와 충돌
Unpin(move 가능)과!Unpin(move 불가) 두 카테고리- Pinned API의 미세한 규칙
12.4 Future와의 관계
async Rust의 Future는 self-referential일 수 있다. async function이 await 지점에서 일시 중단되면, 함수 안의 reference들이 같은 frame을 가리킨다. 이를 안전하게 하려면 Future가 pin되어야 한다.
async fn foo() {
let s = String::from("hello");
let r = &s;
other().await;
println!("{}", r); // r은 같은 frame의 s를 가리킴
}
이 함수가 컴파일되어 만들어지는 Future는 self-referential. Pin이 필수.
12.5 Unpin 자동 derive
대부분의 타입은 Unpin을 자동 구현. 즉 pin되어도 move 가능.
struct Normal { x: i32 }
// 자동: Normal: Unpin
!Unpin (Pin이 강제로 작동)하려면 PhantomPinned를 필드에 추가:
struct CantMove {
x: i32,
_pin: std::marker::PhantomPinned,
}
// CantMove: !Unpin
12.6 일상적 사용
대부분의 Rust 사용자는 Pin을 직접 다룰 일이 없다. async 코드를 짤 때 컴파일러가 알아서 처리. 라이브러리 작성자만 깊이 신경 쓴다.
★ Insight ─────────────────────────────────────
- Pin은 후회되는 디자인이라는 평: 많은 Rust 코어 개발자가 "Pin이 더 우아할 수 있었다"고 말한다. 그러나 backward compatibility 때문에 바꾸기 어렵다. async 모델을 처음부터 다시 디자인한다면 Pin이 다른 모습일 것이다.
- 그래도 Pin이 작동한다: 이 복잡함에도 불구하고 async Rust가 잘 동작한다. tokio, async-std, monoio 같은 런타임이 모두 Pin을 활용. 사용자 입장에서는 거의 보이지 않는다.
- Self-referential의 다른 길: Pin 외에도 self-referential을 다루는 방법이 있다. ouroboros 같은 매크로 라이브러리, 또는 인덱스를 reference 대신 쓰기. 각자 트레이드오프가 있다.
─────────────────────────────────────────────────
13. unsafe — 모든 추상화의 토대
13.1 unsafe가 무엇인가
unsafe 키워드는 컴파일러의 메모리 안전 검사를 사용자의 책임으로 옮긴다. unsafe 블록 안에서는:
- raw pointer 역참조 (
*ptr) - mutable static 변수 접근
- unsafe 함수 호출
- unsafe trait 구현
- union 필드 액세스
이런 작업이 가능. 그러나 여전히 borrow checker는 작동하고, type safety는 유지된다.
13.2 왜 필요한가
Rust의 안전한 추상화도 결국 어딘가에서 OS와 하드웨어를 만나야 한다. Vec은 내부적으로 raw pointer로 메모리를 관리한다. Mutex는 OS의 pthread_mutex를 wrap한다. 이 경계가 unsafe.
impl<T> Vec<T> {
pub fn push(&mut self, value: T) {
// ...
unsafe {
ptr::write(self.as_mut_ptr().add(len), value);
}
// ...
}
}
Vec::push의 안전한 인터페이스 안에 unsafe 블록이 있다. 사용자에게 노출되는 것은 안전한 인터페이스이고, 내부의 unsafe는 라이브러리 작성자가 검증한다.
13.3 안전 캡슐화의 원칙
핵심 원칙: safe API는 어떤 입력에서도 UB를 일으키면 안 된다. 안전한 함수의 외부 동작이 안전하면, 그 함수는 unsafe를 사용해도 "safe abstraction"이다.
이 원칙 덕분에 unsafe 코드를 작은 영역에 격리할 수 있다. 대부분의 사용자는 unsafe를 한 번도 쓰지 않고도 Rust로 모든 것을 할 수 있다.
13.4 unsafe ≠ 위험
"unsafe"라는 단어가 오해를 부른다. unsafe Rust도 여전히:
- 타입 안전
- borrow checker 적용
- thread safety (Send/Sync) 검사
unsafe가 늘리는 것은 정확히: raw pointer 역참조, mutable static, unsafe 함수 호출, unsafe trait, union. 그게 전부.
13.5 unsafe의 검토
unsafe 블록은 코드 리뷰의 핵심 대상. 보통 다음을 검토:
- 안전성 invariant가 무엇인가
- 그 invariant가 unsafe 블록 외부에서 보장되는가
- 모든 입력 case가 처리되었는가
큰 Rust 프로젝트는 보통 unsafe 사용을 모두 문서화하고 정당화한다.
13.6 RustBelt — 형식 증명
RustBelt 프로젝트는 Rust의 unsafe abstractions이 정말 안전한지를 Coq에서 형식 증명했다. Mutex, Arc, RefCell 등의 핵심 추상화가 검증되었다.
이는 "Rust의 안전성은 단지 약속이 아니라 수학적으로 증명 가능"이라는 강력한 결과.
14. GC vs 수동 관리 vs Rust — 비교
| 기준 | GC (Java/Go) | 수동 (C/C++) | Rust |
|---|---|---|---|
| 안전성 | 자동 | 사용자 책임 | 컴파일러 강제 |
| 결정성 | 비결정적 | 결정적 | 결정적 |
| 런타임 비용 | GC pause, RSS | 없음 | 거의 없음 |
| 학습 곡선 | 낮음 | 중간 | 높음 |
| 자원 종류 | 메모리 위주 | 모든 것 | 모든 것 |
| 데이터 race | 가능 | 가능 | 컴파일 에러 |
| 용도 | 일반 | 시스템 | 시스템 |
각자 트레이드오프가 명확하다. Rust는 "안전성과 성능을 동시에 얻는 대신 borrow checker 학습이 필요"한 모델.
15. 일반적 borrow checker 에러와 디버깅
15.1 cannot move out of borrowed content
fn first(v: &Vec<String>) -> String {
v[0] // 에러: borrow에서 move 불가
}
해결: clone(), 또는 &v[0] 반환, 또는 소유권 이동.
15.2 cannot borrow as mutable while immutable
let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4); // 에러
println!("{}", r);
해결: r을 더 빨리 끝나게 하거나, push 후에 다시 borrow.
15.3 borrow may not live long enough
fn f() -> &str {
let s = String::from("hello");
&s // 에러: s가 함수 밖에서 살지 못함
}
해결: String을 owner로 반환.
15.4 자주 쓰이는 회피 패턴
.clone(): 빠른 해결책. 약간의 비용.Rc<T>/Arc<T>: 공유가 진짜로 필요할 때.RefCell<T>: 컴파일 타임 검사를 런타임으로.- Index 사용: reference 대신 인덱스를 쓰면 아예 borrow가 없음.
- 메서드 시그니처 변경:
&self→&mut self또는 반대.
15.5 NLL 이후로 줄어든 좌절
NLL 도입으로 사용자가 "왜 안 되지?" 하는 경우가 크게 줄었다. 그래도 처음에는 어렵다. Rust 사용자의 경험: "처음 3개월 동안은 borrow checker와 싸우다가, 그 후로는 사고방식이 바뀐다."
16. 사례 — Rust로 다시 쓴 것들
16.1 Cloudflare Pingora
2022년 Cloudflare가 발표. 자기 인프라의 Nginx 기반 프록시를 Rust로 다시 쓴 것. 이유:
- Nginx의 멀티프로세스 모델이 자기 워크로드에 안 맞음
- 메모리 안전성
- 모던 Rust 생태계
결과: 더 안정적, 메모리 사용량 감소, 새 기능 추가가 쉬워짐. Nginx 글에서 다뤘다.
16.2 Linux 커널의 Rust
2022년 Linux 6.1에 Rust가 처음 머지. 이후 점진적으로 확장. 2024년 시점:
- drm/asahi (Apple Silicon GPU 드라이버, Rust)
- 일부 PHY 드라이버
- 새 NVMe 드라이버 (실험적)
C가 여전히 메인이지만 Rust가 부분적으로 들어왔다. 이는 거의 30년 동안 C-only였던 Linux 커널의 큰 변화.
16.3 Microsoft Windows
Microsoft가 Windows의 일부 컴포넌트를 Rust로 다시 쓰고 있다. DirectWrite 같은 일부 시스템 라이브러리. 여전히 작은 비율이지만 의지의 표현.
16.4 Firefox 컴포넌트
Mozilla가 Firefox의 일부 (CSS 엔진 Stylo, 일부 미디어 디코더)를 Rust로 작성. 메모리 안전성 + 성능 동시 달성 사례.
16.5 데이터베이스
- TiKV (PingCAP): 분산 KV 저장소. 모두 Rust.
- Materialize: 스트리밍 SQL 데이터베이스. Rust.
- InfluxDB IOx: 시계열 DB의 새 스토리지 엔진. Rust.
16.6 새 시스템 도구들
- ripgrep: grep의 Rust 후속. 사실상 표준이 됨.
- fd: find 대안.
- bat: cat 대안 (syntax highlighting).
- eza: ls 대안.
- bottom: top 대안.
- starship: 셸 prompt.
이 도구들은 빠르고 안전하고 cargo로 쉽게 설치 가능. Rust 생태계의 강점.
17. Rust가 어렵다는 게 사실인가
17.1 진짜 어려운 것
- Lifetime의 미세한 패턴: 처음에는 직관이 안 잡힌다.
- Pin과 async: 여전히 학습 비용이 큼.
- trait object와 generic의 차이: 둘이 비슷해 보이지만 다름.
- macro 작성: macro_rules!와 procedural macro의 차이.
17.2 과장된 어려움
- 소유권의 기본 규칙: 사실 매우 단순. 세 줄로 표현 가능.
- borrow checker: NLL 이후 훨씬 친절해짐.
- 컴파일 에러 메시지: 매우 좋은 편. 어떻게 고칠지 제안까지.
17.3 학습 곡선의 모양
처음 1-3개월: borrow checker와 싸움. 3-6개월: 패턴이 보이기 시작. 6-12개월: 자연스러워짐. 1년 이후: "이게 이렇게 우아한 거였구나."
이 곡선을 통과한 사람은 Rust를 떠나지 않는다는 흥미로운 통계가 있다.
17.4 누구에게 적합한가
- 시스템 프로그래밍을 하는 사람
- 메모리 안전성이 중요한 도메인 (브라우저, OS, 보안)
- 고성능과 안전성을 동시에 원하는 사람
- 함수형 사고에 익숙한 사람 (Rust는 함수형 영향을 받음)
누구에게 비추천?
- 빠르게 prototype을 만들고 싶은 사람 (학습 비용)
- GC 언어로 충분한 워크로드 (REST API 같은 일반 백엔드)
- 짧은 수명의 스크립트
18. 미래 — Rust의 다음 단계
18.1 GAT (Generic Associated Types)
2022년 stable. trait의 associated type이 generic을 가질 수 있게 해준다. async trait의 토대.
trait Iterator {
type Item<'a> where Self: 'a;
fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>;
}
18.2 async trait
2023년 stable. 이전에는 async-trait 매크로 crate가 필요. 지금은 직접 async fn을 trait에 쓸 수 있다.
trait Database {
async fn query(&self, sql: &str) -> Result<Rows>;
}
18.3 Polonius
위에서 본 차세대 borrow checker. 점진적으로 NLL을 대체.
18.4 const generics 확장
const N: usize 같은 const generic이 더 유연해지는 작업. 컴파일 타임 계산이 더 풍부해진다.
18.5 Linear types?
이론적으로는 affine types (Rust의 owner)을 진짜 linear types (반드시 한 번 써야 함)로 확장하는 논의가 있다. 일부 자원 관리에 더 강한 보장 제공. 아직 실험.
19. 결론 — Rust는 메모리 관리의 세 번째 길이다
이 글을 다 읽었다면 다음 질문에 답할 수 있을 것이다:
- 소유권의 세 규칙은 무엇인가?
- 공유 borrow와 가변 borrow의 차이는?
- Lifetime annotation이 필요한 경우는?
- NLL이 옛날 lifetime과 다른 점은?
- Send와 Sync는 무엇을 보장하나?
- Rc, Arc, RefCell, Mutex의 차이는?
- Pin이 왜 필요한가?
- unsafe가 의미하는 것은 무엇이고 무엇이 아닌가?
Rust는 magic이 아니다. 1959년 Lisp에서 시작한 GC, 1960년대부터 있던 reference counting, 1980년대 linear logic — 이 모든 것이 결합되어 만들어진 정교한 엔지니어링 결과이다. 컴파일러가 메모리 안전을 강제할 수 있다는 것은 30년 전에는 학술적 호기심이었다. 지금은 industrial production의 일부이다.
이 글로 메모리 관리 트릴로지가 완성되었다:
세 글을 모두 읽으면, "프로그래밍 언어가 메모리 안전을 어떻게 다루나"라는 질문 전체에 답할 수 있다. 각 모델이 다른 트레이드오프를 만들고, 다른 워크로드에 적합하다. 한 가지 정답은 없다 — 워크로드에 맞는 모델을 고르는 안목이 중요하다.
다음 글에서는 [WebAssembly 런타임 내부] 또는 [컨테이너 내부 (cgroups, namespaces, runc)]를 다룰 예정이다. Rust로 만들어진 새로운 카테고리들의 풍경도 둘러보자.
부록 A — 참고 자료
- The Rust Programming Language ("the book") — 공식 입문서.
- Rust by Example — 예제 중심.
- Rustonomicon — unsafe Rust 가이드.
- RustBelt project — Rust unsafe의 형식 증명.
- Niko Matsakis's blog — borrow checker 디자인 글.
- Polonius design — 차세대 borrow checker.
- Cyclone language paper — Rust의 영적 조상.
- Linus Torvalds on Rust in Linux — Linux의 Rust 도입 결정.
- Cloudflare: How we built Pingora — Rust 프록시 사례.
- Rust async book — async Rust 가이드.
부록 B — 자주 묻는 질문
Q: Rust를 배우는 데 얼마나 걸리나? A: 기본은 1-2주. 익숙해지기까지 3-6개월. "자연스러워지기"까지 6-12개월.
Q: Rust가 C++보다 빠른가? A: 거의 같다. 둘 다 같은 LLVM 백엔드를 쓴다. Rust가 제공하는 안전성은 비용이 거의 없다.
Q: Rust로 GUI 앱을 만들 수 있나? A: 가능하지만 생태계가 아직 미성숙. egui, iced, tauri 같은 것들이 있지만 Qt/GTK만큼 풍부하지는 않다.
Q: Rust가 Java를 대체할까? A: 안 할 것 같다. 두 언어가 다른 niche. Java는 일반 백엔드, Rust는 시스템과 성능 critical.
Q: 모든 새 프로젝트를 Rust로 해야 하나? A: 아니다. 학습 비용이 있고, 모든 워크로드에 적합하지 않다. 시스템 프로그래밍, 메모리 안전성이 중요한 도메인에 가장 적합.
Q: Rust의 GC는 정말 없나?
A: 진짜 없다. Rc/Arc는 reference counting이지만 cycle을 처리하지 못한다. tracing GC를 추가하려는 시도가 일부 있지만 stable에 없다.
Q: borrow checker가 너무 빡빡해서 안전한 코드를 거부하면? A: NLL 이후 많이 좋아졌다. Polonius로 더 좋아질 것이다. 그래도 거부되면 보통 코드 구조의 신호 — 재설계의 기회.
Q: Rust에서 데이터 race가 정말 불가능한가? A: safe Rust에서는 그렇다. unsafe 블록에서는 사용자 책임. 그러나 잘 캡슐화된 라이브러리를 쓰면 사용자는 unsafe를 만질 일이 없다.
부록 C — 미니 용어집
- Ownership: 모든 값의 단일 owner.
- Move: 소유권 이전.
- Borrow: 임시 reference.
- Shared borrow (
&T): 읽기 전용 reference, 다수 가능. - Mutable borrow (
&mut T): 쓰기 reference, 한 번에 하나. - Lifetime: reference의 유효 기간.
'a: lifetime parameter.'static: 프로그램 전체 수명.- NLL: Non-Lexical Lifetimes. 모던 borrow checker.
- Polonius: 차세대 borrow checker.
- Drop: scope 끝의 자동 정리.
- RAII: Resource Acquisition Is Initialization.
- Copy: 비트 단위 복사 가능 trait.
- Clone: 명시적 복사 trait.
- Box: 힙 할당 smart pointer.
- Rc: single-thread reference counting.
- Arc: atomic reference counting.
- RefCell: 런타임 borrow check.
- Mutex/RwLock: lock + 데이터.
- Send: 스레드 간 이동 가능.
- Sync: 스레드 간 공유 reference 가능.
- Pin: 메모리 위치 고정.
- Unpin: pin 후에도 move 가능 (대부분의 타입).
- unsafe: 컴파일러 검사 우회 (제한적).
- Trait object:
dyn Trait. 동적 디스패치. - Generic: 컴파일 타임 다형성.
- MIR: Mid-level IR. 컴파일러 내부 표현.
- Affine type: 최대 한 번 사용 가능.
- Linear type: 정확히 한 번 사용해야 함.
이 글로 메모리 관리 트릴로지가 완성되었다. 가비지 컬렉션, 메모리 할당자, 그리고 이 글까지 셋이 모이면 "프로그래밍 언어가 메모리 안전을 어떻게 다루나"의 풍경 전체가 그려진다. GC는 자동, 수동은 직접, Rust는 컴파일 타임 검증 — 같은 문제에 대한 세 가지 다른 답이다. 어느 것도 절대 정답이 아니고, 각자의 워크로드에 맞는 답을 고르는 것이 중요하다.