- Published on
Rust E0502 해결 - 빌림 겹침 리팩터링 5패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 한 번쯤은 반드시 마주치는 에러가 E0502 입니다. 메시지는 대개 다음 형태입니다.
cannot borrow ... as mutable because it is also borrowed as immutable- 또는 그 반대(가변 빌림이 살아있는 동안 불변 빌림 시도)
핵심은 간단합니다. 같은 값에 대해 불변 빌림이 유효한 동안 가변 빌림을 만들 수 없고, 가변 빌림이 유효한 동안 다른 빌림(불변/가변)을 만들 수 없다는 규칙을 컴파일러가 강제하기 때문입니다.
하지만 실무 코드에서는 이 규칙이 “논리적으로는 안전해 보이는데 왜 안 되지?”로 느껴질 때가 많습니다. 이 글에서는 E0502를 단순히 우회하는 트릭이 아니라, 빌림의 생존 범위를 줄이거나(라이프타임 단축), 데이터 접근 방식을 바꾸거나, 구조를 재배치해서 “의도에 맞는” 코드로 리팩터링하는 5가지 패턴을 정리합니다.
추가로 Rust의 추상화 패턴에 관심 있다면 Rust에서 HKT 없이 Functor·Monad 패턴 구현 글도 함께 보면, 빌림 제약이 설계에 어떤 영향을 주는지 감이 더 잘 옵니다.
E0502가 나는 전형적인 구조
아래 코드는 Vec에서 어떤 값을 읽고(불변 빌림), 같은 Vec를 수정(가변 빌림)하려고 해서 E0502가 납니다.
fn bump_if_positive(v: &mut Vec<i32>, idx: usize) {
let x = &v[idx]; // 불변 빌림 시작
if *x > 0 {
v[idx] += 1; // 같은 v를 가변으로 빌리려 하므로 E0502
}
}
이 문제는 “동시에 접근”이 아니라, 불변 참조 x가 스코프 끝까지 살아있다고 컴파일러가 판단하기 때문에 발생합니다. 해결의 방향은 크게 두 가지입니다.
- 불변 빌림을 더 빨리 끝내기(값 복사, 스코프 분리)
- 서로 다른 영역을 빌리도록 구조 바꾸기(슬라이스 분할, 엔트리 API, 내부 가변성 등)
이제 5가지 패턴으로 구체화해 보겠습니다.
패턴 1: 값 복사로 불변 빌림 생존 범위 끊기
가장 간단하고 자주 쓰는 방법입니다. 참조를 잡아두지 말고, 필요한 값만 복사해서 로컬 변수로 들고 오면 불변 빌림이 즉시 끝납니다.
fn bump_if_positive(v: &mut Vec<i32>, idx: usize) {
let x = v[idx]; // i32는 Copy라서 값 복사, 빌림이 길게 남지 않음
if x > 0 {
v[idx] += 1;
}
}
언제 유효한가
Copy타입이거나,clone()비용이 충분히 작을 때- “읽은 값”과 “수정할 컨테이너”가 같은데, 읽기 참조를 오래 들고 있을 이유가 없을 때
주의점
String,Vec, 큰 구조체 등은clone()비용이 커질 수 있으니 무조건 복사로 해결하려고 하면 성능 문제가 생길 수 있습니다.
패턴 2: 스코프 분리로 빌림을 조기 종료시키기
참조를 꼭 써야 한다면, 불변 빌림이 끝나는 지점을 컴파일러가 확실히 알 수 있도록 스코프를 분리합니다.
fn bump_if_positive(v: &mut Vec<i32>, idx: usize) {
let should_bump = {
let x = &v[idx];
*x > 0
}; // 여기서 x의 불변 빌림 종료
if should_bump {
v[idx] += 1; // 이제 가변 빌림 가능
}
}
포인트
- “조건 판단”만 먼저 끝내고, 실제 변경은 그 다음에 수행
E0502는 종종 “참조를 잡아둔 채로 로직을 길게 늘어뜨린 구조”에서 발생하므로, 이 패턴만으로도 해결되는 경우가 많습니다.
패턴 3: split_at_mut로 서로 다른 구간을 분리 빌림
Vec나 슬라이스에서 서로 다른 인덱스를 동시에 가변으로 다루고 싶을 때, 단순히 &mut v[a]와 &mut v[b]를 만들면 빌림 겹침으로 막힙니다. 이때 Rust가 제공하는 안전한 분할 API를 사용합니다.
fn swap_two(v: &mut [i32], a: usize, b: usize) {
assert!(a != b);
let (i, j) = if a < b { (a, b) } else { (b, a) };
let (left, right) = v.split_at_mut(j);
let x = &mut left[i];
let y = &mut right[0]; // right는 j부터 시작하므로 right[0] == v[j]
std::mem::swap(x, y);
}
왜 이게 되는가
split_at_mut는 슬라이스를 “겹치지 않는 두 조각”으로 나누는 것을 타입 시스템에 반영합니다.- 따라서 각각에서
&mut를 꺼내도 aliasing 문제가 없음을 컴파일러가 증명할 수 있습니다.
적용 사례
- 배열/벡터에서 두 원소를 동시에 수정
- 파티션/퀵소트/윈도우 기반 알고리즘 등
패턴 4: HashMap은 entry로 읽기와 쓰기를 한 번에 묶기
HashMap에서 get()으로 값을 읽어 조건을 판단한 뒤, 같은 키에 insert()나 get_mut()로 쓰려고 하면 E0502가 자주 터집니다.
use std::collections::HashMap;
fn inc_if_exists(m: &mut HashMap<String, i32>, k: &str) {
if let Some(v) = m.get(k) {
if *v > 0 {
// m.insert(k.to_string(), *v + 1); // 여기서 E0502가 나기 쉬움
}
}
}
이때는 entry API가 정답인 경우가 많습니다. entry는 “탐색 결과”를 소유한 핸들로 다루기 때문에, 읽기와 쓰기를 한 흐름으로 만들 수 있습니다.
use std::collections::HashMap;
fn inc_if_exists(m: &mut HashMap<String, i32>, k: &str) {
if let Some(v) = m.get_mut(k) {
if *v > 0 {
*v += 1;
}
}
}
또는 “없으면 기본값을 넣고 증가” 같은 패턴은 더 깔끔합니다.
use std::collections::HashMap;
fn inc(m: &mut HashMap<String, i32>, k: &str) {
let v = m.entry(k.to_string()).or_insert(0);
*v += 1;
}
포인트
get()으로 불변 참조를 잡고 오래 끌고 가는 대신, 처음부터get_mut()또는entry()로 흐름을 설계- 컨테이너 접근을 “두 번” 하지 않도록 바꾸면 빌림 충돌이 사라지는 경우가 많습니다.
패턴 5: 구조 분해(필드 분리) 또는 내부 가변성으로 설계 변경
5-1. 서로 다른 필드는 동시에 빌릴 수 있게 구조 분해
구조체에서 self를 불변으로 빌린 상태에서 self를 다시 가변으로 빌리려 하면 E0502가 발생합니다. 이때는 “필드 단위로 빌림을 분리”하면 해결되는 경우가 많습니다.
struct State {
cache: Vec<i32>,
log: Vec<String>,
}
impl State {
fn process(&mut self, idx: usize) {
// self를 통째로 빌리지 말고, 필요한 필드만 분리
let (cache, log) = (&mut self.cache, &mut self.log);
let x = cache[idx];
if x > 0 {
cache[idx] += 1;
log.push(format!("bumped at {}", idx));
}
}
}
여기서 핵심은 (&mut self.cache, &mut self.log)처럼 서로 다른 필드에 대한 가변 참조는 동시에 존재 가능하다는 점입니다(필드가 겹치지 않기 때문).
5-2. 내부 가변성(RefCell, Mutex, RwLock)은 최후의 수단으로
싱글 스레드에서 “논리적으로는 안전하지만 컴파일러가 증명하기 어려운” 공유 수정이 필요하면 RefCell을 고려할 수 있습니다. 다만 이는 런타임에 빌림 규칙을 검사하므로, 잘못 쓰면 패닉이 날 수 있고 비용도 생깁니다.
use std::cell::RefCell;
struct Ctx {
buf: RefCell<Vec<i32>>,
}
impl Ctx {
fn push_if_positive(&self, x: i32) {
if x > 0 {
self.buf.borrow_mut().push(x);
}
}
}
멀티 스레드라면 Mutex나 RwLock이 필요합니다.
use std::sync::{Arc, Mutex};
fn add(shared: Arc<Mutex<Vec<i32>>>, x: i32) {
let mut guard = shared.lock().unwrap();
guard.push(x);
}
언제 쓰는가
- 콜백/클로저가 얽혀 빌림을 정적으로 풀기 어렵거나
- 그래프/순환 참조/캐시 등 “공유 상태”가 설계상 필수인 경우
경고
- 내부 가변성은
E0502를 “없애는” 대신 “런타임으로 미루는” 선택입니다. - 가능하면 앞선 1~4 패턴(라이프타임 단축, 분할 빌림, 엔트리 API, 구조 재배치)으로 해결하는 편이 유지보수성이 좋습니다.
실전 디버깅 체크리스트
E0502를 마주쳤을 때는 다음 순서로 점검하면 빠릅니다.
- 참조를 변수에 저장해 오래 들고 있지 않은가: 가능하면 값 복사(패턴 1) 또는 스코프 분리(패턴 2)
- 컨테이너를 두 번 접근하고 있지 않은가:
HashMap이면get_mut()/entry()로 재구성(패턴 4) - 서로 다른 인덱스/구간을 동시에 수정하는가: 슬라이스 분할(
split_at_mut)로 증명 가능하게 만들기(패턴 3) - 구조체 전체를 빌리고 있지 않은가: 필드 단위로 빌림을 분리(패턴 5-1)
- 그래도 안 되면 내부 가변성을 고려하되, 비용과 런타임 실패 가능성을 문서화(패턴 5-2)
빌림 문제는 단순히 컴파일러를 “설득”하는 작업이 아니라, 데이터 흐름을 더 명확하게 만드는 리팩터링 기회가 되기도 합니다. 특히 E0502는 코드가 “읽기/쓰기 책임이 뒤섞인 상태”일 때 자주 발생하므로, 위 패턴으로 정리하면 오류 해결과 함께 구조가 단단해지는 효과를 얻을 수 있습니다.
마무리
E0502는 Rust가 제공하는 메모리 안전성의 핵심 규칙이 표면으로 드러난 결과입니다. 해결의 요령은 “동시에 빌리지 말기”가 아니라, 빌림이 겹치지 않도록 프로그램 구조를 바꾸는 것입니다.
- 값 복사로 참조를 없애기
- 스코프를 쪼개 라이프타임을 줄이기
- 슬라이스/필드를 분할해 겹치지 않음을 증명하기
entry같은 컨테이너 친화 API로 접근 횟수 줄이기- 내부 가변성은 마지막 카드로 신중히
이 5가지 패턴을 템플릿처럼 익혀두면, E0502는 더 이상 막막한 에러가 아니라 “리팩터링 신호”로 바뀝니다.