- Published on
Rust 소유권 - E0502 빌림충돌 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 소유권 규칙 자체보다도, 빌림의 “범위”가 내가 생각한 것보다 길게 잡히는 순간이 더 어렵습니다. 특히 E0502는 “불변으로 빌린 상태에서 가변으로 빌리려 했다” 또는 그 반대의 형태로 터지는데, 원인은 대개 다음 둘 중 하나로 수렴합니다.
- 불변 참조(
&T)가 아직 살아 있는데 가변 참조(&mut T)를 만들었다 - 가변 참조가 살아 있는데 불변 참조를 만들었다
이 글에서는 실무에서 자주 등장하는 E0502를 7가지 패턴으로 묶어, “왜 터지는지”와 “어떻게 고칠지”를 코드 템플릿 중심으로 정리합니다.
추가로, E0502를 더 빠르게 진단하는 체크리스트가 필요하면 아래 글도 함께 보면 좋습니다.
E0502 한 줄 정의와 핵심 원리
E0502는 대략 이런 메시지로 나타납니다.
cannot borrow ... as mutable because it is also borrowed as immutablecannot borrow ... as immutable because it is also borrowed as mutable
Rust의 기본 규칙은 간단합니다.
- 같은 값에 대해 불변 참조는 여러 개 가능
- 같은 값에 대해 가변 참조는 오직 하나만 가능
- 그리고 불변 참조와 가변 참조는 동시에 존재할 수 없음
문제는 “동시에”의 기준이 코드 줄이 아니라 스코프와 라이프타임(정확히는 borrow의 유효 범위) 라는 점입니다.
패턴 1) 불변 참조를 변수에 저장해두고, 그 뒤에 가변 변경
가장 흔한 형태입니다.
fn main() {
let mut s = String::from("hello");
let r = &s; // 불변 빌림 시작
s.push('!'); // 여기서 가변 빌림 필요 -> E0502
println!("{}", r);
}
왜 터지나
r이 살아있는 동안 s를 바꿀 수 없습니다. push는 내부 버퍼 재할당이 가능해서, 불변 참조가 가리키는 메모리가 무효화될 수 있기 때문입니다.
해결 템플릿 A: 불변 참조의 사용을 먼저 끝내기
fn main() {
let mut s = String::from("hello");
{
let r = &s;
println!("{}", r);
} // r 스코프 종료
s.push('!');
println!("{}", s);
}
해결 템플릿 B: 필요한 값만 복사/복제
fn main() {
let mut s = String::from("hello");
let snapshot = s.clone();
s.push('!');
println!("before={snapshot}, after={s}");
}
패턴 2) 인덱싱/슬라이스로 불변 빌림을 잡아둔 채, 같은 컨테이너를 수정
Vec나 String에서 특히 자주 만납니다.
fn main() {
let mut v = vec![10, 20, 30];
let first = &v[0]; // 불변 빌림
v.push(40); // 재할당 가능 -> E0502
println!("{first}");
}
해결 템플릿: “값”만 먼저 빼오기
Copy 타입이면 복사로 끝납니다.
fn main() {
let mut v = vec![10, 20, 30];
let first = v[0]; // i32는 Copy
v.push(40);
println!("{first}");
}
Copy가 아니라면 clone 또는 to_owned가 필요합니다.
패턴 3) iter()로 순회하면서 같은 컬렉션을 수정
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() { // v에 대한 불변 빌림이 루프 동안 유지
if *x == 2 {
v.push(99); // E0502
}
}
}
왜 터지나
iter()는 &v를 잡고 요소를 빌려옵니다. 루프가 도는 동안 v는 불변으로 빌려진 상태입니다.
해결 템플릿 A: 2단계 처리(먼저 수집, 나중에 수정)
fn main() {
let mut v = vec![1, 2, 3];
let should_add = v.iter().any(|x| *x == 2);
if should_add {
v.push(99);
}
}
해결 템플릿 B: 인덱스 기반 루프(단, push로 길이 바뀌는 건 주의)
fn main() {
let mut v = vec![1, 2, 3];
let len = v.len();
for i in 0..len {
if v[i] == 2 {
v.push(99);
}
}
}
이 방식은 “처음 길이만큼만” 돈다는 의도가 명확할 때 안전합니다.
패턴 4) get_mut()로 가변 참조를 잡아둔 채, 같은 컨테이너를 다른 방식으로 접근
fn main() {
let mut v = vec![1, 2, 3];
let x = v.get_mut(0).unwrap(); // v의 가변 빌림
let y = &v[1]; // 불변 빌림 시도 -> E0502
*x += 10;
println!("{y}");
}
해결 템플릿: 가변 참조 사용을 최대한 짧게
fn main() {
let mut v = vec![1, 2, 3];
{
let x = v.get_mut(0).unwrap();
*x += 10;
} // 가변 빌림 종료
let y = &v[1];
println!("{y}");
}
대안: 동시에 두 요소를 바꾸고 싶다면 split_at_mut
서로 다른 인덱스에 대한 동시 가변 참조가 필요할 때는 split_at_mut가 정석입니다.
fn main() {
let mut v = vec![1, 2, 3, 4];
let (left, right) = v.split_at_mut(2);
let a = &mut left[0]; // v[0]
let b = &mut right[0]; // v[2]
*a += 10;
*b += 100;
println!("{:?}", v);
}
패턴 5) HashMap에서 get()으로 읽어놓고 insert()/entry()로 수정
use std::collections::HashMap;
fn main() {
let mut m = HashMap::new();
m.insert("a".to_string(), 1);
let v = m.get("a"); // 불변 빌림
m.insert("b".to_string(), 2); // 가변 빌림 -> E0502
println!("{:?}", v);
}
해결 템플릿 A: 필요한 값만 복제해서 빌림을 끊기
use std::collections::HashMap;
fn main() {
let mut m = HashMap::new();
m.insert("a".to_string(), 1);
let v = m.get("a").copied(); // Option<i32>
m.insert("b".to_string(), 2);
println!("{:?}", v);
}
해결 템플릿 B: 읽기+쓰기 목적이면 entry로 합치기
use std::collections::HashMap;
fn main() {
let mut m = HashMap::new();
*m.entry("a".to_string()).or_insert(0) += 1;
*m.entry("b".to_string()).or_insert(0) += 2;
println!("{:?}", m);
}
entry는 “읽기 후 조건부 수정”을 한 번의 가변 빌림으로 처리하게 해줍니다.
패턴 6) 메서드 체이닝/클로저가 빌림을 예상보다 오래 잡는 경우
특히 if let/match/클로저 캡처에서 “빌림이 끝났다고 생각했는데 안 끝난” 상황이 발생합니다.
fn main() {
let mut s = String::from("hello");
let r = s.as_str(); // 불변 빌림처럼 보임
// r을 아래에서 쓰지 않더라도, 코드 구조에 따라 빌림이 길어질 수 있음
s.push('!'); // E0502가 날 수 있는 형태
println!("{r}");
}
해결 템플릿: “참조” 대신 “인덱스/길이/스냅샷”을 저장
fn main() {
let mut s = String::from("hello");
let len = s.len(); // 참조가 아니라 값
s.push('!');
println!("len_before={len}, now={}", s.len());
}
또는 아예 String을 복제해 스냅샷을 들고 가는 것도 명확한 해결입니다.
패턴 7) 자기 자신을 참조하는 구조를 만들려다(또는 메서드에서 self를 두 번 빌리다)
구조체 메서드에서 한 필드를 불변으로 빌린 뒤 다른 필드를 가변으로 빌리면 자주 충돌합니다.
struct State {
name: String,
count: usize,
}
impl State {
fn bump_if_name(&mut self) {
let n = &self.name; // self의 일부를 불변 빌림
if n == "admin" {
self.count += 1; // self를 가변으로 빌림 -> E0502
}
}
}
해결 템플릿 A: 비교에 필요한 값만 복사/복제
struct State {
name: String,
count: usize,
}
impl State {
fn bump_if_name(&mut self) {
let is_admin = self.name == "admin"; // 임시 비교로 끝
if is_admin {
self.count += 1;
}
}
}
해결 템플릿 B: 필드 분해로 빌림 분리(패턴 매칭)
struct State {
name: String,
count: usize,
}
impl State {
fn bump_if_name(&mut self) {
let State { name, count } = self;
if name == "admin" {
*count += 1;
}
}
}
이 방식은 “서로 다른 필드를 각각 빌린다”는 의도를 borrow checker가 이해하기 쉽게 만들어줍니다.
빠른 진단 체크리스트(실전용)
E0502가 뜨면 아래 순서로 보면 대부분 1분 안에 원인이 잡힙니다.
- 에러가 가리키는 “첫 번째 빌림” 라인을 찾는다(대개 불변
&) - 그 빌림이 언제까지 살아있는지 확인한다(변수에 저장했는지, 루프/매치/클로저에 걸렸는지)
- 그 사이에 “두 번째 빌림”이 들어오는 지점을 찾는다(대개
push,insert,get_mut,entry) - 해결은 보통 셋 중 하나다
- 스코프를 쪼개서 빌림을 빨리 끝낸다
- 참조 대신 값(
Copy/clone)을 저장한다 - API를 바꾼다(
entry,split_at_mut, 2단계 처리)
마무리: 패턴을 외우면 E0502는 생산성 도구가 된다
E0502는 “Rust가 귀찮게 굴어서”가 아니라, 참조 무효화/데이터 레이스로 이어질 수 있는 경로를 컴파일 타임에 차단하는 장치입니다. 중요한 건 에러 메시지를 한 번에 이해하려고 하기보다, 위 7패턴 중 어디에 해당하는지 빠르게 분류하고 템플릿으로 고치는 습관입니다.
빌림 충돌을 더 빠르게 푸는 요약 버전이 필요하면 다음 내부 글도 같이 참고하세요.