Rust 소유권 에러 디버깅 사례: cannot move out·cannot borrow as mutable·lifetime 에러 해결

이 글의 핵심

C++ 습관대로 짠 코드가 borrow checker에 막히는 다섯 가지 사례를 따라가며 에러가 왜 나는지부터 설명합니다. 소유권과 빌림, RefCell 내부 가변성, Rc·Arc 공유 소유권을 언제 고를지 패턴으로 정리하고, 이벤트 시스템 예제와 rustc --explain·Clippy를 활용한 디버깅 요령으로 마무리합니다.

들어가며

“C++에서는 되는데 Rust에서는 왜 안 되나요?” Rust를 처음 배울 때 가장 많이 하는 말입니다. 이 글에서는 실제로 겪은 소유권 에러 사례를 통해 borrow checker를 이해하고 해결하는 방법을 다룹니다.

일상에 빗대면, 도서관 책을 빌린 상태에서 표지를 뜯어 가져오려는 것과 비슷합니다. 빌린 것은 반납할 때까지 통째로 있어야 하며, 필요하면 복사본을 새로 만들거나 참조만 읽어야 합니다.


사례 1: “cannot move out of borrowed content”

이 사례의 흐름은 (1) 컴파일러가 빌린 user에서 필드를 옮기려 한 것을 막으며, (2) 참조로 읽거나, clone으로 복사해 소유권을 분리하는 순서로 해결합니다.

문제 코드

struct User {
    name: String,
    email: String,
}

fn process_users(users: &Vec<User>) {
    for user in users {
        let name = user.name; // ❌ cannot move out of `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>)
  • user.name은 String (소유권 타입)
  • let name = user.name은 소유권 이동을 시도
  • 하지만 빌린 데이터에서는 소유권을 가져올 수 없음!

해결 방법

// 방법 1: 참조만 사용
fn process_users(users: &Vec<User>) {
    for user in users {
        let name = &user.name; // ✅ 참조
        println!("Processing: {}", name);
    }
}

// 방법 2: 복사
fn process_users(users: &Vec<User>) {
    for user in users {
        let name = user.name.clone(); // ✅ 복사
        println!("Processing: {}", name);
    }
}

// 방법 3: 소유권 받기
fn process_users(users: Vec<User>) { // &를 제거
    for user in users {
        let name = user.name; // ✅ 소유권 이동
        println!("Processing: {}", name);
    }
}

사례 2: “cannot borrow as mutable more than once”

문제 코드

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: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        // ❌ cannot borrow `*self` as mutable more than once
        for user in &mut self.users {
            self.log(msg.clone()); // 💥 self 전체를 다시 가변 빌림!
        }
    }
}

에러 메시지

error[E0499]: cannot borrow `*self` as mutable more than once at a time
  --> src/main.rs:16:13
   |
15 |         for user in &mut self.users {
   |                     ---------------
   |                     |
   |                     first mutable borrow occurs here
   |                     first borrow later used here
16 |             self.log(msg.clone());
   |             ^^^^^^^^^^^^^^^^^^^^^ second mutable borrow occurs here

왜 에러인가?

  • &mut self.users로 첫 번째 가변 빌림 (루프가 끝날 때까지 유지)
  • self.log()는 &mut self, 즉 users를 포함한 구조체 전체를 다시 가변 빌림
  • Rust는 같은 데이터에 동시에 하나의 가변 참조만 허용

흥미로운 점은 루프 안에서 self.log(...) 대신 self.messages.push(...)를 직접 쓰면 이 코드가 컴파일된다는 것입니다. 함수 본문 안에서는 컴파일러가 self.users와 self.messages가 서로 다른 필드라는 것을 알기 때문에 두 필드를 동시에 가변 빌림하는 것을 허용합니다(분리 빌림, disjoint borrow). 하지만 메서드 시그니처 &mut self는 “구조체 전체를 빌린다”는 약속이라 필드 단위로 쪼개지지 않습니다. 제가 C++에서 넘어와 처음 막혔던 지점도 이것이었습니다. 헬퍼 메서드로 코드를 정리하는 순간 멀쩡하던 코드가 E0499로 깨지기 때문에, Rust에서는 “필드를 직접 다루는 자유 함수”나 “필드만 받는 헬퍼”로 쪼개는 설계를 자주 쓰게 됩니다.

해결 방법

// 방법 1: 필드 분리 빌림 (메서드 대신 필드를 직접 사용)
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        // users와 messages를 따로 빌림
        let users = &mut self.users;
        let messages = &mut self.messages;
        
        for user in users {
            messages.push(msg.clone()); // ✅ 다른 필드
        }
    }
}

// 방법 2: 반복 전에 메시지 생성
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        self.messages.push(msg.clone()); // 먼저 추가
        
        for user in &mut self.users {
            // messages는 더 이상 빌리지 않음
            user.notify();
        }
    }
}

// 방법 3: 인덱스 사용
impl ChatRoom {
    fn broadcast(&mut self, sender: &User) {
        let msg = format!("{}: Hello", sender.name);
        
        for i in 0..self.users.len() {
            self.log(msg.clone()); // ✅ 인덱스는 빌림 아님
            // 사용자에 접근할 때만 잠깐 빌림: self.users[i].notify();
        }
    }
}

세 방법의 차이는 “빌림을 얼마나 오래 쥐고 있느냐”입니다. 방법 1은 두 필드를 처음부터 따로 빌려 겹침을 없애고, 방법 2는 작업 순서를 바꿔 두 빌림이 시간적으로 겹치지 않게 하며, 방법 3은 반복자 대신 정수 인덱스를 들고 있어서 루프 전체에 걸친 빌림 자체를 없앱니다. 인덱스 방식은 가장 유연하지만, 루프 중에 users의 길이가 바뀌면 인덱스가 어긋나거나 범위 밖 접근으로 panic이 날 수 있다는 점을 감안해야 합니다.


사례 3: “lifetime may not live long enough”

문제 코드

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 {
        // ❌ lifetime 에러
        if let Some(user) = self.find(name) {
            return user; // 💥 빌림이 살아있는데 &mut self 필요
        }
        
        self.users.push(User { name: name.to_string(), email: String::new() });
        self.users.last().unwrap()
    }
}

에러 메시지

error[E0502]: cannot borrow `self.users` as mutable because it is also borrowed as immutable
  --> src/main.rs:15:9
   |
12 |         if let Some(user) = self.find(name) {
   |                             ---- immutable borrow occurs here
13 |             return user;
   |                    ---- returning this value requires that `*self` is borrowed for `'1`
...
15 |         self.users.push(...);
   |         ^^^^^^^^^^^ mutable borrow occurs here

해결 방법

// 방법 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(), email: String::new() });
        self.users.last().unwrap()
    }
}

// 방법 2: Entry API (HashMap)
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(), email: String::new() })
    }
}

이 사례는 코드만 보면 “찾았으면 바로 반환하니까 아래의 push와 겹칠 일이 없다”는 것이 사람 눈에는 명백해서 더 당황스럽습니다. 실제로 이는 현재 borrow checker(NLL)의 알려진 한계입니다. 조건부로 반환되는 참조는 함수의 반환 수명 '1 전체 동안 빌림이 살아 있다고 간주되어, 반환하지 않는 경로에서도 빌림이 끝나지 않은 것으로 처리됩니다. 차세대 borrow checker인 Polonius가 이 패턴을 허용하도록 개발되고 있지만 아직 안정 버전의 기본값은 아닙니다. 그래서 지금은 방법 1처럼 position으로 인덱스(빌림이 아닌 정수)를 먼저 얻고 빌림을 새로 시작하거나, 방법 2처럼 “찾거나 넣기”를 한 번에 처리하는 Entry API를 쓰는 것이 정석입니다. find를 두 번 호출하는 방식(contains 후 다시 find)도 컴파일은 되지만 탐색을 두 번 합니다.


사례 4: “cannot return reference to local variable”

문제 코드

fn get_default_user() -> &User {
    let user = User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    };
    &user // ❌ user는 함수 끝에서 소멸
}

에러 메시지

위 코드를 그대로 컴파일하면 먼저 반환 타입 &User에 수명이 없다는 “error[E0106]: missing lifetime specifier”가 납니다. 컴파일러 제안대로 &'static User로 바꾸면 그다음에 본질적인 에러가 나옵니다.

error[E0515]: cannot return reference to local variable `user`
  --> src/main.rs:8:5
   |
8  |     &user
   |     ^^^^^ returns a reference to data owned by the current function

해결 방법

// 방법 1: 소유권 반환
fn get_default_user() -> User {
    User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    }
}

// 방법 2: 정적 수명
fn get_default_user() -> &'static User {
    static DEFAULT: User = User {
        name: String::new(),  // ✅ String::new()는 const fn이라 가능
        email: String::new(), // ❌ 하지만 "Guest".to_string()은 const가 아니라 불가
    };
    &DEFAULT
}

// 방법 2-1: lazy_static 사용 (Rust 1.80+에서는 std::sync::LazyLock으로 대체 가능)
use lazy_static::lazy_static;

lazy_static! {
    static ref DEFAULT_USER: User = User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    };
}

fn get_default_user() -> &'static User {
    &DEFAULT_USER
}

// 방법 3: Box로 힙 할당
fn get_default_user() -> Box<User> {
    Box::new(User {
        name: "Guest".to_string(),
        email: "[email protected]".to_string(),
    })
}

C++ 개발자에게 이 에러는 반가운 에러입니다. C++에서 지역 변수의 참조를 반환하면 경고 하나로 끝나고 런타임에 댕글링 참조가 되지만, Rust는 이를 컴파일 단계에서 거부합니다. 해결의 기본값은 방법 1처럼 값을 반환하는 것입니다. User를 반환해도 실제로는 String 두 개 분량(각각 포인터·길이·용량)의 스택 데이터만 이동하고 힙 문자열은 복사되지 않으므로 비용 걱정은 거의 필요 없습니다. 방법 3의 Box<User>는 이 경우 이점이 없고 힙 할당만 하나 늘어나므로, 트레이트 객체(Box<dyn Trait>)를 반환하거나 크기가 매우 큰 구조체를 옮길 때처럼 이유가 있을 때만 씁니다. 정적 기본값이 꼭 필요하다면 외부 크레이트 없이 static DEFAULT_USER: LazyLock<User> = LazyLock::new(|| ...)를 쓰는 것이 최신 Rust의 관례입니다.


사례 5: 스레드 사이에서 상태를 공유할 때

문제 코드

use std::thread;

struct Counter {
    count: i32,
}

fn main() {
    let counter = Counter { count: 0 };
    
    let handle = thread::spawn(|| {
        counter.count += 1; // ❌ closure may outlive the current function
    });
    
    handle.join().unwrap();
}

에러 메시지

error[E0373]: closure may outlive the current function, but it borrows `counter`, which is owned by the current function
  --> src/main.rs:9:31
   |
9  |     let handle = thread::spawn(|| {
   |                                ^^ may outlive borrowed value `counter`
10 |         counter.count += 1;
   |         ------- `counter` is borrowed here

해결 방법

// 방법 1: Arc + Mutex (멀티스레드 안전)
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let counter_clone = Arc::clone(&counter);
    
    let handle = thread::spawn(move || {
        let mut count = counter_clone.lock().unwrap();
        *count += 1;
    });
    
    handle.join().unwrap();
    println!("Count: {}", *counter.lock().unwrap());
}

// 방법 2: 채널 사용 (메시지 전달)
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    
    thread::spawn(move || {
        tx.send(1).unwrap();
    });
    
    let count = rx.recv().unwrap();
    println!("Count: {}", count);
}

// 방법 3: 원자적 타입 (AtomicI32)
use std::sync::atomic::{AtomicI32, Ordering};

fn main() {
    let counter = Arc::new(AtomicI32::new(0));
    let counter_clone = Arc::clone(&counter);
    
    let handle = thread::spawn(move || {
        counter_clone.fetch_add(1, Ordering::SeqCst);
    });
    
    handle.join().unwrap();
    println!("Count: {}", counter.load(Ordering::SeqCst));
}

E0373이 나는 이유는 thread::spawn이 클로저에 'static 수명을 요구하기 때문입니다. 새 스레드는 main보다 오래 살 수도 있으므로, 컴파일러는 main의 지역 변수를 참조로 빌리는 클로저를 허용하지 않습니다. 컴파일러가 제안하는 move만 붙이면 counter가 스레드로 이동해 컴파일은 되지만, 이후 main에서 counter를 다시 쓸 수 없습니다. 그래서 여러 스레드가 같은 값을 보려면 Arc로 소유권을 공유하고, 수정까지 하려면 Mutex나 원자 타입으로 동기화를 추가해야 합니다. 세 방법 중 단순 카운터라면 락이 필요 없는 AtomicI32가 가장 가볍고, 여러 필드를 함께 일관되게 바꿔야 하면 Mutex, 스레드 사이에 결과만 넘기면 되면 채널이 가장 자연스럽습니다. 스레드가 모두 함수 안에서 끝난다는 것을 보장할 수 있다면 Rust 1.63부터 제공되는 std::thread::scope를 쓰면 Arc 없이 지역 변수를 그대로 빌릴 수 있습니다.


소유권·빌림·Rc·RefCell 중 언제 무엇을 쓰나

소유권 vs 빌림

상황해결 방법예시
읽기만 필요불변 참조 &Tfn print(user: &User)
수정 필요가변 참조 &mut Tfn update(user: &mut User)
소유권 필요값 Tfn consume(user: User)
복사 가능Copy 트레이트fn calc(x: i32)

내부 가변성

타입용도스레드 안전
Cell<T>Copy 타입 내부 가변성❌
RefCell<T>런타임 빌림 검사❌
Mutex<T>멀티스레드 가변성✅
RwLock<T>읽기 많은 경우✅

공유 소유권

타입용도스레드 안전
Rc<T>단일 스레드 공유❌
Arc<T>멀티스레드 공유✅
Weak<T>순환 참조 방지상황에 따라

조합 패턴

// 단일 스레드: Rc + RefCell
use std::rc::Rc;
use std::cell::RefCell;

let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone = Rc::clone(&shared);

shared.borrow_mut().push(4);
println!("{:?}", clone.borrow()); // [1, 2, 3, 4]

// 멀티스레드: Arc + Mutex
use std::sync::{Arc, Mutex};

let shared = Arc::new(Mutex::new(vec![1, 2, 3]));
let clone = Arc::clone(&shared);

thread::spawn(move || {
    clone.lock().unwrap().push(4);
});

이벤트 시스템: C++식 설계를 Rc·RefCell로 옮기기

C++ 스타일 (컴파일 안 됨)

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); // ✅ 이건 괜찮음
        }
    }
}

// 문제: 구독자가 EventManager를 수정하려 하면?
fn main() {
    let mut mgr = EventManager { listeners: vec![] };
    
    mgr.subscribe(|event| {
        mgr.publish(event); // ❌ cannot borrow `mgr` as mutable
    });
}

Rust 스타일 (Rc + RefCell)

use std::rc::Rc;
use std::cell::RefCell;

struct EventManager {
    listeners: Vec<Box<dyn Fn(&Event)>>,
}

fn main() {
    let mgr = Rc::new(RefCell::new(EventManager { listeners: vec![] }));
    let mgr_clone = Rc::clone(&mgr);
    
    mgr.borrow_mut().subscribe(Box::new(move |event| {
        // 클로저 내부에서 mgr_clone 사용
        mgr_clone.borrow().publish(event);
    }));
}

이 구조는 컴파일은 통과하지만 두 가지 함정을 안고 있습니다. 첫째, 콜백 안에서 mgr_clone.borrow_mut()로 새 구독자를 추가하려 하면 바깥의 publish가 이미 borrow()를 쥐고 있기 때문에 런타임에 “already borrowed: BorrowMutError” panic이 납니다. RefCell은 borrow checker의 규칙을 없애는 것이 아니라 검사 시점을 런타임으로 미룰 뿐이라, 컴파일 에러가 panic으로 바뀐 것에 가깝습니다. 둘째, EventManager가 클로저를 소유하고 그 클로저가 다시 Rc<EventManager>를 소유하므로 참조 카운트가 0이 되지 않는 순환 참조가 생겨 메모리가 해제되지 않습니다. 실무에서는 클로저에 Rc::downgrade(&mgr)로 만든 Weak를 넣고 호출 시점에 upgrade()하거나, 콜백이 관리자를 직접 건드리지 않고 “추가할 구독자”나 “발행할 이벤트”를 큐에 쌓게 한 뒤 publish가 끝난 다음 처리하는 방식을 많이 씁니다.


에러 메시지·rustc —explain·Clippy로 원인 좁히기

에러 메시지 읽는 법

error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
  --> src/main.rs:10:5
   |
8  |     let r = &x;
   |             -- immutable borrow occurs here
9  |     
10 |     x.push(1);
   |     ^^^^^^^^^ mutable borrow occurs here
11 |     println!("{}", r);
   |                    - immutable borrow later used here

읽는 순서:

  1. 에러 타입: E0502 (동시 빌림 불가)
  2. 문제 위치: 10번 줄
  3. 원인: 8번 줄에서 불변 빌림
  4. 사용 위치: 11번 줄까지 사용

rustc —explain

$ rustc --explain E0502

This error indicates that you are trying to borrow a value as mutable when it
is already borrowed as immutable.
...

Clippy 활용

$ cargo clippy

warning: this `RefCell` reference is held across an await point
  --> src/main.rs:10:9
   |
10 |     let data = cell.borrow();
   |         ^^^^
   |
   = help: ensure the reference is dropped before calling `await`

위 경고는 clippy::await_holding_refcell_ref 린트입니다. .await 지점에서 다른 태스크가 실행되는 동안 borrow()가 살아 있으면, 그 태스크가 같은 셀을 borrow_mut()할 때 panic이 나므로 참조를 .await 전에 스코프로 감싸 먼저 버리라는 뜻입니다.


마무리

Rust의 borrow checker는 처음엔 답답하지만, 컴파일 타임에 메모리 안전성을 보장해줍니다. 이 사례들을 통해:

  1. 에러 메시지를 정확히 읽는 법을 배웠습니다
  2. 소유권, 빌림, 수명의 관계를 이해했습니다
  3. RefCell, Rc, Arc 등 해결 패턴을 익혔습니다
  4. C++와 다른 사고방식을 습득했습니다

핵심: Borrow checker와 싸우지 말고, Rust의 철학을 이해하고 따르세요.


FAQ

Q1. Rust는 C++보다 어려운가요?

초기 학습 곡선은 가파르지만, 익숙해지면 메모리 버그를 원천 차단할 수 있습니다.

Q2. RefCell을 남용하면 안 되나요?

RefCell은 런타임 검사이므로 panic 가능성이 있습니다. 가능하면 컴파일 타임 빌림을 사용하세요.

Q3. Arc<Mutex>가 성능에 영향을 주나요?

네, 오버헤드가 있습니다. 필요한 곳에만 사용하며, 읽기가 많으면 RwLock을 고려하세요.


같이 보면 좋은 글


Borrow Checker·멀티스레드 체크리스트

Borrow Checker 에러 해결 체크리스트

  • 에러 메시지 정확히 읽기
  • 빌림 범위 확인 (어디서 시작, 어디까지 사용)
  • 소유권이 필요한지 참조만 필요한지 판단
  • 가변/불변 빌림 구분
  • 수명 관계 확인
  • 적절한 패턴 선택 (Rc, RefCell, Arc, Mutex)
  • Clippy로 추가 검사

멀티스레드 안전성 체크리스트

  • Send/Sync 트레이트 확인
  • Arc + Mutex 조합 고려
  • 데드락 가능성 검토
  • 채널 사용 고려
  • 원자적 타입 활용