- Published on
Rust E0502 소유권 충돌 6패턴 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Rust를 쓰다 보면 가장 자주 마주치는 에러 중 하나가 E0502 입니다. 메시지는 대개 다음 형태로 나타납니다.
cannot borrow ... as mutable because it is also borrowed as immutable- 또는 반대로, 가변 대여가 잡힌 상태에서 불변 대여를 시도했다는 내용
핵심은 간단합니다.
- 어떤 값에 대해 불변 대여(
&T)가 살아있는 동안 같은 값을 가변 대여(&mut T) 할 수 없습니다. - 반대로 가변 대여가 살아있는 동안 같은 값에 대한 다른 대여(불변/가변 모두)도 불가능합니다.
문제는 이 규칙이 단순해 보여도, 실제 코드에서는 반복문, 인덱싱, 클로저, 메서드 체이닝 같은 “표현식의 수명” 때문에 의도치 않게 충돌이 생긴다는 점입니다. 이 글에서는 E0502를 유발하는 대표 6패턴과, 실무에서 가장 많이 쓰는 해결책을 코드로 정리합니다.
참고로, 같은 “6가지 해법” 형식으로 문제를 쪼개 해결하는 접근은 다른 언어에서도 유효합니다. 예를 들어 Java에서 groupBy 시 NPE를 패턴별로 잡는 방식이 비슷합니다: Java Stream groupBy NPE 6가지 해법
E0502 빠르게 진단하는 체크리스트
아래 중 하나라도 해당하면 E0502가 날 확률이 높습니다.
- 한 줄에서
get같은 불변 접근과push같은 가변 변경을 같이 함 let x = &v[i]; v.push(...)처럼 참조를 잡아둔 뒤 컬렉션 변경iter()로 돌면서 같은 컬렉션을 수정self.foo()로 불변 대여된 상태에서self.bar_mut()호출- 클로저가 외부 참조를 캡처한 채로 내부에서 변경
- 인덱스 두 개로 같은
Vec원소를 동시에 가변 참조
이제 패턴별로 해결해보겠습니다.
패턴 1) 참조를 오래 잡아두고 나중에 수정
가장 흔한 형태입니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
// 여기서 first가 살아있음
v.push(4); // E0502 가능
println!("{}", first);
}
해결 1: 값 복사 또는 소유로 가져오기
Copy 타입이면 가장 간단합니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{}", first);
}
String 같은 비-Copy 타입이면 clone 또는 to_owned를 고려합니다.
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
let first = v[0].clone();
v.push(String::from("c"));
println!("{}", first);
}
해결 2: 참조 스코프를 줄이기
불변 참조가 필요한 구간을 블록으로 감싸 수명을 끊습니다.
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 first drop
v.push(4);
}
패턴 2) iter()로 순회하면서 같은 컬렉션 수정
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() {
if *x == 2 {
v.push(99); // E0502
}
}
}
iter()는 v를 불변 대여합니다. 그 상태에서 push는 가변 대여가 필요하니 충돌합니다.
해결 1: 2단계로 나누기(읽기 단계, 쓰기 단계)
fn main() {
let mut v = vec![1, 2, 3];
let should_push = v.iter().any(|x| *x == 2);
if should_push {
v.push(99);
}
}
해결 2: 인덱스로 순회하고 변경은 별도 수행
다만 push로 길이가 바뀌면 인덱스 루프가 위험해질 수 있으니, 보통은 “추가할 것들을 모아두고 마지막에 append”가 안전합니다.
fn main() {
let mut v = vec![1, 2, 3];
let mut to_add = Vec::new();
for x in v.iter() {
if *x == 2 {
to_add.push(99);
}
}
v.extend(to_add);
}
패턴 3) 한 표현식에서 불변 접근과 가변 접근이 섞임
예를 들어, 아래는 “읽고 나서 그 값을 기반으로 수정”을 한 줄에 쓰려다 터집니다.
fn main() {
let mut v = vec![10, 20, 30];
// v.last()가 불변 대여를 만들고,
// 그 대여가 표현식 끝까지 살아있다고 판단되면 충돌
if v.last().is_some() {
v.push(40); // E0502가 나는 케이스가 있음
}
}
해결: 중간 변수로 끊어서 수명 단축
fn main() {
let mut v = vec![10, 20, 30];
let has_last = v.last().is_some();
if has_last {
v.push(40);
}
}
이 테크닉은 “메서드 체이닝이 길어질수록 임시 참조가 생각보다 오래 산다”는 Rust의 수명 추론 특성과 잘 맞습니다.
패턴 4) 같은 구조체에서 self 불변 메서드와 가변 메서드가 충돌
다음은 실제 서비스 코드에서 흔합니다.
struct Store {
items: Vec<i32>,
}
impl Store {
fn first(&self) -> Option<&i32> {
self.items.first()
}
fn add(&mut self, x: i32) {
self.items.push(x)
}
fn do_work(&mut self) {
let f = self.first();
// f가 살아있는 동안 self를 &mut로 쓰려 하니 충돌
self.add(10); // E0502
println!("{:?}", f);
}
}
해결 1: 필요한 값만 복사하거나 소유로 만들기
impl Store {
fn do_work(&mut self) {
let f = self.items.first().copied();
self.add(10);
println!("{:?}", f);
}
}
해결 2: 필드 단위로 분리 대여되게 구조를 바꾸기
서로 다른 필드를 동시에 빌리는 건 가능하지만, 같은 필드를 빌리면 안 됩니다. 따라서 “읽기 전용 캐시”와 “쓰기 대상”을 분리하면 해결되는 경우가 많습니다.
struct Store {
cache: Option<i32>,
items: Vec<i32>,
}
impl Store {
fn do_work(&mut self) {
self.cache = self.items.first().copied();
self.items.push(10);
}
}
패턴 5) 인덱스로 같은 컬렉션의 두 원소를 동시에 가변 참조
정렬, 스왑, 그래프/배열 알고리즘에서 자주 나옵니다.
fn main() {
let mut v = vec![1, 2, 3, 4];
let a = &mut v[1];
let b = &mut v[2]; // E0502 또는 E0499 계열로 막힘
*a += *b;
}
Rust는 v[1]과 v[2]가 다르다는 것을 “일반적인 인덱싱”만으로는 안전하게 증명하지 못합니다.
해결: split_at_mut로 슬라이스를 쪼개서 증명
fn main() {
let mut v = vec![1, 2, 3, 4];
let (left, right) = v.split_at_mut(2);
let a = &mut left[1];
let b = &mut right[0]; // 원래 v[2]
*a += *b;
}
이 패턴은 “서로 겹치지 않는 두 구간”을 타입 시스템에 알려주는 정석입니다.
패턴 6) HashMap에서 값 참조를 잡은 채로 다시 수정
HashMap::get으로 값을 빌리고, 같은 맵에 insert나 entry를 쓰려다 E0502가 납니다.
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");
// v가 살아있는 동안 m을 가변으로 쓰기
m.insert("b".to_string(), 2); // E0502
println!("{:?}", v);
}
해결 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);
}
해결 2: entry API로 한 번에 처리
읽기와 쓰기를 분리하지 말고, 애초에 “수정 의도”를 entry로 표현하면 대여 충돌이 사라집니다.
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;
}
실무 팁으로는, 카운팅/집계는 get 후 insert로 쪼개기보다 entry가 거의 항상 더 깔끔합니다.
자주 쓰는 리팩토링 레시피 요약
E0502를 “컴파일러와 싸우는 문제”로 보지 말고, 코드의 의도를 더 명확히 만드는 신호로 보면 해결이 빨라집니다.
- 참조를 오래 들고 있지 말고, 필요한 값만
copied()또는clone() - 불변 단계와 가변 단계를 2단계로 분리
- 체이닝 결과를
let tmp = ...로 받아 수명 단축 - 동일 컬렉션의 2개 가변 원소는
split_at_mut로 증명 HashMap은entry로 읽기+쓰기 의도를 묶기
이런 “패턴 분해 후 해결” 방식은 성능 튜닝에서도 유사합니다. 예를 들어 빌드 캐시가 안 먹는 원인을 케이스로 쪼개는 접근은 다음 글과 결이 같습니다: Docker 빌드 캐시가 안 먹을 때 - BuildKit 원인 8가지
마무리: E0502를 줄이는 설계 습관
E0502는 대개 다음 중 하나를 요구합니다.
- 데이터의 “읽기”와 “쓰기” 타이밍을 분리하라
- 참조 대신 값(복사/클론)을 들고 다녀라
- 자료구조 접근을 더 원자적으로 표현하라(
entry,split_at_mut)
처음에는 불편하지만, 이 규칙 덕분에 런타임에서 디버깅하기 어려운 데이터 레이스/유즈-애프터-프리 류의 버그가 구조적으로 사라집니다. E0502를 만날 때마다 위 6패턴 중 어디에 속하는지 먼저 분류하고, 해당 해법을 적용하면 대부분의 케이스는 빠르게 정리됩니다.