- Published on
Rust borrow checker 오류 E0502·E0499 끝내기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Rust를 쓰다 보면 생산성을 가장 크게 흔드는 순간이 있습니다. 코드가 논리적으로는 맞아 보이는데 컴파일러가 단호하게 막아서는 때입니다. 특히 E0502(불변 대여 중 가변 대여)와 E0499(가변 대여의 중복)는 초반 러닝커브의 대부분을 차지할 정도로 자주 등장합니다.
이 글은 “에러 메시지를 해석하는 법”을 넘어, 같은 문제가 다시는 나오지 않도록 코드를 구조적으로 바꾸는 패턴을 정리합니다. 핵심은 단순합니다.
- 불변 참조(
&T)와 가변 참조(&mut T)의 동시 존재를 제거한다 - 가변 참조는 동시에 하나만 존재하도록 스코프를 쪼갠다
- 참조를 오래 잡지 말고, 값을 복사하거나 인덱스/키만 들고 있다가 나중에 접근한다
- 필요하면 데이터 구조를 분리하거나 내부 가변성으로 설계를 전환한다
아래에서 E0502와 E0499를 각각 “원인 패턴”과 “해결 레시피”로 끝냅니다.
E0502: immutable borrow 중 mutable borrow
에러가 의미하는 것
E0502는 대체로 다음 상황입니다.
- 어떤 값
x에 대해 불변 참조를 만든 뒤(let r = &x;) - 그 불변 참조가 아직 살아있는 동안
- 같은
x에 대해 가변 참조를 만들거나, 가변 접근을 시도함
Rust는 “읽는 동안에는 쓰지 마라”를 컴파일 타임에 강제합니다. 문제는 개발자가 “이미 다 읽었는데요?”라고 생각해도, 컴파일러는 참조가 스코프에 남아 있으면 살아 있다고 판단할 수 있다는 점입니다.
대표 예제: 읽은 뒤 바로 수정하려다 실패
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
}
이 코드는 first가 v를 불변 대여한 상태에서 push가 v를 가변 대여하려 해서 E0502가 납니다. 특히 Vec는 push 시 재할당이 일어날 수 있어, 기존 참조가 무효화될 위험이 있으므로 Rust가 막습니다.
해결 1: 불변 참조를 더 짧게(스코프 축소)
가장 먼저 시도할 것은 “참조가 살아있는 범위를 줄이는 것”입니다.
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 first 드롭
v.push(4);
}
블록을 만들어 참조를 먼저 끝내면, 그 다음 가변 접근이 가능해집니다.
해결 2: 값 복사 또는 clone으로 참조를 없애기
원소가 Copy라면 참조 대신 값을 복사해오면 됩니다.
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);
}
해결 3: 인덱스나 키만 저장하고 나중에 접근
“참조를 저장”하지 말고, “어디를 가리킬지”만 저장하는 방식입니다.
fn main() {
let mut v = vec![10, 20, 30];
let idx = 0usize;
v.push(40);
println!("{}", v[idx]);
}
이 방식은 특히 Vec 재할당 문제를 회피합니다. 다만 인덱스가 유효한지(삭제/삽입으로 바뀌지 않는지)는 설계로 보장해야 합니다.
해결 4: 읽기와 쓰기 단계를 분리(두 단계로 설계)
실무에서 자주 나오는 패턴이 “스캔하면서 수정”입니다. Rust에서는 보통 다음처럼 단계 분리를 합니다.
- 1단계: 필요한 정보(인덱스 목록, 키 목록, 계산 결과)를 불변 접근으로 수집
- 2단계: 수집한 정보로 가변 접근 수행
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let to_double: Vec<usize> = v
.iter()
.enumerate()
.filter_map(|(i, &x)| if x % 2 == 0 { Some(i) } else { None })
.collect();
for i in to_double {
v[i] *= 2;
}
println!("{:?}", v);
}
이 방식은 borrow checker를 “피하는 꼼수”가 아니라, 동시성 관점에서도 더 안전한 구조입니다.
E0499: cannot borrow as mutable more than once
에러가 의미하는 것
E0499는 “가변 참조는 동시에 하나만”이라는 규칙을 위반했을 때 발생합니다.
- 이미
&mut x가 존재하는데 - 같은
x에 대해 다른&mut x를 만들거나 - 첫 번째 가변 참조가 살아있는 동안 다시 가변 접근을 시도
대표 예제: 같은 Vec에서 두 원소를 동시에 &mut로
fn main() {
let mut v = vec![1, 2, 3];
let a = &mut v[0];
let b = &mut v[1];
*a += 10;
*b += 10;
}
사람 눈에는 인덱스가 다르니 안전해 보이지만, Rust는 일반적인 인덱싱(v[i])만으로는 “서로 다른 원소”임을 증명하지 못합니다.
해결 1: split_at_mut로 서로 다른 슬라이스를 증명
표준 라이브러리는 이 문제를 해결하는 API를 제공합니다.
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 += 10;
*b += 10;
println!("{:?}", v);
}
split_at_mut는 내부적으로 “두 슬라이스가 겹치지 않는다”를 보장하므로 borrow checker가 허용합니다.
해결 2: 작업을 순차화해서 가변 참조 수명을 줄이기
동시에 두 개의 &mut가 꼭 필요하지 않다면, 한 번에 하나씩만 잡도록 구조를 바꿉니다.
fn main() {
let mut v = vec![1, 2, 3];
{
let a = &mut v[0];
*a += 10;
} // a 드롭
{
let b = &mut v[1];
*b += 10;
}
println!("{:?}", v);
}
이 패턴은 가장 단순하지만, “참조를 변수로 오래 들고 있는 코드”를 줄이는 데 효과가 큽니다.
해결 3: HashMap에서 두 엔트리를 동시에 바꾸기
HashMap에서도 비슷한 문제가 자주 나옵니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
m.insert("b".into(), 2);
let ra = m.get_mut("a").unwrap();
let rb = m.get_mut("b").unwrap();
*ra += *rb;
}
이 코드는 첫 번째 get_mut로 m을 가변 대여한 상태가 유지되기 때문에 두 번째 get_mut에서 E0499가 납니다.
해결은 여러 가지가 있습니다.
해결 3-1: 값을 먼저 복사하고 나중에 가변 접근
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
m.insert("b".into(), 2);
let b_val = *m.get("b").unwrap();
let a = m.get_mut("a").unwrap();
*a += b_val;
}
불변 접근으로 필요한 값을 가져온 뒤 가변 접근을 수행하면, 가변 대여를 하나만 유지할 수 있습니다.
해결 3-2: remove 후 재삽입(소유권 이동)
키 충돌이나 복잡한 갱신 로직에서는 소유권을 꺼내서 처리하는 방식이 깔끔할 때가 많습니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
m.insert("b".into(), 2);
let b = m.remove("b").unwrap();
*m.get_mut("a").unwrap() += b;
m.insert("b".into(), 2); // 필요하면 복원
}
해결 4: 내부 가변성(RefCell, Mutex)로 설계 전환
참조 규칙을 “런타임 체크”로 옮겨야 하는 경우도 있습니다.
- 단일 스레드에서 공유 구조를 여기저기서 수정해야 한다
- 그래프/트리에서 부모와 자식이 서로를 가리키는 등, 정적 borrow 규칙으로 표현이 어렵다
이때 RefCell을 쓰면 컴파일은 통과하지만, 규칙 위반 시 런타임 패닉이 날 수 있습니다.
use std::cell::RefCell;
fn main() {
let v = RefCell::new(vec![1, 2, 3]);
{
let mut borrow = v.borrow_mut();
borrow.push(4);
}
println!("{:?}", v.borrow());
}
멀티스레드라면 Mutex나 RwLock로 바꿔야 합니다. 다만 내부 가변성은 “편의 기능”이 아니라 “설계 선택”이므로, 가능한 한 먼저 스코프 축소나 단계 분리로 해결하는 것을 권합니다.
에러 메시지 읽는 요령: 핵심은 lifetime 범위
E0502와 E0499는 결국 “참조가 언제까지 살아있다고 컴파일러가 판단하는가”의 문제입니다. 다음을 습관처럼 확인하면 디버깅 속도가 빨라집니다.
- 참조 변수가 선언된 줄과 마지막 사용 지점이 어디인지
println!같은 로그 출력이 참조 수명을 의도치 않게 늘리고 있지 않은지- 반복문에서 참조를 바깥 변수에 저장해 수명을 늘리고 있지 않은지
- 클로저가 참조를 캡처해 수명이 길어지지 않았는지
이 관점은 프론트엔드에서 “정리되지 않은 리소스가 경고를 만든다”는 식의 문제 해결과도 닮아 있습니다. 예를 들어 React에서 이펙트 정리를 제대로 하면 누수가 사라지듯, Rust에서도 “참조의 수명 정리”를 하면 에러가 사라집니다. 관련해서는 React 메모리 누수 경고 해결 - useEffect 클린업 글의 사고방식이 의외로 도움이 됩니다.
실전 체크리스트: E0502·E0499를 재발 방지하는 설계
1) 참조를 상태로 저장하지 말고, 값이나 키를 저장하라
&T를 구조체 필드로 들고 다니기 시작하면 대부분 난이도가 급상승합니다.- 가능하면
T를 소유하거나,usize인덱스,String키 같은 식별자를 저장하고 필요할 때 조회하세요.
2) “스캔”과 “변경”을 분리하라
- 1패스에서 동시에 읽고 쓰면 borrow 충돌이 자주 납니다.
- 2패스로 나누면 코드가 약간 길어져도 안정성이 올라갑니다.
3) 컬렉션은 전용 API를 외워두면 반은 해결된다
Vec:split_at_mut,swap_remove,drain,retainHashMap:entry,and_modify,or_insert, 필요 시remove후 처리
예를 들어 entry는 “조회 후 없으면 생성, 있으면 수정”을 한 번의 가변 대여로 끝내도록 설계된 API입니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
for key in ["a", "b", "a"] {
m.entry(key.to_string())
.and_modify(|v| *v += 1)
.or_insert(1);
}
println!("{:?}", m);
}
4) 성능 최적화는 마지막에 하라
초반에는 clone이나 단계 분리로 명확하게 만든 뒤, 병목이 확인되면 그때 참조 기반 최적화를 시도하는 편이 전체 개발 속도가 빠릅니다. 이는 재시도와 백오프를 “일단 안전하게” 설계하고, 이후 튜닝하는 접근과도 비슷합니다. 관련 사고는 OpenAI 429·insufficient_quota 재시도와 백오프 설계 글의 단계적 설계 방식과 연결됩니다.
결론
E0502와 E0499는 Rust가 까다로워서가 아니라, Rust가 “동시에 읽고 쓰는 위험”과 “동시에 두 군데에서 쓰는 위험”을 컴파일 타임에 제거하기 때문에 생깁니다. 해결의 핵심은 문법 트릭이 아니라 구조입니다.
- 참조 수명을 짧게 만들기
- 참조 대신 값 복사, 키 저장 같은 간접화 사용
- 읽기와 쓰기를 단계로 분리
- 컬렉션 전용 API로 안전한 비중첩 가변 접근 만들기
- 정말 필요할 때만 내부 가변성으로 전환
이 패턴들을 익히면 borrow checker는 장애물이 아니라, 코드의 결함을 조기에 드러내주는 강력한 설계 검증기로 바뀝니다.