- Published on
Rust E0502/E0499 빌림 충돌 6가지 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 언어에서 런타임에 터질 버그가 Rust에서는 컴파일 타임 에러로 나타납니다. 그중에서도 E0502 와 E0499 는 초반에 가장 자주 마주치는 “빌림 충돌” 계열입니다.
E0502: 불변 빌림(&T)이 살아있는 동안 가변 빌림(&mut T)을 만들려고 할 때E0499: 동일한 값에 대해 가변 빌림(&mut T)을 동시에 2개 이상 만들려고 할 때
핵심 규칙은 단순합니다.
- 어떤 값에 대해 가변 참조는 동시에 하나만 존재할 수 있다
- 불변 참조가 존재하는 동안 가변 참조를 만들 수 없다
하지만 실제 코드는 “참조가 언제까지 살아있는지(스코프)”가 눈에 잘 안 보여서 충돌이 발생합니다. 아래는 실무에서 반복적으로 등장하는 6가지 패턴과 해결 전략입니다.
참고: 비동기/런타임에서의 패닉도 결국 “어떤 작업이 언제까지 점유되는가”가 핵심인 경우가 많습니다. Tokio 런타임 점유 이슈를 다룬 글도 함께 보면 감이 빨리 옵니다: Tokio runtime 패닉 - blocking_in_place 원인·해결
1) 불변 참조를 잡아둔 채로 수정하려는 경우 (E0502)
가장 기본적인 형태입니다.
fn main() {
let mut s = String::from("hello");
let r = &s; // 불변 빌림 시작
s.push('!'); // 여기서 가변 접근 시도
println!("{}", r); // 불변 빌림이 아직 사용됨
}
왜 막히나
r 이 println! 에서 사용되므로, 컴파일러는 r 의 생존 범위를 println! 까지로 봅니다. 그 사이에 s.push 는 s 를 가변으로 빌려야 하므로 충돌입니다.
해결법 A: 불변 참조의 사용을 먼저 끝내기
fn main() {
let mut s = String::from("hello");
let r = &s;
println!("{}", r); // 여기서 불변 빌림 사용 종료
s.push('!');
println!("{}", s);
}
해결법 B: 필요한 값만 복사/복제하기
fn main() {
let mut s = String::from("hello");
let snapshot = s.clone();
s.push('!');
println!("before={snapshot}, after={s}");
}
복제 비용이 부담되면 &str 슬라이스로 필요한 부분만 잡거나, 이후 패턴에서 다루는 스코프 쪼개기를 적용합니다.
2) Vec 인덱싱으로 같은 벡터를 두 번 가변 빌림 (E0499)
v[i] 는 내부적으로 IndexMut 를 통해 &mut 를 반환할 수 있어서, 같은 Vec 에서 2개의 &mut 를 만들면 바로 충돌합니다.
fn main() {
let mut v = vec![1, 2, 3];
let a = &mut v[0];
let b = &mut v[1];
*a += *b;
}
해결법: split_at_mut 로 안전하게 분할
fn main() {
let mut v = vec![1, 2, 3];
let (left, right) = v.split_at_mut(1);
let a = &mut left[0];
let b = &mut right[0]; // 원래 v[1]
*a += *b;
println!("{:?}", v);
}
split_at_mut 는 “서로 겹치지 않는 두 구간”임을 라이브러리 레벨에서 보장하므로, 컴파일러가 2개의 &mut 를 허용합니다.
3) HashMap/BTreeMap 에서 get 과 get_mut 를 섞는 경우 (E0502)
불변 조회를 한 뒤, 같은 맵을 가변으로 수정하려는 패턴입니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
let v = m.get("a"); // 불변 빌림
let w = m.get_mut("a"); // 가변 빌림 시도
println!("{:?} {:?}", v, w);
}
해결법 A: 값을 먼저 복사해 스코프를 끊기
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
let old = *m.get("a").unwrap(); // i32는 Copy
*m.get_mut("a").unwrap() = old + 10;
println!("{:?}", m.get("a"));
}
해결법 B: entry API로 한 번에 처리
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
let x = m.entry("a".into()).or_insert(0);
*x += 1;
println!("{:?}", m.get("a"));
}
entry 는 “조회와 삽입/수정”을 하나의 가변 빌림으로 묶어, 불변/가변 혼용을 피하게 해줍니다.
4) 루프에서 원소를 참조한 채 컬렉션을 수정 (E0502/E0499)
반복 중인 컬렉션을 동시에 수정하는 패턴입니다.
fn main() {
let mut v = vec![1, 2, 3];
for x in &v { // v를 불변으로 빌림
if *x == 2 {
v.push(4); // 가변 수정 시도
}
}
}
해결법 A: 인덱스 기반으로 순회하되, push 대상은 따로 모으기
fn main() {
let mut v = vec![1, 2, 3];
let mut to_add = Vec::new();
for x in v.iter() {
if *x == 2 {
to_add.push(4);
}
}
v.extend(to_add);
println!("{:?}", v);
}
해결법 B: retain/drain/partition 같은 “소유권 기반” API 활용
fn main() {
let mut v = vec![1, 2, 3, 2, 4];
// 2를 제거하면서 카운트
let mut removed = 0;
v.retain(|x| {
let keep = *x != 2;
if !keep { removed += 1; }
keep
});
println!("v={:?}, removed={removed}", v);
}
Rust 컬렉션은 “빌림을 오래 들고 있지 않게” 만드는 고수준 메서드가 많습니다. 가능하면 이런 API로 문제를 구조적으로 없애는 게 가장 깔끔합니다.
5) 메서드 체인/클로저가 참조를 예상보다 오래 잡는 경우 (E0502)
NLL(Non-Lexical Lifetimes) 덕분에 많은 경우 참조 생존 범위가 줄어들지만, 클로저/이터레이터 체인에서는 여전히 “참조가 캡처되어 오래 살아있는” 형태가 나오기 쉽습니다.
fn main() {
let mut s = String::from("abc");
let pred = |c: char| s.contains(c); // s를 불변으로 캡처
s.push('d'); // 가변 수정 시도
println!("{}", pred('a'));
}
해결법 A: 캡처 대신 필요한 데이터만 분리
fn main() {
let mut s = String::from("abc");
let snapshot = s.clone();
let pred = move |c: char| snapshot.contains(c);
s.push('d');
println!("{}", pred('a'));
}
해결법 B: 클로저를 짧은 스코프로 제한
fn main() {
let mut s = String::from("abc");
{
let pred = |c: char| s.contains(c);
println!("{}", pred('a'));
} // pred 드랍, 불변 빌림 종료
s.push('d');
println!("{}", s);
}
클로저가 환경을 어떻게 캡처하는지(move 여부 포함)를 의식하면, E0502의 상당수를 “스코프 설계”로 해결할 수 있습니다.
6) 자기 참조 구조/동일 구조 내 두 필드 동시 가변 빌림 (E0499)
구조체에서 self 를 가변으로 빌린 상태에서, 다시 self 의 다른 필드를 빌리려다 충돌하는 패턴이 나옵니다. 특히 “한 필드를 빌린 참조를 로컬 변수로 들고” 다른 필드를 수정하려 할 때 자주 발생합니다.
#[derive(Debug)]
struct State {
buf: Vec<u8>,
pos: usize,
}
impl State {
fn bump_and_read(&mut self) -> u8 {
let b = &mut self.buf[self.pos]; // self.buf 가변 빌림
self.pos += 1; // self 전체를 다시 가변 사용
*b
}
}
fn main() {
let mut st = State { buf: vec![10, 20], pos: 0 };
println!("{}", st.bump_and_read());
}
해결법 A: 먼저 필요한 인덱스/값을 계산하고, 빌림을 늦게 시작
#[derive(Debug)]
struct State {
buf: Vec<u8>,
pos: usize,
}
impl State {
fn bump_and_read(&mut self) -> u8 {
let i = self.pos;
self.pos += 1;
self.buf[i]
}
}
fn main() {
let mut st = State { buf: vec![10, 20], pos: 0 };
println!("{}", st.bump_and_read());
}
여기서는 u8 이 Copy 라서 더 간단합니다. 만약 큰 타입이라면 clone 이나 mem::take 같은 패턴을 고려합니다.
해결법 B: 필드 단위로 “동시에 빌려도 안전함”을 표현하기
두 필드를 동시에 가변으로 다루어야 한다면, 로직을 재구성하거나 표준 라이브러리 도구로 “서로 다른 부분”임을 드러내야 합니다. 예를 들어 split_at_mut 처럼요. 구조체에서는 보통 다음 중 하나로 갑니다.
- 연산 순서를 바꿔 한쪽 빌림을 먼저 끝낸다
- 임시 변수로 값을 빼서 처리한 뒤 다시 넣는다(
std::mem::take,std::mem::replace)
use std::mem;
#[derive(Debug)]
struct State {
buf: Vec<String>,
log: Vec<String>,
}
impl State {
fn move_first_to_log(&mut self) {
// buf를 통째로 빼서(소유권 이동) 빌림 충돌을 제거
let mut buf = mem::take(&mut self.buf);
if let Some(first) = buf.get(0).cloned() {
self.log.push(first);
}
self.buf = buf;
}
}
fn main() {
let mut st = State {
buf: vec!["a".into(), "b".into()],
log: vec![],
};
st.move_first_to_log();
println!("{:?}", st);
}
이 방식은 약간 우회처럼 보이지만, “동시에 두 군데를 가변으로 잡아야 하는 구조” 자체를 깨뜨리는 실전적인 해결책입니다.
에러 메시지 읽는 요령: 진짜 문제는 ‘빌림이 끝나지 않음’
E0502/E0499 를 해결할 때는 보통 아래 질문으로 정리됩니다.
- 이 참조(
&또는&mut)가 언제까지 살아있다고 컴파일러가 판단하는가 - 그 생존 범위 안에서 같은 대상에 대한 또 다른 빌림이 생기는가
- 생존 범위를 줄이거나(스코프/순서 변경), 구조적으로 분리할 수 있는가(
split_at_mut,entry, 소유권 이동)
이 관점은 네트워크 타임아웃/데드라인 문제를 디버깅할 때 “어떤 리소스가 언제까지 점유되는가”를 보는 것과 비슷합니다. 관심 있다면 Go gRPC context deadline exceeded 원인 7가지 도 같은 사고방식을 훈련하는 데 도움이 됩니다.
정리: 6가지 패턴별 치트시트
- 패턴 1: 불변 참조 유지 중 수정
E0502- 해결: 불변 참조 사용을 먼저 끝내기, 스냅샷 복제
- 패턴 2:
Vec원소 2개를 동시에&mutE0499- 해결:
split_at_mut
- 해결:
- 패턴 3: 맵에서
get후get_mutE0502- 해결:
entry, 값 복사/스코프 단축
- 해결:
- 패턴 4: 순회 중 컬렉션 수정
E0502/E0499- 해결: 변경 사항 분리 후
extend, 또는retain/drain
- 해결: 변경 사항 분리 후
- 패턴 5: 클로저/체인이 참조를 오래 캡처
E0502- 해결: 스코프 제한,
move+ 스냅샷
- 해결: 스코프 제한,
- 패턴 6: 구조체 내부에서 필드 빌림과 다른 필드 수정
E0499- 해결: 연산 순서 변경,
mem::take/replace, 로직 재구성
- 해결: 연산 순서 변경,
Rust의 빌림 규칙은 “불편한 제약”이라기보다, 데이터 레이스/유즈애프터프리 같은 결함을 설계 단계에서 제거하는 장치입니다. 위 6가지 패턴을 손에 익히면, 에러를 만났을 때도 코드를 더 단순한 소유권 흐름으로 재구성하는 방향이 자연스럽게 떠오를 겁니다.