Published on

Rust borrow checker E0502 충돌 즉시 해결법

Authors

Rust를 쓰다 보면 가장 자주 마주치는 컴파일 에러 중 하나가 E0502 입니다. 메시지는 대개 비슷합니다. cannot borrow ... as mutable because it is also borrowed as immutable.

핵심은 단순합니다. 같은 값에 대해 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들 수 없다는 규칙을 어겼을 때 E0502가 발생합니다. 문제는 실제 코드에서는 이 “살아있다”의 범위가 생각보다 넓게 잡힐 수 있다는 점입니다. 특히 반복문, 클로저, 반환값, 그리고 NLL(Non-Lexical Lifetimes)로도 해결되지 않는 케이스에서 당황하기 쉽습니다.

이 글에서는 E0502바로 고치는 실전 패턴을 예제 중심으로 정리합니다. 더 넓게 E0499까지 함께 정리한 글은 Rust E0502/E0499 빌림 충돌 에러 한방 해결도 참고하면 좋습니다.

E0502 에러 메시지 10초 해석법

에러는 보통 아래 3가지를 알려줍니다.

  1. 어디서 불변 빌림이 시작됐는지
  2. 어디서 가변 빌림을 시도했는지
  3. 불변 빌림이 어디까지 살아있다고 보는지

예시(형태만 보세요):

  • immutable borrow occurs here
  • mutable borrow occurs here
  • immutable borrow later used here

세 번째 줄이 특히 중요합니다. 컴파일러가 “아직 불변 참조가 나중에 쓰일 예정이니, 그 사이에 가변 참조를 만들면 안 된다”고 판단한 지점입니다.

패턴 1: 불변 참조의 스코프를 강제로 끝내기

가장 빠른 해결책은 불변 빌림을 더 빨리 끝내는 것입니다.

문제 코드

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

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

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

firstprintln!에서 사용되므로, 컴파일러는 first의 생존 범위를 push 이후까지로 봅니다.

해결 1: 값 복사(또는 클론)로 참조를 끊기

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

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

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

Copy 타입이면 이게 가장 깔끔합니다. String 같은 타입이면 clone()이 필요할 수 있습니다.

해결 2: 스코프 블록으로 참조 수명 제한

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

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

    v.push(4);
}

println!을 먼저 실행해 불변 빌림을 소멸시키는 식으로 순서를 바꾸는 것도 같은 원리입니다.

패턴 2: “읽고-쓰고”를 2단계로 분리하기

E0502는 보통 한 함수 안에서 “읽기 위해 불변 빌림”을 만든 뒤, 같은 컨테이너를 “수정하기 위해 가변 빌림”하려다 발생합니다.

문제 코드: HashMap에서 읽고 그 다음 수정

use std::collections::HashMap;

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

    let v = m.get("a");
    *m.get_mut("a").unwrap() += 1; // E0502

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

해결: 먼저 필요한 정보만 뽑아두고(복사), 그 다음 수정

use std::collections::HashMap;

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

    let before = *m.get("a").unwrap(); // i32 Copy
    *m.get_mut("a").unwrap() += 1;

    println!("before={}", before);
}

이 패턴은 데이터가 크면 clone() 비용이 생길 수 있으니, “정말 필요한 최소 데이터만” 복사하는 식으로 설계하는 게 좋습니다.

패턴 3: split_at_mut로 슬라이스를 안전하게 쪼개기

벡터의 서로 다른 원소를 동시에 만지려다 E0502가 자주 납니다.

문제 코드: 같은 벡터에서 읽고 쓰기

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

    let a = &v[0];
    let b = &mut v[1]; // E0502

    *b += *a;
}

해결: split_at_mut로 컴파일러가 “겹치지 않는다”는 걸 알게 하기

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

    let (left, right) = v.split_at_mut(1);
    let a = left[0];      // Copy로 값만 가져오기
    let b = &mut right[0];

    *b += a;
}

split_at_mut(n)은 슬라이스를 0..nn..으로 나누며, 두 조각이 절대 겹치지 않음을 Rust가 보장합니다.

패턴 4: getget_mut을 같은 스코프에서 섞지 말기

특히 컬렉션에서 아래 형태가 흔합니다.

  • let x = coll.get(key).unwrap();
  • 그 다음 coll.get_mut(key) 또는 coll.insert(...)

해결책은 대부분 “불변 참조를 오래 들고 있지 말라”로 귀결됩니다.

해결 옵션

  1. Copy면 값으로 빼기
  2. clone()으로 소유권을 가져오기
  3. 불변 참조 사용을 먼저 끝내고, 수정은 뒤로 미루기
  4. 로직을 함수로 분리해 스코프 자체를 끊기

함수 분리 예시:

use std::collections::HashMap;

fn read_value(m: &HashMap<String, i32>) -> i32 {
    *m.get("a").unwrap()
}

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

    let before = read_value(&m);
    *m.get_mut("a").unwrap() += 1;

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

패턴 5: 반복문에서 “현재 원소 참조”를 잡은 채로 컬렉션 수정하지 않기

반복 중에 push, remove, insert 같은 구조 변경을 하면 거의 항상 빌림이 꼬입니다.

문제 코드

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

    for x in &v {
        if *x == 2 {
            v.push(4); // E0502
        }
    }
}

해결 1: 인덱스 기반으로 두 단계 처리

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

    let mut need_push = false;
    for x in &v {
        if *x == 2 {
            need_push = true;
        }
    }

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

해결 2: 새 벡터를 만들어 결과를 구성(함수형 스타일)

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

    let mut out = Vec::with_capacity(v.len() + 1);
    for &x in &v {
        out.push(x);
        if x == 2 {
            out.push(4);
        }
    }

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

원본을 수정하는 대신 결과를 재구성하면 빌림 충돌이 사라지고 로직도 명확해지는 경우가 많습니다.

패턴 6: 클로저가 참조를 “잡고” 있어서 생기는 E0502

클로저가 환경을 캡처하면 참조 수명이 예상보다 길어질 수 있습니다.

문제 코드

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

    let show = || println!("{}", v[0]);

    v.push(4); // E0502: show가 v를 불변으로 캡처
    show();
}

해결: 필요한 값만 캡처하도록 변경

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

    let first = v[0];
    let show = move || println!("{}", first);

    v.push(4);
    show();
}

또는 클로저를 더 늦게 만들거나, 클로저 사용을 먼저 끝낸 뒤 수정하는 방식도 가능합니다.

패턴 7: 정말 필요할 때만 내부 가변성(RefCell, RwLock) 사용

설계상 “여러 곳에서 읽다가 특정 시점에만 쓰기”가 필요하고, 빌림 규칙을 정적으로 맞추기 어려운 경우가 있습니다. 이때 선택지가 내부 가변성입니다.

  • 단일 스레드: std::cell::RefCell
  • 멀티 스레드: std::sync::RwLock, Mutex

RefCell 예시(런타임에 빌림 검사):

use std::cell::RefCell;

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

    {
        let first = v.borrow()[0];
        println!("{}", first);
    }

    v.borrow_mut().push(4);
    println!("{:?}", v.borrow());
}

주의할 점은, RefCell은 규칙을 없애는 게 아니라 컴파일 타임에서 런타임으로 미루는 것이라서 잘못 쓰면 패닉이 날 수 있습니다. 즉, “즉시 해결”에는 좋지만, 남용하면 유지보수성이 떨어질 수 있습니다.

체크리스트: E0502를 가장 빨리 없애는 순서

  1. 불변 참조를 오래 들고 있지 않은가(출력, 반환, 클로저 캡처 포함)
  2. 필요한 값만 Copy 또는 clone()으로 빼서 참조를 끊을 수 있는가
  3. 스코프 블록 또는 함수 분리로 수명을 짧게 만들 수 있는가
  4. 컬렉션의 서로 다른 영역이면 split_at_mut 같은 분해 API를 쓸 수 있는가
  5. 반복 중 구조 변경이면 “2단계 처리” 또는 “새 컨테이너 생성”으로 바꿀 수 있는가
  6. 마지막 수단으로 내부 가변성(RefCell/RwLock)이 설계적으로 타당한가

마무리

E0502는 “Rust가 불편해서”가 아니라, 동시에 읽기와 쓰기가 섞일 때 생기는 실제 버그 가능성을 컴파일 타임에 잘라내는 장치입니다. 해결은 대개 스코프를 줄이거나(참조 수명 단축), 읽기와 쓰기를 분리하거나(2단계), 혹은 데이터 구조를 분해해(겹치지 않음을 증명) 컴파일러가 안전을 확인할 수 있게 만드는 방향으로 갑니다.

추가로 E0499까지 포함해 자주 터지는 패턴을 더 모아둔 정리글은 Rust E0502/E0499 빌림 충돌 에러 한방 해결에서 이어서 보면 좋습니다.