Published on

Rust E0502·E0499 빌림 충돌 7분 해결법

Authors

서버 장애처럼 보이지만 사실은 컴파일러가 미리 잡아주는 경쟁 상태에 가깝습니다. Rust의 E0502(불변 빌림 중 가변 빌림)와 E0499(가변 빌림이 동시에 둘 이상)는 대개 “빌림의 수명(lifetime)이 생각보다 길게 잡혔다”는 한 가지 사실로 설명됩니다.

이 글은 디테일한 이론보다, 에러를 보고 7분 안에 고치는 절차에 집중합니다. 더 많은 패턴 모음은 Rust E0502/E0499 빌림 충돌 6가지 패턴도 함께 참고하세요.

0분: 에러 메시지에서 딱 2줄만 본다

E0502/E0499는 메시지가 길어도 핵심은 항상 동일합니다.

  • first borrow occurs here 또는 immutable borrow occurs here
  • second borrow occurs here 또는 mutable borrow occurs here

즉 “첫 번째 빌림이 시작된 지점”과 “두 번째 빌림이 시도된 지점”만 찾으면 됩니다. 그리고 대부분의 경우, 첫 번째 빌림이 “끝나지 않았기 때문에” 두 번째가 막힙니다.

1분: 빌림 수명을 의심한다 (NLL로도 안 끊기는 경우)

Rust는 NLL(Non Lexical Lifetimes) 덕분에 블록 끝까지가 아니라 “마지막 사용 지점”까지 빌림을 줄여주지만, 다음 상황에서는 빌림이 길어지기 쉽습니다.

  • 참조를 변수에 담아두고 뒤에서 다시 사용
  • 반복문에서 참조를 잡아둔 채로 같은 컬렉션을 수정
  • iter()로 순회하면서 같은 벡터에 push/remove
  • HashMap::get으로 얻은 참조를 들고 있는 동안 insert/entry 호출

해결의 80퍼센트는 “참조를 오래 들고 있지 않게” 만드는 리팩터링입니다.

2분: 가장 흔한 E0502 패턴과 즉시 해결

패턴 A: 불변 참조를 잡아둔 채 가변 수정

아래 코드는 전형적인 E0502를 냅니다.

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

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

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

원인: firstv에 대한 불변 빌림을 유지하고 있는데, pushv를 가변 빌림해야 합니다.

해결 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];

    println!("{}", v[0]);
    v.push(4);
}

해결 3: 스코프를 인위적으로 끊는다

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

    {
        let first = &v[0];
        println!("{}", first);
    } // 여기서 불변 빌림 종료

    v.push(4);
}

3분: 가장 흔한 E0499 패턴과 즉시 해결

패턴 B: 같은 컬렉션에서 가변 참조를 두 개 만들려 함

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

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

    *a += 1;
    *b += 1;
}

원인: 인덱싱으로 얻는 &mut는 “벡터 전체에 대한 가변 빌림”으로 취급되기 때문에 동시에 두 개를 허용하지 않습니다.

해결 1: split_at_mut로 안전하게 분할

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

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

    *a += 1;
    *b += 1;
}

해결 2: 인덱스로 접근하고 참조는 짧게

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

    v[0] += 1;
    v[1] += 1;
}

“참조를 변수로 오래 들고 있지 말고, 필요한 순간에만 접근”이 핵심입니다.

4분: 반복문에서 터지는 빌림 충돌 (순회하며 수정)

패턴 C: iter()로 읽으면서 같은 벡터를 수정

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

    for x in v.iter() {
        if *x == 2 {
            v.push(99);
        }
    }
}

원인: iter()v를 불변 빌림하는 동안 push가 가변 빌림을 요구합니다.

해결 1: 두 단계로 분리 (읽기 단계, 쓰기 단계)

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

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

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

해결 2: 인덱스 기반 루프 (단, 길이 변화 주의)

길이가 바뀌는 작업이 있다면 while과 종료 조건을 명확히 하세요.

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

    let mut i = 0;
    while i < v.len() {
        if v[i] == 2 {
            v.push(99);
        }
        i += 1;
    }
}

이 방식은 논리 버그(무한 루프, 중복 처리)를 만들기 쉬우니 “정말 순회 중 수정이 필요한지”부터 재검토하는 게 좋습니다.

5분: HashMap에서 자주 만나는 충돌 (get 후 insert)

패턴 D: get으로 참조를 잡고 insert를 시도

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.insert("b".to_string(), 2);

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

원인: vm에 대한 불변 참조를 유지합니다. 그 상태에서 insert는 가변 빌림이라 충돌합니다.

해결 1: 필요한 값만 복사하거나 소유로 가져오기

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").copied();
    m.insert("b".to_string(), 2);

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

해결 2: entry로 한 번에 처리 (읽기와 쓰기 결합)

use std::collections::HashMap;

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

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

entry는 빌림 모델에 맞춘 API라 충돌을 크게 줄여줍니다.

6분: 함수 경계에서 빌림이 꼬이면 “반환 타입”을 의심

참조를 반환하거나, 참조를 담은 구조체를 반환하는 순간 빌림 수명이 길어져 충돌이 연쇄적으로 발생합니다.

패턴 E: 참조를 반환하려다 호출자에서 수정이 막힘

fn first(v: &Vec<i32>) -> &i32 {
    &v[0]
}

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

    // r이 살아있는 동안 v를 바꾸려 하면 충돌 가능
    // v.push(4);

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

해결: 소유권을 반환하거나, 값만 반환

fn first_value(v: &[i32]) -> i32 {
    v[0]
}

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

    v.push(4);
    println!("{}", x);
}

참조 반환이 꼭 필요하다면, 호출자에서 “수정이 필요한 구간”과 “참조를 쓰는 구간”을 분리하도록 API를 재설계해야 합니다.

7분: 그래도 안 되면 쓰는 최후의 3단계 체크리스트

1) 빌림을 “값”으로 바꿀 수 있는가

  • Copy면 그냥 복사
  • Clone 비용이 허용되면 복제
  • 문자열/버퍼는 to_string() 같은 소유화가 오히려 설계를 단순하게 함

2) 데이터를 쪼갤 수 있는가

  • 슬라이스는 split_at_mut
  • 구조체는 필드 단위로 분해해서 서로 다른 필드를 각각 가변 빌림
struct State {
    a: i32,
    b: i32,
}

fn main() {
    let mut s = State { a: 1, b: 2 };

    let a = &mut s.a;
    let b = &mut s.b;

    *a += 10;
    *b += 20;
}

같은 구조체라도 “서로 다른 필드”는 동시에 가변 빌림이 가능합니다.

3) 내부 가변성(Interior Mutability)이 진짜 필요한가

정말로 “불변 참조로 공유하되 내부는 바뀌어야” 한다면 RefCell, 멀티스레드라면 Mutex/RwLock 같은 내부 가변성을 고려합니다. 다만 이는 컴파일 타임 보장을 런타임 체크로 바꾸는 선택입니다.

use std::cell::RefCell;

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

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

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

RefCell은 런타임에 빌림 규칙을 위반하면 패닉이 납니다. 즉 “에러를 없애는 도구”가 아니라 “설계 의도를 표현하는 도구”로 써야 합니다.

디버깅 팁: 에러를 더 빨리 읽는 습관

  • 참조 변수(let r = ...)를 보면 “이 참조가 마지막으로 쓰이는 줄”을 찾고, 그 아래에서 같은 대상에 가변 접근이 있는지 확인
  • 반복문 내부에서 컬렉션을 수정한다면, 대부분 두 단계 처리로 분리 가능
  • HashMapgetinsert 대신 entry를 우선 고려

동시성 문제를 런타임에서 잡는 대신 컴파일 타임에 잡는다는 점에서, Rust의 빌림 충돌은 인프라 장애 대응과도 닮아 있습니다. 원인 지점을 빠르게 좁히고, “수명을 줄이거나” “대상을 분리하거나” “API를 바꾸는” 3가지 축으로 접근하면 해결 속도가 급격히 빨라집니다.

추가로 Rust 런타임 계열 트러블슈팅이 필요하다면 Rust Tokio runtime dropped 패닉 해결 가이드도 함께 보면 좋습니다. 컴파일 타임 이슈와 런타임 이슈를 분리해서 보는 습관이 디버깅 시간을 확 줄여줍니다.