- Published on
Rust 소유권 에러 E0502 한방에 고치는 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 가장 자주 마주치는 컴파일 에러 중 하나가 E0502 입니다. 메시지는 대개 이렇게 생겼죠.
cannot borrow ... as mutable because it is also borrowed as immutable
의미는 단순합니다. 어떤 값에 대해 불변 대여(&T)가 살아있는 동안, 같은 값을 가변 대여(&mut T)로 빌릴 수 없다는 규칙 위반입니다.
하지만 실전에서는 “난 분명히 읽고 나서 쓰는 건데?” 같은 억울한 케이스가 많습니다. 이 글에서는 E0502를 한방에 해결하는 데 도움이 되는 대표 패턴들을 모아, “왜 발생했는지”보다 “어떻게 고칠지”에 집중합니다.
참고로 이런 류의 문제는 성능 최적화에서도 자주 등장합니다. 예를 들어 병렬 처리나 캐시 설계처럼, 한 번의 접근을 어떻게 구조화하느냐가 전체 구조를 바꾸기도 합니다. 관련 사고방식은 Java Stream 병렬화가 느린 6가지 이유와 해결 같은 글에서도 비슷하게 적용됩니다.
E0502를 빠르게 이해하는 핵심: “대여 수명”
Rust 컴파일러는 “참조가 언제까지 살아있는지”를 기준으로 충돌을 판단합니다.
- 불변 참조(
&T)가 살아있는 구간에는 - 같은 대상에 대한 가변 참조(
&mut T)를 만들 수 없습니다.
따라서 해결의 본질은 보통 둘 중 하나입니다.
- 불변 참조의 수명을 더 짧게 만들기
- 읽기와 쓰기 경로를 구조적으로 분리하기
이제 패턴별로 바로 적용 가능한 해결책을 보겠습니다.
패턴 1) “값을 미리 복사/추출해서” 대여 수명 끊기
가장 흔한 실수는 불변 참조를 잡아둔 채로, 같은 컬렉션을 수정하려는 경우입니다.
문제 코드
fn push_if_long(v: &mut Vec<String>) {
let first = &v[0]; // 불변 대여
if first.len() > 10 {
v.push("extra".to_string()); // 가변 대여 시도 -> E0502
}
}
first가 v를 불변으로 빌린 상태라서, push가 필요한 가변 대여가 막힙니다.
해결: 필요한 값만 미리 “소유”로 빼기
fn push_if_long(v: &mut Vec<String>) {
let first_len = v[0].len(); // 길이만 복사(정수는 Copy)
if first_len > 10 {
v.push("extra".to_string());
}
}
usize는Copy라서 참조를 오래 잡지 않습니다.- “읽기” 단계에서 필요한 최소 정보만 뽑아두면, 이후 “쓰기”가 자연스럽게 됩니다.
변형: clone 이 필요한 경우
값이 Copy가 아니라면 clone으로 소유권을 가져오는 것도 실무에서 흔한 선택입니다.
fn push_if_starts_with_a(v: &mut Vec<String>) {
let first = v[0].clone();
if first.starts_with('A') {
v.push("extra".to_string());
}
}
clone 비용이 민감하다면, 뒤에서 소개할 패턴(스코프 축소, 인덱스 분리, split_at_mut)을 우선 고려하세요.
패턴 2) 스코프를 줄여서 참조를 “빨리 죽이기”
Rust는 NLL(Non-Lexical Lifetimes)로 참조 수명을 꽤 똑똑하게 줄여주지만, 여전히 코드 구조에 따라 참조가 길게 살아남는 형태가 있습니다.
문제 코드
fn update(v: &mut Vec<i32>) {
let x = &v[0];
// 여기서 x를 쓴 뒤
println!("{}", x);
// 수정하고 싶은데
v.push(1); // E0502가 날 수 있는 전형적인 구조
}
해결: 블록으로 수명을 명시적으로 제한
fn update(v: &mut Vec<i32>) {
{
let x = &v[0];
println!("{}", x);
} // 여기서 x drop
v.push(1);
}
이 패턴은 “읽기”와 “쓰기” 사이에 명확한 경계를 만들어줍니다.
패턴 3) iter()로 돌면서 같은 컬렉션을 수정하지 않기 (2단계 처리)
for item in v.iter()로 순회 중에 v.push() 같은 변경을 하면 거의 항상 대여 충돌이 납니다.
문제 코드
fn append_positives(v: &mut Vec<i32>) {
for x in v.iter() {
if *x > 0 {
v.push(*x); // E0502
}
}
}
해결 A: 먼저 수집하고 나중에 append
fn append_positives(v: &mut Vec<i32>) {
let to_add: Vec<i32> = v.iter().copied().filter(|x| *x > 0).collect();
v.extend(to_add);
}
- 1단계: 불변 접근으로 필요한 값만 수집
- 2단계: 가변 접근으로 한 번에 반영
해결 B: 인덱스 기반으로 “초기 길이”만큼만 순회
fn append_positives(v: &mut Vec<i32>) {
let n = v.len();
for i in 0..n {
let x = v[i];
if x > 0 {
v.push(x);
}
}
}
n을 고정하면push로 길이가 늘어나도 순회 범위가 흔들리지 않습니다.- 단,
v[i]는 범위 체크가 있고, 매우 민감하면get_unchecked같은 최적화 여지가 있지만 보통은 과합니다.
패턴 4) 같은 슬라이스에서 “서로 다른 두 가변 참조”가 필요하면 split_at_mut
E0502는 불변/가변뿐 아니라, “같은 컨테이너에서 두 개의 가변 참조를 동시에” 만들 때도 자주 유도됩니다.
예를 들어 두 원소를 swap하거나, 두 포인터를 동시에 수정하려는 경우입니다.
문제 코드
fn bump_two(v: &mut [i32], i: usize, j: usize) {
let a = &mut v[i];
let b = &mut v[j];
*a += 1;
*b += 1;
}
인덱스가 다르더라도, 컴파일러는 “둘이 같은 요소일 수도 있다”고 보수적으로 판단합니다.
해결: split_at_mut로 비중첩을 증명
fn bump_two(v: &mut [i32], i: usize, j: usize) {
assert!(i != j);
let (lo, hi) = 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])
};
*lo += 1;
*hi += 1;
}
split_at_mut(k)는 슬라이스를 “겹치지 않는 두 조각”으로 나눠주므로, 각 조각에서 가변 참조를 뽑아도 안전합니다.
패턴 5) HashMap에서 조회와 갱신을 동시에 하고 싶으면 entry
HashMap에서 E0502가 자주 나는 형태는 이겁니다.
문제 코드
use std::collections::HashMap;
fn inc(m: &mut HashMap<String, i32>, key: String) {
if let Some(v) = m.get(&key) { // 불변 대여
if *v > 10 {
*m.get_mut(&key).unwrap() += 1; // 가변 대여 -> E0502
}
}
}
해결: entry로 “한 번만 빌려서” 처리
use std::collections::HashMap;
fn inc(m: &mut HashMap<String, i32>, key: String) {
let v = m.entry(key).or_insert(0);
if *v > 10 {
*v += 1;
}
}
entry는 내부적으로 “해당 키에 대한 단일 가변 접근” 흐름을 제공합니다.- 조회 후 갱신 같은 2단계 접근을 1단계로 합치면 E0502가 사라집니다.
패턴 6) 구조체 메서드에서 self를 빌린 채로 다른 필드를 수정하려면 “필드 분해”
구조체에서 특정 필드를 불변으로 참조한 뒤, 다른 필드를 수정하려다가 충돌하는 케이스가 많습니다.
문제 코드
struct App {
config: String,
logs: Vec<String>,
}
impl App {
fn tick(&mut self) {
let c = &self.config; // self 불변 대여가 config를 통해 생김
if c.contains("debug") {
self.logs.push("tick".to_string()); // self 가변 대여 -> 충돌
}
}
}
해결 A: 필요한 값만 추출해서 수명 끊기
impl App {
fn tick(&mut self) {
let is_debug = self.config.contains("debug");
if is_debug {
self.logs.push("tick".to_string());
}
}
}
해결 B: 필드를 로컬 변수로 분해해서 “동시에” 다루기
impl App {
fn tick(&mut self) {
let App { config, logs } = self;
if config.contains("debug") {
logs.push("tick".to_string());
}
}
}
이 방식은 컴파일러가 “서로 다른 필드”임을 더 명확히 알 수 있어, 대여 충돌을 줄이는 데 도움이 됩니다.
패턴 7) 정말로 공유 가변성이 필요하면 RefCell 또는 RwLock (마지막 수단)
E0502는 “컴파일 타임에 안전을 증명할 수 없는” 구조를 막습니다. 그런데 비즈니스 로직상 공유 가변성이 자연스럽게 필요한 경우도 있습니다.
- 단일 스레드:
std::cell::RefCell - 멀티 스레드:
std::sync::Mutex,std::sync::RwLock
예시로 RefCell을 쓰면 컴파일 타임 제약을 런타임 체크로 옮깁니다.
use std::cell::RefCell;
struct Store {
data: RefCell<Vec<i32>>,
}
impl Store {
fn add_if_any(&self) {
let has_any = !self.data.borrow().is_empty();
if has_any {
self.data.borrow_mut().push(1);
}
}
}
- 장점: 설계가 단순해질 수 있음
- 단점: 런타임에 borrow 규칙 위반 시 패닉, 오버헤드
따라서 가능하면 앞선 패턴(수명 단축, 2단계 처리, entry, split_at_mut)로 해결하고, 내부 가변성은 정말 필요할 때만 선택하는 게 좋습니다.
실무 체크리스트: E0502가 나오면 이렇게 고친다
- 불변 참조(
&T)를 오래 잡고 있지 않은가? 필요 값만Copy또는clone으로 빼라 - 읽기와 쓰기를 같은 스코프에서 섞지 말고, 블록으로 경계를 만들어라
- 순회하면서 수정하지 말고, “수집 후 반영” 2단계로 바꿔라
- 같은 슬라이스의 두 요소를 동시에 수정해야 하면
split_at_mut로 분리하라 HashMap은get/get_mut조합 대신entry를 우선 고려하라- 구조체는 필드 분해로 컴파일러가 비중첩을 이해하게 만들어라
- 최후의 수단으로
RefCell/Mutex/RwLock을 고려하라
마무리
E0502는 “Rust가 불편하다”의 상징처럼 느껴지지만, 관점을 바꾸면 데이터 접근 순서를 더 명확하게 만들라는 설계 피드백에 가깝습니다. 특히 위 패턴들은 단순히 컴파일을 통과시키는 요령이 아니라, 코드의 의도를 더 선명하게 만들어 유지보수성까지 좋아지는 경우가 많습니다.
비슷하게, 구조를 조금만 바꿔도 성능과 안정성이 크게 개선되는 사례는 다른 분야에도 흔합니다. 예를 들어 배포 파이프라인에서 캐시 구조를 바꾸는 것만으로도 큰 차이가 나기도 하는데, 관심 있다면 GitHub Actions Docker 레이어 캐시로 배포 3배 빠르게도 같이 참고해보세요.
원하는 코드 조각이 있다면(특히 Vec 순회 중 수정, HashMap 중첩 접근, 구조체 필드 동시 접근) 그대로 붙여주시면, E0502가 나는 지점을 기준으로 가장 비용이 적은 패턴으로 리팩터링해드릴게요.