Published on

Rust E0502/E0499 빌림 충돌 에러 한방 해결

Authors

서로 다른 언어에서라면 런타임 버그로 남았을 문제가, Rust에서는 컴파일 타임에 E0502 또는 E0499로 터지곤 합니다. 처음엔 답답하지만, 이 두 에러는 사실상 같은 메시지를 다른 각도에서 말합니다.

  • E0502: 불변 빌림(immutable borrow)이 살아있는 동안 가변 빌림(mutable borrow)을 시도했다
  • E0499: 같은 값에 대해 가변 빌림을 동시에 2번 이상 만들었다

핵심은 "빌림의 범위가 내가 생각한 것보다 길다" 입니다. Rust는 "참조가 마지막으로 사용되는 지점"까지 빌림을 유지합니다(NLL, Non Lexical Lifetimes 덕분에 과거보다 많이 유연해졌지만, 여전히 구조적으로 겹치면 막힙니다).

아래는 에러를 한 번에 정리하는 실전 패턴들입니다.

E0502/E0499를 빠르게 해석하는 법

컴파일러가 주는 메시지에서 꼭 봐야 할 포인트는 3가지입니다.

  1. 어디서 빌림이 시작됐는지: borrow occurs here
  2. 어디서 충돌이 발생했는지: mutable borrow occurs here 또는 second mutable borrow occurs here
  3. 왜 아직 살아있는지: immutable borrow later used here 같은 라인

즉, "충돌 지점"이 아니라 **"나중에 참조를 마지막으로 쓰는 지점"**을 줄이면 해결됩니다.

대표 상황 1: Vec에서 읽고 동시에 수정하기

아래 코드는 매우 흔한 E0502 패턴입니다.

fn main() {
    let mut v = vec![1, 2, 3];

    let first = &v[0];
    v.push(4);

    println!("{}", first);
}

firstprintln!에서 사용되기 전까지 살아있기 때문에, 그 사이 v.push(4)v를 가변으로 빌리면서 충돌합니다. push는 재할당(reallocation)을 일으킬 수 있어, 기존 참조가 무효가 될 수 있으니 Rust가 막는 겁니다.

해결 1) 값을 복사해서 참조 수명 끊기

원소 타입이 Copy라면 가장 간단합니다.

fn main() {
    let mut v = vec![1, 2, 3];

    let first = v[0]; // i32는 Copy
    v.push(4);

    println!("{}", first);
}

해결 2) 참조 사용을 앞당겨 빌림 범위 줄이기

fn main() {
    let mut v = vec![1, 2, 3];

    let first = &v[0];
    println!("{}", first); // 여기서 마지막 사용

    v.push(4);
}

해결 3) 스코프 블록으로 빌림을 "강제로" 끝내기

fn main() {
    let mut v = vec![1, 2, 3];

    {
        let first = &v[0];
        println!("{}", first);
    } // 여기서 first drop

    v.push(4);
}

이 패턴은 Copy가 아닌 타입에서도 유용합니다.

대표 상황 2: HashMap에서 get하고 insert하기

HashMap에서도 똑같이 터집니다.

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<String, usize> = HashMap::new();
    m.insert("a".to_string(), 1);

    let v = m.get("a");
    m.insert("b".to_string(), 2);

    println!("{:?}", v);
}

m.get("a")&usize를 반환하므로 m에 대한 불변 빌림이 생기고, 그 상태에서 insert가 가변 빌림을 요구해 충돌합니다.

해결 1) 필요한 값만 복사하거나 복제

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<String, usize> = HashMap::new();
    m.insert("a".to_string(), 1);

    let v = m.get("a").copied();
    m.insert("b".to_string(), 2);

    println!("{:?}", v);
}

해결 2) entry API로 읽기와 쓰기를 한 덩어리로

읽고 수정하는 목적이라면 entry가 정답인 경우가 많습니다.

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<String, usize> = HashMap::new();

    *m.entry("a".to_string()).or_insert(0) += 1;
    *m.entry("b".to_string()).or_insert(0) += 1;

    println!("{:?}", m);
}

entry는 내부적으로 빌림 충돌이 나지 않도록 설계된 API라, "get하고 나중에 insert" 같은 패턴을 대체할 수 있습니다.

대표 상황 3: 같은 슬라이스를 두 번 가변으로 잡기 (E0499)

다음은 전형적인 E0499입니다.

fn main() {
    let mut a = [10, 20, 30];

    let x = &mut a[0];
    let y = &mut a[1];

    *x += 1;
    *y += 1;
}

사람은 a[0]a[1]이 다르다는 걸 알지만, Rust는 인덱싱 연산만으로는 "서로 다른 위치"를 증명하지 못합니다. 그래서 동일한 배열 a에 대한 가변 참조를 동시에 2개 만드는 걸 금지합니다.

해결 1) split_at_mut로 "서로 다른 영역"을 증명

fn main() {
    let mut a = [10, 20, 30];

    let (left, right) = a.split_at_mut(1);
    let x = &mut left[0];
    let y = &mut right[0];

    *x += 1;
    *y += 1;

    println!("{:?}", a);
}

split_at_mut는 슬라이스를 겹치지 않는 두 조각으로 나눠주며, Rust가 안전성을 증명할 수 있게 해줍니다.

해결 2) 인덱스를 저장하고 한 번씩만 빌리기

"동시에" 두 참조가 필요한 게 아니라면, 빌림을 짧게 만들면 됩니다.

fn main() {
    let mut a = [10, 20, 30];

    let i = 0;
    let j = 1;

    a[i] += 1;
    a[j] += 1;

    println!("{:?}", a);
}

대표 상황 4: 반복문에서 컬렉션을 순회하며 동시에 수정

다음 코드는 거의 확실하게 빌림 충돌을 냅니다.

fn main() {
    let mut v = vec![1, 2, 3, 4];

    for x in &v {
        if *x % 2 == 0 {
            v.push(*x);
        }
    }
}

for x in &vv를 불변으로 빌린 상태에서 루프가 도는 동안, push로 가변 빌림을 만들려고 해서 E0502가 발생합니다.

해결 1) 결과를 별도 버퍼에 모아서 마지막에 합치기

fn main() {
    let mut v = vec![1, 2, 3, 4];
    let mut to_add = Vec::new();

    for x in &v {
        if *x % 2 == 0 {
            to_add.push(*x);
        }
    }

    v.extend(to_add);
    println!("{:?}", v);
}

해결 2) drain_filter 또는 retain 등 "전용 API" 활용

표준 라이브러리의 retain은 특히 유용합니다.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    v.retain(|x| x % 2 == 0);
    println!("{:?}", v);
}

문제 자체를 "순회하면서 수정"이 아닌, "의도에 맞는 API"로 바꾸면 빌림 충돌이 자연스럽게 사라집니다.

한방 해결 체크리스트: 에러가 나면 이렇게 바꿔라

1) 참조를 "값"으로 바꿀 수 있는가

  • Copy면 그냥 복사
  • 아니면 clone 또는 to_owned로 소유권을 가져오기
  • Option<&T> 대신 Option<T>로 설계 변경

이 방법은 가장 직관적이지만, 불필요한 복사가 생길 수 있습니다.

2) 빌림의 마지막 사용 지점을 앞당길 수 있는가

  • println! 같은 로깅을 먼저 수행
  • 계산 결과만 변수로 저장하고 참조는 즉시 끝내기
  • 블록 스코프를 사용해 참조를 빨리 drop 시키기

3) "동시에" 잡은 가변 참조를 "분리"할 수 있는가

  • 슬라이스면 split_at_mut
  • 구조체 필드면 필드를 분해해서 각각 빌리기

예: 구조체의 서로 다른 필드를 동시에 가변으로 쓰고 싶다면, 통째로 &mut self를 오래 잡지 말고 필요한 필드를 지역 변수로 분해하는 식으로 수명을 줄입니다.

4) 읽기-수정을 한 API로 묶을 수 있는가

  • HashMapentry
  • Vecretain, drain, splice 등 목적에 맞는 메서드

"get한 다음 insert"처럼 2단계로 나누면 빌림이 길어지기 쉽습니다.

5) 정말로 공유 가변성이 필요한가

동시성/비동기 코드에서 ArcMutex를 섞다 보면 빌림 충돌과는 별개로 교착이나 성능 문제가 같이 옵니다. Tokio 환경이라면 락을 오래 잡지 않는 구조가 중요합니다. 이 주제는 Rust Tokio join! 교착? spawn·Mutex 오용 해결도 함께 참고하면 좋습니다.

실전 예제: E0502를 "구조"로 해결하는 리팩터링

예를 들어, 아래처럼 "읽기 단계"와 "쓰기 단계"가 섞이면 빌림 충돌이 자주 납니다.

use std::collections::HashMap;

fn bump(m: &mut HashMap<String, usize>, key: &str) {
    let cur = m.get(key);
    if cur.is_none() {
        m.insert(key.to_string(), 1);
        return;
    }

    // cur를 아직 쓰고 있는 상태에서 insert/modify를 시도하면 충돌하기 쉬움
    let next = cur.unwrap() + 1;
    m.insert(key.to_string(), next);
}

위 코드는 cur.unwrap() 때문에 &usize 참조가 살아있는 동안 insert가 일어나 E0502로 이어질 수 있습니다.

entry로 바꾸면 의도가 선명해지고 빌림도 안전해집니다.

use std::collections::HashMap;

fn bump(m: &mut HashMap<String, usize>, key: &str) {
    *m.entry(key.to_string()).or_insert(0) += 1;
}

이게 흔히 말하는 "한방 해결"에 가장 가까운 형태입니다. 빌림 규칙을 억지로 피하는 게 아니라, Rust가 선호하는 데이터 갱신 모델로 옮기는 것이기 때문입니다.

마무리: E0502/E0499는 버그가 아니라 설계 힌트

E0502E0499는 "컴파일러가 까다롭다"의 신호가 아니라, 코드가 다음 중 하나의 구조를 갖고 있다는 힌트입니다.

  • 참조를 너무 오래 들고 있다
  • 동시에 가변 참조를 여러 개 만들려고 한다
  • 순회와 수정을 한 루프에 섞었다
  • 읽기-수정을 두 단계로 쪼개서 빌림이 겹쳤다

해결은 대개 3가지 중 하나로 수렴합니다.

  • 필요한 값만 복사/복제해서 참조를 없애기
  • 스코프/순서 변경으로 빌림 수명을 줄이기
  • entry, split_at_mut, retain 같은 전용 API로 의도를 표현하기

이 패턴을 몸에 익히면, E0502/E0499는 "막히는 에러"가 아니라 "더 안전한 코드로 가는 리팩터링 가이드"로 보이기 시작합니다.