Published on

Rust 소유권 지옥 탈출 - cannot move out 해결

Authors

Rust를 쓰다 보면 어느 순간 컴파일러가 이렇게 말합니다.

  • cannot move out of ...
  • cannot move out of borrowed content
  • cannot move out of ... which is behind a shared reference

처음엔 “내가 뭘 옮겼다는 거지?” 싶지만, 이 에러는 사실 Rust의 핵심(소유권/이동/빌림)이 제대로 작동하고 있다는 신호입니다. 문제는 신호를 읽는 법이 익숙하지 않다는 것뿐이죠.

이 글에서는 cannot move out이 터지는 대표 상황을 패턴별로 분해하고, 실무에서 바로 쓰는 해결책(설계 변경 포함)을 코드로 정리합니다.

트러블슈팅 관점에서 원인-재현-해결 흐름을 좋아한다면, Go의 동시성 문제를 같은 방식으로 정리한 글도 참고할 만합니다: Go 채널 데드락 원인 7가지와 재현·해결

cannot move out이 의미하는 것

Rust에서 “move”는 메모리를 복사하는 행위가 아니라, 소유권을 다른 변수로 이전하는 행위입니다. 소유권이 이동하면 원래 변수는 더 이상 그 값을 사용할 수 없습니다.

그리고 다음 규칙이 핵심입니다.

  • 공유 참조(&T) 뒤에 있는 값은 move할 수 없다.
  • 빌린 상태(참조가 살아있는 동안)에는 원본을 move할 수 없다.
  • 구조체/열거형의 필드 일부만 move하면(부분 이동) 나머지 사용이 제한될 수 있다.

즉, 에러 메시지의 본질은 대부분 이것입니다.

  • “지금 그 값은 네 것이 아니야(참조 뒤에 있어).”
  • “지금 누가 빌려서 보고 있어(borrow 중이야).”
  • “이미 일부를 떼어가서 전체를 다시 쓰면 위험해(부분 이동).”

케이스 1: &T 뒤의 값을 꺼내려 했다

가장 흔한 패턴입니다.

재현

#[derive(Debug)]
struct User {
    name: String,
}

fn get_name(u: &User) -> String {
    // error: cannot move out of `u.name` which is behind a shared reference
    u.name
}

u&User이고, 그 뒤에 있는 String을 통째로 move하려 했기 때문에 실패합니다.

해결 1) 참조를 반환한다

소유권이 꼭 필요 없다면, 가장 좋은 해결은 빌려서 쓰기입니다.

fn get_name(u: &User) -> &str {
    u.name.as_str()
}

반환 타입을 &String보다 &str로 낮추면 호출자 입장에서 더 유연합니다.

해결 2) 소유권이 필요하면 clone() 한다

fn get_name_owned(u: &User) -> String {
    u.name.clone()
}

clone()은 “비용을 지불하고 소유권을 얻는다”는 명시적 선택입니다. 핫패스에서 남발하면 성능에 영향이 있을 수 있으니 의도를 분명히 하세요.

케이스 2: Option/Result에서 move하려다 참조 때문에 막혔다

실무에서는 Option<String> 같은 필드에서 특히 자주 터집니다.

재현

#[derive(Debug)]
struct Profile {
    nickname: Option<String>,
}

fn take_nickname(p: &mut Profile) -> Option<String> {
    // 아래처럼 쓰고 싶지만, 상황에 따라 borrow 이슈가 얽히면 에러가 나기 쉽다
    p.nickname
}

p&mut Profile이라면 사실 move 자체는 가능할 것 같지만, Rust는 “필드만 빼고 구조체는 유지” 같은 상황을 엄격히 다룹니다. 이럴 때 정석은 컨테이너 API로 안전하게 비우고 가져오기입니다.

해결: Option::take()

fn take_nickname(p: &mut Profile) -> Option<String> {
    p.nickname.take()
}

take()는 내부 값을 None으로 바꾸고, 기존 값을 반환합니다. 즉, “꺼내는 동시에 자리를 안전한 기본값으로 채운다”는 패턴입니다.

Result에서는 mem::replacestd::mem::take를 함께 고려할 수 있습니다.

케이스 3: 패턴 매칭에서 부분 이동(partial move)이 발생했다

구조체를 매칭하면서 String 같은 non-Copy 필드를 move하면, 그 이후 원래 구조체 전체를 쓰기 어려워집니다.

재현

#[derive(Debug)]
struct Task {
    id: u64,
    title: String,
}

fn demo(t: Task) {
    let Task { title, .. } = t; // title이 move됨

    // error: borrow of partially moved value: `t`
    println!("{:?}", t);

    println!("{}", title);
}

해결 1) 필요한 건 참조로 빌려온다: ref 패턴

fn demo(t: Task) {
    let Task { ref title, .. } = t; // title: &String

    println!("{:?}", t);
    println!("{}", title);
}

해결 2) 아예 분해 후 원본을 쓰지 않는다

원본 t가 더 이상 필요 없다면, 부분 이동은 문제가 아닙니다.

fn demo(t: Task) {
    let Task { title, id } = t;
    println!("{}:{}", id, title);
}

해결 3) 소유권이 필요하지만 t도 유지해야 한다면 clone()

fn demo(t: Task) {
    let title = t.title.clone();
    println!("{:?}", t);
    println!("{}", title);
}

이 경우 title의 복제 비용을 감수할 가치가 있는지 판단해야 합니다.

케이스 4: 반복문에서 for x in &vec인데 내부 값을 move하려 했다

재현

fn demo(v: Vec<String>) {
    for s in &v {
        // error: cannot move out of `*s` which is behind a shared reference
        let owned: String = *s;
        println!("{}", owned);
    }
}

해결 1) 그냥 빌려서 쓴다

fn demo(v: Vec<String>) {
    for s in &v {
        println!("{}", s);
    }
}

해결 2) 소유권이 필요하면 cloned()

fn demo(v: Vec<String>) {
    for owned in v.iter().cloned() {
        println!("{}", owned);
    }
}

해결 3) 아예 벡터를 소비(consuming)한다

벡터를 더 이상 쓰지 않을 거면 가장 깔끔합니다.

fn demo(v: Vec<String>) {
    for owned in v {
        println!("{}", owned);
    }
}

케이스 5: self&self로 받았는데 필드를 move하려 했다

메서드 구현에서 자주 만납니다.

재현

struct App {
    token: String,
}

impl App {
    fn token(&self) -> String {
        // error: cannot move out of `self.token` which is behind a shared reference
        self.token
    }
}

해결 1) &str 반환

impl App {
    fn token(&self) -> &str {
        self.token.as_str()
    }
}

해결 2) 소유권을 넘기는 설계로 바꾸기: self 소비

impl App {
    fn into_token(self) -> String {
        self.token
    }
}

API 설계에서 &selfself냐는 매우 중요합니다. “호출 이후에도 객체를 계속 써야 하는가?”가 기준입니다.

해결 3) 내부를 Option으로 만들고 take()

“한 번만 꺼낼 수 있는 값”이라면 Option이 의도를 코드에 박아줍니다.

struct App {
    token: Option<String>,
}

impl App {
    fn take_token(&mut self) -> Option<String> {
        self.token.take()
    }
}

케이스 6: 빌림이 살아있는 동안 move하려 했다 (NLL로도 안 되는 경우)

Rust의 NLL(Non-Lexical Lifetimes) 덕분에 예전보다 많이 좋아졌지만, 여전히 “참조를 들고 있는 동안 원본을 move”는 불가합니다.

재현

fn demo(mut s: String) {
    let r = &s;

    // error: cannot move out of `s` because it is borrowed
    let moved = s;

    println!("{}", r);
    println!("{}", moved);
}

해결: 참조의 사용을 먼저 끝내거나, 스코프를 분리

fn demo(mut s: String) {
    {
        let r = &s;
        println!("{}", r);
    } // r의 생명주기 종료

    let moved = s;
    println!("{}", moved);
}

이 패턴은 “참조를 오래 들고 있지 마라”라는 설계 원칙으로 이어집니다. 특히 큰 함수에서 참조를 상단에 만들고 아래에서 이것저것 하다 보면 이런 충돌이 쉽게 납니다.

실무 치트키: 상황별 선택 가이드

cannot move out을 봤을 때, 다음 질문 순서로 판단하면 빠릅니다.

  1. 정말 소유권이 필요한가?
    • 아니오: &T, &str, as_ref(), iter()로 해결
  2. 소유권이 필요하지만 복사가 가능한가?
    • 가능(작고 값 타입): Copy면 그냥 대입
    • 불가(힙 데이터): clone() 비용을 감수할지 판단
  3. 한 번만 꺼내는 게 맞는가?
    • 맞다: Option<T>로 감싸고 take()
  4. 컨테이너에서 안전하게 교체/초기화가 필요한가?
    • std::mem::take(&mut x) 또는 std::mem::replace(&mut x, new)
  5. API 설계를 바꿀 수 있는가?
    • getter는 참조 반환
    • 소유권을 넘기는 동작은 self 소비 메서드(into_*)로 분리

이런 “원인 체크리스트” 방식은 인프라 트러블슈팅에도 통합니다. 예를 들어 쿠버네티스 이미지 풀 실패도 원인 범주를 나누면 빨리 좁혀집니다: Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트

보너스: std::mem::takereplace로 구조체 필드 꺼내기

Option::take()가 없는 타입에서도 비슷한 패턴이 필요합니다.

#[derive(Debug, Default)]
struct State {
    buf: String,
}

fn drain_buf(st: &mut State) -> String {
    // st.buf를 기본값(String::default() == "")으로 바꾸고 기존 값을 가져온다
    std::mem::take(&mut st.buf)
}

replace는 “기본값”이 아니라 “내가 지정한 값”으로 교체합니다.

fn swap_buf(st: &mut State, new_buf: String) -> String {
    std::mem::replace(&mut st.buf, new_buf)
}

이 두 함수는 cannot move out을 우회하는 트릭이 아니라, 이동 후 원본을 유효한 상태로 유지하게 만드는 정공법입니다.

결론: 소유권 에러는 설계 피드백이다

cannot move out은 귀찮지만, Rust가 “이 코드 경로에서 누가 값을 소유해야 하는지”를 강제로 명확히 하게 만듭니다. 해결책은 크게 세 가지로 수렴합니다.

  • 빌려서 쓰기(참조 반환, 스코프 축소)
  • 비용을 내고 복제하기(clone, cloned)
  • 값의 생애주기를 모델링하기(Option + take, mem::take/replace, self 소비 API)

한 번 패턴이 눈에 익으면, 오히려 런타임 버그가 컴파일 타임에 사라지는 쾌감이 생깁니다. 다음에 cannot move out을 보면 “아, 이건 소유권 경계가 애매하다는 신호구나”라고 받아들이고, 위 체크리스트로 빠르게 수습해보세요.