- Published on
Rust 소유권 - E0502·E0499 borrow checker 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 소유권 규칙 자체는 이해했는데도 컴파일러가 E0502 또는 E0499로 막아서는 순간이 자주 옵니다. 특히 컬렉션을 순회하면서 동시에 수정하거나, 참조를 변수에 오래 붙잡아 두는 코드에서 폭발합니다.
이 글은 “왜 안 되는지”를 규칙으로만 설명하지 않고, 해결 가능한 형태로 코드를 바꾸는 패턴을 중심으로 정리합니다. (참고로 async 환경에서는 대여가 await를 넘어가며 더 복잡해질 수 있는데, 그 경우는 Rust async에서 Send/Sync 컴파일 오류 해결도 함께 보면 좋습니다.)
E0502와 E0499를 한 문장으로 이해하기
E0502: 불변 대여가 살아있는데 가변 대여를 하려고 함
- 상황: 어떤 값에 대해
&T를 들고 있는 동안, 같은 값에 대해&mut T가 필요해짐 - 핵심: 불변 참조의 생존 범위가 생각보다 길다
E0499: 가변 대여를 동시에 2번 하려고 함
- 상황: 어떤 값에 대해
&mut T를 이미 빌렸는데, 같은 값에 대해 또&mut T가 필요해짐 - 핵심: Rust는 동시에 두 개의 가변 참조를 허용하지 않음 (데이터 레이스/aliasing 방지)
둘 다 결론은 같습니다.
- 같은 데이터에 대해 “읽기 참조”와 “쓰기 참조”가 겹치면 안 됨
- “쓰기 참조”는 동시에 하나만 가능
먼저 확인할 것: 참조의 생존 범위가 길어지는 흔한 패턴
Rust의 NLL(Non-Lexical Lifetimes) 덕분에 예전보다 생존 범위는 줄어들었지만, 아래 패턴은 여전히 자주 문제를 만듭니다.
let r = &x;처럼 참조를 변수에 바인딩하고, 그 변수를 나중에까지 사용println!/format!등으로 참조를 뒤에서 사용- iterator 체인에서 클로저가 참조를 캡처
즉, “이미 끝났다고 생각한 불변 대여”가 컴파일러 입장에서는 아직 끝나지 않은 상태가 됩니다.
E0502 대표 케이스 1: 읽고 나서 같은 값 수정하기
아래 코드는 s를 불변으로 빌린 다음, 같은 s를 가변으로 빌리려 해서 E0502가 납니다.
fn main() {
let mut s = String::from("hello");
let len = s.len(); // 불변 접근(대여)
// 여기서 s를 수정하려고 하면 충돌이 날 수 있음
s.push('!');
println!("{len} {s}");
}
해결 1: 불변 대여를 “값”으로 끊기
len()은 usize를 반환하므로, 실제로는 참조를 오래 들고 있을 이유가 없습니다. 문제는 종종 다른 형태에서 생깁니다. 예를 들어 let r = &s;를 오래 들고 있거나, r을 뒤에서 쓰는 경우입니다.
가장 안전한 처방은 불변 참조 대신 필요한 값을 복사/소유하게 만드는 겁니다.
fn main() {
let mut s = String::from("hello");
let snapshot = s.clone(); // 비용은 있지만 소유로 끊는다
s.push('!');
println!("before={snapshot}, after={s}");
}
- 장점: 가장 단순하고 확실
- 단점:
clone()비용
해결 2: 스코프로 생존 범위 줄이기
참조가 필요한 구간을 블록으로 감싸면, 블록이 끝나는 시점에 대여도 끝납니다.
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("first={first}");
} // 여기서 first의 대여가 종료
v.push(4);
println!("v={v:?}");
}
이 패턴은 특히 “로그 출력 때문에 불변 참조가 뒤까지 살아남는” 케이스를 정리할 때 유용합니다.
E0502 대표 케이스 2: iterator로 순회하며 push/insert
다음 코드는 매우 흔합니다.
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() {
if *x == 2 {
v.push(99); // E0502: iter()가 불변 대여 중인데 push는 가변 대여 필요
}
}
}
v.iter()가 v 전체를 불변으로 빌린 상태에서 루프가 도는 동안, push()는 v 전체를 가변으로 빌려야 해서 충돌합니다.
해결 1: “수정 계획”을 먼저 수집하고 나중에 적용
읽기 단계와 쓰기 단계를 분리합니다.
fn main() {
let mut v = vec![1, 2, 3];
let mut should_push = false;
for x in v.iter() {
if *x == 2 {
should_push = true;
}
}
if should_push {
v.push(99);
}
println!("{v:?}");
}
조건이 여러 개면 “추가할 값 목록”을 Vec로 모아두고 마지막에 extend() 하는 방식이 일반적입니다.
해결 2: 인덱스로 순회하거나 while로 제어
인덱스 기반 순회는 불변 iterator 대여를 피할 수 있습니다. 다만 push()로 길이가 바뀌는 상황에서는 루프 조건을 조심해야 합니다.
fn main() {
let mut v = vec![1, 2, 3];
let mut i = 0;
while i < v.len() {
if v[i] == 2 {
v.push(99);
}
i += 1;
}
println!("{v:?}");
}
- 장점: 단순
- 단점: 로직 실수로 무한 루프/의도치 않은 재처리 가능
E0499 대표 케이스 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;
}
컴파일러는 v[0]과 v[1]이 서로 다르다는 걸 일반적으로 증명하지 못합니다. 그래서 “같은 v에 대한 두 개의 가변 대여”로 보고 막습니다.
해결 1: split_at_mut로 안전하게 분할
표준 라이브러리가 “서로 겹치지 않는 두 슬라이스”를 보장해주는 API를 제공합니다.
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;
println!("{v:?}");
}
split_at_mut(k)는[..k]와[k..]가 겹치지 않음을 타입 시스템에 알려줍니다.- 두 포인터가 aliasing 하지 않으니
&mut두 개가 동시에 가능해집니다.
해결 2: 접근 순서를 바꾸고 대여를 짧게 만들기
동시에 들고 있을 필요가 없다면, 스코프를 나눠서 하나씩 처리합니다.
fn main() {
let mut v = vec![10, 20, 30];
{
let a = &mut v[0];
*a += 1;
}
{
let b = &mut v[1];
*b += 1;
}
println!("{v:?}");
}
이 방식은 “두 값을 동시에 비교/스왑” 같은 경우에는 불편하므로 그땐 split_at_mut가 더 적합합니다.
E0499 대표 케이스 2: HashMap에서 get과 insert를 섞을 때
HashMap에서 값을 읽고(혹은 참조를 잡고) 같은 맵에 다시 쓰려고 하면, 대여가 겹치며 오류가 납니다.
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");
if v.is_some() {
m.insert("b".to_string(), 2); // E0502 또는 E0499 계열로 막히는 패턴
}
}
해결: Entry API로 “한 번의 가변 접근”으로 끝내기
entry()는 내부적으로 필요한 대여 규칙을 만족하는 형태로 설계되어 있습니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
*m.entry("a".to_string()).or_insert(0) += 1;
*m.entry("b".to_string()).or_insert(0) += 10;
println!("{m:?}");
}
- 읽기와 쓰기를 분리하지 않고도 안전하게 갱신 가능
- 카운팅/집계 로직에서 사실상 정답 패턴
실전 리팩터링 체크리스트
E0502/E0499를 보면 아래 순서대로 점검하면 해결 속도가 빨라집니다.
- 참조를 변수에 오래 저장했는가
let r = &x;를 만들었다면, 정말 그 참조가 뒤까지 필요했는지 확인
- 읽기 단계와 쓰기 단계를 섞었는가
- 순회하며 수정하는 코드면, 수정할 것들을 먼저 모으고 나중에 적용
- 동시에 두 개의
&mut가 필요한가- 필요 없다면 스코프 분리
- 필요하다면
split_at_mut,chunks_exact_mut,Entry같은 “겹치지 않음을 증명하는 API” 사용
- clone이 가장 싸게 끝나는가
- 성능이 중요한 경로가 아니라면
clone()은 생산성을 크게 올립니다
- 성능이 중요한 경로가 아니라면
보너스: async에서 더 자주 터지는 이유
async 함수에서는 await가 끼는 순간, 로컬 변수(참조 포함)가 상태 머신에 캡처될 수 있습니다. 그 결과 “참조가 생각보다 오래 살아있다” 문제가 더 자주 발생합니다. 이때는
await전에 필요한 값을 소유로 만들어 두기Mutex/RwLock가드가await를 넘어가지 않게 스코프를 끊기
같은 원칙이 그대로 적용됩니다. async 관련 컴파일 오류 맥락은 Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking와 함께 보면, “안전 규칙을 지키면서 구조를 바꾸는 감각”을 더 빨리 잡을 수 있습니다.
정리
E0502는 “불변 대여와 가변 대여가 겹침”E0499는 “가변 대여가 동시에 2개 이상”- 해결의 본질은 대여 생존 범위를 줄이거나, 읽기/쓰기 단계를 분리하거나, 겹치지 않음을 보장하는 표준 API로 구조를 바꾸는 것입니다.
borrow checker는 귀찮게 막는 존재가 아니라, 런타임 버그를 컴파일 타임으로 옮기는 장치입니다. 위 패턴들을 손에 익히면, E0502/E0499는 “원인 파악이 쉬운 리팩터링 신호”로 바뀝니다.