- Published on
Rust 소유권·빌림 - E0502 해결 패턴 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 레퍼런스를 안전하게 공존시키는 것이 Rust의 핵심이지만, 그만큼 자주 마주치는 컴파일 에러가 있습니다. 바로 E0502: cannot borrow ... as mutable because it is also borrowed as immutable 입니다. 한국어로 풀면 “불변으로 빌려둔 상태에서 같은 값을 가변으로 다시 빌릴 수 없다”는 뜻이죠.
이 글에서는 E0502가 나는 전형적인 상황을 먼저 이해한 뒤, 실무에서 바로 꺼내 쓸 수 있는 해결 패턴 7가지를 코드로 정리합니다. NLL(Non-Lexical Lifetimes)이 도입된 이후에도 여전히 E0502는 자주 등장하는데, 대부분은 **레퍼런스의 생존 범위를 좁히거나(스코프 분리), 데이터 흐름을 바꾸거나(값 복사/이동), 소유권 구조를 바꾸는 것(내부 가변성/분리 소유)**으로 해결됩니다.
관련해서 NLL 관점의 설명이 더 필요하면 다음 글도 함께 보면 좋습니다: Rust E0502, NLL로 빌림 충돌 풀기
E0502가 나는 전형적인 형태
가장 흔한 패턴은 아래처럼 “읽기용 불변 빌림을 잡아둔 채로” 같은 컨테이너를 “쓰기용 가변 빌림”하려는 경우입니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // 불변 빌림
v.push(4); // 가변 빌림 시도 (재할당/재배치 가능)
println!("{}", first);
}
Vec는 push 과정에서 capacity가 부족하면 재할당이 발생할 수 있고, 그 순간 기존 요소의 주소가 바뀔 수 있습니다. 그래서 Rust는 first 같은 레퍼런스가 살아 있는 동안 v를 가변으로 빌리는 것을 금지합니다.
이제부터는 이 문제를 푸는 “선택지”를 7가지로 나눠 설명합니다. 상황에 따라 정답은 달라집니다.
패턴 1) 스코프를 쪼개 레퍼런스 생존 범위 줄이기
가장 먼저 시도할 옵션입니다. 불변 빌림을 더 빨리 끝내면 가변 빌림이 가능해집니다.
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 first의 불변 빌림 종료
v.push(4); // 이제 OK
}
핵심은 “레퍼런스 변수를 가능한 한 짧게 유지”하는 것입니다. 특히 함수가 길어질수록, 중간에 let r = ...;로 레퍼런스를 저장해두는 습관이 E0502를 만들기 쉽습니다.
언제 유용한가
- 단순히 출력/검증 등 읽기 작업 후 바로 쓰기 작업을 해야 할 때
- 불변 참조를 오래 들고 있을 이유가 없을 때
패턴 2) 필요한 값만 복사하거나 clone해서 레퍼런스 제거
레퍼런스가 문제라면 레퍼런스를 만들지 않으면 됩니다. Copy 타입이면 값 복사가 가장 간단합니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{}", first);
}
String처럼 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);
}
트레이드오프
- 장점: 코드가 단순해지고 빌림 문제가 사라짐
- 단점:
clone은 비용이 들 수 있음(문자열/큰 구조체)
패턴 3) 인덱스/키만 저장하고, 가변 작업 후 다시 접근
레퍼런스 대신 “어디를 볼지”만 저장해두는 방법입니다. 특히 Vec에서 흔히 쓰입니다.
fn main() {
let mut v = vec![10, 20, 30];
let idx = 0usize;
v.push(40);
println!("{}", v[idx]);
}
이 패턴은 “가변 작업이 재할당을 일으켜도, 인덱스는 유효하다”는 점을 활용합니다. 다만 중간에 remove나 insert로 인덱스 의미가 바뀌면 위험해집니다.
언제 유용한가
- 읽고 싶은 위치가 고정되어 있고, 구조 변경이 인덱스를 깨지 않을 때
- 레퍼런스를 오래 들고 있을 필요가 없을 때
패턴 4) split_at_mut 등으로 “서로 다른 영역”임을 증명하기
E0502의 본질은 “같은 값에 대한 불변/가변 빌림 충돌”인데, 실제로는 서로 다른 요소를 다루는 경우가 많습니다. 이때 Rust에게 “겹치지 않는다”를 증명해주면 됩니다.
대표적으로 Vec는 split_at_mut를 제공합니다.
fn bump_two(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;
}
fn main() {
let mut v = vec![1, 2, 3, 4];
bump_two(&mut v, 0, 3);
println!("{:?}", v);
}
이 방식은 “한 번에 두 개의 &mut를 얻고 싶다” 같은 상황에서 특히 강력합니다.
언제 유용한가
- 같은 슬라이스/벡터의 서로 다른 요소를 동시에 수정해야 할 때
- 컴파일러가 aliasing 불가능함을 추론하지 못할 때
패턴 5) 연산 순서를 바꿔 ‘읽기 후 쓰기’로 정렬하기
의외로 많은 E0502는 “필요 없는 레퍼런스를 먼저 만들어서” 생깁니다. 즉, 쓰기 작업을 먼저 끝내고 그 다음 읽으면 해결됩니다.
fn main() {
let mut v = vec![1, 2, 3];
// 먼저 변경
v.push(4);
// 그 다음 읽기
let first = &v[0];
println!("{}", first);
}
실무에서는 다음 같은 형태가 많습니다.
- 변경 전에 상태 점검을 하려고 참조를 잡아둠
- 로그/메트릭을 찍으려고 참조를 잡아둠
이때는 “로그를 값으로 만들기” 혹은 “변경 이후에 로그 찍기”로 정리할 수 있습니다.
패턴 6) 소유권을 분리해 구조적으로 빌림 충돌을 없애기
E0502가 반복된다면 설계 신호일 수 있습니다. 한 구조체가 너무 많은 것을 품고 있거나, 한 컨테이너에 읽기/쓰기 대상이 섞여 있을 수 있습니다.
예를 들어, Vec 자체를 수정하면서 동시에 특정 요소를 참조해야 하는 경우라면 “요소를 빼서 작업한 뒤 다시 넣는” 식으로 소유권을 분리할 수 있습니다.
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
// 첫 요소를 소유권 이동으로 꺼냄
let mut first = std::mem::take(&mut v[0]);
// 이제 v는 자유롭게 변경 가능
v.push(String::from("c"));
// first를 수정
first.push('!');
// 다시 넣기
v[0] = first;
println!("{:?}", v);
}
std::mem::take는 자리에 기본값을 남기고 값을 “꺼내오는” 패턴입니다(여기서는 String의 기본값은 빈 문자열).
언제 유용한가
- 특정 필드를 길게 작업해야 해서 참조를 오래 잡아야 할 때
- 컬렉션 구조 변경과 요소 변경이 강하게 얽혀 있을 때
패턴 7) 내부 가변성(RefCell, Mutex, RwLock)로 런타임 빌림으로 전환
정적 빌림 규칙이 너무 빡빡해서 구조적으로 풀기 어렵다면, 내부 가변성을 고려할 수 있습니다. 이는 “컴파일 타임”이 아니라 “런타임”에 빌림 규칙을 검사합니다.
단일 스레드라면 RefCell이 대표적입니다.
use std::cell::RefCell;
fn main() {
let v = RefCell::new(vec![1, 2, 3]);
// 불변 빌림
{
let r = v.borrow();
println!("first={}", r[0]);
}
// 가변 빌림
v.borrow_mut().push(4);
println!("{:?}", v.borrow());
}
멀티스레드라면 Mutex/RwLock을 씁니다.
use std::sync::{Arc, Mutex};
fn main() {
let v = Arc::new(Mutex::new(vec![1, 2, 3]));
{
let guard = v.lock().unwrap();
println!("first={}", guard[0]);
}
v.lock().unwrap().push(4);
}
주의점
RefCell은 규칙을 어기면 런타임 패닉이 납니다Mutex/RwLock은 데드락/경합/성능 이슈가 생길 수 있습니다- 즉, “최후의 선택지”로 두고, 먼저 스코프/구조 리팩터링을 시도하는 편이 좋습니다
E0502 디버깅 체크리스트
- 레퍼런스를 변수에 저장해 생존 범위를 불필요하게 늘리고 있지 않은가
- 읽기 참조가 꼭 레퍼런스여야 하는가(값 복사/
clone가능 여부) - 인덱스/키만 들고 있다가 나중에 다시 접근할 수 있는가
- 동일 컨테이너의 서로 다른 영역이라면
split_at_mut같은 API로 분할 가능한가 - 연산 순서를 바꿔서 쓰기를 먼저 끝낼 수 있는가
- 소유권을 분리(
take,replace,drain,swap_remove등)하는 편이 더 자연스러운가 - 정말로 내부 가변성이 필요한가(단일/멀티 스레드 구분)
마무리: “레퍼런스를 오래 잡지 말라”가 핵심
E0502는 소유권/빌림 모델을 제대로 이해하고 있다는 신호이기도 합니다. 중요한 건 규칙을 외우는 것이 아니라, 레퍼런스의 생존 범위를 최소화하고, 필요하면 값/인덱스/소유권 이동으로 데이터 흐름을 재구성하는 습관입니다.
실제로 Rust에서 성능과 안정성을 함께 잡는 코드는, 대개 “짧게 빌리고 빨리 놓는” 구조를 갖습니다. E0502를 만날 때마다 위 7가지 패턴을 체크리스트처럼 대입해보면, 대부분의 케이스는 깔끔하게 정리됩니다.