- Published on
Rust 소유권·빌림으로 E0502·E0499 해결 6패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버·CLI·라이브러리 어느 쪽이든 Rust를 쓰다 보면 결국 한 번은 E0502(불변/가변 빌림 충돌)와 E0499(가변 빌림 중복)과 싸우게 됩니다. 에러 메시지는 친절하지만, 실제 코드에서는 "어디를 어떻게 바꾸면" 되는지가 막막한 경우가 많습니다.
이 글은 소유권/빌림 규칙을 다시 설명하는 대신, 현업에서 자주 쓰는 해결 패턴 6가지를 상황별로 정리합니다. 각 패턴은 "왜 되는지"와 "언제 쓰면 좋은지"까지 포함합니다.
관련해서 런타임 문제를 추적할 때는 빌림 이슈와 달리 패닉 스택을 좁혀야 하는데, 그 흐름은 Rust Tokio에서 thread panicked 원인 추적법도 함께 참고하면 좋습니다.
E0502·E0499를 한 문장으로 요약
E0502: 어떤 값에 대해 불변 참조가 살아있는 동안 같은 값에 대한 가변 참조를 만들려고 할 때E0499: 어떤 값에 대한 가변 참조가 살아있는 동안 같은 값에 대한 또 다른 가변 참조를 만들려고 할 때
핵심은 "참조가 살아있는 범위"(lifetime)입니다. Rust는 보수적으로 판단하므로, 사람이 보기엔 "이쯤이면 끝났지" 싶은 참조도 스코프가 끝날 때까지 살아있다고 볼 수 있습니다.
아래 패턴들은 결국 이 둘 중 하나를 합니다.
- 참조가 살아있는 범위를 줄이거나
- 동시에 같은 곳을 빌리지 않도록 구조를 바꾸거나
- 아예 빌림 대신 소유권 이동/복사를 하거나
- 규칙을 런타임 체크로 바꾸는(Interior Mutability) 방식으로 전환
패턴 1) 스코프를 쪼개서 빌림 수명을 줄이기
가장 간단하고 효과적인 처방입니다. 불변 빌림을 "딱 필요한 만큼만" 살게 만들면 E0502가 풀립니다.
문제 예시
fn bump_if_positive(v: &mut Vec<i32>) {
let first = &v[0]; // 불변 빌림
if *first > 0 {
v.push(1); // 가변 빌림 필요 -> E0502 가능
}
}
해결: 필요한 값만 복사해서 참조를 빨리 끝내기
fn bump_if_positive(v: &mut Vec<i32>) {
let first_val = v[0]; // i32는 Copy
if first_val > 0 {
v.push(1);
}
}
해결: 블록 스코프로 참조 수명 강제 종료
fn bump_if_positive(v: &mut Vec<i32>) {
let is_pos = {
let first = &v[0];
*first > 0
}; // 여기서 first 드롭
if is_pos {
v.push(1);
}
}
언제 쓰나
Copy가능한 값(i32, bool 등)이라면 첫 번째 방식이 가장 깔끔- 참조를 꼭 써야 한다면 블록 스코프가 안전한 기본기
패턴 2) 계산은 불변으로 끝내고, 변경은 나중에 한 번에
"읽기"와 "쓰기"를 섞어 쓰면 빌림이 겹치기 쉽습니다. 읽기 단계에서는 불변 빌림만 사용하고, 변경 단계에서는 가변 빌림만 사용하도록 단계를 분리합니다.
문제 예시
fn normalize_and_push(v: &mut Vec<i32>) {
let max_ref = v.iter().max().unwrap(); // v를 불변으로 빌림
v.push(*max_ref); // 같은 v에 가변 빌림 -> E0502
}
해결: 먼저 값으로 뽑아두기
fn normalize_and_push(v: &mut Vec<i32>) {
let max_val = v.iter().copied().max().unwrap();
v.push(max_val);
}
언제 쓰나
iter()로 뭔가를 찾고 난 뒤push/remove/insert를 하고 싶을 때- "조회 결과"만 있으면 되는 경우(참조 자체가 필요 없는 경우)
패턴 3) 컬렉션을 동시에 가변으로 잡아야 하면 split_at_mut로 분할
같은 슬라이스/벡터의 서로 다른 인덱스를 동시에 수정하려고 하면 E0499가 터집니다. Rust는 인덱스가 다르다는 사실을 일반적인 인덱싱으로는 증명하지 못합니다.
문제 예시
fn swap(v: &mut [i32], i: usize, j: usize) {
let a = &mut v[i];
let b = &mut v[j];
std::mem::swap(a, b); // E0499 가능
}
해결: split_at_mut로 "서로 겹치지 않음"을 타입으로 증명
fn swap(v: &mut [i32], i: usize, j: usize) {
assert!(i != j);
let (lo, hi) = if i < j {
(i, j)
} else {
(j, i)
};
let (left, right) = v.split_at_mut(hi);
let a = &mut left[lo];
let b = &mut right[0];
std::mem::swap(a, b);
}
언제 쓰나
- 동일 슬라이스에서 서로 다른 요소를 동시에 수정
- 정렬/셔플/그래프 알고리즘 등에서 자주 등장
패턴 4) HashMap/BTreeMap에서 "조회 후 수정"은 get_mut/entry로 합치기
맵에서 흔한 실수는 get으로 불변 빌림을 만든 다음, 같은 맵을 insert나 get_mut으로 다시 가변 빌림하려는 것입니다.
문제 예시
use std::collections::HashMap;
fn inc(m: &mut HashMap<String, i32>, k: String) {
if let Some(v) = m.get(&k) { // 불변 빌림
m.insert(k, v + 1); // 가변 빌림 -> E0502
}
}
해결 1: get_mut
use std::collections::HashMap;
fn inc(m: &mut HashMap<String, i32>, k: String) {
if let Some(v) = m.get_mut(&k) {
*v += 1;
}
}
해결 2: entry로 "없으면 생성"까지 한 번에
use std::collections::HashMap;
fn inc(m: &mut HashMap<String, i32>, k: String) {
*m.entry(k).or_insert(0) += 1;
}
언제 쓰나
- 카운터/집계 로직
- 캐시 갱신, LRU 비슷한 구조
패턴 5) 옵션/필드 값을 잠깐 "꺼내서" 작업하기: Option::take
구조체의 필드를 가변으로 빌린 상태에서, 같은 구조체의 다른 필드에 접근하려 하면 빌림이 겹쳐 E0499/E0502가 나기 쉽습니다. 이때 Option::take로 필드의 소유권을 잠깐 밖으로 빼면, 구조체 전체를 더 이상 빌리지 않아도 됩니다.
문제 예시(전형적인 self 참조 충돌)
struct S {
buf: Vec<u8>,
pending: Option<Vec<u8>>,
}
impl S {
fn flush(&mut self) {
let p = self.pending.as_mut().unwrap(); // pending에 대한 가변 빌림
self.buf.extend_from_slice(p); // self에 또 접근 -> 충돌 가능
}
}
해결: take로 소유권 이동 후, 작업하고 다시 넣기
struct S {
buf: Vec<u8>,
pending: Option<Vec<u8>>,
}
impl S {
fn flush(&mut self) {
let p = self.pending.take();
if let Some(p) = p {
self.buf.extend_from_slice(&p);
// 필요하면 다시 보관
// self.pending = Some(p);
}
}
}
언제 쓰나
self의 필드 간 상호작용에서 빌림이 꼬일 때- 큐/버퍼/상태 머신에서 "꺼내서 처리" 패턴이 자연스러울 때
패턴 6) 정말로 공유 가변이 필요하면 RefCell/Mutex로 규칙을 런타임으로 이동
어떤 구조는 컴파일 타임 빌림 규칙으로는 표현이 어렵습니다.
- 트리/그래프에서 부모-자식이 서로를 가리킴
- 콜백/핸들러가 동일 상태를 공유하며 수정
- 캐시가 여러 곳에서 갱신됨
이때는 "Rust 규칙을 포기"하는 게 아니라, 체크 시점을 컴파일 타임에서 런타임으로 옮기는 선택을 합니다.
단일 스레드: Rc + RefCell
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Default)]
struct State {
hits: usize,
}
fn main() {
let st = Rc::new(RefCell::new(State::default()));
// 여러 핸들러가 공유
let a = st.clone();
let b = st.clone();
a.borrow_mut().hits += 1;
b.borrow_mut().hits += 1;
let hits = st.borrow().hits;
println!("hits={}", hits);
}
RefCell은 빌림 규칙 위반 시 패닉이 날 수 있으니, "논리적으로는 안전한데 빌림 표현만 어려운" 경우에만 쓰는 것이 좋습니다.
멀티 스레드: Arc + Mutex(또는 RwLock)
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Default)]
struct State {
hits: usize,
}
fn main() {
let st = Arc::new(Mutex::new(State::default()));
let mut handles = vec![];
for _ in 0..4 {
let st = st.clone();
handles.push(thread::spawn(move || {
let mut guard = st.lock().unwrap();
guard.hits += 1;
}));
}
for h in handles {
h.join().unwrap();
}
let hits = st.lock().unwrap().hits;
println!("hits={}", hits);
}
비동기 환경에서는 tokio::sync::Mutex 같은 비동기 락을 쓰는 것이 일반적이며, 락 경합/데드락이 생기면 원인 추적이 어려워질 수 있습니다. 이때는 Rust Tokio에서 thread panicked 원인 추적법처럼 스택과 태스크 경계를 기준으로 좁혀가는 접근이 도움이 됩니다.
체크리스트: 어떤 패턴을 먼저 시도할까
- 참조가 "생각보다 오래" 살아있다: 패턴 1(스코프 분리)
- 읽고 나서 쓰는 흐름이다: 패턴 2(읽기/쓰기 단계 분리)
- 같은 슬라이스의 다른 요소를 동시에 수정: 패턴 3(
split_at_mut) - 맵에서 조회 후 갱신: 패턴 4(
entry/get_mut) - 구조체 필드 간 충돌: 패턴 5(
take로 소유권 이동) - 구조적으로 공유 가변이 필요: 패턴 6(
RefCell/Mutex)
마무리: 에러를 "없애는" 게 아니라 설계를 드러내기
E0502와 E0499는 단순한 문법 에러가 아니라, 코드가 "동시에 무엇을 읽고/쓰려 하는지"를 Rust가 정확히 짚어준 결과입니다. 위 6패턴은 에러를 우회하는 트릭이라기보다, 의도를 더 명확한 형태(스코프, 단계, 분할, 소유권 이동, 런타임 체크)로 옮기는 방법입니다.
처음에는 빌림 규칙이 답답하지만, 익숙해지면 동시성/상태 관리에서 발생하는 많은 버그를 컴파일 단계에서 차단하는 강력한 도구가 됩니다.