Rust 메모리 안전성 완벽 가이드 | 소유권·Borrow checker·수명·unsafe 실전
이 글의 핵심
Rust 메모리 안전성 완벽 가이드에 대한 실전 가이드입니다. 소유권·Borrow checker·수명·unsafe 실전 등을 예제와 함께 상세히 설명합니다.
들어가며: “Rust는 왜 메모리 버그가 없을까?”
왜 Rust 메모리 안전성인가
C/C++에서 use-after-free, 댕글링 포인터, Data Race는 런타임에만 드러나고, 컴파일러는 대부분 통과시킵니다. Rust는 소유권·Borrow checker·수명으로 이런 버그를 컴파일 단계에서 차단합니다. 이 글은 실무에서 겪는 문제 시나리오, 완전한 Rust 메모리 안전 예제(소유권·빌림·수명·unsafe), 자주 하는 실수, 베스트 프랙티스, 프로덕션 패턴까지 한 번에 다룹니다.
이 글에서 다루는 것:
- 문제 시나리오: 실무에서 겪는 메모리 버그와 Rust가 막는 방식
- 소유권·이동: 누가 메모리를 해제하는지 타입으로 보장
- Borrow checker: 불변/가변 빌림 규칙, 이중 빌림 차단
- 수명(lifetime): 댕글링 참조 컴파일 타임 차단
- unsafe: FFI·성능 최적화 시 안전한 사용법
- 자주 하는 실수: borrow checker 에러 해결 패턴
- 베스트 프랙티스: clone 최소화, 참조 활용, unsafe 격리
- 프로덕션 패턴: Arc·Mutex·채널, 수명 명시 패턴
관련 글: C++ vs Rust, Rust vs C++ 메모리.
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 문제 시나리오: 실무에서 겪는 메모리 버그
- 소유권과 이동
- Borrow checker 완전 가이드
- 수명(lifetime) 완전 가이드
- unsafe와 메모리 안전 경계
- 동시성: Send·Sync·Arc·Mutex
- 자주 하는 실수와 해결법
- 베스트 프랙티스
- 프로덕션 패턴
- 정리 및 체크리스트
1. 문제 시나리오: 실무에서 겪는 메모리 버그
시나리오 1: “버퍼를 넘긴 뒤 다시 썼어요”
"네트워크 패킷을 파싱한 Vec<u8>를 다른 스레드로 넘겼는데,
원본을 다시 쓰다가 크래시가 났어요."
C++에서: std::move 후 원본은 “유효하지만 unspecified”. 컴파일 통과, 런타임 UB.
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`
}
시나리오 2: “로컬 포인터를 반환했어요”
"함수에서 문자열을 만들고 참조를 반환했는데,
호출자가 사용할 때 이미 해제된 메모리를 가리키고 있어요."
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 리터럴이 필요합니다.
시나리오 3: “스레드에 Rc를 넘겼어요”
"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();
}
시나리오 4: “이터레이터 무효화”
"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
}
}
시나리오 5: “순환 참조로 메모리 누수”
"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로 순환 참조 방지
}
시나리오 6: “Mutex 없이 공유 변수”
"여러 스레드가 같은 카운터를 증가시키는데, mutex 없이 했더니
결과가 매번 달라요."
Rust에서: Send·Sync로 “스레드 안전하지 않은 공유”를 컴파일 단계에서 차단합니다.
문제 시나리오 다이어그램
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
2. 소유권과 이동
소유권 규칙
- 각 값은 하나의 소유자만 가집니다.
- 소유자가 스코프를 벗어나면 값이 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모두 사용 가능합니다.
소유권과 함수
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
3. Borrow checker 완전 가이드
빌림 규칙
- 불변 참조
&T: 여러 개 동시에 가능. - 가변 참조
&mut T: 동시에 하나만. 불변 참조와도 동시에 불가. - 참조의 수명은 원본보다 길 수 없음.
불변 빌림 예제
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을 더 이상 쓰지 않으므로 빌림 해제
}
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
4. 수명(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 중 더 짧은 수명을 가짐”을 의미합니다.- 컴파일러가 댕글링 가능성을 검사합니다.
구조체와 수명
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);
}
수명 생략 규칙
컴파일러가 추론할 수 있으면 생략 가능합니다.
// 수명 생략 전
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 스코프 내에서만 유효
5. unsafe와 메모리 안전 경계
unsafe가 필요한 경우
- Raw 포인터 역참조
- FFI (C 라이브러리 호출)
- 안전하지 않은 함수 호출
- 가변 정적 변수 접근
- union 필드 접근
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
}
안전한 래퍼 패턴
// unsafe를 최소한의 공간에 격리
fn safe_wrapper(ptr: *const i32) -> Option<i32> {
if ptr.is_null() {
return None;
}
Some(unsafe { *ptr })
}
unsafe 체크리스트
- unsafe 블록을 최대한 좁게
- 불변 조건 문서화
- 테스트로 경계 검증
- 안전한 API로 감싸서 노출
6. 동시성: Send·Sync·Arc·Mutex
Send와 Sync
- Send: 스레드 간 이동이 안전한 타입
- Sync: 스레드 간 공유 참조
&T가 안전한 타입
Rc vs Arc
| 타입 | Send | Sync | 용도 |
|---|---|---|---|
Rc<T> | X | X | 단일 스레드 공유 |
Arc<T> | O | O | 멀티스레드 공유 |
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
}
채널: 메시지 전달
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);
let mut handles = vec![];
for _ in 0..10 {
let counter = &counter;
let handle = thread::spawn(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", counter.load(Ordering::SeqCst)); // 10
}
7. 자주 하는 실수와 해결법
에러 1: “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);
에러 2: “borrowed value does not live long enough”
원인: 수명이 맞지 않는 참조 반환.
// ❌ 잘못된 코드
// fn get_name() -> &str {
// let s = String::from("hello");
// &s
// }
// ✅ 해결: 소유권 반환
fn get_name() -> String {
String::from("hello")
}
에러 3: “cannot borrow as mutable”
원인: 이미 불변 빌림이 있는 상태에서 가변 빌림.
// ❌ 잘못된 코드
let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4); // 에러
// ✅ 해결: 스코프 분리 또는 값 복사
let mut v = vec![1, 2, 3];
let x = v[0];
v.push(4);
에러 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);
});
에러 5: “temporary value dropped while borrowed”
원인: 임시 값에 대한 참조를 오래 유지.
// ❌ 잘못된 코드
// let r: &str = format!("hello").as_str();
// ✅ 해결: 소유권 유지
let s = format!("hello");
let r: &str = &s;
에러 6: “cannot infer an appropriate lifetime”
원인: 수명 추론 실패.
// ❌ 잘못된 코드
// fn first_word(s: &str) -> &str {
// s.split_whitespace().next().unwrap_or("")
// }
// 에러: 반환 타입에 수명 필요
// ✅ 해결: 수명 명시
fn first_word<'a>(s: &'a str) -> &'a str {
s.split_whitespace().next().unwrap_or("")
}
에러 7: “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
에러 해결 플로우
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[변수에 바인딩]
8. 베스트 프랙티스
1. clone() 최소화
// ❌ 불필요한 clone
fn process(s: String) {
let s2 = s.clone();
do_something(&s2);
}
// ✅ 참조로 빌림
fn process(s: &str) {
do_something(s);
}
2. 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(())
}
3. Rc vs Arc 선택
// 단일 스레드: Rc (가벼움)
use std::rc::Rc;
let r = Rc::new(42);
// 멀티스레드: Arc
use std::sync::Arc;
let a = Arc::new(42);
4. unsafe 격리
// ✅ 안전한 API로 감싸기
pub fn safe_slice_from_raw(ptr: *const u8, len: usize) -> Option<&[u8]> {
if ptr.is_null() || len == 0 {
return None;
}
Some(unsafe { std::slice::from_raw_parts(ptr, len) })
}
5. Clippy 활용
cargo clippy
6. 문서화
/// # Safety
/// `ptr` must be a valid, non-null pointer to a valid `T`.
unsafe fn from_raw(ptr: *const T) -> &T {
&*ptr
}
9. 프로덕션 패턴
패턴 1: 수명 명시
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
패턴 2: Arc<Mutex> 공유 상태
use std::sync::{Arc, Mutex};
struct SharedState {
counter: Arc<Mutex<u32>>,
}
impl SharedState {
fn increment(&self) {
*self.counter.lock().unwrap() += 1;
}
}
패턴 3: 채널 기반 파이프라인
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);
}
}
패턴 4: Builder에서 소유권 이동
struct Config {
name: String,
}
impl Config {
fn new(name: String) -> Self {
Config { name }
}
fn build(self) -> Application {
Application { config: self }
}
}
패턴 5: 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())
}
패턴 6: 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()
}
}
구현 체크리스트
- clone() 최소화, 참조 활용
- Result·Option 처리 (? 또는 match)
- 스레드 사용 시 Arc, Rc 금지
- unsafe 블록 최소화, 문서화
- cargo clippy 정기 실행
- 수명 에러 시 소유권 반환 검토
10. 정리 및 체크리스트
Rust 메모리 안전성 요약
| 영역 | Rust 보장 |
|---|---|
| 이동 후 사용 | 컴파일 에러 |
| 댕글링 참조 | 수명으로 컴파일 에러 |
| 이터레이터 무효화 | Borrow checker로 컴파일 에러 |
| Data Race | Send/Sync로 컴파일 에러 |
| null 역참조 | Option으로 검사 |
| 이중 해제 | 소유권으로 불가능 |
학습 순서
- 소유권·이동 → 2. 빌림 → 3. 수명 → 4. 동시성 → 5. unsafe
참고 자료
- The Rust Book
- Rustonomicon (unsafe)
- Rust by Example
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- Rust vs C++ 메모리 안전성 | 컴파일러 오류 차이 [#47-3]
이 글에서 다루는 키워드 (관련 검색어)
Rust 메모리 안전성, 소유권, Borrow checker, 수명, lifetime, unsafe, Send Sync, Arc Mutex 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Rust 프로젝트 메모리 설계, borrow checker 에러 해결, FFI·unsafe 경계 설계, 동시성 안전 코드 작성 시 참고하세요.
Q. borrow checker 에러가 너무 많아요.
A. clone()으로 우회할 수 있지만, 성능이 중요하면 참조와 수명을 익혀야 합니다. Rc·Arc로 소유권 공유를 명시하면 요구사항을 자연스럽게 만족할 수 있습니다.
Q. unsafe는 언제 써야 하나요?
A. FFI, 성능이 극히 중요한 경로, 또는 안전한 Rust로 표현 불가능한 로직에서만 사용합니다. 가능한 한 안전한 API로 감싸서 노출하세요.
Q. 선행으로 읽으면 좋은 글은?
A. C++ vs Rust, Rust vs C++ 메모리를 먼저 읽으면 소유권 개념을 대비해 이해하기 쉽습니다.
Q. 더 깊이 공부하려면?
A. The Rust Book, Rustonomicon(unsafe), Rust by Example을 참고하세요.
한 줄 요약: Rust의 소유권·Borrow checker·수명·unsafe를 이해하면 메모리 안전한 코드를 작성할 수 있습니다.
이전 글: C++ vs Rust 완전 비교
다음 글: Rust vs C++ 메모리 안전성
관련 글
- C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
- Rust vs C++ 메모리 안전성 | 컴파일러 오류 차이 [#47-3]
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]
- C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
- C++ 함수 객체(Functor) 완벽 가이드 | operator·상태 보유