- Published on
Rust 소유권 에러 E0502·E0499 패턴별 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 컴파일러가 가장 친절하면서도 냉정하게 막아서는 순간이 있습니다. 특히 E0502 와 E0499 는 “소유권/빌림 규칙을 이해하고 있는가?”를 매번 시험하는 대표 에러입니다.
E0502: 같은 값에 대해 불변 빌림이 살아있는 동안 가변 빌림을 시도했을 때E0499: 같은 값에 대해 동시에 2개 이상의 가변 빌림을 시도했을 때
이 글에서는 두 에러를 “왜 발생했는지”보다 더 중요한 “어떤 코드 패턴에서 반복되는지”에 초점을 맞춰, 실무에서 가장 자주 쓰는 해결법을 패턴별로 정리합니다.
관련해서 5분 요약 버전이 필요하면 다음 글도 함께 보세요: Rust E0502·E0499 빌림 충돌 5분 해결
E0502: 불변 빌림이 끝나기 전에 가변 빌림을 시도함
패턴 1) 불변 참조를 잡아둔 채로 수정하려는 경우
아래 코드는 name_ref 가 user.name 을 불변으로 빌린 상태에서, 같은 user 를 가변으로 빌려 수정하려 해서 E0502 가 납니다.
#[derive(Debug)]
struct User {
name: String,
age: u32,
}
fn main() {
let mut user = User {
name: "alice".to_string(),
age: 20,
};
let name_ref = &user.name; // 불변 빌림 시작
user.age += 1; // 가변 빌림 필요: E0502
println!("{}", name_ref);
}
해결 A) 불변 빌림의 생존 범위를 줄이기(스코프 분리)
불변 참조가 필요한 구간을 블록으로 감싸 “빌림이 끝나는 지점”을 명확히 만들면 됩니다.
fn main() {
let mut user = User {
name: "alice".to_string(),
age: 20,
};
{
let name_ref = &user.name;
println!("{}", name_ref);
} // 여기서 불변 빌림 종료
user.age += 1; // OK
}
해결 B) 참조 대신 값 복사/복제하기
String 은 Copy 가 아니므로 clone() 이 필요합니다. 비용이 있지만, 로직이 단순해지고 빌림 충돌이 사라집니다.
fn main() {
let mut user = User {
name: "alice".to_string(),
age: 20,
};
let name = user.name.clone();
user.age += 1;
println!("{}", name);
}
clone() 이 부담이라면, 종종 “로그/메트릭 출력용 문자열” 정도는 format! 으로 즉시 만들고 참조를 오래 들고 있지 않게 구성하는 편이 낫습니다.
패턴 2) iter() 로 순회하며 같은 컬렉션을 수정하려는 경우
Vec 를 iter() 로 불변 순회하는 동안, 같은 Vec 에 push 하면 내부 버퍼 재할당 가능성 때문에 안전하지 않습니다. 그래서 E0502 가 납니다.
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() {
if *x == 2 {
v.push(4); // E0502
}
}
}
해결 A) 2단계 처리: 먼저 수집, 나중에 수정
fn main() {
let mut v = vec![1, 2, 3];
let should_push = v.iter().any(|x| *x == 2);
if should_push {
v.push(4);
}
}
해결 B) 인덱스 기반 루프(단, 길이 변화에 주의)
길이가 늘어날 수 있으면 종료 조건을 고정해야 무한 루프를 피합니다.
fn main() {
let mut v = vec![1, 2, 3];
let n = v.len();
for i in 0..n {
if v[i] == 2 {
v.push(4);
}
}
}
이 방식은 “기존 원소만 검사하고 뒤에 추가는 허용” 같은 요구에 적합합니다.
해결 C) retain / drain_filter / 새 벡터로 재구성
수정이 “추가”가 아니라 “필터링/변환”이라면 컬렉션 API를 쓰는 편이 더 안전하고 빠릅니다.
fn main() {
let mut v = vec![1, 2, 3, 2];
v.retain(|x| *x != 2);
assert_eq!(v, vec![1, 3]);
}
패턴 3) HashMap 에서 get() 으로 읽고 entry() 로 쓰기
get() 으로 불변 빌림을 잡아두고, 같은 맵에 entry() 로 가변 접근하면 E0502 가 납니다.
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); // E0502
println!("{:?}", v);
}
해결 A) entry() 로 읽기와 쓰기를 한 번에
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
let x = m.entry("a".to_string()).or_insert(0);
*x += 1;
}
해결 B) 읽기 결과를 값으로 복사해 빌림을 끊기
값 타입이 Copy 면 특히 깔끔합니다.
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").unwrap_or(&0); // i32는 Copy
m.entry("a".to_string()).and_modify(|x| *x += 1);
println!("{}", v);
}
E0499: 동시에 가변 빌림을 2번 이상 시도함
패턴 1) 같은 벡터에서 두 원소를 동시에 &mut 로 잡기
가장 흔한 E0499 입니다.
fn main() {
let mut v = vec![10, 20, 30];
let a = &mut v[0];
let b = &mut v[1]; // E0499
*a += 1;
*b += 1;
}
Rust는 두 인덱스가 다르다는 사실을 일반적인 인덱싱만으로는 증명하지 못합니다.
해결 A) split_at_mut 로 비겹치는 슬라이스를 만들기
서로 겹치지 않는 두 구간으로 쪼개면 컴파일러가 안전을 증명할 수 있습니다.
fn main() {
let mut v = vec![10, 20, 30];
let (left, right) = v.split_at_mut(1);
let a = &mut left[0];
let b = &mut right[0];
*a += 1;
*b += 1;
assert_eq!(v, vec![11, 21, 30]);
}
해결 B) 표준 라이브러리의 get_many_mut 사용(버전 확인)
Rust 버전에 따라 안정화 여부가 다를 수 있어, 팀 표준 툴체인에서는 먼저 확인하세요.
fn main() {
let mut v = vec![10, 20, 30];
// 사용 가능 버전이라면 다음처럼 여러 mutable 참조를 동시에 얻을 수 있습니다.
// let [a, b] = v.get_many_mut([0, 1]).unwrap();
// *a += 1;
// *b += 1;
}
툴체인 제약이 있으면 split_at_mut 가 가장 이식성 높은 해법입니다.
패턴 2) 메서드 체인/클로저에서 &mut self 가 중첩되는 경우
다음 코드는 self.items.get_mut() 로 이미 가변 빌림이 발생했는데, 그 상태에서 self.log() 를 호출하면서 또 &mut self 가 필요해 E0499 로 이어질 수 있습니다.
struct Store {
items: Vec<i32>,
logs: Vec<String>,
}
impl Store {
fn log(&mut self, msg: String) {
self.logs.push(msg);
}
fn bump_and_log(&mut self, idx: usize) {
let x = self.items.get_mut(idx).unwrap();
*x += 1;
// 여기서 self 전체를 다시 &mut 로 빌리려 하면 충돌 가능
self.log(format!("bumped {}", idx));
}
}
위 예시는 NLL(Non-Lexical Lifetimes) 덕분에 상황에 따라 통과할 수도 있지만, 실제로는 x 를 더 오래 사용하거나 클로저로 넘기는 순간 쉽게 E0499 로 변합니다.
해결 A) 가변 참조 사용 구간을 끝낸 뒤에 다른 &mut self 호출
핵심은 “x 의 생존을 최소화”입니다.
impl Store {
fn bump_and_log(&mut self, idx: usize) {
{
let x = self.items.get_mut(idx).unwrap();
*x += 1;
} // items에 대한 가변 빌림 종료
self.log(format!("bumped {}", idx));
}
}
해결 B) 구조 분해로 서로 다른 필드를 분리해 빌림
서로 다른 필드는 동시에 가변 빌림이 가능합니다. “self 전체”를 다시 빌리지 않게 만드는 테크닉입니다.
impl Store {
fn bump_and_log(&mut self, idx: usize) {
let Store { items, logs } = self;
if let Some(x) = items.get_mut(idx) {
*x += 1;
}
logs.push(format!("bumped {}", idx));
}
}
이 패턴은 서비스 코드에서 특히 유용합니다. 한 메서드 안에서 self.cache 도 만지고 self.metrics 도 만지는 경우, 구조 분해를 해두면 빌림 충돌이 크게 줄어듭니다.
패턴 3) 반복문에서 이전 원소의 &mut 를 들고 다음 반복에서 또 빌림
fn main() {
let mut v = vec![1, 2, 3, 4];
let mut prev = &mut v[0];
for i in 1..v.len() {
let cur = &mut v[i]; // E0499 가능
*cur += *prev;
prev = cur;
}
}
해결 A) 인덱스로 상태를 들고 있고, 실제 &mut 는 매 반복마다 짧게
fn main() {
let mut v = vec![1, 2, 3, 4];
let mut prev_idx = 0;
for i in 1..v.len() {
let prev_val = v[prev_idx];
v[i] += prev_val;
prev_idx = i;
}
assert_eq!(v, vec![1, 3, 6, 10]);
}
값을 복사할 수 있는 타입이라면 이 방식이 가장 단순합니다.
해결 B) split_at_mut 로 현재와 이전을 분리
fn main() {
let mut v = vec![1, 2, 3, 4];
for i in 1..v.len() {
let (left, right) = v.split_at_mut(i);
let prev = left.last().unwrap();
let cur = &mut right[0];
*cur += *prev;
}
assert_eq!(v, vec![1, 3, 6, 10]);
}
자주 쓰는 “해결 전략” 체크리스트
1) 빌림을 짧게: 스코프/블록으로 생존 범위를 끊어라
let r = &x;를 만들었다면, 그 참조가 실제로 필요한 마지막 줄이 어디인지 확인- 필요 이상으로 참조를 들고 있으면
E0502와E0499모두를 유발
2) “읽기와 쓰기”를 분리하거나, entry() 같은 단일 API로 합쳐라
- 컬렉션은 “읽고 나서 쓰기”가 아니라 “한 번에 처리”할 수 있는 API가 많음
HashMap은 특히entry()가 정답인 경우가 많음
3) 동시에 두 &mut 가 필요하면 “비겹침”을 컴파일러가 증명할 수 있게 만들어라
split_at_mut는 가장 범용적인 도구- 같은 슬라이스에서 서로 다른 원소를 동시에 수정해야 한다면 거의 항상 후보
4) 값 복사/복제는 최후가 아니라 “의도 표현”일 때가 많다
Copy타입은 과감히 값으로 들고 다니면 빌림 충돌이 크게 줄어듦String이나 큰 구조체는clone()비용을 측정하되, 코드 복잡도를 줄이는 쪽이 전체 성능에 유리한 경우도 많음
디버깅 팁: 에러 메시지에서 꼭 봐야 할 2줄
컴파일러는 보통 다음 정보를 줍니다.
- “첫 빌림이 어디서 시작했는지”
- “그 빌림이 살아있다고 판단되는 마지막 사용 지점이 어디인지”
E0502 나 E0499 를 보면, 대개 해결은 그 두 지점 사이를 줄이거나(스코프), 빌림의 종류를 바꾸거나(값 복사), 비겹침을 증명하는 API를 쓰는 것(split_at_mut) 중 하나로 귀결됩니다.
마무리: 패턴으로 외우면 해결 속도가 빨라진다
E0502 와 E0499 는 “러스트가 불편하다”가 아니라, 데이터 경합/유즈 애프터 프리 같은 버그를 컴파일 타임에 제거하려는 설계의 결과입니다. 중요한 건 규칙 자체를 암기하는 게 아니라, 자주 터지는 코드를 패턴으로 묶어 즉시 대응하는 것입니다.
E0502: 불변 참조를 오래 들고 있지 말고, 읽기/쓰기를 분리하거나 단일 API로 합치기E0499: 동시에 두&mut가 필요하면split_at_mut등으로 비겹침을 증명하기
추가로 더 많은 케이스(이터레이터 체인, 트리 구조, 그래프, ECS 스타일 데이터 레이아웃)까지 확장하고 싶다면, 위 패턴을 기준으로 코드를 “빌림 생존 범위”와 “비겹침 증명” 관점에서 다시 모델링해보면 대부분 풀립니다.