- Published on
Rust borrow checker 에러 E0502·E0499 5패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 배우거나 실무에서 적용할 때 난이도를 급격히 올리는 지점이 바로 borrow checker입니다. 특히 E0502(immutable borrow와 mutable borrow 충돌)와 E0499(동시에 둘 이상의 mutable borrow)가 대표적입니다.
이 글은 에러 메시지를 “읽고 끝”이 아니라, 실제 코드에서 반복되는 충돌 형태를 5개의 패턴으로 분류하고, 각 패턴별로 가장 덜 고통스러운 수정 방법을 제시합니다. 핵심은 단 하나입니다.
- 빌림의 수명(lifetime)을 짧게 만들고
- 읽기와 쓰기를 분리하며
- 동시에 잡고 있는 참조의 개수를 줄이는 것
E0502·E0499를 한 문장으로 이해하기
E0502: “이미&T로 빌려서 읽고 있는데, 같은 범위에서&mut T로 쓰려고 했네?”E0499: “이미&mut T를 하나 빌렸는데, 같은 범위에서 또&mut T를 빌리려고 했네?”
Rust는 동일한 데이터에 대해
- 여러 개의
&T는 허용(동시 읽기) &mut T는 오직 하나만 허용(독점 쓰기)&T와&mut T는 동시에 불가
라는 규칙을 강제합니다.
패턴 1) 읽기 참조를 잡은 채로 같은 값 수정하기 (E0502)
가장 흔한 형태입니다. “먼저 읽고, 그 값을 이용해 수정”하려다가 읽기 borrow가 범위를 너무 길게 잡아먹습니다.
문제 코드
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
}
first가 v의 내부를 가리키는 동안 push는 재할당이 일어날 수 있어 안전하지 않습니다. 그래서 E0502가 납니다.
해결 1: 값을 복사해 borrow 수명 줄이기
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{}", first);
}
Copy 가능한 타입이면 “참조”가 아니라 “값”을 들고 있으면 됩니다.
해결 2: 읽기와 쓰기를 블록으로 분리
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 first drop
v.push(4);
}
참조가 살아있는 범위를 인위적으로 줄여서 push가 가능한 시점을 만들었습니다.
패턴 2) 반복문에서 요소 참조를 들고 컬렉션을 동시에 변경 (E0502)
for x in &v로 순회하면서 v.push 같은 변경을 하려는 경우입니다.
문제 코드
fn main() {
let mut v = vec![1, 2, 3];
for x in &v {
if *x == 2 {
v.push(99);
}
}
}
&v로 순회하는 순간, 루프 전체 동안 v는 immutable borrow 상태가 됩니다. 그 상태에서 push는 mutable borrow가 필요하니 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(99);
}
}
읽기 단계(iter)와 쓰기 단계(push)를 분리합니다.
해결 2: 변경을 별도 버퍼에 모아 마지막에 반영
fn main() {
let mut v = vec![1, 2, 3];
let mut to_add = vec![];
for x in v.iter() {
if *x == 2 {
to_add.push(99);
}
}
v.extend(to_add);
}
이 패턴은 실무에서 “스트리밍 처리 중 결과 축적” 같은 상황에 특히 유용합니다.
패턴 3) 같은 컬렉션에서 두 개의 &mut를 동시에 얻기 (E0499)
서로 다른 인덱스를 수정하고 싶어서 아래처럼 쓰는 경우가 많습니다.
문제 코드
fn main() {
let mut v = vec![10, 20, 30];
let a = &mut v[0];
let b = &mut v[1];
*a += 1;
*b += 1;
}
사람 눈에는 인덱스가 다르니 안전해 보이지만, Rust는 일반적인 인덱싱만으로는 “서로 다른 위치”임을 증명할 수 없습니다. 그래서 E0499가 납니다.
해결 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]; // 원래 v[1]
*a += 1;
*b += 1;
}
split_at_mut는 표준 라이브러리가 “겹치지 않음”을 보장하는 API라 borrow checker가 허용합니다.
해결 2: 인덱스 기반으로 순차 처리(동시 보유를 피함)
fn main() {
let mut v = vec![10, 20, 30];
v[0] += 1;
v[1] += 1;
}
동시에 &mut를 들고 있을 필요가 없다면 가장 단순한 해결책입니다.
패턴 4) 메서드가 &mut self를 잡고 있는 동안 내부 필드를 또 빌리기 (E0499·E0502)
구조체 메서드에서 자주 터집니다. 특히 “필드 하나를 빌려서 작업하면서, 다른 필드를 수정”하려 할 때입니다.
문제 코드
struct App {
buf: Vec<String>,
cache: String,
}
impl App {
fn update(&mut self) {
let first = self.buf.first().unwrap();
self.cache = first.clone();
self.buf.push("new".to_string());
}
}
first는 self.buf에 대한 immutable borrow입니다. 그 상태에서 self.buf.push는 mutable borrow가 필요하니 E0502가 납니다.
해결 1: 필요한 값만 뽑아서 참조를 빨리 끝내기
struct App {
buf: Vec<String>,
cache: String,
}
impl App {
fn update(&mut self) {
let first_cloned = self.buf.first().cloned().unwrap();
self.cache = first_cloned;
self.buf.push("new".to_string());
}
}
cloned()로 소유권을 가져오면 참조가 남지 않아 이후 변경이 자유롭습니다.
해결 2: 필드 단위로 분해해서 borrow 범위를 명확히
struct App {
buf: Vec<String>,
cache: String,
}
impl App {
fn update(&mut self) {
let App { buf, cache } = self;
let first_cloned = buf.first().cloned().unwrap();
*cache = first_cloned;
buf.push("new".to_string());
}
}
self 전체가 아니라 필드별로 다루면, 코드 구조가 borrow checker 친화적으로 바뀝니다.
패턴 5) 참조를 반환하면서 동시에 내부를 변경하려는 설계 (E0502·E0499)
API 설계에서 가장 골치 아픈 패턴입니다. “내부에서 무언가를 업데이트한 뒤, 그 내부의 참조를 리턴”하려는 형태가 충돌을 만듭니다.
문제 코드
use std::collections::HashMap;
struct Store {
map: HashMap<String, String>,
}
impl Store {
fn get_or_insert_and_return_ref(&mut self, k: String) -> &String {
if !self.map.contains_key(&k) {
self.map.insert(k.clone(), "init".to_string());
}
self.map.get(&k).unwrap()
}
}
contains_key와 get는 immutable borrow를 만들고, insert는 mutable borrow가 필요합니다. 같은 함수 범위에서 섞이면서 충돌이 나기 쉽습니다.
해결 1: entry API로 한 번에 처리
use std::collections::HashMap;
struct Store {
map: HashMap<String, String>,
}
impl Store {
fn get_or_insert_and_return_ref(&mut self, k: String) -> &String {
self.map.entry(k).or_insert_with(|| "init".to_string())
}
}
entry는 내부적으로 borrow 규칙을 만족시키는 방식으로 구현되어 있어, 사용자 코드에서 borrow 충돌을 크게 줄여줍니다.
해결 2: 참조 대신 소유 값을 반환하도록 API 변경
use std::collections::HashMap;
struct Store {
map: HashMap<String, String>,
}
impl Store {
fn get_or_insert_value(&mut self, k: String) -> String {
self.map
.entry(k)
.or_insert_with(|| "init".to_string())
.clone()
}
}
성능보다 단순성이 중요한 경로(예: 설정 로딩, 작은 캐시)라면 “참조 반환”을 고집하지 않는 게 유지보수에 유리합니다.
디버깅 체크리스트: borrow checker를 이기는 6가지 습관
- 참조를 변수에 오래 저장하지 말고, 가능하면 바로 사용하고 끝내기
- 읽기(
iter,get)와 쓰기(push,insert)를 한 함수 안에서 섞지 말고 단계 분리 Copy가능한 값은 참조 대신 값으로 들고 다니기- 같은 컬렉션에서 두 요소를 동시에 수정해야 하면
split_at_mut같은 “안전 증명 API” 사용 - 구조체 메서드에서는
let App { ... } = self;로 필드 분해를 고려 HashMap은entry를 우선 검토(대부분의 borrow 충돌이 여기서 사라짐)
마무리: 에러 코드를 외우지 말고 “패턴”을 외우기
E0502와 E0499는 문법 지식보다 “코드 구조” 문제로 발생하는 경우가 대부분입니다. 참조를 오래 잡지 않고, 읽기와 쓰기를 분리하고, 동시에 들고 있는 &mut를 없애는 방향으로 리팩터링하면 해결됩니다.
비슷한 맥락에서, 복잡도가 올라갈수록 문제를 “패턴”으로 분류해 해결하는 습관이 중요합니다. 성능이나 구조 튜닝도 결국 같은 접근이 필요합니다. 예를 들어 시스템이 커지며 병목이 생길 때는 Next.js 14 RSC로 번들 커질 때 6가지 해결법처럼 원인 유형을 나눠 조치하는 글이 큰 도움이 됩니다.
다음 단계로는, 오늘 정리한 5패턴을 본인 코드베이스에서 실제로 검색해 보세요. push, insert, get, iter, first, split_at_mut, entry 같은 키워드 주변이 대부분의 발생 지점입니다. 이를 의식적으로 리팩터링하면 borrow checker는 “장벽”이 아니라 “설계 리뷰어”로 바뀝니다.