- Published on
Rust E0502/E0499 빌림 충돌 5패턴 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 언어에서 흔히 “동시에 읽고 쓰면 안 된다”는 규칙은 런타임에서 락이나 규약으로 해결되는 경우가 많습니다. Rust는 이를 컴파일 타임에 강제하고, 그 과정에서 대표적으로 E0502(불변 빌림이 살아있는 동안 가변 빌림 시도)와 E0499(동시에 두 개 이상의 가변 빌림 시도) 에러를 자주 마주치게 됩니다.
이 글은 “왜 안 되는지”를 길게 설명하기보다, 실제 코드에서 반복적으로 나타나는 빌림 충돌을 5가지 패턴으로 나누고, 각각을 가장 안전하고 읽기 좋게 고치는 방법을 제시합니다.
추가로, 컴파일러가 힌트로 주는 “borrowed here” 메시지를 읽는 감각은 TypeScript의 엄격 옵션을 다루는 것과 유사한 면이 있습니다. 엄격함이 불편해도, 패턴을 익히면 생산성이 올라갑니다. 필요하다면 TypeScript 5.5+ noUncheckedIndexedAccess 오류 실전해결처럼 “규칙을 코드 구조로 흡수하는” 접근이 도움이 됩니다.
E0502/E0499 빠른 요약
E0502: 이미&T(불변 참조)를 잡아둔 상태에서 같은 값에&mut T(가변 참조)를 만들려고 할 때E0499: 같은 값에 대해&mut T를 2개 이상 동시에 만들려고 할 때
핵심은 “참조의 생존 범위(lifetime)와 겹침(overlap)”입니다. Rust는 한 시점에 다음만 허용합니다.
- 여러 개의
&T(읽기 전용은 여러 개 가능) - 또는 딱 하나의
&mut T(쓰기 가능은 하나만 가능)
이제부터 실제로 많이 터지는 5패턴을 보겠습니다.
패턴 1) 읽기 참조를 잡아둔 채로 수정하기 (E0502)
문제 코드
Vec에서 최대값을 읽어둔 뒤, 그 값을 기준으로 Vec을 수정하려는 경우가 흔합니다.
fn bump_if_needed(v: &mut Vec<i32>) {
let max_ref = v.iter().max().unwrap();
// max_ref가 v를 불변으로 빌리고 있는 동안
// v를 가변으로 빌리려 해서 E0502
v.push(*max_ref + 1);
}
해결 1: 값 복사로 참조 수명 끊기
불변 참조가 “살아있지 않게” 만들면 됩니다. 가장 단순한 방법은 참조가 아닌 값을 만들어두는 것입니다.
fn bump_if_needed(v: &mut Vec<i32>) {
let max_val = *v.iter().max().unwrap();
v.push(max_val + 1);
}
i32처럼 Copy 타입이면 비용이 거의 없고 코드도 가장 깔끔합니다.
해결 2: 스코프 블록으로 참조를 빨리 drop
복사가 부담되는 타입이라면 스코프를 분리해 참조의 생존 범위를 명확히 끝낼 수 있습니다.
fn push_len_after_read(v: &mut Vec<String>) {
let len;
{
let first = v.first().unwrap();
len = first.len();
} // 여기서 first drop
v.push(format!("len={}", len));
}
이 패턴은 “읽기 후 쓰기”가 한 함수 안에서 섞일 때 매우 자주 씁니다.
패턴 2) 같은 컬렉션에서 두 원소를 동시에 가변 빌림 (E0499)
문제 코드
두 인덱스를 swap하거나, 두 원소를 동시에 업데이트하려고 하면 바로 걸립니다.
fn add_both(v: &mut Vec<i32>, i: usize, j: usize) {
let a = &mut v[i];
let b = &mut v[j];
*a += 1;
*b += 1;
}
컴파일러 입장에서는 i == j일 가능성을 배제할 수 없고, 같은 Vec에서 &mut를 두 개 만들면 aliasing 위험이 생깁니다.
해결 1: split_at_mut로 “서로 다른 슬라이스”임을 증명
split_at_mut는 내부적으로 “좌/우가 겹치지 않는다”는 사실을 API로 보장합니다.
fn add_both(v: &mut [i32], i: usize, j: usize) {
assert!(i != j);
let (a, b) = if i < j {
let (left, right) = v.split_at_mut(j);
(&mut left[i], &mut right[0])
} else {
let (left, right) = v.split_at_mut(i);
(&mut right[0], &mut left[j])
};
*a += 1;
*b += 1;
}
포인트는 “가변 참조 2개”가 아니라 “서로 다른 슬라이스에서 가변 참조 1개씩”을 만드는 구조로 바꾸는 것입니다.
해결 2: 안전한 표준 메서드 활용 (swap)
단순 swap은 굳이 참조 두 개를 만들 필요가 없습니다.
fn swap_two(v: &mut [i32], i: usize, j: usize) {
v.swap(i, j);
}
표준 라이브러리 메서드는 이미 빌림 규칙을 만족하도록 구현되어 있어, 가능한 경우 가장 좋은 선택입니다.
패턴 3) HashMap에서 get한 참조를 유지한 채로 insert/entry 호출 (E0502)
문제 코드
HashMap에서 값을 읽고, 조건에 따라 같은 맵을 수정하려는 코드에서 자주 발생합니다.
use std::collections::HashMap;
fn ensure_default(map: &mut HashMap<String, i32>, key: String) {
let v = map.get(&key);
// v가 살아있는 동안 map을 가변으로 빌림
if v.is_none() {
map.insert(key, 0);
}
}
해결 1: entry API로 읽기/쓰기를 한 번에
entry는 “없으면 넣고, 있으면 가져오기”를 빌림 충돌 없이 표현하는 정석입니다.
use std::collections::HashMap;
fn ensure_default(map: &mut HashMap<String, i32>, key: String) {
map.entry(key).or_insert(0);
}
해결 2: 필요한 정보만 복사/추출
값이 Copy가 아니더라도, 조건 판단에 필요한 것만 빼서 참조 수명을 끊을 수 있습니다.
use std::collections::HashMap;
fn increment_if_exists(map: &mut HashMap<String, i32>, key: &str) {
let exists = map.contains_key(key);
if exists {
*map.get_mut(key).unwrap() += 1;
}
}
contains_key는 불변 빌림을 짧게 끝내고, 이후 get_mut로 가변 빌림을 잡습니다.
패턴 4) 반복문에서 아이템을 빌린 채로 컬렉션을 수정 (E0502/E0499)
문제 코드
반복 중에 push/remove를 하고 싶은 유혹이 큽니다.
fn remove_negatives(v: &mut Vec<i32>) {
for x in v.iter() {
if *x < 0 {
// iter가 v를 불변으로 빌린 동안
// v를 가변으로 수정하려 해서 E0502
v.retain(|y| *y >= 0);
break;
}
}
}
해결 1: 수정은 반복 뒤에, 반복은 “결정”만
먼저 조건을 계산하고, 그 다음에 수정합니다.
fn remove_negatives(v: &mut Vec<i32>) {
let has_negative = v.iter().any(|x| *x < 0);
if has_negative {
v.retain(|x| *x >= 0);
}
}
해결 2: 인덱스 기반 while 루프 + swap_remove
순서가 중요하지 않다면 swap_remove는 매우 유용합니다.
fn remove_negatives_unordered(v: &mut Vec<i32>) {
let mut i = 0;
while i < v.len() {
if v[i] < 0 {
v.swap_remove(i);
} else {
i += 1;
}
}
}
여기서는 v[i]를 읽는 순간의 불변 빌림이 표현상 짧게 끝나고, 즉시 swap_remove로 수정해도 충돌이 나지 않는 구조입니다.
해결 3: “새 벡터로 수집” 전략
성능/할당이 허용된다면 가장 단순하고 안전합니다.
fn filter_non_negative(v: Vec<i32>) -> Vec<i32> {
v.into_iter().filter(|x| *x >= 0).collect()
}
패턴 5) 구조체 메서드에서 self를 빌린 채로 다른 필드를 가변 접근 (E0502/E0499)
문제 코드
self.field를 불변으로 잡아둔 뒤, 같은 self의 다른 필드를 수정하려 할 때 발생합니다.
struct App {
name: String,
logs: Vec<String>,
}
impl App {
fn log_name(&mut self) {
let n = &self.name; // self를 불변으로 빌림(필드)
self.logs.push(n.clone()); // 동시에 self를 가변으로 빌림
}
}
해결 1: 필요한 값만 먼저 소유/복사
impl App {
fn log_name(&mut self) {
let name = self.name.clone();
self.logs.push(name);
}
}
해결 2: 필드 분해로 “서로 다른 필드”를 명시
Rust는 서로 다른 필드에 대한 분해 바인딩을 통해 빌림을 분리할 수 있습니다.
impl App {
fn log_name(&mut self) {
let App { name, logs } = self;
logs.push(name.clone());
}
}
이 방식은 불필요한 clone을 피하기 어렵다면(예: name을 그대로 쓰고 싶다면) 다른 설계를 고려해야 합니다.
해결 3: Option::take로 소유권을 잠깐 빼서 처리
필드가 Option이라면, take는 빌림 충돌을 우아하게 피하는 도구입니다.
struct App2 {
current: Option<String>,
logs: Vec<String>,
}
impl App2 {
fn consume_current(&mut self) {
if let Some(s) = self.current.take() {
self.logs.push(s);
}
}
}
take는 Option 안의 값을 꺼내고 그 자리에 None을 넣어, “한 번에 소유권 이동”을 만들어줍니다.
디버깅 체크리스트: 에러 메시지를 이렇게 읽기
- 에러가
E0502면 “불변 참조가 너무 오래 살아있다”부터 의심 - 에러가
E0499면 “동시에&mut를 두 개 만들고 있다”부터 의심 - 해결 방향은 대체로 아래 중 하나
- 참조를 값으로 바꿔 수명 단축 (
Copy,clone, 필요한 데이터만 추출) - 스코프를 쪼개 참조 drop 시점 앞당기기
- API를 바꿔 겹치지 않음을 증명 (
split_at_mut,entry) - 반복과 수정을 분리(먼저 결정, 나중에 변경)
- 소유권 이동 도구 사용 (
mem::take,Option::take)
- 참조를 값으로 바꿔 수명 단축 (
마무리: 빌림 규칙을 “코드 구조”로 표현하자
E0502/E0499는 Rust가 까다로워서가 아니라, “동시 접근의 모호함”을 코드에서 제거하라고 요구하는 신호입니다. 위 5패턴은 실무에서 가장 자주 반복되므로, 한 번 손에 익으면 대부분의 빌림 충돌은 기계적으로 해결됩니다.
특히 entry와 split_at_mut, 그리고 “반복과 변경을 분리”하는 습관은 Rust 코드베이스의 안정성을 크게 올립니다. 빌드가 엄격한 환경에서 규칙을 구조로 녹이는 감각은 다른 생태계에서도 유용한데, 예를 들어 CI 파이프라인을 재사용 가능한 단위로 쪼개는 접근은 GitHub Actions 재사용 워크플로우로 모노레포 CI 통합 같은 주제와도 닮아 있습니다.
원하는 코드 조각(에러 로그 포함)을 주면, 위 패턴 중 어디에 해당하는지 분류해서 가장 작은 수정으로 통과시키는 리팩터링안을 함께 제시할 수 있습니다.