Published on

Rust E0502 빌림 충돌 5패턴 해결 가이드

Authors

Rust를 쓰다 보면 "불변으로 빌린 상태에서 가변으로 빌릴 수 없다"는 메시지와 함께 E0502를 자주 만납니다. 원리는 단순합니다.

  • 어떤 값에 대해 불변 참조(&)가 살아있는 동안에는 그 값에 대한 가변 참조(&mut)를 만들 수 없음
  • 반대로 가변 참조가 살아있는 동안에는 그 값에 대한 다른 어떤 참조도 만들 수 없음

문제는 "살아있다"의 범위가 우리가 생각하는 줄 단위가 아니라 **스코프와 생명주기(라이프타임)**로 결정된다는 점입니다. 특히 루프, 클로저, 인덱싱, 컬렉션 API 조합에서 E0502가 터지기 쉽습니다.

아래는 실무에서 가장 자주 만나는 5가지 패턴과 해결 전략입니다.

참고로 비슷하게 런타임 환경/비동기에서 막히는 케이스는 Tokio runtime 패닉 - blocking_in_place 원인·해결도 함께 보면 디버깅 감각을 잡는 데 도움이 됩니다.


패턴 1: 읽은 뒤 같은 컬렉션을 수정하려다 충돌

가장 흔한 형태입니다. 먼저 get 등으로 불변 참조를 얻고, 그 값을 사용한 뒤 같은 Vec를 수정하려고 하면 충돌합니다.

fn main() {
    let mut v = vec![10, 20, 30];

    let first = v.get(0).unwrap(); // 불변 빌림
    v.push(*first);                // 같은 v를 가변으로 빌리려 함 -> E0502
}

해결 1) 필요한 값만 복사/클론해서 불변 빌림을 빨리 끝내기

참조가 아니라 "값"만 필요하다면 가장 깔끔합니다.

fn main() {
    let mut v = vec![10, 20, 30];

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

Copy가 아니라면 clone()을 고려합니다.

fn main() {
    let mut v = vec![String::from("a"), String::from("b")];

    let first = v[0].clone();
    v.push(first);
}

해결 2) 스코프를 쪼개서 참조 생존 범위를 명확히 줄이기

fn main() {
    let mut v = vec![10, 20, 30];

    let first_value = {
        let first_ref = v.get(0).unwrap();
        *first_ref
    }; // 여기서 first_ref 드롭

    v.push(first_value);
}

패턴 2: 루프에서 인덱스로 읽고 같은 루프에서 수정

루프 안에서 v[i] 같은 읽기와 push/remove 같은 구조 변경을 함께 하려다 E0502 또는 다른 빌림/인덱스 관련 에러가 납니다.

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

    for i in 0..v.len() {
        let x = &v[i];
        if *x == 2 {
            v.push(99); // E0502 가능: 불변 참조 x가 살아있는 동안 가변 변경
        }
    }
}

해결 1) 루프 바디에서 참조 대신 값을 사용

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

    for i in 0..v.len() {
        let x = v[i];
        if x == 2 {
            v.push(99);
        }
    }
}

다만 이 코드는 v.len()이 처음에 고정되기 때문에, push로 늘어난 요소는 순회하지 않습니다. 의도가 "기존 요소만 검사"라면 OK, 아니라면 다음 해결이 낫습니다.

해결 2) 2단계 처리(읽기 단계와 쓰기 단계 분리)

"어디를 바꿀지"만 먼저 계산하고, 그 다음에 변경합니다.

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

    let mut need_push = false;
    for &x in v.iter() {
        if x == 2 {
            need_push = true;
        }
    }

    if need_push {
        v.push(99);
    }
}

이 방식은 빌림 규칙을 만족시키는 동시에, 로직을 더 테스트하기 쉽게 만듭니다.


패턴 3: HashMap에서 get으로 읽은 뒤 entry로 수정

HashMap에서 특히 빈번합니다. get이 반환한 참조가 살아있는 동안, 같은 맵에 대해 entryinsert로 가변 접근을 하면 충돌합니다.

use std::collections::HashMap;

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

    let v = m.get("a").unwrap();
    *m.entry("a".to_string()).or_insert(0) += *v; // E0502
}

해결 1) entry 하나로 읽기+쓰기 통합

entry API는 "없으면 만들고, 있으면 수정"을 한 번에 처리하기 좋습니다.

use std::collections::HashMap;

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

    let add = 5;
    let e = m.entry("a".to_string()).or_insert(0);
    *e += add;
}

"현재 값에 기반해 업데이트"가 필요하면, 업데이트에 필요한 값을 먼저 복사해두고 entry로 들어갑니다.

use std::collections::HashMap;

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

    let current = *m.get("a").unwrap(); // i32 Copy
    *m.entry("a".to_string()).or_insert(0) += current;
}

해결 2) get_mut로 가변 참조 하나만 유지

"같은 키"를 수정하는 거라면 get_mut이 더 직관적입니다.

use std::collections::HashMap;

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

    if let Some(v) = m.get_mut("a") {
        *v += 1;
    }
}

패턴 4: 슬라이스/벡터에서 동시에 두 요소를 가변으로 잡기

다음은 E0502뿐 아니라 E0499(동시에 두 개의 &mut 불가)로도 자주 이어지는 패턴입니다. 핵심은 "한 컬렉션에서 두 위치를 동시에 가변 참조"하려는 시도입니다.

fn swap_bad(v: &mut [i32], i: usize, j: usize) {
    let a = &mut v[i];
    let b = &mut v[j];
    std::mem::swap(a, b); // 빌림 충돌
}

해결) split_at_mut로 영역을 분리해 서로 다른 슬라이스로 만들기

split_at_mut는 Rust가 안전성을 증명할 수 있게 "서로 겹치지 않는 두 구간"을 만들어 줍니다.

fn swap_ok(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);
}

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

이 패턴은 정렬, 그래프 인접 리스트 업데이트, DP 테이블 갱신 등에서 매우 자주 쓰입니다.


패턴 5: 클로저/이터레이터가 불변 빌림을 오래 잡고 있어서 수정이 막힘

이터레이터 체인이나 클로저는 "생각보다 오래" 참조를 붙잡습니다. 특히 iter()로 만든 이터레이터가 스코프 끝까지 살아있으면, 그 뒤의 가변 작업이 막힙니다.

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

    let it = v.iter();      // v를 불변으로 빌림
    let count = it.count(); // 여기서 소비되지만, it 바인딩이 남아있어

    v.push(count as i32);   // E0502가 나는 형태를 종종 만남
}

컴파일러 최적화(NLL)가 많은 경우 해결해주지만, 코드 구조에 따라 여전히 막힐 수 있습니다.

해결 1) 이터레이터 바인딩을 만들지 말고 즉시 소비

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

    let count = v.iter().count();
    v.push(count as i32);
}

해결 2) collect로 필요한 결과만 소유 형태로 뽑아낸 뒤 수정

"필터링 결과"를 기반으로 원본을 수정해야 하는 경우에 특히 유용합니다.

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

    let evens: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 0).collect();
    // 여기서부터는 v에 대한 불변 빌림이 끝남

    v.clear();
    v.extend(evens);

    assert_eq!(v, vec![2, 4]);
}

해결 3) 내부 가변성(Interior Mutability)로 설계를 바꾸기

정말로 "읽는 동안에도" 수정이 필요하다면 RefCell, RwLock, Mutex 같은 내부 가변성을 고려합니다. 다만 이는 컴파일 타임 안전을 런타임 체크로 일부 옮기는 트레이드오프입니다.

use std::cell::RefCell;

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

    {
        let read = v.borrow();
        let sum: i32 = read.iter().sum();
        drop(read); // 명시적으로 읽기 빌림 종료

        v.borrow_mut().push(sum);
    }

    assert_eq!(&*v.borrow(), &vec![1, 2, 3, 6]);
}

동시성 환경에서는 std::sync::RwLock을 쓰는 편이 일반적이며, 비동기에서는 tokio::sync::RwLock 같은 타입을 씁니다. 이 주제는 E0502 자체를 "회피"하기보다는, 요구사항에 맞는 동기화 모델을 선택하는 문제로 확장됩니다.


E0502를 빠르게 푸는 체크리스트

1) 참조가 아니라 값이 필요한가

  • Copy면 그냥 값으로 받기
  • String, Vec, 큰 구조체면 clone() 비용을 감수할지 판단

2) 불변 빌림과 가변 빌림을 "단계"로 분리할 수 있는가

  • 먼저 읽어서 "결정"만 만들고
  • 그 다음에 한 번에 수정

3) 한 컨테이너에서 두 군데를 동시에 가변으로 잡는가

  • 슬라이스면 split_at_mut
  • 맵/트리면 API를 바꿔 한 번에 처리(entry, get_mut)

4) 클로저/이터레이터가 참조를 오래 잡고 있지는 않은가

  • 바인딩을 없애고 즉시 소비
  • collect로 소유 데이터로 변환 후 원본 수정

5) 정말로 "읽는 동안 수정"이 요구사항인가


마무리: E0502는 "컴파일러가 증명할 수 있게" 쓰는 문제

E0502는 Rust가 까다롭게 굴어서가 아니라, 데이터 레이스/유즈 애프터 프리를 원천 차단하기 위해 "동시에 성립할 수 없는 조건"을 막는 과정에서 나옵니다. 해결의 핵심은 보통 둘 중 하나입니다.

  • 참조의 생존 범위를 줄여서 컴파일러가 안전함을 증명하게 만들기
  • API/자료구조 접근 방식을 바꿔서 애초에 충돌이 없는 형태로 만들기

위 5패턴을 템플릿처럼 기억해두면, E0502를 만났을 때 에러 메시지를 오래 읽지 않아도 "어디서 불변 빌림이 길어졌는지"를 빠르게 찾아낼 수 있습니다.