- Published on
Rust E0502/E0507 소유권·빌림 에러 7분 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 생산성을 가장 많이 갉아먹는 순간이 있습니다. 컴파일러가 친절하긴 한데, 당장 뭘 고치라는 건지 감이 안 올 때죠. 그중에서도 E0502 와 E0507 는 초반에 거의 반드시 만나게 되는 “소유권·빌림” 대표 에러입니다.
이 글은 두 에러를 원인 분류 → 즉시 적용 가능한 해결 패턴으로 정리해, 다음에 같은 에러가 나면 7분 안에 끝내는 것을 목표로 합니다.
(참고로, 문제를 “재현 → 원인 → 해결”로 분해하는 방식은 DB 데드락 트러블슈팅과도 유사합니다. 복잡한 원인을 짧게 줄이는 접근이 필요하면 MySQL InnoDB 데드락 1213 재현·원인·해결 같은 글의 사고방식도 도움이 됩니다.)
0. 7분 체크리스트(결론부터)
E0502: 불변 빌림이 살아있는 동안 가변 빌림을 시도했는가?- 해결 1) 불변 참조의 수명(스코프)을 줄이기
- 해결 2) 필요한 값만 미리 복사/클론해서 참조를 빨리 끊기
- 해결 3) 로직을 두 단계로 분리(읽기 단계 / 쓰기 단계)
- 해결 4) 컬렉션 인덱싱이면
split_at_mut/take/mem::replace고려
E0507: 이동(move)할 수 없는 곳에서 값을 이동하려 했는가?- 해결 1) 이동 대신 참조로 빌리기
- 해결 2) 정말 필요하면
clone - 해결 3) 구조체 필드를 꺼내야 하면
Option::take또는mem::replace - 해결 4) 트레잇 메서드면
self/&self/&mut self시그니처를 재검토
이제 각 에러를 빠르게 “패턴”으로 익혀봅니다.
1. E0502: 불변으로 빌렸는데 가변으로 또 빌림
1-1. E0502의 핵심 규칙
Rust의 규칙을 한 줄로 줄이면 이겁니다.
- 어떤 값에 대해 불변 참조(
&T)가 존재하는 동안 - 같은 값에 대해 가변 참조(
&mut T)를 만들 수 없다
즉 “읽는 중에 쓰지 마라”가 아니라, 더 정확히는 같은 대상에 대한 aliasing을 컴파일 타임에 차단합니다.
1-2. 흔한 재현 코드
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // 불변 빌림 시작
v.push(4); // 가변 빌림 필요
println!("{first}");
}
여기서 first 가 v 를 불변으로 빌린 상태인데, push 는 내부 버퍼 재할당 가능성 때문에 v 전체에 대한 가변 접근이 필요합니다. 그래서 충돌합니다.
1-3. 해결 패턴 A: 스코프를 줄여 참조를 빨리 끊기
가장 빠른 해결은 불변 참조를 더 짧게 살게 만드는 겁니다.
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{first}");
} // 여기서 first 드롭, 불변 빌림 종료
v.push(4);
}
“읽기”가 끝났으면 참조도 끝내는 게 정답인 경우가 많습니다.
1-4. 해결 패턴 B: 필요한 값만 미리 복사해서 참조를 없애기
값이 Copy 라면 참조 대신 값을 복사해 들고 있으면 됩니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{first}");
}
String 같은 비-Copy 타입이면 clone 을 고려합니다. (성능/메모리 비용은 의식적으로 감수해야 합니다.)
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
let first = v[0].clone();
v.push(String::from("c"));
println!("{first}");
}
1-5. 해결 패턴 C: 읽기 단계와 쓰기 단계를 분리
읽어서 계산한 뒤, 그 결과로 쓰는 구조라면 중간 결과만 소유하도록 설계하면 깔끔합니다.
fn main() {
let mut v = vec![1, 2, 3];
let sum: i32 = v.iter().sum(); // 여기서는 불변 빌림
// sum이 값으로 떨어졌으니 불변 빌림 종료
v.push(sum);
}
이 패턴은 비즈니스 로직이 커질수록 더 중요해집니다. “참조를 오래 들고 계산하는 코드”는 E0502를 자주 유발합니다.
1-6. 해결 패턴 D: 같은 벡터의 두 원소를 동시에 가변 참조하고 싶다면
아래 코드는 자주 실패합니다.
fn swap_bad(v: &mut Vec<i32>, i: usize, j: usize) {
let a = &mut v[i];
let b = &mut v[j];
std::mem::swap(a, b);
}
인덱스가 다르더라도 컴파일러는 “서로 다른 원소”임을 일반적으로 증명하지 못합니다.
이럴 때는 split_at_mut 로 “서로 겹치지 않는 두 슬라이스”를 만들어 증명해줍니다.
fn swap_ok(v: &mut [i32], i: usize, j: usize) {
assert!(i != j);
let (a, b) = if i < j {
let (left, right) = v.split_at_mut(j);
(&mut left[i], &mut right[0])
} else {
let (left, right) = v.split_at_mut(i);
(&mut right[0], &mut left[j])
};
std::mem::swap(a, b);
}
이건 E0502라기보다 “동시 가변 빌림” 계열의 전형적인 우회법이고, 실무에서 매우 자주 씁니다.
2. E0507: 이동할 수 없는 곳에서 값을 이동하려 함
2-1. E0507의 핵심 규칙
E0507 은 보통 이런 상황에서 발생합니다.
&T나&mut T같은 참조를 통해 접근한 값을- 그 자리에서 소유권 이동(move) 하려고 할 때
즉, “빌린 것에서 소유권을 뽑아오려 했다”가 핵심입니다.
2-2. 흔한 재현 코드: &self 메서드에서 필드 꺼내기
struct User {
name: String,
}
impl User {
fn take_name_bad(&self) -> String {
self.name // E0507: &self에서 String을 move할 수 없음
}
}
self 가 &self 이므로 self.name 은 “빌린 구조체의 필드”입니다. String 은 move 타입이라 참조에서 바로 꺼낼 수 없습니다.
2-3. 해결 패턴 A: 참조를 반환하기
소유권이 필요 없고 읽기만 하면 된다면 &str 같은 참조를 돌려주세요.
struct User {
name: String,
}
impl User {
fn name(&self) -> &str {
&self.name
}
}
API 설계 측면에서도 가장 비용이 적습니다.
2-4. 해결 패턴 B: 정말 소유권이 필요하면 clone
impl User {
fn name_owned(&self) -> String {
self.name.clone()
}
}
clone 은 명시적으로 비용을 지불하는 선택입니다. 호출 빈도가 높다면 캐싱/인터닝/Cow 등 다른 설계를 검토할 수 있습니다.
2-5. 해결 패턴 C: &mut self + Option::take 로 “꺼내기”
필드를 “가져가고 나면 비워도 된다”면, 필드를 Option<T> 로 감싸고 take 를 쓰는 게 정석입니다.
struct User {
name: Option<String>,
}
impl User {
fn take_name(&mut self) -> Option<String> {
self.name.take()
}
}
이 패턴은 E0507을 가장 우아하게 없애는 방법 중 하나입니다. “한 번만 소비되는 값”을 타입으로 표현할 수 있어서, 런타임 버그도 줄어듭니다.
2-6. 해결 패턴 D: mem::replace 로 기본값과 교체
필드를 Option 으로 바꾸기 어렵다면, 교체로 빼올 수 있습니다.
use std::mem;
struct User {
name: String,
}
impl User {
fn take_name(&mut self) -> String {
mem::replace(&mut self.name, String::new())
}
}
String::new() 대신 의미 있는 기본값이 있다면 그걸 넣으면 됩니다.
2-7. 트레잇/클로저에서 자주 나는 E0507: Fn vs FnOnce
클로저가 캡처한 값을 move 하려면 그 클로저는 본질적으로 FnOnce 입니다.
fn call_twice<F: Fn()>(f: F) {
f();
f();
}
fn main() {
let s = String::from("hi");
call_twice(|| {
drop(s); // s를 move 하므로 E0507 류 에러로 이어짐
});
}
해결은 의도에 따라 다릅니다.
- 두 번 호출해야 하면: move 하지 말고 참조/clone
- 한 번만 호출하는 API면:
FnOnce로 받기
fn call_once<F: FnOnce()>(f: F) {
f();
}
fn main() {
let s = String::from("hi");
call_once(|| drop(s));
}
3. E0502와 E0507을 동시에 줄이는 “설계 습관”
3-1. 참조를 오래 들고 있지 말고, 값으로 중간 결과를 만들기
- 긴 함수에서
let x = &something.field;같은 참조를 초반에 잡고 - 후반에
something을 수정하는 흐름
이게 E0502의 온상입니다. 중간 결과는 가능한 한 소유한 값으로 떨어뜨리세요.
3-2. “꺼낼 수 있는 필드”는 처음부터 Option 이나 상태 머신으로 모델링
E0507은 종종 “필드를 한 번 꺼내고 싶다”는 요구에서 시작합니다. 그 요구를 타입에 반영하면 해결이 쉬워집니다.
- 한 번 소비:
Option<T>+take - 상태 전이:
enum State { ... }로 상태 머신
3-3. 에러 메시지에서 꼭 봐야 할 줄
컴파일러 메시지에서 다음 단서가 실마리입니다.
immutable borrow occurs here/mutable borrow occurs hereborrow later used here(참조가 아직 살아있다는 뜻)cannot move out of(move 시도)which is behind a shared reference(공유 참조&뒤에 있음)
이 포인트만 표시해도 “스코프 줄이기”인지 “take/replace”인지 방향이 바로 나옵니다.
4. 실전 미니 예제: 캐시 갱신 로직에서 E0502 고치기
예를 들어, 캐시에서 값을 읽고 로그 찍고, 없으면 갱신하는 코드가 있다고 합시다.
use std::collections::HashMap;
fn get_or_insert_bad(map: &mut HashMap<String, String>, key: String) -> String {
if let Some(v) = map.get(&key) {
// v는 map에 대한 불변 빌림
map.insert(key, v.clone());
// 여기서 map을 가변으로 빌리며 충돌(E0502 계열)
return v.clone();
}
map.insert(key.clone(), "new".to_string());
map.get(&key).unwrap().clone()
}
해결은 “읽기 단계에서 필요한 값만 소유”로 빼면 됩니다.
use std::collections::HashMap;
fn get_or_insert_ok(map: &mut HashMap<String, String>, key: String) -> String {
let existing = map.get(&key).cloned(); // Option<String>으로 소유
if let Some(v) = existing {
map.insert(key, v.clone());
return v;
}
map.insert(key.clone(), "new".to_string());
map.get(&key).unwrap().clone()
}
map.get(&key) 로 만든 참조를 오래 끌고 가지 않으니, 가변 작업과 충돌하지 않습니다.
5. 마무리: 7분 안에 끝내는 사고 순서
- 에러가
E0502인가E0507인가를 먼저 분류 E0502면 “불변 참조가 어디서 시작해서 어디서 마지막으로 쓰이는지” 찾기E0507면 “내가 move 하려는 값이 참조 뒤에 있는지, 필드인지, 클로저 캡처인지” 확인- 그 다음은 레시피대로 적용
- 스코프 줄이기
- 값으로 복사/클론해서 참조 끊기
- 읽기/쓰기 단계 분리
Option::take또는mem::replace- 필요하면
FnOnce로 시그니처 변경
타입 시스템이 빡세게 느껴질 수 있지만, 일단 이 두 에러는 패턴으로 외워두면 디버깅 시간이 급격히 줄어듭니다. 다른 언어에서 타입 좁히기나 빌드 에러를 “원인별 체크리스트”로 해결하듯이, Rust도 대표 에러는 공략법이 정해져 있습니다. 비슷한 방식의 문제 해결 글이 필요하면 TypeScript 5.5 never 좁히기 깨짐? 해결 6가지 도 함께 참고해보면, 에러를 분류하고 재현 최소화하는 감각을 더 빨리 얻을 수 있습니다.