- Published on
Rust 소유권 에러 E0502/E0499 한방 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/CLI/라이브러리 무엇을 만들든 Rust를 쓰다 보면 결국 한 번은 마주치는 벽이 있습니다. 바로 소유권/빌림 규칙이 강제하는 동시 참조 불가 상황에서 터지는 컴파일 에러입니다. 그중에서도 빈도가 압도적으로 높은 것이 E0502(불변 빌림 중 가변 빌림)와 E0499(가변 빌림 중복)입니다.
이 글은 “규칙 설명”보다 실제 코드에서 어떻게 한 번에 고치는지에 집중합니다. 에러 메시지를 읽는 법부터, 스코프를 자르는 법, 컬렉션을 안전하게 둘로 나누는 법, 인덱스/핸들로 접근을 분리하는 법, 최후의 수단인 내부 가변성까지 한 흐름으로 정리합니다.
참고로 이런 류의 문제는 Go에서
context/select로 누수를 잡는 것처럼, 원인을 패턴화해두면 해결 속도가 급격히 빨라집니다. 동시성/리소스 관련 패턴이 궁금하면 Go goroutine 누수 잡기 - context·select 패턴도 함께 보면 좋습니다.
E0502/E0499를 “한 문장”으로 이해하기
E0502: 어떤 값에 대한 불변 참조(&T)가 살아있는 동안, 같은 값에 대한 가변 참조(&mut T)를 만들려고 해서 실패E0499: 어떤 값에 대한 가변 참조(&mut T)가 살아있는 동안, 같은 값에 대한 또 다른 가변 참조를 만들려고 해서 실패
핵심은 “같은 값”과 “살아있는 동안(수명, lifetime)”입니다. 대부분의 해결은 결국 아래 둘 중 하나로 귀결됩니다.
- 참조가 살아있는 범위를 줄인다(스코프/수명 단축)
- ‘같은 값’이 아니게 만든다(데이터 구조/접근 방식 변경)
에러 메시지에서 먼저 볼 포인트 3가지
Rust 컴파일러는 보통 아래 정보를 제공합니다.
- 첫 번째 빌림이 발생한 위치
- 두 번째 빌림(충돌)이 발생한 위치
- 첫 번째 빌림이 끝나지 않았다고 판단한 범위
실전에서는 3번이 가장 중요합니다. “내가 보기엔 이미 안 쓰는데?” 싶은 경우가 많고, 그때는 대개 변수에 바인딩된 참조가 스코프 끝까지 살아있기 때문입니다.
패턴 1) 가장 빠른 해결: 스코프 쪼개기(수명 단축)
전형적인 E0502 예시
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // E0502: immutable borrow occurs here, mutable borrow later
println!("{first}");
}
first가 &v[0]로 바인딩되어 있고, println!에서 사용되므로 컴파일러는 first의 수명이 push 이후까지 이어진다고 봅니다.
해결 1: 사용을 앞당기거나, 블록으로 감싸서 참조 수명 종료
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{first}");
} // 여기서 `first` 수명 종료
v.push(4);
}
해결 2: 필요한 값만 복사(특히 Copy 타입)
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{first}");
}
이 방식은 “참조”를 들고 있지 않게 만들어 충돌을 제거합니다.
패턴 2) E0499의 단골: 같은 컬렉션에서 두 원소를 동시에 &mut로 잡기
다음은 실무에서 가장 자주 보는 형태입니다.
fn swap(v: &mut Vec<i32>, i: usize, j: usize) {
let a = &mut v[i];
let b = &mut v[j]; // E0499: cannot borrow `*v` as mutable more than once
std::mem::swap(a, b);
}
인덱스가 다르더라도, 컴파일러 입장에서는 v 전체에 대한 &mut가 두 번 생긴 것으로 취급됩니다(서로 다른 원소라는 것을 일반적으로 증명할 수 없기 때문).
해결 1: split_at_mut로 “같은 값이 아님”을 증명
fn swap(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])
};
std::mem::swap(a, b);
}
split_at_mut는 슬라이스를 서로 겹치지 않는 두 조각으로 나누므로, 각 조각에서 &mut를 꺼내도 안전합니다.
해결 2: get_many_mut(버전에 따라 사용 가능)로 다중 가변 참조 획득
Rust 버전에 따라 표준 라이브러리에서 slice::get_many_mut가 제공됩니다. 환경에 따라 안정화 여부가 다를 수 있으니, 가능하면 split_at_mut를 먼저 떠올리는 것이 안전합니다.
패턴 3) 반복문에서 E0502/E0499가 터지는 이유: “루프 바디 전체”로 수명이 늘어남
예를 들어, 맵을 순회하면서 동시에 수정하려고 하면 자주 막힙니다.
use std::collections::HashMap;
fn bump(map: &mut HashMap<String, i32>) {
for (k, v) in map.iter() {
if k.starts_with("a") {
*map.get_mut(k).unwrap() += 1; // E0502/E0499 류 충돌
}
println!("{k} {v}");
}
}
iter()가 map에 대한 불변 빌림을 유지하는 동안, 루프 안에서 get_mut로 가변 빌림을 만들려 하니 충돌합니다.
해결 1: 2패스(키 목록 수집 후 수정)
use std::collections::HashMap;
fn bump(map: &mut HashMap<String, i32>) {
let keys: Vec<String> = map
.keys()
.filter(|k| k.starts_with("a"))
.cloned()
.collect();
for k in keys {
*map.get_mut(&k).unwrap() += 1;
}
}
메모리를 조금 더 쓰지만, 의도가 명확하고 가장 예측 가능하게 컴파일됩니다.
해결 2: retain/entry 같은 “빌림 친화 API”로 재구성
HashMap::entry는 수정 흐름을 한 번의 가변 접근으로 묶어주기 때문에, 설계를 바꾸면 소유권 문제가 사라지는 경우가 많습니다.
use std::collections::HashMap;
fn bump_one(map: &mut HashMap<String, i32>, k: String) {
let e = map.entry(k).or_insert(0);
*e += 1;
}
패턴 4) 함수 경계에서 빌림이 꼬일 때: “참조를 반환하지 말고 결과를 반환”
E0502/E0499는 종종 “헬퍼 함수가 참조를 반환”하면서 시작됩니다.
fn pick_first(v: &Vec<String>) -> &String {
&v[0]
}
fn main() {
let mut v = vec!["a".to_string(), "b".to_string()];
let r = pick_first(&v);
v.push("c".to_string()); // E0502
println!("{r}");
}
해결: 참조 대신 소유값(복제/이동) 또는 인덱스 반환
- 값이 작거나
Clone비용이 허용되면String을 복제 - 복제가 부담되면 “어디를 가리키는지”만 반환(인덱스/키)
fn pick_first_index(v: &[String]) -> usize {
assert!(!v.is_empty());
0
}
fn main() {
let mut v = vec!["a".to_string(), "b".to_string()];
let idx = pick_first_index(&v);
v.push("c".to_string());
println!("{}", v[idx]);
}
이 패턴은 특히 파서/컴파일러/게임 ECS처럼 “참조를 들고 오래 살아야 하는” 구조에서 효과적입니다.
패턴 5) 구조체 메서드에서 자기 자신을 두 번 빌리는 문제: 필드 분해(destructuring)
다음은 self를 가변으로 빌린 상태에서, 다른 필드를 또 빌리며 충돌하는 형태입니다.
struct App {
buf: Vec<u8>,
pos: usize,
}
impl App {
fn push_byte(&mut self, b: u8) {
let p = &mut self.pos;
self.buf.push(b); // E0499 류로 이어질 수 있음(상황에 따라)
*p += 1;
}
}
해결: 필요한 필드를 먼저 로컬로 꺼내거나, 필드 분해로 “서로 다른 필드”임을 명확히
struct App {
buf: Vec<u8>,
pos: usize,
}
impl App {
fn push_byte(&mut self, b: u8) {
let App { buf, pos } = self;
buf.push(b);
*pos += 1;
}
}
필드 분해는 컴파일러가 “동일한 self에 대한 중복 빌림”이 아니라 “서로 다른 필드에 대한 독립적 빌림”으로 추론할 여지를 줍니다.
패턴 6) 정말로 동시에 읽고/써야 한다면: 내부 가변성(RefCell, Mutex, RwLock)
소유권 규칙은 기본적으로 컴파일 타임에 안전을 증명하려고 합니다. 하지만 프로그램 구조상 “동시에 잡아야만” 하는 경우가 있습니다.
- 단일 스레드에서 런타임 검사로 충분:
RefCell<T> - 멀티 스레드 공유:
Mutex<T>또는RwLock<T>
RefCell로 E0502/E0499를 우회(런타임 borrow 체크)
use std::cell::RefCell;
fn main() {
let v = RefCell::new(vec![1, 2, 3]);
{
let first = v.borrow()[0]; // 불변 borrow
println!("{first}");
} // 불변 borrow 종료
v.borrow_mut().push(4); // 가변 borrow
}
RefCell은 규칙을 없애는 게 아니라 검사를 런타임으로 옮기는 것입니다. 잘못 쓰면 패닉이 날 수 있으니, “구조적으로 컴파일 타임에 풀 수 없는가?”를 먼저 확인한 뒤 선택하세요.
실무 체크리스트: E0502/E0499가 뜨면 이 순서로 본다
- 참조 변수(
let r = &...)가 스코프 끝까지 살아있는지 확인하고, 블록으로 감싸 수명 단축 - 불변 참조가 필요하다면 값 복사(
Copy) 또는clone으로 참조 자체를 없애기 - 컬렉션에서 두 원소를
&mut로 잡아야 하면split_at_mut로 비겹침을 증명 - 순회하면서 수정하려면 2패스(키 수집 후 수정) 또는
entry/전용 API로 재구성 - 구조체 메서드에서 꼬이면 필드 분해로 빌림 단위를 필드로 좁히기
- 정말 불가피하면
RefCell/Mutex/RwLock로 내부 가변성(대신 런타임 비용/데드락/패닉 리스크 관리)
디버깅 팁: “컴파일러가 생각하는 수명”을 눈으로 확인하기
- 참조를 만든 줄과, 마지막으로 사용한 줄 사이에 가변 접근이 있는지 찾습니다.
- 참조가 마지막으로 사용되는 지점을 앞당길 수 있는지(출력/계산 순서 변경) 봅니다.
- “참조를 반환하는 함수”가 있다면, 인덱스/키/핸들 반환로 바꿀 수 있는지 검토합니다.
이 과정은 시스템 장애 원인을 추적할 때 로그/스택을 좁혀가는 것과 유사합니다. 운영 환경에서 원인 추적 루틴을 정리해둔 글로는 systemd 서비스 자동 재시작 원인 추적 가이드도 참고할 만합니다.
마무리: “빌림 충돌”은 버그가 아니라 설계 피드백이다
E0502/E0499는 처음엔 귀찮지만, 익숙해지면 코드의 데이터 흐름을 더 명확하게 만들라는 신호로 읽힙니다. 특히 아래 두 가지를 습관화하면 체감 난이도가 급격히 내려갑니다.
- 참조는 짧게 들고, 필요하면 값/인덱스로 바꾼다
- 동시에
&mut가 필요하면 API(split_at_mut,entry)로 안전을 증명한다
다음에 같은 에러를 만나면, 에러 메시지를 “규칙 위반”으로 보지 말고 “수명/접근 단위를 줄여라”라는 리팩터링 힌트로 받아들이면 해결 속도가 훨씬 빨라집니다.