- Published on
Rust E0502 뜰 때 소유권 리팩터링 5가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 언어에서 넘어온 개발자들이 Rust에서 가장 자주 마주치는 에러 중 하나가 E0502입니다. 메시지는 대개 다음 형태입니다.
cannot borrow ... as mutable because it is also borrowed as immutable
핵심은 간단합니다. 같은 값에 대해 불변 빌림이 살아있는 동안 가변 빌림을 만들 수 없다는 규칙을 위반한 겁니다. 그런데 실전에서는 “내가 보기엔 이미 안 쓰는데?” 싶은 순간에도 터집니다. 이유는 Rust가 빌림의 생존 범위를 문맥적으로 추론하되, 우리가 생각하는 ‘논리적 사용 종료’와 ‘스코프 상 종료’가 어긋나기 쉽기 때문입니다.
이 글에서는 E0502가 자주 발생하는 전형적인 코드 형태를 먼저 보고, 그 다음 리팩터링으로 구조를 바꾸어 해결하는 5가지 패턴을 정리합니다. (단순히 clone()으로 도망치기보다, 왜 그 구조가 안전한지까지 같이 이해하는 것이 목표입니다.)
참고로 런타임/비동기 환경에서의 Rust 문제 해결 패턴이 궁금하다면, Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking 글도 같이 보면 “컴파일은 되는데 런타임에서 터지는” 케이스까지 이어서 감을 잡기 좋습니다.
E0502가 나는 전형적인 상황
대표 예시는 컬렉션에서 어떤 값을 읽어 기준을 만들고, 같은 컬렉션을 수정하려는 코드입니다.
use std::collections::HashMap;
fn bump(map: &mut HashMap<String, i32>, key: &str) {
let current = map.get(key); // 불변 빌림
// 여기서 map을 가변 빌림하려고 하면 충돌
*map.entry(key.to_string()).or_insert(0) += current.unwrap_or(&0);
}
위 코드는 의도는 단순하지만, current가 map에 대한 불변 빌림을 잡고 있는 동안 entry()가 map을 가변으로 빌리려 하며 E0502가 납니다.
해결은 “불변 빌림을 빨리 끝내거나”, “읽기와 쓰기를 분리하거나”, “데이터 구조/소유권을 바꾸거나” 중 하나로 귀결됩니다.
리팩터링 1) 불변 빌림의 생존 범위를 줄이기 (스코프 분리)
가장 먼저 시도할 수 있는 건 불변 참조를 더 짧은 스코프로 제한하는 것입니다. 즉, 값을 참조로 들고 있지 말고 필요한 값만 뽑아 소유하거나 복사해서 빌림을 끝냅니다.
use std::collections::HashMap;
fn bump(map: &mut HashMap<String, i32>, key: &str) {
// i32는 Copy라서 값으로 뽑아오면 불변 빌림이 즉시 끝남
let current_value = map.get(key).copied().unwrap_or(0);
*map.entry(key.to_string()).or_insert(0) += current_value;
}
포인트
copied()또는cloned()로 “참조”가 아니라 “값”을 확보하면 빌림이 그 줄에서 종료됩니다.Copy타입이면 비용이 거의 없고,Clone타입이면 비용이 생길 수 있으니 주의합니다.
이 패턴은 특히 “먼저 읽고, 그 값을 기반으로 같은 구조를 수정”할 때 가장 깔끔합니다.
리팩터링 2) 읽기 단계와 쓰기 단계를 완전히 분리하기 (두 단계 처리)
불변 빌림이 길어지는 이유는 대개 읽기 결과를 들고 다음 로직을 진행하기 때문입니다. 그럴 때는 아예 처리 흐름을 두 단계로 나누는 게 좋습니다.
예를 들어, 어떤 벡터에서 조건을 만족하는 인덱스를 찾고 그 원소를 수정하려고 할 때:
fn mark_first_over_limit(v: &mut Vec<i32>, limit: i32) {
// 1) 읽기 단계: 수정할 위치만 결정
let idx_opt = v.iter().position(|x| *x > limit);
// 2) 쓰기 단계: 위치가 정해진 뒤에만 가변 빌림
if let Some(idx) = idx_opt {
v[idx] = 0;
}
}
포인트
iter()는 불변 빌림,v[idx] = ...는 가변 접근입니다.- “어떤 걸 바꿀지”를 먼저 결정하고, 그 다음에만 바꾸면 충돌이 사라집니다.
이 방식은 비용이 거의 없고, 로직도 읽기 쉬워지는 경우가 많습니다.
리팩터링 3) split_at_mut / get_many_mut로 “서로 다른 부분”을 동시에 빌리기
E0502는 “같은 값”을 동시에 빌리려 할 때 나지만, 실제로는 서로 다른 원소를 다루는 경우가 많습니다. Rust는 기본적으로 “같은 슬라이스에서 두 개의 가변 참조”를 안전하게 추론하지 못하므로, 표준 라이브러리의 안전한 분할 API를 써야 합니다.
split_at_mut로 두 구간 분리
fn swap_adjacent(v: &mut [i32], i: usize) {
assert!(i + 1 < v.len());
let (left, right) = v.split_at_mut(i + 1);
let a = &mut left[i];
let b = &mut right[0];
std::mem::swap(a, b);
}
포인트
split_at_mut는 슬라이스를 두 조각으로 나누며, 두 조각이 겹치지 않음을 Rust가 보장합니다.- “같은 컬렉션을 동시에 수정”하는 알고리즘(스왑, 파티셔닝, 투 포인터 등)에서 E0502의 정석 해법입니다.
추가로, Rust 버전에 따라 slice::get_many_mut 같은 API를 활용할 수도 있습니다. 다만 안정화 상태나 사용 가능 버전이 다를 수 있으니, 팀의 MSRV 정책에 맞춰 선택하세요.
리팩터링 4) mem::take / mem::replace로 소유권을 잠깐 빼내서 수정 후 되돌리기
구조체 필드 하나를 읽어서 다른 필드를 수정하려고 할 때 E0502가 자주 납니다. 예를 들어 self.name을 참조한 채로 self.cache를 갱신하려는 상황이 그렇습니다.
이럴 때는 필드의 소유권을 잠깐 꺼내서(빈 값으로 대체) 작업한 뒤 다시 넣는 패턴이 강력합니다.
use std::mem;
#[derive(Default)]
struct State {
buf: String,
log: Vec<String>,
}
impl State {
fn push_logged(&mut self) {
// buf를 잠깐 빼내면, self에 대한 빌림 충돌을 피하기 쉬움
let mut buf = mem::take(&mut self.buf);
buf.push_str("-done");
self.log.push(buf.clone());
// 수정된 값을 다시 돌려놓기
self.buf = buf;
}
}
포인트
mem::take는 대상 타입이Default를 구현해야 합니다.replace는 기본값이 없어도 원하는 대체 값을 넣을 수 있습니다.- 이 패턴은 “필드 간 상호 참조 때문에 빌림이 꼬이는” 문제를 구조적으로 풀어줍니다.
특히 복잡한 상태 머신이나 파서처럼 self 내부에 여러 필드가 얽힌 코드에서, take는 E0502를 단번에 정리해주는 경우가 많습니다.
리팩터링 5) 내부 가변성(Interior Mutability)로 설계를 바꾸기: RefCell, Mutex, RwLock
여기까지는 기본적으로 “컴파일 타임에 빌림을 맞추는” 접근입니다. 그런데 어떤 설계는 본질적으로 “읽는 동안에도 내부 상태를 갱신”해야 합니다.
- 캐시를 조회하면서 미스 카운트를 올린다
- 트리/그래프를 순회하면서 방문 표시를 한다
- 공유 레지스트리를 읽으면서 지연 초기화를 수행한다
이런 경우는 내부 가변성이 더 적합할 수 있습니다.
단일 스레드: RefCell
use std::cell::RefCell;
use std::collections::HashMap;
struct Cache {
hits: RefCell<u64>,
map: HashMap<String, String>,
}
impl Cache {
fn get(&self, key: &str) -> Option<&String> {
if self.map.contains_key(key) {
*self.hits.borrow_mut() += 1;
}
self.map.get(key)
}
}
멀티 스레드: Mutex / RwLock
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Metrics {
count: u64,
}
fn inc(m: Arc<Mutex<Metrics>>) {
let mut guard = m.lock().unwrap();
guard.count += 1;
}
포인트
- 내부 가변성은 E0502를 “피하는 트릭”이 아니라 런타임에 빌림/동기화 규칙을 강제하는 설계입니다.
RefCell은 규칙 위반 시 런타임 패닉이 날 수 있고,Mutex는 데드락/경합 비용이 있습니다.- 따라서 “정말로 동시에 읽기와 쓰기가 섞이는 설계인가?”를 먼저 확인하고, 필요한 경우에만 도입하는 것이 좋습니다.
비동기 런타임에서 Mutex를 사용할 때는 블로킹/비블로킹 선택도 중요해집니다. Tokio 환경이라면 std::sync::Mutex 대신 tokio::sync::Mutex를 고려하거나, CPU 바운드 작업은 Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking에서 다룬 것처럼 분리하는 게 안전합니다.
E0502 디버깅 체크리스트
리팩터링을 적용하기 전에, 아래를 순서대로 점검하면 원인이 빨리 보입니다.
- 불변 참조를 변수로 들고 있는가? 들고 있다면 값으로 뽑아
Copy또는Clone으로 스코프를 줄일 수 있는지 확인 - 한 함수에서 읽기와 쓰기가 섞여 있는가? 가능하면 두 단계로 분리
- 같은 컬렉션의 서로 다른 원소를 동시에 수정하는가?
split_at_mut같은 분할 API로 의도를 표현 - 구조체 필드 간 빌림이 엉키는가?
mem::take로 소유권을 잠깐 이동 - 설계상 읽기 중 쓰기가 필수인가? 내부 가변성(
RefCell,Mutex,RwLock) 도입 검토
마무리: E0502는 “컴파일러가 리팩터링 포인트를 알려주는 신호”
E0502는 귀찮지만, 대부분의 경우 코드 구조를 더 명확하게 만들 기회이기도 합니다. 특히 Rust에서 좋은 리팩터링은 단순히 에러를 없애는 게 아니라, 데이터의 라이프타임과 변경 지점을 코드에 드러내는 것에 가깝습니다.
정리하면 다음 우선순위를 추천합니다.
- 1순위: 스코프 줄이기, 두 단계 분리 (가장 단순하고 비용 적음)
- 2순위: 슬라이스/컬렉션 분할 API로 의도 표현
- 3순위:
take/replace로 소유권 이동 - 4순위: 내부 가변성은 설계 요구가 명확할 때만
이 5가지를 손에 익히면, E0502는 더 이상 “막히는 에러”가 아니라 “코드가 더 안전해지는 방향을 알려주는 가이드”로 바뀝니다.