- Published on
Rust 소유권 에러 E0502·E0499 한방 해결 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 초반에 가장 자주 발목을 잡는 컴파일 에러가 E0502와 E0499입니다. 둘 다 “빌림(borrow) 규칙 위반”이지만, 실제로는 코드를 어떻게 구조화하느냐 문제인 경우가 많습니다.
E0502: 불변 대여(&T)가 살아있는 동안 가변 대여(&mut T)를 하려 할 때E0499: 한 스코프에서 동일 대상에 대한 가변 대여(&mut T)를 두 번 이상 만들 때
이 글은 “원인을 이해”에서 멈추지 않고, 실무에서 바로 적용 가능한 한방 해결 패턴으로 정리합니다.
1) 에러가 나는 진짜 이유: 대여의 ‘수명’이 생각보다 길다
Rust 컴파일러는 대여가 실제로 언제까지 필요한지(수명)를 분석합니다. 그런데 우리가 흔히 쓰는 코드 스타일(체이닝, 한 함수 안에 다 넣기, 반복문 내부에서 참조 유지 등)이 대여를 필요 이상으로 오래 유지시키는 경우가 많습니다.
즉, 해결의 핵심은 대부분 다음 중 하나입니다.
- 대여 스코프를 더 짧게 만들기
- 참조 대신 값(복사/클론/추출)으로 바꾸기
- 동일 컨테이너를 동시에 두 번 빌리지 않는 접근 방식으로 변경하기
- 정말 필요할 때만 interior mutability를 사용하기
아래에서 패턴별로 E0502와 E0499를 정면으로 해결해보겠습니다.
2) 패턴 A: 스코프 쪼개기(대여를 빨리 끝내기)
가장 간단하고 효과적인 방법입니다. 불변 대여를 사용하는 구간과 가변 대여를 사용하는 구간을 명확히 분리합니다.
E0502 예시: 읽고 난 뒤 수정하려다 충돌
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
println!("{}", first);
}
해결: 불변 참조가 끝난 뒤에 수정
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 `first`의 대여가 종료
v.push(4);
println!("{:?}", v);
}
이 패턴은 특히 “로그 찍으려고 참조를 잡아둔 상태에서 수정” 같은 상황에서 자주 먹힙니다. (운영 환경에서 로그/메트릭 때문에 스코프가 길어지는 문제는, 원인-해결을 패턴화한다는 점에서 OpenAI 429/RateLimitError 재시도·백오프 패턴 같은 글과 사고방식이 유사합니다.)
3) 패턴 B: 참조 대신 값으로 ‘추출’하기(복사/클론/계산 결과 저장)
참조를 들고 있으면 대여가 계속 살아있습니다. 반대로 값을 뽑아두면 컨테이너에 대한 대여가 사라집니다.
E0502: 요소를 참조로 들고 있다가 수정
fn main() {
let mut v = vec![10, 20, 30];
let x = &v[1];
v[1] = 999; // E0502
println!("{}", x);
}
해결 1: Copy 타입이면 값 복사
fn main() {
let mut v = vec![10, 20, 30];
let x = v[1]; // i32는 Copy
v[1] = 999;
println!("{}", x);
}
해결 2: Clone으로 값 복제
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
let x = v[1].clone();
v[1].push('!');
println!("{}", x);
println!("{:?}", v);
}
clone()은 비용이 있을 수 있지만, 대여 충돌을 구조적으로 제거한다는 점에서 가장 명확한 해결책이기도 합니다.
4) 패턴 C: split_at_mut로 “서로 다른 영역”임을 증명하기
E0499의 대표 케이스는 “같은 벡터에서 두 요소를 동시에 &mut로 잡기”입니다.
E0499 재현: 두 인덱스를 동시에 가변 참조
fn main() {
let mut v = vec![1, 2, 3, 4];
let a = &mut v[1];
let b = &mut v[2]; // error[E0499]
*a += 10;
*b += 100;
}
컴파일러 입장에선 v[1]과 v[2]가 다른 위치라는 걸 “인덱싱 연산만으로는” 안전하게 증명하기 어렵습니다.
해결: 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[1]; // v[1]
let b = &mut right[0]; // v[2]
*a += 10;
*b += 100;
println!("{:?}", v);
}
이 패턴은 배열/벡터를 “구간으로 나눠” 동시에 수정해야 하는 알고리즘(스왑, 파티션, 투 포인터)에서 거의 정석입니다.
5) 패턴 D: 인덱스(또는 키)만 들고 다니고, 실제 대여는 짧게
반복문에서 참조를 오래 들고 있지 말고, 식별자만 저장한 뒤 필요할 때마다 짧게 빌립니다.
E0502/E0499가 섞여 터지는 흔한 형태
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
let v = m.get("a");
m.insert("b".into(), 2); // E0502 가능
println!("{:?}", v);
}
해결: 필요한 값만 먼저 꺼내기
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
let v = m.get("a").copied();
m.insert("b".into(), 2);
println!("{:?}", v);
}
copied()는 Copy 타입일 때 참조를 값으로 바꿔줍니다. String 같은 타입이면 cloned()를 고려하세요.
6) 패턴 E: “읽기-쓰기”를 한 번에 끝내기(get_mut, entry 활용)
읽고 나서 다시 쓰는 흐름은 대여 충돌을 만들기 쉽습니다. 가능하면 한 번의 가변 대여로 읽기와 쓰기를 끝내는 API를 씁니다.
HashMap 카운팅: entry가 정답
use std::collections::HashMap;
fn main() {
let mut cnt: HashMap<String, usize> = HashMap::new();
let words = vec!["rust", "rust", "borrow"];
for w in words {
*cnt.entry(w.to_string()).or_insert(0) += 1;
}
println!("{:?}", cnt);
}
이 방식은 불변 조회 후 가변 삽입 같은 흐름을 없애서 E0502/E0499를 원천 차단합니다. 데이터 갱신을 “트랜잭션처럼” 한 곳에서 끝내는 사고방식은, 이벤트 발행 일관성을 다루는 Outbox 패턴 중복발행·순서꼬임 실무 해결법과도 연결됩니다.
7) 패턴 F: 반복문에서 컬렉션을 동시에 순회/수정하지 말고, 2단계로 나누기
가장 흔한 실수는 “순회하면서 같은 컬렉션을 수정”입니다.
문제 예시: 필터링하면서 삭제
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
for x in &v {
if *x % 2 == 0 {
v.retain(|y| y != x); // E0502 또는 논리 버그 유발
}
}
}
해결 1: retain 한 번으로 끝내기
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
v.retain(|x| x % 2 != 0);
println!("{:?}", v);
}
해결 2: 변경 대상만 따로 수집 후 적용
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let to_remove: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 0).collect();
for r in to_remove {
v.retain(|x| *x != r);
}
println!("{:?}", v);
}
성능은 상황에 따라 다르지만, 중요한 건 대여 충돌이 사라지는 구조로 바뀐다는 점입니다.
8) 패턴 G: 정말 필요할 때만 interior mutability(RefCell, Mutex)를 사용
소유권 규칙을 “회피”하는 마지막 수단입니다. 단일 스레드에서 런타임 체크로 바꾸려면 RefCell을, 멀티 스레드 공유는 Mutex/RwLock을 씁니다.
RefCell로 런타임 대여 검사
use std::cell::RefCell;
fn main() {
let v = RefCell::new(vec![1, 2, 3]);
{
let mut b = v.borrow_mut();
b.push(4);
}
let r = v.borrow();
println!("{:?}", &*r);
}
주의할 점은, RefCell은 컴파일 타임이 아니라 런타임에 대여 규칙을 검사하므로 잘못 쓰면 패닉이 날 수 있습니다. 빌드가 통과한다고 끝이 아니라는 점에서, 장애 원인을 로그로 좁혀가는 Kubernetes CrashLoopBackOff 원인별 로그·해결 9가지 같은 접근이 필요해집니다.
9) 실전 체크리스트: E0502·E0499를 보면 바로 떠올릴 것
에러 메시지를 봤을 때 아래 순서로 점검하면 해결 속도가 확 빨라집니다.
- 참조를 변수에 담아 오래 들고 있지 않은가
- 가능하면 값으로 추출(
copied,cloned, 계산 결과 저장)
- 가능하면 값으로 추출(
- 불변 참조와 가변 참조 구간이 섞여 있지 않은가
- 스코프 블록으로 분리하거나, 로그/출력 시점을 앞당기기
- 동일 컨테이너에서
&mut를 두 번 만들고 있지 않은가split_at_mut로 분리, 또는 인덱스/키만 저장하고 짧게 빌리기
- 읽고-쓰기를 두 단계로 나누고 있지 않은가
entry,get_mut같은 “한 번의 가변 대여로 끝내는 API”로 합치기
- 정말 공유 가변 상태가 필요한가
- 필요하면
RefCell/Mutex, 단 런타임 비용과 패닉/락 고려
- 필요하면
10) 결론: ‘대여 충돌’은 문법이 아니라 구조 문제다
E0502와 E0499는 Rust가 까다로워서가 아니라, 데이터 접근 구조를 명확히 하라는 신호에 가깝습니다.
- 대여를 짧게: 스코프 분리
- 참조를 줄이고 값으로: 추출/복사/클론
- 동시에 수정해야 하면 안전하게 분리:
split_at_mut - 읽기-쓰기 흐름을 합치기:
entry,get_mut - 최후에만 interior mutability
이 패턴들을 손에 익히면, 소유권 에러는 “막히는 벽”이 아니라 “코드 품질을 올리는 리팩터링 트리거”가 됩니다.