- Published on
Rust E0502/E0507 빌림 충돌 해결 패턴 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Rust를 쓰다 보면 빌림 검사기와의 줄다리기는 피할 수 없습니다. 그중에서도 E0502(불변으로 빌린 상태에서 가변으로 또 빌리거나 그 반대)와 E0507(빌린 값에서 소유권이 필요한 move를 시도)가 실무에서 가장 빈번하게 등장합니다.
핵심은 두 가지입니다.
E0502: 동시에 살아있는 빌림의 범위를 줄이거나(스코프 축소), 접근 순서를 바꿔서(2-phase로) 동시에 존재하지 않게 만들기E0507: move가 필요한 연산을 빌린 값에 직접 하지 말고, 소유권을 얻는 경로(clone,to_owned,mem::take,Option::take)로 바꾸기
이미 E0502/E0499 중심으로 패턴을 정리한 글이 있다면 함께 보세요: Rust 소유권·빌림으로 E0502·E0499 해결 6패턴
아래는 E0502와 E0507을 함께 커버하는 7가지 해결 패턴입니다.
1) 불변 빌림을 먼저 끝내기: 스코프 강제 축소
E0502는 “불변 참조가 살아있는 동안 가변 참조를 만들었다”가 대부분입니다. 해결의 1순위는 불변 빌림을 더 빨리 끝내는 것입니다.
문제 예시
fn bump_if_needed(v: &mut Vec<i32>) {
let first = v.first(); // 여기서 v를 불변 빌림
if let Some(x) = first {
if *x == 0 {
v.push(1); // E0502: 불변 빌림이 살아있는데 가변 빌림 시도
}
}
}
해결: 값 복사 또는 스코프 블록
fn bump_if_needed(v: &mut Vec<i32>) {
let first_val = v.first().copied(); // i32는 Copy라서 빌림을 길게 유지하지 않음
if first_val == Some(0) {
v.push(1);
}
}
Copy가 안 되는 타입이라면 스코프를 잘라서 불변 빌림을 먼저 drop시키는 방식도 자주 씁니다.
fn bump_if_needed(v: &mut Vec<String>) {
let should_push = {
let first = v.first();
matches!(first, Some(s) if s == "0")
}; // 여기서 first의 빌림이 종료
if should_push {
v.push("1".to_string());
}
}
2) 접근을 2단계로 분리: 읽기와 쓰기 분리
한 함수/블록에서 “읽고 나서 바로 수정”을 하려다 E0502가 납니다. 이때는 읽기 단계에서 필요한 정보만 추출하고, 쓰기 단계에서만 가변 접근을 하도록 구조를 바꿉니다.
use std::collections::HashMap;
fn inc_if_present(map: &mut HashMap<String, i32>, key: &str) {
// 1) 읽기 단계: 존재 여부만 판단
let present = map.contains_key(key);
// 2) 쓰기 단계: 가변 접근
if present {
*map.get_mut(key).unwrap() += 1;
}
}
contains_key와 get_mut을 연달아 부르면 해시를 두 번 하게 되니, 성능이 중요하면 entry API로 바로 가는 편이 좋습니다(아래 패턴 4).
3) 인덱스/키를 먼저 계산하고, 참조는 나중에 잡기
컬렉션에서 어떤 위치를 찾고(position/find) 그 다음 수정하려는 흐름에서 E0502가 자주 납니다. 원인은 iterator가 컬렉션을 빌린 상태로 유지되기 때문입니다.
fn set_first_zero(v: &mut Vec<i32>) {
let idx = v.iter().position(|x| *x == 0); // v를 불변으로 빌림
if let Some(i) = idx {
v[i] = 42; // E0502가 나는 케이스가 종종 등장(상황에 따라)
}
}
실제로는 NLL(Non-Lexical Lifetimes) 덕분에 위가 통과하는 경우도 많지만, iterator/클로저 캡처가 얽히면 빌림이 길어져 실패합니다. 안전한 습관은 인덱스만 계산하고, 참조를 잡는 코드는 빌림이 끝난 뒤에 두는 것입니다.
fn set_first_zero(v: &mut Vec<i32>) {
let idx = {
// 블록으로 iterator 빌림을 확실히 종료
v.iter().position(|x| *x == 0)
};
if let Some(i) = idx {
v[i] = 42;
}
}
키 기반 자료구조에서도 동일합니다. 키를 String으로 만들어야 한다면, 참조를 오래 잡지 말고 키를 먼저 소유(to_string)한 뒤 수정 단계로 넘어갑니다.
4) HashMap::entry로 읽기+쓰기 충돌 제거
E0502의 대표적인 실무 케이스는 HashMap에서 “값을 읽고, 없으면 넣고, 있으면 수정” 같은 흐름입니다. get과 insert를 섞으면 빌림이 꼬이기 쉽습니다.
나쁜 패턴(충돌 가능)
use std::collections::HashMap;
fn add_count(map: &mut HashMap<String, usize>, key: String) {
if let Some(v) = map.get(&key) {
// v를 읽고...
let next = v + 1;
map.insert(key, next); // 여기서 가변 접근과 충돌하기 쉬움
} else {
map.insert(key, 1);
}
}
좋은 패턴: entry
use std::collections::HashMap;
fn add_count(map: &mut HashMap<String, usize>, key: String) {
*map.entry(key).or_insert(0) += 1;
}
entry는 내부적으로 “해당 키 슬롯에 대한 단일한 가변 접근”으로 문제를 정리해 줍니다. 성능 측면에서도 해시 계산을 중복하지 않는 장점이 있습니다.
5) 서로 다른 원소를 동시에 가변으로 다루기: split_at_mut/get_many_mut
E0502와 비슷한 계열로, 벡터에서 두 원소를 동시에 수정하려고 할 때 컴파일러는 “둘이 같은 원소일 수 있다”고 판단해 막습니다. 이때는 서로 다른 영역임을 타입 수준에서 증명해야 합니다.
split_at_mut 패턴
fn swap_neighbors(v: &mut [i32], i: usize) {
if i + 1 >= v.len() { return; }
let (left, right) = v.split_at_mut(i + 1);
let a = &mut left[i];
let b = &mut right[0];
std::mem::swap(a, b);
}
get_many_mut 패턴(버전에 따라 사용 가능)
표준 라이브러리의 get_many_mut는 여러 인덱스의 가변 참조를 한 번에 얻되, 중복 인덱스면 None을 줘서 안전을 보장합니다.
fn add_two(v: &mut [i32], a: usize, b: usize) {
if let Some([x, y]) = v.get_many_mut([a, b]) {
*x += 1;
*y += 1;
}
}
이 패턴은 “내가 서로 다른 원소를 수정한다”는 의도를 컴파일러가 이해하도록 만드는 정석입니다.
6) E0507 해결 1: 빌린 곳에서 move하지 말고 clone/to_owned
E0507은 보통 다음 형태로 발생합니다.
&T또는&mut T만 있는데, 어떤 API가T를 요구해서 move하려고 함- 예:
Option<T>에서T를 꺼내고 싶은데 현재는&Option<T>만 있음
문제 예시
fn consume_string(s: String) -> usize {
s.len()
}
fn bad(x: &String) -> usize {
consume_string(*x) // E0507: cannot move out of `*x` which is behind a shared reference
}
해결: 소유권을 복제해서 넘기기
fn good(x: &String) -> usize {
consume_string(x.clone())
}
문자열 슬라이스로 충분하다면, 아예 API를 바꾸는 게 더 좋습니다.
fn consume_str(s: &str) -> usize {
s.len()
}
fn good2(x: &String) -> usize {
consume_str(x.as_str())
}
즉 E0507을 만났을 때는 “내가 정말로 소유권이 필요한가?”를 먼저 확인하고, 필요 없다면 함수 시그니처를 &str, &[u8] 같은 빌림 기반으로 바꾸는 게 장기적으로 가장 깔끔합니다.
7) E0507 해결 2: 컨테이너에서 값 꺼내기 take/mem::take/replace
E0507이 가장 골치 아픈 케이스는 구조체 필드에 있는 String 같은 non-Copy 값을 “꺼내서” 다른 곳으로 move하고 싶은데, 현재는 &mut self만 있는 경우입니다. 이때는 필드를 빈 값으로 치환한 뒤 원래 값을 가져오는 패턴을 씁니다.
Option::take (가장 추천)
필드를 Option<T>로 두면 이동이 쉬워집니다.
struct Job {
payload: Option<String>,
}
impl Job {
fn take_payload(&mut self) -> Option<String> {
self.payload.take() // payload를 None으로 만들고, 기존 값을 반환
}
}
std::mem::take (기본값으로 교체)
T: Default면 mem::take로 간단히 해결됩니다.
use std::mem;
struct Buffer {
data: Vec<u8>,
}
impl Buffer {
fn drain(&mut self) -> Vec<u8> {
mem::take(&mut self.data) // 빈 Vec로 교체 후 기존 Vec를 move
}
}
std::mem::replace (원하는 값으로 교체)
use std::mem;
struct State {
name: String,
}
impl State {
fn rename_and_get_old(&mut self, new_name: String) -> String {
mem::replace(&mut self.name, new_name)
}
}
이 계열은 E0507을 “move를 금지”가 아니라 “move를 합법화하는 경로”로 바꿔주는 패턴이라, 실무에서 빈도가 매우 높습니다.
마무리: 오류 메시지를 설계 힌트로 읽기
E0502는 대부분 동시 빌림이 문제이므로, 빌림을 짧게 만들거나(스코프 축소), 읽기/쓰기를 분리하거나(2단계), API를entry같은 원자적 패턴으로 바꾸면 해결됩니다.E0507은 대부분 소유권이 필요한 연산을 빌린 값에 하려는 문제이므로,clone/to_owned로 소유권을 만들거나,take/mem::take/replace로 “꺼낼 수 있는 구조”로 바꾸면 해결됩니다.
에러 전파와 자원 관리 관점에서 언어 설계가 어떻게 다른지 비교해 보면 감이 더 잘 옵니다. 예외 대신 타입으로 흐름을 강제하는 접근은 Rust뿐 아니라 C++23의 std::expected에서도 유사한 철학을 볼 수 있습니다: C++23 std - -expected로 예외 없이 에러전파·자원관리
다음에 E0502/E0507이 뜨면, “컴파일러가 싫어하는 건 내가 아니라 동시에 살아있는 참조와 move의 위치”라는 점을 떠올리고, 위 7가지 중 가장 비용이 낮은(복사보다 스코프/구조 변경이 우선) 패턴부터 적용해 보세요.