C++ 개발자가 보는 Rust 메모리 안전성: 소유권, Borrow Checker, 수명, unsafe 경계
들어가며: “Rust는 왜 메모리 버그가 없을까?”
왜 Rust 메모리 안전성인가
C/C++에서 use-after-free, 댕글링 포인터, Data Race는 런타임에만 드러나며, 컴파일러는 대부분 통과시킵니다. Rust는 소유권·Borrow checker·수명으로 이런 버그를 컴파일 단계에서 차단합니다. 이 글은 실무에서 겪는 문제 시나리오, 소유권·빌림·수명·unsafe 예제, 자주 만나는 컴파일 에러와 해결법, 실무 패턴을 C++ 개발자의 시각에서 정리합니다.
C++ 개발자에게 Rust의 규칙은 낯선 발명이라기보다 이미 알고 있는 좋은 습관을 컴파일러가 강제하는 것에 가깝습니다. std::unique_ptr의 단일 소유권, “레퍼런스를 넘길 때는 원본이 더 오래 살아야 한다”는 규칙, “공유 데이터는 락으로 보호한다”는 원칙은 C++에서는 리뷰와 AddressSanitizer·ThreadSanitizer로 지키는 관례지만, Rust에서는 타입 시스템의 일부입니다. 그 대가로 컴파일러가 증명하지 못하는 올바른 코드도 거부당하는 경우가 있고, 이 “싸움”이 Rust 학습 초기의 대부분을 차지합니다. 아래 예제들은 그 거부가 어떤 C++ 버그에 대응하는지에 초점을 맞춥니다.
이 글에서 다루는 것:
- 문제 시나리오: 실무에서 겪는 메모리 버그와 Rust가 막는 방식
- 소유권·이동: 누가 메모리를 해제하는지 타입으로 보장
- Borrow checker: 불변/가변 빌림 규칙, 이중 빌림 차단
- 수명(lifetime): 댕글링 참조 컴파일 타임 차단
- unsafe: FFI·성능 최적화 시 안전한 사용법
- 자주 하는 실수: borrow checker 에러 해결 패턴
- 베스트 프랙티스: clone 최소화, 참조 활용, unsafe 격리
- 프로덕션 패턴: Arc·Mutex·채널, 수명 명시 패턴 관련 글: C++ vs Rust.
실무에서 겪는 메모리 버그
"네트워크 패킷을 파싱한 Vec<u8>를 다른 스레드로 넘겼는데,
원본을 다시 쓰다가 크래시가 났습니다."
C++에서: std::move 후 표준 라이브러리 객체는 “유효하지만 지정되지 않은(valid but unspecified)” 상태입니다. size() 호출 자체는 UB가 아니지만 보통 빈 벡터가 되어 있어서, 원본을 계속 쓰는 코드는 컴파일도 되고 크래시도 없이 조용히 틀린 결과를 냅니다. 원본이 빈 상태라는 가정 아래 v[0]에 접근하면 그때는 UB입니다. 사용자 정의 타입의 이동 생성자가 원본을 어떤 상태로 남기는지는 구현에 달려 있어 더 예측하기 어렵습니다.
Rust에서: 이동 후 원본은 사용 불가입니다. 같은 코드를 쓰면 컴파일 에러가 납니다. Rust의 이동은 비트 단위 복사 후 원본을 컴파일러가 “죽은 변수”로 표시하는 것이라, 이동 생성자도 원본의 소멸자 호출도 없습니다.
fn on_packet_received(packet: Vec<u8>) {
let handle = std::thread::spawn(move || {
parse_packet(&packet);
});
handle.join().unwrap();
// println!("{}", packet.len()); // 컴파일 에러: use of moved value: `packet`
}
"함수에서 문자열을 만들고 참조를 반환했는데,
호출자가 사용할 때 이미 해제된 메모리를 가리키고 있습니다."
Rust에서: 수명 검사로 컴파일 에러가 납니다.
// fn get_name() -> &str {
// let s = String::from("hello");
// &s // 컴파일 에러: borrowed value does not live long enough
// }
fn get_name() -> String {
String::from("hello") // 소유권 반환
}
주의사항: &str을 반환하려면 호출자가 소유한 버퍼나 'static 리터럴이 필요합니다.
"Rc<T>를 스레드로 넘기려 했는데 Rust에서 컴파일 에러가 나요."
원인: Rc는 non-atomic이라 Send가 아닙니다. 스레드 간 전달 시 컴파일 에러.
// use std::rc::Rc;
// use std::thread;
// let r = Rc::new(42);
// thread::spawn(move || { println!("{}", r); }); // 에러: Rc는 Send가 아님
use std::sync::Arc;
use std::thread;
fn main() {
let r = Arc::new(42);
let r_clone = r.clone();
let handle = thread::spawn(move || {
println!("{}", r_clone); // OK: Arc는 Send + Sync
});
handle.join().unwrap();
}
”이터레이터 무효화”
"Vec를 순회하면서 push를 했더니, 이터레이터가 무효화되어 크래시가 났습니다."
Rust에서: Borrow checker가 반복 중 가변 빌림을 막습니다.
let mut v = vec![1, 2, 3];
for x in &v {
if *x == 2 {
// v.push(4); // 컴파일 에러: cannot borrow `v` as mutable
}
}
C++의 for (auto& x : v) { v.push_back(4); }는 컴파일되고, 벡터가 재할당되는 순간 반복자가 해제된 메모리를 가리킵니다. 용량이 충분하면 재할당이 없어 테스트에서는 멀쩡하다가 데이터가 많아질 때만 크래시가 나는, 재현이 까다로운 버그입니다. Rust에서는 for x in &v가 반복하는 동안 v를 불변으로 빌리고 있으므로, 같은 스코프에서 &mut v가 필요한 push는 무조건 거부됩니다. 재할당이 일어날지 여부와 상관없이 막는다는 점이 중요합니다.
”순환 참조로 메모리 누수”
"Rc로 A와 B가 서로를 가리키게 했는데, 참조 카운트가 0이 안 되어
메모리가 해제되지 않아요."
해결: Rc::downgrade로 Weak를 만들어 순환을 끊습니다. prev를 Weak로 두면 참조 카운트에 포함되지 않아 순환이 끊깁니다.
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>,
prev: Option<Weak<RefCell<Node>>>, // Weak로 순환 참조 방지
}
이 항목은 다른 항목과 성격이 다릅니다. Rust는 메모리 누수를 막아 주지 않습니다. 순환 참조로 인한 누수는 안전한 Rust에서도 얼마든지 만들 수 있고, std::mem::forget이나 Box::leak처럼 의도적으로 누수를 만드는 함수도 unsafe 없이 호출할 수 있습니다. Rust가 보장하는 “메모리 안전성”은 해제된 메모리 접근, 이중 해제, 데이터 경쟁 같은 정의되지 않은 동작이 없다는 뜻이지, 모든 메모리가 제때 해제된다는 뜻이 아닙니다. C++의 std::shared_ptr 순환과 똑같이 Weak로 설계 단계에서 끊어야 합니다.
”Mutex 없이 공유 변수”
"여러 스레드가 같은 카운터를 증가시키는데, mutex 없이 했더니
결과가 매번 달라요."
Rust에서: Send·Sync로 “스레드 안전하지 않은 공유”를 컴파일 단계에서 차단합니다. 여러 스레드에서 let mut count = 0;을 수정하려고 하면, 클로저가 count를 가변으로 빌려야 하는데 thread::spawn은 'static 클로저를 요구하므로 컴파일되지 않습니다. 결국 Arc<Mutex<i32>>나 AtomicI32처럼 동기화가 타입에 드러나는 방식만 남습니다. 다만 이것이 막는 것은 데이터 경쟁뿐이고, 락 순서가 엇갈려 생기는 데드락이나 “확인 후 실행” 사이의 논리적 경쟁 상태는 Rust도 막지 못합니다.
메모리 버그 유형 다이어그램
flowchart TB
subgraph Problems[실무 메모리 버그]
P1[이동 후 사용]
P2[댕글링 참조]
P3[이터레이터 무효화]
P4[Data Race]
P5[순환 참조]
end
subgraph Rust[Rust 해결]
R1[소유권·이동]
R2[수명 검사]
R3[Borrow checker]
R4[Send/Sync]
R5[Weak]
end
P1 --> R1
P2 --> R2
P3 --> R3
P4 --> R4
P5 --> R5
소유권과 이동
소유권 규칙
- 각 값은 하나의 소유자만 가집니다.
- 소유자가 스코프를 벗어나면 값이 drop됩니다.
- 이동이 기본: 대입·함수 인자·반환 시 소유권이 이동합니다.
완전한 소유권 예제
fn main() {
// 소유권: s가 "hello"를 소유
let s = String::from("hello");
// 이동: s의 소유권이 t로 이동, s는 사용 불가
let t = s;
// println!("{}", s); // 컴파일 에러: use of moved value: `s`
println!("{}", t); // OK
// Copy 타입은 복사 (이동 아님)
let x = 42;
let y = x;
println!("{} {}", x, y); // OK: i32는 Copy
}
코드 설명:
String은 소유 타입: 힙 메모리를 소유하며, drop 시 해제합니다.let t = s;에서s의 소유권이t로 이동합니다.s는 더 이상 유효하지 않습니다.i32는 Copy: 복사가 발생하며,x와y모두 사용 가능합니다.
C++과 기본값이 반대라는 점이 가장 큰 차이입니다. C++에서 auto t = s;는 복사이고 이동하려면 std::move를 써야 하지만, Rust에서 let t = s;는 이동이고 복사하려면 .clone()을 명시해야 합니다. 그래서 Rust 코드에서는 비싼 깊은 복사가 항상 눈에 보이고, 실수로 큰 벡터를 복사하는 일이 줄어듭니다. Copy는 비트 복사만으로 충분한 타입(정수, bool, 참조, Copy 필드로만 된 구조체)에만 붙일 수 있고, Drop을 구현한 타입에는 붙일 수 없습니다. 소멸자가 있는 타입을 비트 복사하면 이중 해제가 되기 때문입니다.
소유권과 함수
fn take_ownership(s: String) {
println!("{}", s);
} // s가 drop됨
fn main() {
let s = String::from("hello");
take_ownership(s);
// println!("{}", s); // 컴파일 에러: s는 이미 이동됨
}
소유권 반환
fn create_string() -> String {
let s = String::from("hello");
s // 소유권 반환 (이동)
}
fn main() {
let s = create_string();
println!("{}", s); // OK
}
Box: 힙 할당 소유권
fn main() {
let b = Box::new(42);
println!("{}", *b);
// b가 스코프를 벗어나면 Box가 drop되고 힙 메모리 해제
}
소유권 다이어그램
flowchart LR
subgraph Before[이동 전]
S1[String s]
S2[hello]
S1 --> S2
end
subgraph After[이동 후]
T1[String t]
T2[hello]
T1 --> T2
S3[s: 사용 불가]
end
Borrow checker의 빌림 규칙
빌림 규칙
- 불변 참조
&T: 여러 개 동시에 가능. - 가변 참조
&mut T: 동시에 하나만. 불변 참조와도 동시에 불가. - 참조의 수명은 원본보다 길 수 없음.
이 규칙을 흔히 “공유 XOR 가변(aliasing XOR mutability)“이라고 부릅니다. 여러 곳에서 동시에 볼 수 있거나, 한 곳에서만 고칠 수 있거나 둘 중 하나라는 뜻입니다. 이 규칙이 막는 버그는 스레드와 무관하게 단일 스레드에서도 생깁니다. 이터레이터 무효화, “참조를 들고 있는 동안 원본 컨테이너가 재할당됨”, 함수 인자 두 개가 같은 객체를 가리켜 한쪽을 수정하면 다른 쪽이 바뀌는 앨리어싱 버그가 모두 이 범주입니다. 부수 효과로 컴파일러는 &mut 참조가 앨리어싱되지 않는다고 확신할 수 있어서, C의 restrict에 해당하는 최적화를 자동으로 적용할 수 있습니다.
불변 빌림 예제
fn main() {
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2); // OK: 여러 불변 참조
}
가변 빌림: 동시에 하나만
fn main() {
let mut v = vec![1, 2, 3];
let r1 = &mut v;
// let r2 = &mut v; // 컴파일 에러: cannot borrow `v` as mutable more than once
r1.push(4);
}
불변과 가변 동시 빌림 불가
fn main() {
let mut v = vec![1, 2, 3];
let r = &v;
// v.push(4); // 컴파일 에러: cannot borrow `v` as mutable while it is borrowed as immutable
println!("{}", r[0]);
}
스코프로 빌림 해제
fn main() {
let mut v = vec![1, 2, 3];
{
let r = &v[0];
println!("{}", r);
} // r의 스코프 종료
v.push(4); // OK: r이 더 이상 v를 빌리지 않음
}
이터레이터와 빌림
fn main() {
let mut v = vec![1, 2, 3];
// 반복 중 가변 수정 시도 → 컴파일 에러
// for x in &v {
// if *x == 2 {
// v.push(4); // 에러
// }
// }
// 해결 1: 인덱스 루프
let mut i = 0;
while i < v.len() {
if v[i] == 2 {
v.push(4);
}
i += 1;
}
// 해결 2: collect로 필터링 후 수정
let to_add: Vec<_> = v.iter().filter(|&&x| x == 2).cloned().collect();
for x in to_add {
v.push(x + 2);
}
}
NLL (Non-Lexical Lifetimes)
Rust 2018부터 NLL로 “더 이상 사용하지 않는 참조”는 빌림이 해제된 것으로 봅니다.
fn main() {
let mut v = vec![1, 2, 3];
let r = &v[0];
println!("{}", r); // r 사용 완료
v.push(4); // OK: r을 더 이상 쓰지 않으므로 빌림 해제
}
NLL 이전에는 빌림이 변수의 렉시컬 스코프(중괄호 끝)까지 유지됐기 때문에, 앞 절처럼 인위적인 { } 블록을 만들어야 했습니다. 지금은 참조가 마지막으로 사용된 지점까지만 빌림으로 봅니다. 그래서 같은 코드라도 v.push(4) 뒤에 println!("{}", r);를 한 줄 추가하는 순간 컴파일 에러로 바뀝니다. 에러 메시지가 push 줄이 아니라 “나중에 여기서 사용됨(borrow later used here)“이라는 두 번째 위치를 함께 가리키는 이유가 이것이므로, borrow checker 에러를 읽을 때는 이 “later used” 위치부터 확인하는 것이 빠릅니다.
Borrow checker 다이어그램
flowchart TB
subgraph Rules[빌림 규칙]
R1["불변 &T: 여러 개 OK"]
R2["가변 &mut T: 하나만"]
R3["&T와 &mut T 동시 불가"]
end
subgraph Violation[위반 시]
V1[컴파일 에러]
end
R1 --> V1
R2 --> V1
R3 --> V1
수명(lifetime): 댕글링 방지와 생략 규칙
수명이란
참조가 유효한 범위. “이 참조가 가리키는 값보다 참조가 더 오래 살 수 없다”를 컴파일러가 검사합니다.
댕글링 방지
// fn dangling() -> &str {
// let s = String::from("hello");
// &s // 컴파일 에러: `s` does not live long enough
// }
fn valid() -> String {
let s = String::from("hello");
s // 소유권 반환
}
수명 파라미터
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("short");
let s2 = String::from("longer");
let result = longest(s1.as_str(), s2.as_str());
println!("{}", result); // "longer"
}
코드 설명:
'a: 수명 파라미터. “반환 참조는 x, y 중 더 짧은 수명을 가짐”을 의미합니다.- 컴파일러가 댕글링 가능성을 검사합니다.
수명 파라미터에 대해 가장 흔한 오해는 이것이 값의 수명을 늘리거나 지정한다고 생각하는 것입니다. 수명 표기는 아무것도 바꾸지 않고, 입력과 출력 참조 사이의 관계를 선언할 뿐입니다. longest의 시그니처는 “반환값은 x나 y 중 하나를 빌린 것이니, 둘 다 살아 있는 동안만 쓰라”는 계약이고, 컴파일러는 호출 지점에서 이 계약을 확인합니다. 예를 들어 s2를 안쪽 블록에서 만들고 그 블록 밖에서 result를 쓰면, 실제로는 s1이 반환되는 경우라도 컴파일러는 에러를 냅니다. 함수 본문이 아니라 시그니처만 보고 판단하기 때문입니다. 이 덕분에 함수 구현을 바꿔도 호출하는 쪽 코드의 안전성 판단이 흔들리지 않습니다.
구조체와 수명
struct Excerpt<'a> {
text: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find '.'");
let excerpt = Excerpt { text: first_sentence };
println!("{}", excerpt.text);
}
수명 생략 규칙
컴파일러가 추론할 수 있으면 생략 가능합니다. 정확히는 추론이 아니라 정해진 규칙입니다. 참조 입력이 하나뿐이면 출력은 그 입력의 수명을 따르고, 메서드에서 &self나 &mut self가 있으면 출력은 self의 수명을 따릅니다. 참조 입력이 둘 이상이고 self가 없으면(앞의 longest) 규칙이 적용되지 않아 직접 적어야 합니다.
// 수명 생략 전
fn first_word<'a>(s: &'a str) -> &'a str {
s.split_whitespace().next().unwrap_or("")
}
// 수명 생략 후 (동일)
fn first_word(s: &str) -> &str {
s.split_whitespace().next().unwrap_or("")
}
‘static 수명
fn get_static() -> &'static str {
"hello" // 문자열 리터럴은 'static
}
수명 검사 시퀀스
sequenceDiagram
participant Caller
participant fn_longest
participant x
participant y
Caller->>fn_longest: longest(&s1, &s2)
fn_longest->>x: &'a str
fn_longest->>y: &'a str
Note over fn_longest: 반환 참조 수명 ≤ min(x, y)
fn_longest-->>Caller: &'a str
Note over Caller: Caller 스코프 내에서만 유효
unsafe와 메모리 안전 경계
unsafe가 필요한 경우
- Raw 포인터 역참조
- FFI (C 라이브러리 호출)
- 안전하지 않은 함수 호출
- 가변 정적 변수 접근
- union 필드 접근
unsafe는 borrow checker를 끄는 스위치가 아닙니다. unsafe 블록 안에서도 소유권과 빌림 규칙은 그대로 검사되고, 허용되는 것은 위 다섯 가지 “추가 능력”뿐입니다. 대신 그 능력을 쓸 때 컴파일러가 확인하지 못하는 조건(포인터가 유효하다, 정렬이 맞다, 다른 &mut와 겹치지 않는다)을 프로그래머가 보증한다는 선언입니다. 그 보증이 틀리면 C++과 똑같이 정의되지 않은 동작이 되며, 그 결과는 unsafe 블록 밖의 멀쩡한 코드에서 터질 수 있습니다.
unsafe 블록 최소화
// 포인터를 정수로 바꾸는 것 자체는 안전 (unsafe 불필요)
fn get_ptr_address<T>(r: &T) -> usize {
r as *const T as usize
}
// raw 포인터 역참조 (unsafe 필요)
unsafe fn dereference_raw(ptr: *const i32) -> i32 {
*ptr
}
FFI 예제
// C 라이브러리와 연동
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
let x = -42;
let result = unsafe { abs(x) };
println!("{}", result); // 42
}
C 함수 선언은 Rust 컴파일러가 검증할 수 없으므로 호출은 항상 unsafe입니다. 시그니처를 잘못 적으면(예: C의 long을 Windows와 Linux에서 크기가 다른데 i64로 고정) 컴파일도 링크도 성공한 뒤 런타임에 값이 깨집니다. std::ffi::c_long 같은 타입 별칭이나 bindgen으로 헤더에서 자동 생성하는 편이 안전합니다. Rust 2024 에디션부터는 extern 블록 자체도 unsafe extern "C" { ... }로 적어야 하며, 그 안의 함수 중 정말 안전한 것은 safe fn으로 표시해 unsafe 없이 호출할 수 있게 됐습니다.
안전한 래퍼 패턴
// ❌ 건전하지 않음(unsound): 안전한 함수인데 임의의 포인터를 역참조
// fn safe_wrapper(ptr: *const i32) -> Option<i32> {
// if ptr.is_null() { return None; }
// Some(unsafe { *ptr }) // 해제된 포인터나 0x1234도 null은 아님
// }
// ✅ 전제 조건을 호출자에게 넘기거나, 유효성이 보장되는 타입을 받는다
/// # Safety
/// `ptr`은 null이거나, 읽기 가능한 유효한 i32를 가리켜야 합니다.
unsafe fn read_if_not_null(ptr: *const i32) -> Option<i32> {
if ptr.is_null() { None } else { Some(unsafe { *ptr }) }
}
안전한 래퍼를 만들 때 가장 흔한 실수가 위의 첫 번째 형태입니다. null 검사만으로는 포인터가 유효하다는 것을 알 수 없으므로, 안전한 함수 safe_wrapper는 안전한 코드에서 safe_wrapper(0x1234 as *const i32)처럼 호출해도 UB를 일으킵니다. Rust에서는 안전한 함수는 어떤 인자로 호출해도 UB가 없어야 한다는 것이 규칙이고, 이를 어기면 “unsound”라고 부릅니다. 래퍼가 진짜로 안전하려면 유효성이 타입으로 보장된 입력(&i32, &[u8], 자체 핸들 타입)을 받거나, 함수 자체를 unsafe fn으로 두고 # Safety 문서로 전제 조건을 호출자에게 넘겨야 합니다. 처음 FFI 래퍼를 작성할 때 이 구분을 놓쳐서 “unsafe를 감쌌으니 안전하다”고 착각하기 쉽습니다.
unsafe 블록 작성 규칙
- unsafe 블록을 최대한 좁게
- 불변 조건 문서화
- 테스트로 경계 검증
- 안전한 API로 감싸서 노출
동시성: Send·Sync·Arc·Mutex
Send와 Sync
- Send: 스레드 간 이동이 안전한 타입
- Sync: 스레드 간 공유 참조
&T가 안전한 타입
Rc vs Arc
| 타입 | Send | Sync | 용도 |
|---|---|---|---|
Rc<T> | X | X | 단일 스레드 공유 |
Arc<T> | O (T: Send + Sync일 때) | O (T: Send + Sync일 때) | 멀티스레드 공유 |
Arc가 무조건 스레드 안전한 것은 아닙니다. Arc<RefCell<T>>는 RefCell이 Sync가 아니므로 스레드로 넘길 수 없습니다. Arc는 참조 카운트만 원자적으로 관리할 뿐 내부 값의 동시 수정은 보호하지 않기 때문에, 수정이 필요하면 Mutex나 RwLock, 원자 타입을 안에 넣어야 합니다. C++의 std::shared_ptr도 제어 블록의 카운트는 원자적이지만 가리키는 객체는 보호하지 않는다는 점에서 같은 구조인데, C++에서는 이 차이를 잊어도 컴파일되고 Rust에서는 컴파일되지 않는다는 점이 다릅니다.
Arc + Mutex 패턴
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); // 10
}
Rust의 Mutex는 C++의 std::mutex와 달리 보호할 데이터를 안에 품습니다. 락을 잡지 않고는 데이터에 접근할 방법 자체가 없고, lock()이 돌려주는 가드가 스코프를 벗어나면 자동으로 해제됩니다. “락을 잡는 걸 깜빡했다”는 C++의 흔한 버그가 구조적으로 불가능한 이유입니다. lock().unwrap()의 unwrap은 포이즈닝 때문에 필요합니다. 락을 잡은 스레드가 패닉하면 데이터가 반쯤 수정된 상태일 수 있으므로, 이후 lock()은 Err(PoisonError)를 돌려줍니다. 위 예제처럼 unwrap()하면 한 스레드의 패닉이 다른 스레드로 전파되는데, 대부분의 경우 이것이 원하는 동작입니다.
채널: 메시지 전달
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(42).unwrap();
});
let received = rx.recv().unwrap();
println!("{}", received); // 42
}
Atomic
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
fn main() {
let counter = AtomicU32::new(0);
// thread::scope: 스코프 안의 스레드는 스코프가 끝나기 전에 모두 join되므로
// 지역 변수 참조를 빌려줄 수 있음 (thread::spawn은 'static 요구)
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
counter.fetch_add(1, Ordering::SeqCst);
});
}
});
println!("{}", counter.load(Ordering::SeqCst)); // 10
}
지역 변수의 참조를 thread::spawn에 넘기면 borrowed value does not live long enough(argument requires that 'counter' is borrowed for 'static) 에러가 납니다. 스폰된 스레드가 main보다 오래 살 수 있다고 컴파일러가 가정하기 때문입니다. Rust 1.63부터 들어온 thread::scope는 스코프를 벗어나기 전에 모든 스레드를 join한다고 보장하므로 Arc 없이 지역 데이터를 빌려줄 수 있습니다. C++20의 std::jthread를 벡터에 담아 스코프 끝에서 join하는 패턴과 비슷하지만, Rust는 그 보장을 컴파일러가 확인한다는 점이 다릅니다.
use of moved value부터 already borrowed까지: 컴파일 에러 해결
”use of moved value”
원인: 이동 후 원본 사용.
// ❌ 잘못된 코드
let s = String::from("hello");
let t = s;
println!("{}", s); // 에러
// ✅ 해결 1: clone
let s = String::from("hello");
let t = s.clone();
println!("{}", s);
// ✅ 해결 2: 참조로 빌림
let s = String::from("hello");
let t = &s;
println!("{}", s);
”borrowed value does not live long enough”
원인: 수명이 맞지 않는 참조 반환.
// ❌ 잘못된 코드
// fn get_name() -> &str {
// let s = String::from("hello");
// &s
// }
// ✅ 해결: 소유권 반환
fn get_name() -> String {
String::from("hello")
}
”cannot borrow as mutable”
원인: 이미 불변 빌림이 있는 상태에서 가변 빌림.
// ❌ 잘못된 코드
let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4); // 에러: 아래에서 r을 다시 사용하므로 빌림이 살아 있음
println!("{}", r);
// ✅ 해결: 스코프 분리 또는 값 복사
let mut v = vec![1, 2, 3];
let x = v[0];
v.push(4);
”cannot be sent between threads safely”
원인: Rc 등 non-Send 타입을 스레드로 전달.
// ❌ 잘못된 코드
// let r = Rc::new(42);
// thread::spawn(move || { println!("{}", r); });
// ✅ 해결: Arc 사용
let r = Arc::new(42);
let r_clone = r.clone();
thread::spawn(move || {
println!("{}", r_clone);
});
”temporary value dropped while borrowed”
원인: 임시 값에 대한 참조를 오래 유지.
// ❌ 잘못된 코드
// let r: &str = format!("hello").as_str();
// ✅ 해결: 소유권 유지
let s = format!("hello");
let r: &str = &s;
”missing lifetime specifier” (E0106)
원인: 참조 입력이 둘 이상이라 생략 규칙으로 반환 수명을 정할 수 없음. (입력이 하나인 fn first_word(s: &str) -> &str는 생략 규칙으로 컴파일됩니다.)
// ❌ 잘못된 코드
// fn pick(x: &str, y: &str) -> &str {
// if x.len() > y.len() { x } else { y }
// }
// 에러: missing lifetime specifier — 반환값이 x와 y 중 무엇을 빌리는지 알 수 없음
// ✅ 해결: 수명 명시
fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
“already borrowed”
원인: RefCell에서 이미 빌림 중인데 다시 빌림 시도.
use std::cell::RefCell;
// ❌ 런타임 패닉
// let r = RefCell::new(42);
// let a = r.borrow();
// let b = r.borrow_mut(); // 패닉: already borrowed
// ✅ 해결: 스코프 분리
let r = RefCell::new(42);
{
let a = r.borrow();
println!("{}", *a);
}
let b = r.borrow_mut(); // OK
이 에러만 컴파일 에러가 아니라 런타임 패닉(already borrowed: BorrowMutError)이라는 점에 주의해야 합니다. RefCell은 빌림 규칙 검사를 컴파일 시점에서 실행 시점으로 미루는 도구라서, 규칙을 어기면 C++처럼 조용히 넘어가는 대신 즉시 패닉합니다. 콜백 안에서 같은 RefCell을 다시 빌리는 코드, 예를 들어 borrow_mut()로 잡은 상태에서 옵저버를 호출했는데 옵저버가 같은 객체를 borrow()하는 구조에서 자주 터집니다. borrow checker를 피하려고 Rc<RefCell<T>>를 남발하면 컴파일 에러가 런타임 패닉으로 바뀔 뿐이라는 점을 기억해 두면 좋습니다.
에러를 대하는 태도
처음 Rust를 쓰는 C++ 개발자가 가장 흔히 겪는 패턴은 borrow checker 에러가 날 때마다 .clone()이나 Rc<RefCell<>>로 덮는 것입니다. 당장은 컴파일되지만 코드가 느려지고 구조가 흐려집니다. 에러가 반복된다면 대개 데이터 구조가 “여러 곳에서 서로를 가리키며 수정하는” C++식 객체 그래프이기 때문이고, 소유 관계를 트리로 만들거나 인덱스(벡터 안의 위치)로 참조하도록 바꾸면 에러가 한꺼번에 사라지는 경우가 많습니다.
에러 해결 플로우
flowchart TB
E1[use of moved value] --> S1[clone 또는 참조]
E2[borrowed value does not live long enough] --> S2[소유권 반환 또는 수명 명시]
E3[cannot borrow as mutable] --> S3[스코프 분리]
E4[cannot be sent between threads] --> S4[Arc 사용]
E5[temporary dropped] --> S5[변수에 바인딩]
clone 최소화·unsafe 격리 같은 작성 습관
clone() 최소화
// ❌ 불필요한 clone
fn process(s: String) {
let s2 = s.clone();
do_something(&s2);
}
// ✅ 참조로 빌림
fn process(s: &str) {
do_something(s);
}
Result·Option 처리
// ✅ ? 연산자로 에러 전파
fn read_file(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}
fn main() -> Result<(), std::io::Error> {
let content = read_file("file.txt")?;
println!("{}", content);
Ok(())
}
Rc vs Arc 선택
// 단일 스레드: Rc (가벼움)
use std::rc::Rc;
let r = Rc::new(42);
// 멀티스레드: Arc
use std::sync::Arc;
let a = Arc::new(42);
unsafe 격리
// ✅ 전제 조건을 명시한 unsafe fn + 수명을 호출자가 정하도록
/// # Safety
/// `ptr`이 null이 아니면 `len`바이트를 읽을 수 있어야 하고,
/// 반환된 슬라이스를 쓰는 동안 그 메모리가 해제·수정되지 않아야 합니다.
pub unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> Option<&'a [u8]> {
if ptr.is_null() {
return None;
}
Some(unsafe { std::slice::from_raw_parts(ptr, len) })
}
반환 타입의 'a는 입력에 참조가 없어서 생략할 수 없습니다(생략하면 missing lifetime specifier). 호출자가 원하는 어떤 수명으로든 고를 수 있다는 뜻이므로 매우 강력하고 위험한 약속이며, 그래서 함수가 unsafe여야 합니다. 실제 FFI 래퍼에서는 C 버퍼를 소유하는 Rust 구조체를 만들고 fn as_slice(&self) -> &[u8]로 구조체의 수명에 묶어 노출하면, 사용하는 쪽은 unsafe 없이 안전하게 쓸 수 있습니다.
Clippy 활용
cargo clippy
문서화
/// # Safety
/// `ptr` must be a valid, non-null, aligned pointer to a valid `T`
/// that outlives `'a` and is not mutated while the reference is alive.
unsafe fn from_raw<'a, T>(ptr: *const T) -> &'a T {
unsafe { &*ptr }
}
Arc<Mutex<T>>·채널·Cow를 쓰는 실무 패턴
수명 명시
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Arc<Mutex<T>> 공유 상태
use std::sync::{Arc, Mutex};
struct SharedState {
counter: Arc<Mutex<u32>>,
}
impl SharedState {
fn increment(&self) {
*self.counter.lock().unwrap() += 1;
}
}
채널 기반 파이프라인
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 0..10 {
tx.send(i).unwrap();
}
});
for received in rx {
println!("{}", received);
}
}
Builder에서 소유권 이동
struct Config {
name: String,
}
struct Application {
config: Config,
}
impl Config {
fn new(name: String) -> Self {
Config { name }
}
fn build(self) -> Application {
Application { config: self }
}
}
build(self)가 &self가 아니라 self를 받는다는 점이 핵심입니다. 호출하면 Config의 소유권이 Application으로 넘어가므로, 빌드 후 같은 설정 객체를 다시 수정하거나 두 번 빌드하는 실수가 컴파일 에러가 됩니다. C++에서는 std::move(config) 후 재사용을 막을 방법이 없어 주석으로 경고하던 규칙을 타입으로 표현한 것입니다.
Option·Result 조합
fn find_and_parse(v: &[String]) -> Option<i32> {
v.iter()
.find(|s| s.starts_with("num="))
.and_then(|s| s.strip_prefix("num="))
.and_then(|s| s.parse().ok())
}
Cow (Clone-on-Write)
use std::borrow::Cow;
fn process(input: Cow<str>) -> String {
if input.contains("bad") {
input.replace("bad", "good").into()
} else {
input.into_owned()
}
}
Cow는 “대부분은 입력을 그대로 쓰고, 가끔만 수정한 사본이 필요한” 경우에 할당을 아끼는 도구입니다. 빌린 &str로 들어온 값이 수정 없이 끝나면 복사가 일어나지 않습니다. 다만 위 함수처럼 반환 타입이 String이면 결국 into_owned()에서 복사가 생기므로, 할당을 정말 줄이려면 반환 타입도 Cow<'_, str>로 두어 수정이 없을 때는 빌린 값을 그대로 돌려줘야 합니다.
Rust 메모리 안전성 요약
버그 유형별 Rust 보장
| 영역 | Rust 보장 |
|---|---|
| 이동 후 사용 | 컴파일 에러 |
| 댕글링 참조 | 수명으로 컴파일 에러 |
| 이터레이터 무효화 | Borrow checker로 컴파일 에러 |
| Data Race | Send/Sync로 컴파일 에러 |
| null 역참조 | Option으로 검사 |
| 이중 해제 | 소유권으로 불가능 |
| 메모리 누수 | 보장하지 않음 (Rc 순환, mem::forget은 안전한 코드) |
| 데드락 | 보장하지 않음 |
표의 “컴파일 에러” 보장은 모두 안전한 Rust 범위에서의 이야기입니다. unsafe 블록이나 unsound한 라이브러리가 끼면 같은 버그가 다시 가능해지므로, 실무에서는 unsafe가 있는 모듈을 좁게 모으고 cargo miri test로 정의되지 않은 동작을 검사하는 것이 일반적입니다. Miri는 C++의 AddressSanitizer·UndefinedBehaviorSanitizer와 비슷한 역할을 하는 인터프리터로, 수명이 끝난 참조 사용이나 앨리어싱 규칙 위반을 잡아냅니다.
학습 순서
- 소유권·이동 → 2. 빌림 → 3. 수명 → 4. 동시성 → 5. unsafe
참고 자료
- The Rust Book
- Rustonomicon (unsafe)
- Rust by Example
같이 보면 좋은 글
- C++ vs Rust: 소유권, 메모리 안전성, 에러 처리, 동시성, 성능 비교
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]
- C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
- C++ 함수 객체 vs 람다 vs std::function
자주 묻는 질문 (FAQ)
Q. Rc로 감싼 값을 스레드에 넘기면 왜 컴파일 에러가 나나요?
A. Rc의 참조 카운트는 원자적 연산 없이 증감하기 때문에 여러 스레드가 동시에 건드리면 카운트가 깨질 수 있고, 그래서 Rc는 Send 트레이트를 구현하지 않습니다. thread::spawn은 넘겨받는 클로저와 값이 Send이기를 요구하므로, Rc를 캡처하면 cannot be sent between threads safely 에러가 납니다. 스레드 간 공유에는 원자적 카운트를 쓰는 Arc를 사용하고, 공유한 값을 수정해야 한다면 Arc<Mutex<T>>처럼 잠금과 함께 감싸면 됩니다.
Q. borrow checker 에러가 너무 많아요.
A. 에러 메시지의 “borrow later used here” 위치를 먼저 보면 빌림이 어디까지 이어지는지 알 수 있습니다. clone()으로 우회할 수 있지만, 같은 종류의 에러가 반복된다면 서로를 가리키며 수정하는 객체 그래프 구조가 원인인 경우가 많습니다. 소유 관계를 트리로 정리하거나 벡터 인덱스로 참조하도록 바꾸면 Rc<RefCell<T>> 없이 풀리는 경우가 많습니다.
Q. unsafe는 언제 써야 하나요?
A. FFI, 성능이 극히 중요한 경로, 또는 안전한 Rust로 표현 불가능한 로직에서만 사용합니다. 가능한 한 안전한 API로 감싸서 노출하세요. Rust의 소유권·Borrow checker·수명·unsafe를 이해하면 메모리 안전한 코드를 작성할 수 있습니다. 이전 글: C++ vs Rust 완전 비교 다음 글: 나만의 Redis 클론 코딩: Modern C++ 기반 인메모리 Key-Value 스토어