Rust 소유권 에러 예제: cannot move out·cannot borrow as mutable·조건부 반환 빌림과 수명 에러 해결
이 글의 핵심
C++ 습관대로 짠 코드가 borrow checker에 막히는 다섯 가지 예제를 따라가며 에러가 왜 나는지부터 설명합니다. 소유권과 빌림, RefCell 내부 가변성, Rc·Arc 공유 소유권을 언제 고를지 정리하고, 이벤트 시스템 예제로 RefCell의 런타임 panic과 Rc 순환 참조를 Weak로 피하는 방법, rustc --explain·Clippy를 활용한 디버깅 요령을 다룹니다.
“C++에서는 되는데 Rust에서는 왜 안 되나요?”는 Rust를 처음 배울 때 가장 자주 나오는 질문입니다. 이 글은 C++ 습관대로 작성했을 때 borrow checker에 막히기 쉬운 코드를 다섯 가지 예제로 재현하고, 컴파일러 메시지를 읽는 법과 원인, 해결 방법을 차례로 설명합니다. 에러 메시지 블록은 해당 코드를 컴파일했을 때 나오는 형식을 보여 주며, 줄 번호와 세부 문구는 rustc 버전에 따라 조금씩 다를 수 있습니다.
예제 1: cannot move out of borrowed content (E0507)
빌린 값에서 필드의 소유권을 꺼내려 할 때 나는 에러입니다. 예전 rustc는 “cannot move out of borrowed content”라고 표시했고, 지금은 “behind a shared reference”라는 문구를 씁니다.
struct User {
name: String,
email: String,
}
fn process_users(users: &Vec<User>) {
for user in users {
let name = user.name; // 에러
println!("Processing: {}", name);
}
}
error[E0507]: cannot move out of `user.name` which is behind a shared reference
--> src/main.rs:8:20
|
8 | let name = user.name;
| ^^^^^^^^^ move occurs because `user.name` has type `String`, which does not implement the `Copy` trait
users가 &Vec<User>이므로 for user in users의 user는 &User입니다. user.name은 String이고, let name = user.name은 그 String의 소유권을 name으로 옮기려는 시도입니다. 빌린 데이터의 일부를 옮겨 가면 원래 주인의 User에 구멍이 생기므로, 컴파일러는 이를 막습니다.
// 방법 1: 참조만 사용 (가장 흔한 해결)
fn process_users(users: &[User]) {
for user in users {
let name = &user.name;
println!("Processing: {}", name);
}
}
// 방법 2: 독립된 복사본이 필요할 때만 clone
fn collect_names(users: &[User]) -> Vec<String> {
users.iter().map(|u| u.name.clone()).collect()
}
// 방법 3: 호출자가 더 이상 users를 쓰지 않는다면 소유권을 받음
fn into_names(users: Vec<User>) -> Vec<String> {
users.into_iter().map(|u| u.name).collect()
}
매개변수를 &Vec<User> 대신 &[User]로 받은 것은 Clippy의 ptr_arg 린트가 권하는 형태로, 벡터뿐 아니라 배열이나 슬라이스도 받을 수 있게 됩니다. clone은 컴파일러가 자주 제안하지만, 문자열을 출력만 할 거라면 힙 할당만 늘리므로 참조로 충분한지 먼저 확인합니다.
예제 2: cannot borrow as mutable more than once (E0499)
struct User {
name: String,
inbox: Vec<String>,
}
impl User {
fn notify(&mut self, msg: &str) {
self.inbox.push(msg.to_string());
}
}
struct ChatRoom {
users: Vec<User>,
messages: Vec<String>,
}
impl ChatRoom {
fn log(&mut self, msg: String) {
self.messages.push(msg);
}
fn broadcast(&mut self, sender: &str) {
let msg = format!("{}: Hello", sender);
for user in &mut self.users {
user.notify(&msg);
self.log(format!("delivered to {}", user.name)); // 에러
}
}
}
error[E0499]: cannot borrow `*self` as mutable more than once at a time
--> src/main.rs:26:13
|
24 | for user in &mut self.users {
| ---------------
| |
| first mutable borrow occurs here
| first borrow later used here
25 | user.notify(&msg);
26 | self.log(format!("delivered to {}", user.name));
| ^^^^ second mutable borrow occurs here
&mut self.users로 만든 첫 번째 가변 빌림은 루프가 끝날 때까지 유지됩니다. 그 안에서 self.log()를 호출하면 &mut self, 즉 users를 포함한 구조체 전체를 다시 가변으로 빌리게 되므로, “같은 데이터에 가변 참조는 동시에 하나만”이라는 규칙에 걸립니다.
흥미로운 점은 루프 안에서 self.log(...) 대신 self.messages.push(...)를 직접 쓰면 이 코드가 컴파일된다는 것입니다. 함수 본문 안에서는 컴파일러가 self.users와 self.messages가 서로 다른 필드라는 것을 알기 때문에 두 필드를 동시에 가변으로 빌리는 것을 허용합니다(분리 빌림, disjoint borrow). 하지만 메서드 시그니처의 &mut self는 “구조체 전체를 빌린다”는 약속이라 필드 단위로 쪼개지지 않습니다. C++에서 넘어온 사람이 자주 막히는 지점이 바로 이것입니다. 헬퍼 메서드로 코드를 정리하는 순간 멀쩡하던 코드가 E0499로 깨지기 때문에, Rust에서는 필드만 받는 헬퍼 함수로 쪼개는 설계를 자주 쓰게 됩니다.
impl ChatRoom {
// 방법 1: 필드를 직접 따로 빌림
fn broadcast(&mut self, sender: &str) {
let msg = format!("{}: Hello", sender);
let messages = &mut self.messages;
for user in &mut self.users {
user.notify(&msg);
messages.push(format!("delivered to {}", user.name));
}
}
// 방법 2: 필요한 필드만 받는 연관 함수로 헬퍼를 바꿈
fn log_to(messages: &mut Vec<String>, msg: String) {
messages.push(msg);
}
// 방법 3: 반복자 대신 인덱스를 사용
fn broadcast_by_index(&mut self, sender: &str) {
let msg = format!("{}: Hello", sender);
for i in 0..self.users.len() {
self.users[i].notify(&msg);
let line = format!("delivered to {}", self.users[i].name);
self.log(line);
}
}
}
세 방법의 차이는 빌림을 얼마나 오래 쥐고 있느냐입니다. 방법 1은 두 필드를 처음부터 따로 빌려 겹침을 없애고, 방법 2는 헬퍼가 구조체 전체 대신 필요한 필드만 받게 바꿔 Self::log_to(&mut self.messages, ...)처럼 호출할 수 있게 하며, 방법 3은 반복자 대신 정수 인덱스를 들고 있어서 루프 전체에 걸친 빌림 자체를 없앱니다. 방법 3에서 line을 먼저 만든 것은 self.users[i].name의 빌림이 self.log 호출 전에 끝나게 하기 위해서입니다. 인덱스 방식은 가장 유연하지만, 루프 중에 users의 길이가 바뀌면 인덱스가 어긋나거나 범위 밖 접근으로 panic이 날 수 있다는 점을 감안해야 합니다.
예제 3: 조건부로 참조를 반환할 때 (E0502)
struct UserCache {
users: Vec<User>,
}
impl UserCache {
fn find(&self, name: &str) -> Option<&User> {
self.users.iter().find(|u| u.name == name)
}
fn get_or_create(&mut self, name: &str) -> &User {
if let Some(user) = self.find(name) {
return user;
}
self.users.push(User { name: name.to_string(), inbox: Vec::new() }); // 에러
self.users.last().unwrap()
}
}
error[E0502]: cannot borrow `self.users` as mutable because it is also borrowed as immutable
--> src/main.rs:14:9
|
10 | fn get_or_create(&mut self, name: &str) -> &User {
| - let's call the lifetime of this reference `'1`
11 | if let Some(user) = self.find(name) {
| ---- immutable borrow occurs here
12 | return user;
| ---- returning this value requires that `*self` is borrowed for `'1`
13 | }
14 | self.users.push(User { name: name.to_string(), inbox: Vec::new() });
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
이 예제는 사람 눈에는 “찾았으면 바로 반환하니까 아래의 push와 겹칠 일이 없다”가 명백해서 더 당황스럽습니다. 실제로 이는 현재 borrow checker(NLL)의 알려진 한계로, “NLL problem case #3”이라고 불립니다. 조건부로 반환되는 참조는 함수의 반환 수명 '1 전체 동안 빌림이 살아 있다고 간주되어, 반환하지 않는 경로에서도 빌림이 끝나지 않은 것으로 처리됩니다. 차세대 borrow checker인 Polonius가 이 패턴을 허용하도록 개발되고 있지만 아직 안정 버전의 기본값은 아닙니다.
// 방법 1: 빌림이 아닌 인덱스를 먼저 얻음
impl UserCache {
fn get_or_create(&mut self, name: &str) -> &User {
if let Some(idx) = self.users.iter().position(|u| u.name == name) {
return &self.users[idx];
}
self.users.push(User { name: name.to_string(), inbox: Vec::new() });
self.users.last().unwrap()
}
}
// 방법 2: 저장소를 HashMap으로 바꾸고 Entry API로 "찾거나 넣기"를 한 번에
use std::collections::HashMap;
struct UserCache {
users: HashMap<String, User>,
}
impl UserCache {
fn get_or_create(&mut self, name: &str) -> &User {
self.users
.entry(name.to_string())
.or_insert_with(|| User { name: name.to_string(), inbox: Vec::new() })
}
}
position이 반환하는 것은 빌림이 아닌 정수이므로, 반환 경로에서 &self.users[idx]로 빌림을 새로 시작할 수 있습니다. Entry API는 탐색과 삽입을 한 번에 처리하므로 해시도 한 번만 계산합니다. 다만 entry는 키를 소유한 String으로 받기 때문에 이미 있는 이름을 조회할 때도 to_string() 할당이 일어난다는 점은 알아 둡니다. 조회가 대부분이라면 if let Some(u) = self.users.get(name) 뒤에 삽입하는 방식이 할당은 줄지만, 이 경우에도 예제와 같은 E0502를 피하려면 contains_key로 먼저 확인한 뒤 get과 insert를 따로 호출해야 합니다.
예제 4: 지역 변수의 참조 반환 (E0106, E0515)
fn get_default_user() -> &User {
let user = User {
name: "Guest".to_string(),
inbox: Vec::new(),
};
&user
}
이 코드를 그대로 컴파일하면 먼저 반환 타입 &User에 수명이 없다는 error[E0106]: missing lifetime specifier가 납니다. 인자가 없는 함수라 반환 참조가 무엇을 빌리는지 추론할 근거가 없기 때문입니다. 컴파일러 제안대로 &'static User로 바꾸면 그다음에 본질적인 에러가 나옵니다.
error[E0515]: cannot return reference to local variable `user`
--> src/main.rs:6:5
|
6 | &user
| ^^^^^ returns a reference to data owned by the current function
// 방법 1: 값을 반환 (기본값)
fn get_default_user() -> User {
User { name: "Guest".to_string(), inbox: Vec::new() }
}
// 방법 2: 프로그램 전체에서 하나뿐인 정적 기본값 (Rust 1.80+)
use std::sync::LazyLock;
static DEFAULT_USER: LazyLock<User> = LazyLock::new(|| User {
name: "Guest".to_string(),
inbox: Vec::new(),
});
fn default_user() -> &'static User {
&DEFAULT_USER
}
C++에서는 지역 변수의 참조를 반환해도 경고 하나로 끝나고 런타임에 댕글링 참조가 되지만, Rust는 이를 컴파일 단계에서 거부합니다. 해결의 기본값은 값을 반환하는 것입니다. User를 반환해도 String과 Vec의 포인터·길이·용량, 즉 스택 데이터만 이동하고 힙 내용은 복사되지 않으므로 비용 걱정은 거의 필요 없습니다. Box<User>를 반환하는 방법도 컴파일은 되지만 이 경우 힙 할당만 하나 늘어나므로, 트레이트 객체(Box<dyn Trait>)를 반환할 때처럼 이유가 있을 때만 씁니다.
정적 기본값이 필요할 때 static DEFAULT: User = User { ... }로 직접 쓰려면 초기화 식이 상수 평가 가능해야 합니다. String::new()와 Vec::new()는 const fn이라 가능하지만 "Guest".to_string()은 힙 할당이 필요해 불가능하므로, 최초 접근 시 초기화하는 LazyLock을 씁니다. Rust 1.80 이전에는 once_cell이나 lazy_static 크레이트가 같은 역할을 했습니다.
예제 5: 스레드 사이에서 상태를 공유할 때 (E0373)
use std::thread;
struct Counter {
count: i32,
}
fn main() {
let mut counter = Counter { count: 0 };
let handle = thread::spawn(|| {
counter.count += 1; // 에러
});
handle.join().unwrap();
}
error[E0373]: closure may outlive the current function, but it borrows `counter.count`, which is owned by the current function
--> src/main.rs:9:32
|
9 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `counter`
10| counter.count += 1;
| ------------- `counter.count` is borrowed here
E0373이 나는 이유는 thread::spawn이 클로저에 'static 수명을 요구하기 때문입니다. 새 스레드는 main보다 오래 살 수도 있으므로, 컴파일러는 main의 지역 변수를 참조로 빌리는 클로저를 허용하지 않습니다. 컴파일러가 제안하는 move만 붙이면 counter가 스레드로 이동해 컴파일은 되지만, 이후 main에서 counter를 다시 쓸 수 없습니다.
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
// 방법 1: Arc + Mutex — 여러 필드를 함께 일관되게 바꿀 때
fn with_mutex() {
let counter = Arc::new(Mutex::new(0));
let c = Arc::clone(&counter);
let handle = thread::spawn(move || {
*c.lock().unwrap() += 1;
});
handle.join().unwrap();
println!("Count: {}", *counter.lock().unwrap());
}
// 방법 2: 원자 타입 — 단순 카운터
fn with_atomic() {
let counter = Arc::new(AtomicI32::new(0));
let c = Arc::clone(&counter);
let handle = thread::spawn(move || {
c.fetch_add(1, Ordering::Relaxed);
});
handle.join().unwrap();
println!("Count: {}", counter.load(Ordering::Relaxed));
}
// 방법 3: 채널 — 상태를 공유하지 않고 결과만 넘길 때
fn with_channel() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(1).unwrap();
});
println!("Received: {}", rx.recv().unwrap());
}
// 방법 4: 스코프 스레드 — 함수 안에서 스레드가 반드시 끝날 때 (Rust 1.63+)
fn with_scope() {
let mut counter = Counter { count: 0 };
thread::scope(|s| {
s.spawn(|| counter.count += 1);
});
println!("Count: {}", counter.count);
}
여러 스레드가 같은 값을 보려면 Arc로 소유권을 공유하고, 수정까지 하려면 Mutex나 원자 타입으로 동기화를 추가해야 합니다. 단순 카운터라면 락이 필요 없는 원자 타입이 가장 가볍고, 다른 데이터와의 순서 보장이 필요 없는 카운터에는 Ordering::Relaxed로 충분합니다. 스레드 사이에 결과만 넘기면 되면 채널이 가장 자연스럽습니다. thread::scope는 스코프가 끝나기 전에 모든 스레드가 join된다는 것을 보장하므로, 'static 요구 없이 지역 변수를 그대로 빌릴 수 있습니다.
소유권·빌림·Rc·RefCell 중 무엇을 쓸까
| 상황 | 선택 | 예시 |
|---|---|---|
| 읽기만 필요 | 불변 참조 &T | fn print(user: &User) |
| 수정 필요 | 가변 참조 &mut T | fn update(user: &mut User) |
| 소유권이 필요 | 값 T | fn consume(user: User) |
| 작은 값의 복사 | Copy 타입 | fn calc(x: i32) |
| 타입 | 용도 | 스레드 간 공유 |
|---|---|---|
Cell<T> | 값을 통째로 꺼내고 넣는 내부 가변성 | 불가 |
RefCell<T> | 빌림 규칙을 런타임에 검사 | 불가 |
Mutex<T> | 스레드 간 배타적 접근 | 가능 |
RwLock<T> | 읽기가 많은 공유 데이터 | 가능 |
Rc<T> | 단일 스레드 공유 소유권 | 불가 |
Arc<T> | 스레드 간 공유 소유권 | 가능 |
rc::Weak<T> / sync::Weak<T> | 순환 참조를 끊는 약한 참조 | rc는 불가, sync는 가능 |
Cell<T>는 흔히 Copy 타입 전용으로 소개되지만, get만 Copy를 요구하고 set, replace, take는 어떤 타입에도 쓸 수 있습니다. 단일 스레드에서는 Rc<RefCell<T>>, 멀티스레드에서는 Arc<Mutex<T>>가 가장 흔한 조합입니다.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// 단일 스레드: Rc + RefCell
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let other = Rc::clone(&shared);
shared.borrow_mut().push(4);
println!("{:?}", other.borrow()); // [1, 2, 3, 4]
// 멀티스레드: Arc + Mutex
let shared = Arc::new(Mutex::new(vec![1, 2, 3]));
let other = Arc::clone(&shared);
thread::spawn(move || other.lock().unwrap().push(4)).join().unwrap();
println!("{:?}", shared.lock().unwrap()); // [1, 2, 3, 4]
}
이벤트 시스템: C++식 설계를 Rc·RefCell로 옮기기
C++에서는 이벤트 관리자를 포인터로 들고 있는 콜백이 관리자에 다시 접근하는 설계가 흔합니다. Rust로 그대로 옮기면 막힙니다.
struct Event {
name: String,
}
struct EventManager {
listeners: Vec<Box<dyn Fn(&Event)>>,
}
impl EventManager {
fn subscribe(&mut self, callback: impl Fn(&Event) + 'static) {
self.listeners.push(Box::new(callback));
}
fn publish(&self, event: &Event) {
for listener in &self.listeners {
listener(event);
}
}
fn listener_count(&self) -> usize {
self.listeners.len()
}
}
fn main() {
let mut mgr = EventManager { listeners: vec![] };
mgr.subscribe(|_event| {
println!("{}", mgr.listener_count()); // 에러: 클로저가 mgr를 빌림
});
}
콜백은 'static이어야 하는데 지역 변수 mgr를 빌리고 있고, 동시에 mgr.subscribe가 mgr를 가변으로 빌리므로 수명 에러와 빌림 충돌이 함께 납니다. 관리자를 여러 곳에서 공유하려면 공유 소유권(Rc)과 내부 가변성(RefCell)이 필요합니다. 다만 콜백이 Rc 자체를 들고 있으면 관리자가 콜백을, 콜백이 관리자를 소유하는 순환 참조가 생겨 참조 카운트가 0이 되지 않고 메모리가 해제되지 않습니다. 그래서 콜백에는 Weak를 넣습니다.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
fn main() {
let mgr = Rc::new(RefCell::new(EventManager { listeners: vec![] }));
let weak: Weak<RefCell<EventManager>> = Rc::downgrade(&mgr);
mgr.borrow_mut().subscribe(move |event| {
if let Some(mgr) = weak.upgrade() {
// publish가 borrow()를 쥐고 있으므로 borrow()는 되지만 borrow_mut()는 panic
println!("{} received by {} listeners", event.name, mgr.borrow().listener_count());
}
});
mgr.borrow().publish(&Event { name: "start".to_string() });
}
그림: 실제 실행 결과(rustc 1.97.0, Ubuntu 24.04) — 위 코드는 컴파일되고 start received by 1 listeners를 출력한다. 콜백 안에서 mgr.borrow_mut().subscribe(...)를 호출하도록 바꾸면 컴파일은 통과하지만 실행 중 RefCell already borrowed로 panic한다. (환경: Linux 6.18.44 · g++ (13.3.0-6ubuntu2~24.04.1))
이 구조에도 함정이 하나 남습니다. 콜백 안에서 mgr.borrow_mut()로 새 구독자를 추가하려 하면, 바깥의 publish가 이미 borrow()를 쥐고 있기 때문에 런타임에 panic이 납니다. 메시지는 rustc 1.97에서 RefCell already borrowed이고, 예전 버전에서는 already borrowed: BorrowMutError로 나왔습니다. RefCell은 borrow checker의 규칙을 없애는 것이 아니라 검사 시점을 런타임으로 미룰 뿐이라, 컴파일 에러가 panic으로 바뀐 것에 가깝습니다. 콜백이 관리자를 수정해야 한다면, 콜백은 “추가할 구독자”나 “발행할 이벤트”를 별도의 큐에 쌓기만 하고 publish가 끝난 다음 큐를 처리하는 방식이 안전합니다. 또는 try_borrow_mut()로 실패를 Result로 받아 panic 대신 처리할 수도 있습니다.
에러 메시지·rustc —explain·Clippy로 원인 좁히기
let mut x = vec![1, 2, 3];
let r = &x;
x.push(4);
println!("{:?}", r);
error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let r = &x;
| -- immutable borrow occurs here
4 | x.push(4);
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{:?}", r);
| - immutable borrow later used here
빌림 에러는 세 군데 표시를 순서대로 읽으면 됩니다. ^^^가 가리키는 곳이 규칙을 어긴 지점이고, -로 표시된 “immutable borrow occurs here”가 충돌하는 빌림이 시작된 곳, “later used here”가 그 빌림이 아직 살아 있어야 하는 이유입니다. NLL 덕분에 빌림은 마지막 사용 지점까지만 살아 있으므로, 위 코드는 println!을 push 앞으로 옮기기만 해도 컴파일됩니다. “later used here”가 가리키는 사용을 앞당기거나, 그 전에 필요한 값을 복사해 두는 것이 가장 흔한 해결입니다.
에러 코드의 자세한 설명과 예시는 rustc --explain E0502로 볼 수 있습니다. 같은 내용이 온라인 Rust 에러 코드 색인에도 있습니다.
cargo clippy는 컴파일은 되지만 위험한 빌림 패턴도 알려 줍니다. 비동기 코드에서 자주 만나는 것은 clippy::await_holding_refcell_ref로, RefCell의 borrow()가 .await 지점을 넘어 살아 있으면 경고합니다. .await에서 다른 태스크가 실행되는 동안 그 태스크가 같은 셀을 borrow_mut()하면 panic이 나므로, 참조를 블록으로 감싸 .await 전에 버리라는 뜻입니다. Mutex 가드에 대해서는 clippy::await_holding_lock이 같은 역할을 합니다.