- Published on
Rust E0502 빌림 충돌 5가지 패턴 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust의 E0502는 "불변으로 빌린 상태에서 가변으로 빌릴 수 없다" 또는 그 반대 상황에서 발생하는 대표적인 빌림 검사기 에러입니다. 특히 Vec, HashMap, 슬라이스, 반복자와 함께 코드를 작성할 때 자주 튀어나오며, 초반에는 "왜 이게 동시에 빌린 거지?"라는 느낌을 줍니다.
이 글은 E0502를 유발하는 전형적인 5가지 패턴을 묶어서, 원인 구조를 이해하고 바로 적용 가능한 해결법을 코드로 정리합니다. 핵심은 대부분 동일합니다.
- 불변 빌림의 수명(스코프)을 줄이기
- 참조를 오래 들고 있지 말고 값을 복사하거나 소유권을 이동
- 컬렉션을 동시에 읽고 쓰고 싶다면 인덱스 기반, 분할 빌림, 단계 분리
추가로, 문제를 "진단하고 재현되는 최소 코드로 줄이는" 접근은 다른 장애 분석에도 유효합니다. 예를 들어 DB 잠금 문제를 원인별로 쪼개는 방식은 MySQL 8.0 MDL 잠금 대기·교착 진단과 해결 같은 글에서 보듯이, 결국 스코프와 경합 구간을 줄이는 방향으로 귀결됩니다.
E0502 한 줄 정의와 발생 조건
에러 메시지는 보통 다음 형태입니다.
cannot borrow ... as mutable because it is also borrowed as immutable- 또는
cannot borrow ... as immutable because it is also borrowed as mutable
Rust 규칙은 단순합니다.
- 어떤 값에 대해 불변 참조는 여러 개 가능
- 가변 참조는 오직 하나만 가능
- 그리고 가변 참조가 살아있는 동안 불변 참조는 공존 불가
문제는 "살아있다"의 기준이 우리가 생각한 라인 단위가 아니라, 참조가 마지막으로 사용되는 지점까지(Non-Lexical Lifetimes, NLL) 이어진다는 점입니다.
이제 실제로 자주 부딪히는 5가지 패턴으로 들어가겠습니다.
패턴 1) &v[i]를 잡아둔 채 v.push() 하기
문제 코드
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{first}");
}
first가 v를 불변으로 빌린 상태에서 push는 v를 가변으로 빌립니다. 더 근본적으로는 push가 리어로케이션을 일으키면 first가 가리키는 메모리가 무효가 될 수 있으므로 Rust가 막습니다.
해결 1: 참조 대신 값을 복사하기(Copy 타입)
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0];
v.push(4);
println!("{first}");
}
정수처럼 Copy인 타입이면 가장 간단합니다.
해결 2: 불변 빌림 스코프를 줄이기
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{first}");
}
v.push(4);
}
참조를 쓰는 목적이 단순 출력/검증이라면 이 방식이 깔끔합니다.
해결 3: 미리 용량 확보하기(reserve)
reserve는 리어로케이션 가능성을 낮추지만, 빌림 규칙 자체를 바꾸진 않습니다. 따라서 참조를 들고 있는 동안 push는 여전히 불가합니다. 다만 구조를 바꾸기 어렵고 성능까지 고려한다면 reserve는 함께 고려할 가치가 있습니다.
패턴 2) iter()로 순회하면서 같은 컬렉션을 수정하기
문제 코드
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() {
if *x % 2 == 1 {
v.push(*x);
}
}
}
v.iter()가 v 전체를 불변으로 빌린 상태에서 루프 내부 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);
}
읽기와 쓰기를 분리하면 빌림이 겹치지 않습니다. 데이터 파이프라인을 2단계로 나누는 접근은, 예를 들어 API 재시도 백오프 설계에서 "관측"과 "행동"을 분리하는 것과 유사한 사고 방식입니다. 관련해서는 OpenAI API 429·Rate Limit 재시도 백오프 설계처럼 단계별로 책임을 나누는 설계가 도움이 됩니다.
해결 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);
}
}
}
이 방식은 "원래 요소만" 순회하도록 original_len을 고정해야 안전합니다.
해결 3: 제자리 변형이면 iter_mut() 사용
"읽으면서 수정"이 아니라 "각 요소를 수정"이라면 iter_mut()이 정답입니다.
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter_mut() {
*x *= 10;
}
}
패턴 3) 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");
m.insert("b".to_string(), 2);
println!("{:?}", v);
}
get이 m을 불변으로 빌린 참조를 반환하고, 그 참조가 살아있는 동안 insert는 m을 가변으로 빌리므로 충돌합니다. 또한 insert가 리해시를 유발하면 기존 참조가 무효가 될 수 있습니다.
해결 1: 값 복사/복제해서 참조 수명 단축
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").copied();
m.insert("b".to_string(), 2);
println!("{:?}", v);
}
i32는 Copy라 copied()로 간단히 해결됩니다.
해결 2: entry API로 한 번에 처리
"있으면 수정, 없으면 삽입" 류는 entry가 가장 Rust스럽습니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
*m.entry("a".to_string()).or_insert(0) += 1;
*m.entry("b".to_string()).or_insert(0) += 2;
}
entry는 내부적으로 필요한 빌림을 안전하게 조율해주므로 E0502를 피하기 좋습니다.
패턴 4) 슬라이스에서 한 원소 참조와 다른 원소 가변 참조를 동시에 얻기
문제 코드
fn main() {
let mut a = [10, 20, 30];
let x = &a[0];
let y = &mut a[1];
*y += *x;
}
사람 입장에서는 인덱스가 다르니 안전해 보이지만, Rust는 일반 인덱싱으로는 "서로 다른 위치"임을 증명할 수 없어서 전체 배열을 빌린 것으로 취급합니다.
해결 1: split_at_mut로 안전하게 분할 빌림
fn main() {
let mut a = [10, 20, 30];
let (left, right) = a.split_at_mut(1);
let x = left[0];
let y = &mut right[0];
*y += x;
}
핵심은 split_at_mut이 "서로 겹치지 않는 두 슬라이스"를 만들어준다는 점입니다.
해결 2: 읽는 값은 복사해서 들고 오기
fn main() {
let mut a = [10, 20, 30];
let x = a[0];
a[1] += x;
}
가능하면 이게 가장 단순합니다.
패턴 5) 메서드 체인/클로저가 빌림을 예상보다 오래 끌고 가는 경우
NLL 덕분에 많은 경우 "마지막 사용 지점"에서 빌림이 끝나지만, 클로저가 참조를 캡처하거나, 체인된 호출이 참조를 반환하면 빌림이 길어져 E0502가 발생하기 쉽습니다.
문제 코드: 클로저가 &mut self와 충돌
struct App {
buf: Vec<i32>,
}
impl App {
fn first_ref(&self) -> Option<&i32> {
self.buf.first()
}
fn push_value(&mut self, x: i32) {
self.buf.push(x);
}
fn run(&mut self) {
let f = self.first_ref();
self.push_value(10);
println!("{:?}", f);
}
}
fn main() {
let mut app = App { buf: vec![1, 2, 3] };
app.run();
}
first_ref가 self를 불변으로 빌린 참조를 반환했고, 그 참조 f가 살아있는 동안 push_value가 &mut self를 요구해 충돌합니다.
해결 1: 필요한 값만 뽑아 소유/복사하기
struct App {
buf: Vec<i32>,
}
impl App {
fn run(&mut self) {
let f = self.buf.first().copied();
self.buf.push(10);
println!("{:?}", f);
}
}
fn main() {
let mut app = App { buf: vec![1, 2, 3] };
app.run();
}
Option<&i32> 대신 Option<i32>로 바꿔 빌림을 끝냅니다.
해결 2: 연산 순서를 바꾸고 스코프를 강제로 종료
struct App {
buf: Vec<i32>,
}
impl App {
fn run(&mut self) {
{
let f = self.buf.first();
println!("{:?}", f);
}
self.buf.push(10);
}
}
fn main() {
let mut app = App { buf: vec![1, 2, 3] };
app.run();
}
"출력 후 수정"이 요구사항에 맞는 경우에만 적용하세요.
해결 3: mem::take로 컬렉션을 잠시 빼서 작업하기
"읽기 단계에서 참조를 오래 들고 있어야" 하고, 동시에 self를 수정해야 한다면, 컬렉션을 통째로 꺼내서(소유권 이동) 작업한 뒤 다시 넣는 패턴이 유용합니다.
use std::mem;
struct App {
buf: Vec<i32>,
}
impl App {
fn run(&mut self) {
let mut buf = mem::take(&mut self.buf);
let first = buf.first().copied();
buf.push(10);
self.buf = buf;
println!("{:?}", first);
}
}
fn main() {
let mut app = App { buf: vec![1, 2, 3] };
app.run();
}
이 방식은 "한동안 self 내부의 특정 필드에 대한 빌림/참조를 깔끔히 끊고" 로직을 작성할 수 있게 해줍니다. 다만 큰 버퍼를 자주 take하면 구조적으로 복잡해질 수 있으니, 필요할 때만 쓰는 게 좋습니다.
E0502를 빨리 푸는 체크리스트
- 참조(
&T,&mut T)를 변수에 담아 오래 들고 있지 않은가 - 컬렉션을 순회하면서 같은 컬렉션을 수정하고 있지 않은가
HashMap에서get결과 참조를 들고insert/remove를 하지 않는가- 인덱스가 다르다는 이유로 동시에 참조를 만들었는데, Rust가 증명할 수 없는 형태가 아닌가
- 클로저/이터레이터 체인이 참조를 캡처하거나 반환해 빌림 수명이 길어진 것은 아닌가
이런 식으로 원인을 패턴으로 분류해두면, 타입 가드가 기대대로 좁혀지지 않는 상황을 원인별로 분해하는 것처럼 문제 해결 속도가 빨라집니다. 비슷한 접근 예시는 TS 5.5에서 is로 narrowing 안 될 때 7가지도 참고할 만합니다.
마무리
E0502는 "Rust가 까다롭다"의 상징처럼 보이지만, 실제로는 잠재적인 유즈-애프터-프리, 데이터 레이스, 리어로케이션 무효 참조를 컴파일 타임에 차단하는 장치입니다.
정리하면, 해결의 대부분은 다음 3가지로 수렴합니다.
- 참조 대신 값(
Copy/Clone)을 들고 와서 빌림을 끝내기 - 읽기와 쓰기를 단계로 분리하기(중간 버퍼,
extend,collect) - Rust가 안전을 증명할 수 있는 API 사용하기(
entry,split_at_mut,iter_mut)
다음에 E0502가 나오면, 당황하기보다 "내 참조가 어디까지 살아있지?"를 먼저 확인하고, 위 5가지 패턴 중 어디에 속하는지부터 매칭해보면 해결이 빨라집니다.