- Published on
Rust E0502 해결 - 소유권·빌림 충돌 5패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 언어에서라면 “읽고 나서 수정하면 되지”로 끝날 상황이 Rust에서는 E0502로 즉시 막힙니다. E0502는 한 값에 대해 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들 수 없다는 규칙을 위반했을 때 발생합니다.
핵심은 “동시에”라는 말이 실행 시점이 아니라 컴파일러가 추론한 참조의 생존 범위(lifetime) 기준이라는 점입니다. 특히 반복문, 클로저, 메서드 체이닝, 인덱싱 등이 섞이면 “이미 불변으로 빌렸는데 왜 또 빌림?” 같은 상황이 자주 생깁니다.
이 글은 실무에서 자주 터지는 E0502를 5개 패턴으로 분류하고, 각 패턴마다 가장 흔히 쓰는 해결책을 제시합니다. (자기참조 구조체나 Pin 이슈는 다른 주제이므로 생략합니다. 필요하면 Rust self-referential 구조체가 불가능한 이유와 Pin도 함께 보세요.)
E0502 한 줄 정의
- 불변 빌림:
let r = &x;는x를 읽기 전용으로 빌립니다. - 가변 빌림:
let m = &mut x;는x를 수정 가능하게 빌립니다. - 규칙: 같은 스코프에서 불변 빌림이 살아있는 동안
&mut를 만들 수 없습니다(반대도 동일).
이 규칙은 데이터 레이스/댕글링 포인터를 컴파일 타임에 제거하기 위한 것으로, 해결은 보통 “참조 생존 범위를 줄이기”, “읽기 결과를 값으로 복사/소유”, “데이터 구조를 쪼개기”로 귀결됩니다.
패턴 1) 읽기 참조를 잡아둔 채로 같은 값 수정
가장 교과서적인 E0502입니다.
fn main() {
let mut s = String::from("hello");
let r = &s; // 불변 빌림 시작
s.push('!'); // 여기서 가변 빌림 필요
println!("{r}");
}
해결 1: 불변 참조의 생존 범위를 줄이기(스코프 분리)
fn main() {
let mut s = String::from("hello");
{
let r = &s;
println!("{r}");
} // r 드롭
s.push('!');
println!("{s}");
}
해결 2: 필요한 값만 복사/소유해서 들고 있기
String 전체가 아니라 길이/첫 글자 등 복사 가능한 값만 필요하면 참조를 잡지 마세요.
fn main() {
let mut s = String::from("hello");
let len = s.len(); // usize는 Copy
s.push('!');
println!("len={len}, s={s}");
}
String 자체를 이후에도 쓰고 싶다면 clone()이 해법이 될 수 있지만, 비용이 있으니 “정말 소유가 필요한가”를 먼저 따져보는 게 좋습니다.
패턴 2) 컬렉션에서 한 원소를 불변으로 보고, 같은 컬렉션을 수정
벡터/맵에서 특정 원소를 참조해 둔 상태로, 같은 컬렉션에 push/insert/remove를 하면 자주 터집니다. 이유는 간단합니다. 재할당(reallocation)으로 참조가 무효화될 수 있기 때문입니다.
fn main() {
let mut v = vec![10, 20, 30];
let first = &v[0]; // v 전체를 불변으로 빌린 것으로 취급
v.push(40); // v를 가변으로 빌려야 함
println!("{first}");
}
해결 1: 인덱스(값)를 저장하고, 나중에 다시 접근
fn main() {
let mut v = vec![10, 20, 30];
let idx = 0usize;
let first_val = v[idx]; // i32 Copy
v.push(40);
println!("{first_val}");
}
해결 2: 변경이 필요 없도록 로직 순서 바꾸기
불변 참조를 쓰는 작업을 먼저 끝내고, 그 다음 수정합니다.
fn main() {
let mut v = vec![10, 20, 30];
let first_val = v[0];
println!("{first_val}");
v.push(40);
}
해결 3: split_at_mut로 “서로 다른 영역”을 명시
서로 다른 인덱스 두 개를 동시에 수정/조회하려다 E0502가 날 때는 split_at_mut가 정석입니다.
fn bump_two(v: &mut [i32], i: usize, j: usize) {
assert!(i != j);
let (a, b) = if i < j {
let (l, r) = v.split_at_mut(j);
(&mut l[i], &mut r[0])
} else {
let (l, r) = v.split_at_mut(i);
(&mut r[0], &mut l[j])
};
*a += 1;
*b += 1;
}
이 패턴은 “컴파일러가 두 참조가 겹치지 않음을 증명할 수 있게” 구조를 바꿔주는 해법입니다.
패턴 3) 반복문에서 불변 순회 중, 같은 컬렉션을 수정
for x in v.iter()로 순회하는 동안 v.push() 같은 수정은 불가능합니다.
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() {
if *x == 2 {
v.push(99); // E0502
}
}
}
해결 1: 2단계 처리(수집 후 반영)
“순회로 결정하고, 수정은 나중에”가 가장 안전합니다.
fn main() {
let mut v = vec![1, 2, 3];
let mut to_add = Vec::new();
for x in v.iter() {
if *x == 2 {
to_add.push(99);
}
}
v.extend(to_add);
println!("{v:?}");
}
해결 2: 인덱스 기반 while 루프(신중히)
길이가 변할 수 있는 순회라면 인덱스 기반으로 제어할 수 있습니다. 다만 무한 루프/중복 처리 위험이 있으니 의도를 명확히 하세요.
fn main() {
let mut v = vec![1, 2, 3];
let mut i = 0usize;
while i < v.len() {
if v[i] == 2 {
v.push(99);
}
i += 1;
}
println!("{v:?}");
}
해결 3: drain_filter 대체 전략(안정성/버전 고려)
특정 조건으로 제거하며 다른 곳에 옮기고 싶다면, 안정화 여부에 따라 retain + 별도 수집 등으로 풀어야 합니다. 핵심은 “순회 참조와 수정의 동시성”을 피하는 것입니다.
패턴 4) HashMap에서 get으로 읽은 뒤 entry로 수정
맵에서 값을 읽은 다음 같은 키를 entry로 갱신하려 하면 get이 만든 불변 빌림이 entry의 가변 빌림을 막습니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".to_string(), 1);
let v = m.get("a"); // 불변 빌림
m.entry("a".to_string()) // 가변 빌림 필요
.and_modify(|x| *x += 1);
println!("{v:?}");
}
해결 1: entry로 읽기와 쓰기를 한 번에 처리
읽기/수정을 분리하지 말고 entry로 합치면 빌림이 단일화됩니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
let key = "a".to_string();
let v_ref = m.entry(key).or_insert(0);
*v_ref += 1;
println!("{v_ref}");
}
해결 2: 필요 값만 복사해서 들고 있기
값이 Copy라면 copied()로 참조 생존을 끊어낼 수 있습니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".to_string(), 1);
let old = m.get("a").copied().unwrap_or(0);
*m.entry("a".to_string()).or_insert(0) = old + 1;
}
키가 String이면 entry에 넘길 때 소유권이 필요하므로, 실제 코드에서는 &str 키를 쓰거나(예: HashMap<&'static str, i32>), 키를 미리 만들어 재사용하는 식으로 할당을 줄이는 것도 고려합니다.
패턴 5) 클로저/메서드 체이닝이 빌림을 “생각보다 오래” 붙잡음
클로저는 캡처한 참조를 클로저가 살아있는 동안 유지할 수 있고, 메서드 체이닝은 임시 값의 드롭 시점 때문에 빌림이 예상보다 길어져 E0502를 유발합니다.
5-1) 클로저가 &mut를 캡처한 뒤, 같은 값을 다시 빌리기
fn main() {
let mut s = String::from("hi");
let mut add = || s.push('!'); // s를 가변으로 캡처
let r = &s; // E0502: 이미 가변으로 빌린 상태
println!("{r}");
add();
}
해결: 클로저 수명 줄이기 또는 함수로 분리
fn main() {
let mut s = String::from("hi");
{
let mut add = || s.push('!');
add();
} // add 드롭
let r = &s;
println!("{r}");
}
또는 클로저가 굳이 필요 없다면 함수로 빼서 캡처 자체를 없애는 게 더 명확합니다.
fn add_bang(s: &mut String) {
s.push('!');
}
fn main() {
let mut s = String::from("hi");
add_bang(&mut s);
println!("{s}");
}
5-2) 체이닝 중 만들어진 참조가 다음 단계까지 살아남음
예를 들어, 어떤 메서드가 내부적으로 참조를 반환하거나, 슬라이스/이터레이터를 만든 상태에서 원본을 수정하면 동일 문제가 납니다.
fn main() {
let mut v = vec![1, 2, 3];
let it = v.iter(); // v 불변 빌림
v.push(4); // E0502
for x in it {
println!("{x}");
}
}
해결: 이터레이터 소비를 먼저 끝내거나, 필요한 값을 수집
fn main() {
let mut v = vec![1, 2, 3];
let sum: i32 = v.iter().copied().sum();
v.push(4);
println!("sum={sum}, v={v:?}");
}
디버깅 체크리스트: E0502를 빠르게 푸는 순서
- 불변 참조(
&)가 어디서 만들어졌는지 찾습니다. 보통let r = &x,iter(),get(),[]인덱싱이 출발점입니다. - 그 참조가 언제까지 살아있는지 봅니다.
println!한 번 때문에 스코프 끝까지 붙잡히는 경우가 많습니다. - 해결은 아래 중 하나로 귀결됩니다.
- 스코프를 쪼개서 참조를 빨리 드롭
- 참조 대신
Copy값/clone()한 소유 값을 들고 있기 entry/split_at_mut등으로 “겹치지 않음”을 구조적으로 증명- 순회와 수정을 2단계로 분리
마무리: E0502를 “컴파일러와 협상”하는 관점
E0502는 러스트가 까다로워서가 아니라, **동시성/안전성에서 가장 위험한 형태(읽는 중에 쓰기)**를 원천 봉쇄하기 때문에 자주 보입니다. 실무에서는 “참조를 최대한 짧게”, “읽기와 쓰기를 한 함수/한 단계로”, “컬렉션 수정은 순회와 분리”라는 3원칙만 지켜도 발생 빈도가 크게 줄어듭니다.
추가로, 참조 생존 범위가 직관과 다르게 느껴진다면 NLL(Non-Lexical Lifetimes)과 임시 값 드롭 규칙을 함께 공부하는 것이 도움이 됩니다. 러스트의 이런 제약은 결국 런타임 락/GC 없이도 안전성을 얻는 비용이며, 한 번 패턴이 눈에 익으면 오히려 리팩터링의 방향을 명확히 잡아줍니다.
관련해서 “제약을 우회하려고 unsafe나 내부 가변성(RefCell)로 덮기” 전에, 구조적으로 해결할 수 있는지 먼저 확인하세요. 대부분은 이 글의 5패턴 중 하나로 설명되고, 안전한 형태로 정리할 수 있습니다.