Published on

Rust E0502/E0507 빌림 충돌 해결 패턴 7가지

Authors

서버 사이드 Rust를 쓰다 보면 빌림 검사기와의 줄다리기는 피할 수 없습니다. 그중에서도 E0502(불변으로 빌린 상태에서 가변으로 또 빌리거나 그 반대)와 E0507(빌린 값에서 소유권이 필요한 move를 시도)가 실무에서 가장 빈번하게 등장합니다.

핵심은 두 가지입니다.

  • E0502: 동시에 살아있는 빌림의 범위를 줄이거나(스코프 축소), 접근 순서를 바꿔서(2-phase로) 동시에 존재하지 않게 만들기
  • E0507: move가 필요한 연산을 빌린 값에 직접 하지 말고, 소유권을 얻는 경로(clone, to_owned, mem::take, Option::take)로 바꾸기

이미 E0502/E0499 중심으로 패턴을 정리한 글이 있다면 함께 보세요: Rust 소유권·빌림으로 E0502·E0499 해결 6패턴

아래는 E0502E0507을 함께 커버하는 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_keyget_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에서 “값을 읽고, 없으면 넣고, 있으면 수정” 같은 흐름입니다. getinsert를 섞으면 빌림이 꼬이기 쉽습니다.

나쁜 패턴(충돌 가능)

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: Defaultmem::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가지 중 가장 비용이 낮은(복사보다 스코프/구조 변경이 우선) 패턴부터 적용해 보세요.