- Published on
Rust E0502/E0499 빌림 충돌 5패턴 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 컴파일러가 가장 먼저 “선 넘었다”고 알려주는 에러가 E0502(불변 빌림과 가변 빌림의 충돌), E0499(가변 빌림의 중복)입니다. 둘 다 핵심은 같습니다. 동일한 데이터에 대해, 동시에(같은 라이프타임 구간에) 규칙에 어긋나는 빌림이 존재한다는 뜻입니다.
이 글에서는 실무에서 반복적으로 등장하는 충돌을 5가지 패턴으로 분류하고, 각 패턴별로 “왜 발생하는지”와 “가장 안전하고 읽기 좋은 해결법”을 코드로 정리합니다.
참고로 이런 류의 문제는 언어는 달라도 디버깅 패턴이 비슷합니다. 예를 들어 Pandas의 뷰/복사 혼동이 경고로 드러나는 것처럼, Rust는 빌림 규칙을 컴파일 타임에 강제합니다. 유사한 진단-해결 글로는 Pandas SettingWithCopyWarning 원인·해결 7가지도 함께 보면 “원인 분해” 관점에 도움이 됩니다.
E0502와 E0499를 빠르게 해석하는 법
E0502: 이미&T(불변 참조)를 잡아둔 상태에서 같은 대상에&mut T를 만들려고 함E0499: 이미&mut T를 잡아둔 상태에서 같은 대상에 또&mut T를 만들려고 함
중요한 포인트는 **“동시에”의 기준이 실행 순서가 아니라 “스코프(라이프타임)”**라는 점입니다. Rust는 참조가 마지막으로 사용되는 지점까지 라이프타임을 연장할 수 있고(비-어휘적 라이프타임, NLL), 그 결과 “내 눈에는 끝난 것 같은데” 컴파일러는 아직 참조가 살아있다고 판단할 수 있습니다.
패턴 1) 불변 참조를 잡아둔 채로 수정하려는 경우 (E0502)
전형적인 실패 코드
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // E0502: immutable borrow occurs here
println!("{first}");
}
first가 v를 불변으로 빌린 상태인데, push는 재할당(reallocation) 가능성이 있어 v 전체를 가변으로 빌려야 합니다. 그래서 충돌합니다.
해결 1: 값 복사(또는 클론)로 참조 라이프타임 제거
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{first}");
}
Copy가 아닌 타입이면 clone() 또는 필요한 데이터만 추출하는 방식으로 해결합니다.
해결 2: 스코프를 줄여 참조를 빨리 끝내기
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{first}");
} // 여기서 first 드롭
v.push(4);
}
언제 이 패턴을 쓰나
- 로깅/검증 때문에 잠깐 참조가 필요할 때
- 참조를 오래 들고 있을 이유가 없는데 무심코 변수로 빼둔 경우
패턴 2) 같은 컨테이너에서 “읽고” 결과로 “쓰기”를 하는 루프 (E0502)
전형적인 실패 코드: iter()로 읽으면서 같은 벡터를 수정
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() {
if *x % 2 == 1 {
v.push(*x); // E0502
}
}
}
v.iter()가 루프 전체 동안 v를 불변으로 빌립니다. 그 상태에서 push는 가변 빌림이 필요하니 충돌합니다.
해결 1: 2단계 처리(읽기 단계와 쓰기 단계 분리)
fn main() {
let mut v = vec![1, 2, 3];
let to_add: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 1).collect();
v.extend(to_add);
assert_eq!(v, vec![1, 2, 3, 1, 3]);
}
읽기 단계에서는 불변 참조만, 쓰기 단계에서는 가변 참조만 사용하게 만들면 규칙이 깔끔해집니다.
해결 2: 인덱스로 순회하되, 길이 변화에 주의
fn main() {
let mut v = vec![1, 2, 3];
let original_len = v.len();
for i in 0..original_len {
let x = v[i];
if x % 2 == 1 {
v.push(x);
}
}
}
언제 이 패턴을 쓰나
- “기존 데이터 기반으로 추가 append” 같은 작업
- 스트리밍처럼 보이지만 실제로는 배치로 나눌 수 있는 작업
패턴 3) 같은 슬라이스에서 서로 다른 두 요소를 &mut로 동시에 잡기 (E0499)
전형적인 실패 코드
fn swap_first_two(v: &mut [i32]) {
let a = &mut v[0];
let b = &mut v[1]; // E0499
std::mem::swap(a, b);
}
컴파일러는 v[0]과 v[1]이 서로 다른 메모리라는 걸 “일반적인 인덱싱 연산”만으로는 증명하지 못합니다. 그래서 보수적으로 막습니다.
해결 1: 표준 라이브러리의 split_at_mut 사용
fn swap_first_two(v: &mut [i32]) {
let (left, right) = v.split_at_mut(1);
let a = &mut left[0];
let b = &mut right[0];
std::mem::swap(a, b);
}
split_at_mut는 “두 슬라이스가 겹치지 않는다”는 것을 타입 시스템에 반영해줍니다.
해결 2: 전용 API 사용
이미 제공되는 메서드가 있으면 그게 가장 안전합니다.
fn swap_first_two(v: &mut [i32]) {
v.swap(0, 1);
}
언제 이 패턴을 쓰나
- 배열/슬라이스에서 두 지점을 동시에 갱신해야 하는 알고리즘
- 그래프/DP/정렬처럼 “서로 다른 인덱스의 동시 가변 참조”가 필요한 코드
패턴 4) HashMap에서 항목을 읽고 같은 HashMap을 다시 수정 (E0502/E0499)
HashMap은 내부적으로 버킷 재배치가 일어날 수 있어, 엔트리 참조를 들고 있는 동안 맵을 다시 가변 접근하면 충돌이 자주 납니다.
전형적인 실패 코드: get으로 참조를 잡고 insert
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();
m.insert("b".to_string(), *v + 1); // E0502
}
해결 1: 필요한 값만 복사해 참조를 끊기
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".to_string(), 1);
let a_val = *m.get("a").unwrap();
m.insert("b".to_string(), a_val + 1);
}
해결 2: entry API로 “읽기+쓰기”를 한 번에
카운팅/누적처럼 전형적인 패턴은 entry가 정답인 경우가 많습니다.
use std::collections::HashMap;
fn main() {
let mut counts: HashMap<String, usize> = HashMap::new();
let key = "apple".to_string();
*counts.entry(key).or_insert(0) += 1;
}
해결 3: 두 키를 동시에 &mut로 잡아야 한다면 구조를 바꾸기
서로 다른 키에 대해 동시에 &mut를 얻는 것은 일반적으로 불가능합니다(동일 맵에 대한 2개의 가변 빌림). 이때는 다음 중 하나로 설계를 조정합니다.
- 값을 복사한 뒤 다시 넣기
- 업데이트를 순차적으로 수행
- 데이터 구조를 분리(예: 두
HashMap으로 분리하거나, 값에 내부 가변성 부여)
언제 이 패턴을 쓰나
- 캐시 갱신, 카운터, 집계 로직
- 하나의 맵을 “상태 저장소”로 쓰면서 여러 곳에서 업데이트하는 코드
패턴 5) 구조체 메서드에서 self의 한 필드를 빌린 채 다른 필드를 수정 (E0502/E0499)
실무에서 체감 난이도가 높은 케이스입니다. 특히 self.field에 대한 참조를 로컬 변수로 빼두면, 그 참조가 살아있는 동안 self를 다시 가변으로 쓰기 어렵습니다.
전형적인 실패 코드
struct App {
buf: String,
len: usize,
}
impl App {
fn update_len_and_append(&mut self, s: &str) {
let b = &self.buf; // 불변 빌림
self.len = b.len(); // 여기까지는 OK처럼 보이지만
self.buf.push_str(s); // E0502가 나는 형태로 자주 변형됨
}
}
위 예시는 컴파일러/NLL에 따라 통과할 수도 있지만, 실제로는 b를 더 뒤에서 쓰거나, 다른 로직이 끼면 쉽게 충돌합니다. 핵심은 필드 참조를 오래 들고 있지 말고, 필요한 값만 뽑아 쓰거나, 필드 단위로 분리 빌림을 유도하는 것입니다.
해결 1: 필요한 값만 먼저 계산해서 저장
struct App {
buf: String,
len: usize,
}
impl App {
fn update_len_and_append(&mut self, s: &str) {
let current_len = self.buf.len();
self.len = current_len;
self.buf.push_str(s);
}
}
해결 2: “필드 분해”로 서로 다른 필드에 대한 빌림을 분리
struct App {
buf: String,
len: usize,
}
impl App {
fn append_and_refresh(&mut self, s: &str) {
let App { buf, len } = self;
buf.push_str(s);
*len = buf.len();
}
}
이 방식은 self 전체를 다시 빌리지 않고, 서로 다른 필드를 명시적으로 다루게 해 빌림 충돌을 줄입니다.
해결 3: 내부 가변성(RefCell, Cell, Mutex)은 최후의 수단으로
컴파일 타임 빌림 규칙을 런타임 체크로 바꾸는 방법입니다. 단, 런타임 패닉(예: RefCell의 중복 가변 대여) 가능성이 생기므로 “정말 구조적으로 필요할 때”만 씁니다.
use std::cell::RefCell;
struct App {
buf: RefCell<String>,
}
impl App {
fn append(&self, s: &str) {
self.buf.borrow_mut().push_str(s);
}
}
언제 이 패턴을 쓰나
self내부에 캐시를 두고, 논리적으로는 불변 메서드에서 캐시만 갱신해야 할 때- 콜백/클로저로 인해 빌림 스코프를 깔끔히 자르기 어려울 때
디버깅 체크리스트: 빌림 충돌을 빠르게 줄이는 6가지 질문
- 지금 참조(
&또는&mut)를 변수로 “저장”해두고 있나 - 그 참조가 마지막으로 사용되는 지점이 어디인가(로그 한 줄 때문에 라이프타임이 늘어나지 않았나)
- 읽기와 쓰기를 한 루프에서 섞고 있나(2단계로 분리 가능한가)
- 컨테이너에서 동시에 두 군데를 가변 참조해야 하나(슬라이스면
split_at_mut, 맵이면 설계 변경) self전체가 아니라 특정 필드만 다루도록 분해할 수 있나- 정말로 내부 가변성이 필요한가(필요하다면
RefCell의 런타임 비용과 패닉 가능성을 감수할 가치가 있나)
마무리: “스코프를 줄이거나, 단계를 나누거나, 구조를 분리”
E0502/E0499는 Rust가 안전성을 지키기 위해 강하게 거는 제약이지만, 반복해서 겪다 보면 해결 전략이 패턴화됩니다.
- 참조를 오래 들고 있지 않게 스코프를 줄이기
- 읽기와 쓰기를 단계로 분리하기
- 동시에 두 곳을 바꿔야 한다면 표준 도구(
split_at_mut,swap,entry)를 쓰거나 데이터 구조를 분리하기 - 그래도 안 되면 내부 가변성은 의도적으로 도입하기
이 5패턴을 기준으로 에러 메시지를 분류하면, 컴파일러가 요구하는 형태로 코드를 재구성하는 속도가 확실히 빨라집니다.