- Published on
Rust borrow checker E0502·E0499 에러 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Rust를 쓰다 보면 컴파일러가 코드를 “안전하지 않다”고 판단해 빌드를 막는 순간이 옵니다. 그중에서도 E0502(immutable borrow와 mutable borrow 충돌), E0499(동시에 두 개 이상의 mutable borrow 시도)는 초반 러스트 학습 곡선을 가파르게 만드는 대표 에러입니다.
하지만 이 에러들은 단순히 “러스트가 까다롭다”가 아니라, 데이터 레이스·use-after-free·iterator invalidation 같은 버그를 컴파일 타임에 제거하기 위한 규칙이 코드 구조에 반영되지 않았다는 신호입니다. 이 글에서는 에러 메시지를 해석하는 법, 자주 발생하는 패턴, 그리고 실제로 팀 코드베이스에서 가장 많이 쓰는 해결 전략을 코드로 정리합니다.
문제 원인을 “경고를 무시하고 우회”하는 방식이 아니라, 소유권/대여(ownership/borrowing) 모델에 맞게 설계를 바꾸는 방식으로 접근하겠습니다. (다른 언어의 경고 해결 글을 좋아한다면, 문제를 원인별로 쪼개서 해결하는 방식은 Pandas SettingWithCopyWarning 완전 해결법 같은 글과 결이 비슷합니다.)
E0502: immutable borrow 중에 mutable borrow를 하려는 경우
에러 의미
E0502는 “이미 &T로 빌려서 읽고 있는데, 같은 값에 대해 &mut T로 쓰기 빌림을 하려 한다”는 뜻입니다. Rust 규칙은 간단합니다.
- 읽기 빌림(
&T)은 여러 개 가능 - 쓰기 빌림(
&mut T)은 동시에 딱 하나만 가능 - 그리고 읽기 빌림이 살아있는 동안에는 쓰기 빌림을 만들 수 없음
핵심은 “동시에”의 기준이 개발자가 생각하는 “이 줄이 끝났으니 끝난 것”이 아니라, 빌림의 스코프(lifetime) 라는 점입니다. NLL(Non-Lexical Lifetimes) 덕분에 과거보다 많이 완화됐지만, 여전히 변수에 참조를 담아두면 스코프가 길어져 충돌이 납니다.
대표 패턴 1: 참조를 변수에 담아두고, 이후에 수정
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // immutable borrow
v.push(4); // mutable borrow -> E0502
println!("{}", first);
}
왜 문제일까요? push는 내부 버퍼가 재할당(realloc)될 수 있어 &v[0]가 가리키는 주소가 무효가 될 수 있습니다. Rust는 이를 컴파일 타임에 막습니다.
해결 1: 값을 복사/클론해서 참조 수명을 줄이기
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{}", first);
}
Copy가 아닌 타입이라면 clone을 고려합니다.
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
let first = v[0].clone();
v.push(String::from("c"));
println!("{}", first);
}
해결 2: 참조 사용 범위를 블록으로 제한
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 immutable borrow 종료
v.push(4);
}
대표 패턴 2: iter()로 읽고 있는데 같은 컬렉션을 수정
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() {
if *x == 2 {
v.push(4); // E0502
}
}
}
해결 1: 두 단계로 나누기(읽기 단계와 쓰기 단계 분리)
fn main() {
let mut v = vec![1, 2, 3];
let should_push = v.iter().any(|x| *x == 2);
if should_push {
v.push(4);
}
}
해결 2: 인덱스 기반 루프 + 필요한 값만 복사
fn main() {
let mut v = vec![1, 2, 3];
let mut i = 0;
while i < v.len() {
let x = v[i]; // Copy로 값만 가져오기
if x == 2 {
v.push(4);
}
i += 1;
}
}
이 방식은 “순회 중 push로 길이가 변한다”는 논리적 문제도 생길 수 있으니, 의도한 동작인지 꼭 확인해야 합니다.
E0499: mutable borrow가 동시에 두 번 발생
에러 의미
E0499는 “이미 &mut로 빌린 상태에서, 같은 값에 대해 또 다른 &mut를 만들려 한다”는 뜻입니다. Rust는 동시 두 개의 mutable 참조를 금지합니다. 이유는 간단합니다.
- 두 개의
&mut가 같은 메모리를 가리키면, 한쪽 변경이 다른 쪽에서 예측 불가능 - aliasing + mutation 조합을 원천 차단
대표 패턴 1: 같은 벡터에서 두 원소를 동시에 &mut로 얻기
fn main() {
let mut v = vec![10, 20, 30];
let a = &mut v[0];
let b = &mut v[1]; // E0499
*a += 1;
*b += 1;
}
서로 다른 인덱스인데 왜 안 될까요? 컴파일러는 일반적인 인덱싱 연산만으로는 “서로 다른 위치”임을 증명하지 못합니다.
해결 1: 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;
println!("{:?}", v);
}
split_at_mut는 “왼쪽과 오른쪽이 겹치지 않는다”는 사실을 API가 보장하므로, 컴파일러가 안전을 증명할 수 있습니다.
해결 2: 인덱스가 런타임에 결정된다면 get_many_mut 고려
Rust 버전에 따라 slice::get_many_mut 같은 API를 사용할 수 있습니다. 다만 안정화/버전 제약이 있을 수 있어, 팀 표준 버전에 맞춰 선택하세요.
대표 패턴 2: 구조체 메서드에서 self를 두 번 &mut로 빌림
struct App {
a: i32,
b: i32,
}
impl App {
fn bump_both(&mut self) {
let x = &mut self.a;
let y = &mut self.b; // E0499 (상황에 따라)
*x += 1;
*y += 1;
}
}
NLL로 많은 경우 통과하지만, 중간에 함수 호출이 끼거나 참조가 더 오래 살아남으면 실패할 수 있습니다.
해결 1: 동시에 참조를 들고 있지 않게 순서를 바꾸기
impl App {
fn bump_both(&mut self) {
self.a += 1;
self.b += 1;
}
}
해결 2: 필드를 분해(destructure)해서 분리된 mutable 참조로 만들기
impl App {
fn bump_both(&mut self) {
let App { a, b } = self;
*a += 1;
*b += 1;
}
}
이 패턴은 “서로 다른 필드”라는 사실을 컴파일러가 더 잘 이해하도록 돕습니다.
실전 해결 전략: 빌림 스코프를 줄이고, 단계를 분리하라
E0502/E0499는 대부분 아래 전략 중 하나로 정리됩니다.
1) 참조를 오래 들고 있지 않기
- 참조를 변수에 저장하는 대신, 필요한 순간에만 사용
- 블록을 만들어 스코프를 강제로 종료
- 가능하면 값 복사(
Copy) 또는clone으로 소유권을 가져오기
fn main() {
let mut s = String::from("hello");
// 나쁜 예: 참조를 오래 들고 감
let r = s.as_str();
// s.push('!'); // 여기서 E0502 가능
println!("{}", r);
// 좋은 예: 필요한 시점에만 빌림
s.push('!');
println!("{}", s.as_str());
}
2) “읽기-계산-쓰기”를 한 번에 하지 말고 두 단계로
특히 컬렉션을 순회하면서 수정하려는 욕구가 강할수록, 단계를 나누면 해결이 쉬워집니다.
fn remove_negatives(v: &mut Vec<i32>) {
// 1단계: 제거할 인덱스 수집(읽기)
let to_remove: Vec<usize> = v
.iter()
.enumerate()
.filter_map(|(i, x)| if *x < 0 { Some(i) } else { None })
.collect();
// 2단계: 뒤에서부터 제거(쓰기)
for i in to_remove.into_iter().rev() {
v.remove(i);
}
}
3) API를 “빌림 친화적”으로 설계
함수 시그니처가 불필요하게 &mut를 오래 요구하면 호출부에서 충돌이 납니다. 가능한 한:
- 입력은
&T또는 소유권(T)으로 받고 - 출력은 필요한 데이터만 반환
- 내부에서만 짧게
&mut를 사용
예를 들어, 아래처럼 “조회 + 수정”을 한 함수에서 다 하려 하면 호출부에서 빌림 충돌이 잘 납니다.
fn get_and_update(map: &mut std::collections::HashMap<String, i32>, key: &str) -> i32 {
let v = map.get(key).unwrap();
// map.insert(key.to_string(), *v + 1); // E0502 가능
*v
}
대신 entry API로 한 번에 처리하면 깔끔합니다.
use std::collections::HashMap;
fn bump(map: &mut HashMap<String, i32>, key: &str) -> i32 {
let e = map.entry(key.to_string()).or_insert(0);
*e += 1;
*e
}
자주 쓰는 “정석” 도구들
split_at_mut: 같은 슬라이스에서 두 mutable 참조가 필요할 때
- 정렬, 파티션, 투 포인터 알고리즘에서 자주 등장
- 두 구간이 겹치지 않음을 API가 보장
std::mem::take / std::mem::replace: 소유권을 잠시 빼서 작업
구조체 필드를 빌린 채로 다른 필드를 수정해야 하는 상황에서 유용합니다.
use std::mem;
#[derive(Default)]
struct State {
buf: Vec<u8>,
total: usize,
}
impl State {
fn flush(&mut self) {
// buf를 통째로 꺼내서(소유권 이동) 처리
let buf = mem::take(&mut self.buf);
self.total += buf.len();
// buf는 여기서 drop
}
}
RefCell / RwLock은 “최후의 수단”으로
컴파일 타임 빌림 규칙을 런타임 체크로 바꾸는 도구들입니다.
- 단일 스레드 내부 가변성:
RefCell - 멀티 스레드 공유:
Mutex,RwLock
다만 이는 borrow checker 에러를 “해결”한다기보다 “검사를 런타임으로 미룬다”에 가깝습니다. 성능/데드락/패닉 가능성을 감수해야 하므로, 먼저 설계와 스코프 정리로 풀 수 있는지 확인하는 게 좋습니다.
컴파일러 메시지 읽는 요령
에러 메시지에서 중요한 건 보통 두 가지입니다.
- “첫 번째 빌림이 발생한 위치”
- “두 번째 빌림이 발생한 위치”
그리고 종종 “첫 번째 빌림이 여기까지 사용된다” 같은 라인이 함께 나옵니다. 이 지점이 실제로는 println! 같은 단순 사용일 수도 있고, 함수 인자로 참조가 전달되면서 수명이 길어진 것일 수도 있습니다.
이 디버깅 방식은 관찰 지점을 늘려 원인을 좁혀가는 점에서, 운영 환경에서 원인별로 분류해 해결하는 접근과 유사합니다. 예를 들어 인증 에러를 invalid_grant 원인별로 쪼개는 방식은 OAuth PKCE invalid_grant 6가지 원인·해결 같은 글이 참고가 됩니다.
결론: E0502/E0499는 “코드 구조를 개선하라”는 신호
E0502와 E0499는 러스트 개발자가 반드시 통과해야 하는 관문이지만, 익숙해지면 오히려 설계를 더 명확하게 만드는 가이드가 됩니다.
- E0502: 읽기 참조가 살아있는 동안 쓰기를 시도했다
- E0499: 동시에 두 개의 쓰기 참조를 만들려 했다
해결의 대부분은 다음으로 귀결됩니다.
- 참조 수명을 짧게 만들기(블록, 즉시 사용, 값 복사/클론)
- 읽기와 쓰기를 단계로 분리하기
split_at_mut,entry,mem::take같은 “안전함을 증명하는 API”로 표현하기
이 원칙으로 코드를 재구성하면, 단순히 에러를 없애는 수준을 넘어 유지보수성과 동시성 안전성까지 함께 얻을 수 있습니다.