- Published on
Rust 소유권 지옥 탈출 - cannot move out 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 어느 순간 컴파일러가 이렇게 말합니다.
cannot move out of ...cannot move out of borrowed contentcannot 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::replace나 std::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 설계에서 &self냐 self냐는 매우 중요합니다. “호출 이후에도 객체를 계속 써야 하는가?”가 기준입니다.
해결 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을 봤을 때, 다음 질문 순서로 판단하면 빠릅니다.
- 정말 소유권이 필요한가?
- 아니오:
&T,&str,as_ref(),iter()로 해결
- 아니오:
- 소유권이 필요하지만 복사가 가능한가?
- 가능(작고 값 타입):
Copy면 그냥 대입 - 불가(힙 데이터):
clone()비용을 감수할지 판단
- 가능(작고 값 타입):
- 한 번만 꺼내는 게 맞는가?
- 맞다:
Option<T>로 감싸고take()
- 맞다:
- 컨테이너에서 안전하게 교체/초기화가 필요한가?
std::mem::take(&mut x)또는std::mem::replace(&mut x, new)
- API 설계를 바꿀 수 있는가?
- getter는 참조 반환
- 소유권을 넘기는 동작은
self소비 메서드(into_*)로 분리
이런 “원인 체크리스트” 방식은 인프라 트러블슈팅에도 통합니다. 예를 들어 쿠버네티스 이미지 풀 실패도 원인 범주를 나누면 빨리 좁혀집니다: Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트
보너스: std::mem::take와 replace로 구조체 필드 꺼내기
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을 보면 “아, 이건 소유권 경계가 애매하다는 신호구나”라고 받아들이고, 위 체크리스트로 빠르게 수습해보세요.