Published on

Rust E0502/E0499 빌림 충돌 7가지 패턴

Authors

Rust를 쓰다 보면 결국 한 번은 E0502(불변/가변 빌림 충돌)과 E0499(가변 빌림 중복)과 만나게 됩니다. 둘 다 “동시에 성립할 수 없는 빌림”을 컴파일 타임에 막는 오류인데, 실제로는 코드 구조(스코프, 참조의 생존 범위, 컨테이너 접근 방식)가 원인인 경우가 대부분입니다.

이 글에서는 현업에서 자주 튀어나오는 빌림 충돌을 7가지 패턴으로 분류하고, 각 패턴별로 왜 발생하는지가장 안전하고 읽기 좋은 해결법을 함께 정리합니다.

참고로, 이런 류의 문제는 결국 “충돌을 피하도록 구조를 바꾸는 리팩터링”이 핵심입니다. Git에서 충돌을 줄이기 위해 습관과 흐름을 바꾸는 것과 비슷한 결이죠. 관련해서는 Git rebase 충돌 최소화 - rerere·autosquash 실전도 함께 보면 도움이 됩니다.


E0502/E0499 빠른 해석

  • E0502: 어떤 값에 대해 불변 참조(&T)가 살아있는 동안 같은 값에 **가변 참조(&mut T)**를 만들려고 할 때
  • E0499: 어떤 값에 대해 가변 참조(&mut T)가 살아있는 동안 같은 값에 대해 또 다른 가변 참조를 만들려고 할 때

Rust 규칙을 한 줄로 요약하면:

  • &T는 여러 개 OK (읽기 전용이므로)
  • &mut T는 동시에 딱 하나만 OK (쓰기 가능하므로)
  • &T&mut T는 동시에 OK가 아님

이제 실제로 어디서 충돌이 나는지 패턴으로 보겠습니다.


패턴 1) 불변 참조를 잡아둔 채로 같은 값 수정 (E0502)

가장 흔한 형태입니다. “먼저 읽고, 그 다음 수정”을 같은 스코프에서 해버리면 불변 빌림이 끝나지 않은 것으로 간주될 수 있습니다.

문제가 되는 코드

fn main() {
    let mut s = String::from("hello");

    let view = &s;            // 불변 빌림
    s.push_str(" world");    // 가변 빌림 시도 -> E0502

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

해결 1: 불변 참조 사용을 먼저 끝내기(스코프 분리)

fn main() {
    let mut s = String::from("hello");

    {
        let view = &s;
        println!("{}", view);
    } // 여기서 불변 빌림 종료

    s.push_str(" world");
    println!("{}", s);
}

해결 2: 필요한 값만 복사/복제해 참조 생존 범위 줄이기

fn main() {
    let mut s = String::from("hello");

    let snapshot = s.clone(); // String 복제 (비용 있음)
    s.push_str(" world");

    println!("{} -> {}", snapshot, s);
}

핵심은 “불변 참조를 오래 들고 있지 말기”입니다.


패턴 2) Vec에서 원소 참조를 잡고 push/insert 하기 (E0502)

Vecpush 시 재할당(reallocation)이 발생할 수 있습니다. 원소에 대한 참조를 들고 있으면, 재할당으로 참조가 무효화될 수 있으니 Rust는 이를 금지합니다.

문제가 되는 코드

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

    let first = &v[0];
    v.push(4); // E0502: 불변 빌림 중 가변 빌림

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

해결 1: 인덱스만 저장하고 나중에 다시 접근

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

    let i = 0usize;
    v.push(4);

    println!("{}", v[i]);
}

해결 2: 값을 복사해두기(원소가 Copy일 때)

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

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

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

실무 팁: Vec에서 “참조를 들고 구조를 바꾸는 작업”은 거의 항상 충돌합니다. 참조 대신 인덱스/키를 들고 가는 방식이 안전합니다.


패턴 3) 같은 컬렉션에서 두 개의 &mut를 동시에 얻기 (E0499)

예: v[i]v[j]를 동시에 바꾸고 싶어서 각각 &mut를 얻으려는 경우입니다.

문제가 되는 코드

fn swap_wrong(v: &mut Vec<i32>, i: usize, j: usize) {
    let a = &mut v[i];
    let b = &mut v[j]; // E0499
    std::mem::swap(a, b);
}

컴파일러는 ij가 다르다는 것을 일반적으로 증명할 수 없습니다.

해결 1: split_at_mut 사용(서로 다른 구간으로 분할)

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);
}

해결 2: 표준 라이브러리 제공 API 활용

단순 swap이면:

fn swap_simple(v: &mut Vec<i32>, i: usize, j: usize) {
    v.swap(i, j);
}

실무 팁: “서로 다른 두 원소를 동시에 가변으로 만지기”는 split_at_mut/swap/get_many_mut(버전에 따라 제공) 같은 전용 도구가 정답인 경우가 많습니다.


패턴 4) HashMap에서 get_mut 두 번, 혹은 getinsert (E0499/E0502)

HashMap도 내부 재배치가 가능하고, 동일 map에 대한 중복 가변 빌림은 금지됩니다.

문제가 되는 코드: 두 키를 동시에 수정

use std::collections::HashMap;

fn bump_two(m: &mut HashMap<String, i32>, a: &str, b: &str) {
    let va = m.get_mut(a).unwrap();
    let vb = m.get_mut(b).unwrap(); // E0499
    *va += 1;
    *vb += 1;
}

해결 1: 한 번에 끝내기(스코프/순서 제어)

키가 같을 수도 있다면 분기해야 합니다.

use std::collections::HashMap;

fn bump_two(m: &mut HashMap<String, i32>, a: &str, b: &str) {
    if a == b {
        if let Some(v) = m.get_mut(a) {
            *v += 2;
        }
        return;
    }

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

해결 2: entry API로 업데이트를 “참조 하나”로 처리

use std::collections::HashMap;

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

실무 팁: HashMapentry가 사실상 “빌림 충돌 회피용 정석”입니다.


패턴 5) 반복 중에 같은 컬렉션을 수정하려는 경우 (E0502/E0499)

for x in v.iter()로 순회하면서 v.push(...) 같은 구조 변경을 하면 충돌합니다.

문제가 되는 코드

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

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

해결 1: 2단계 처리(수집 후 반영)

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

    let mut to_add = Vec::new();
    for x in v.iter() {
        if *x == 2 {
            to_add.push(99);
        }
    }

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

해결 2: 인덱스 기반 while로 제어(의도적으로)

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

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

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

주의: while 방식은 로직에 따라 무한 루프가 될 수 있어(계속 push) 의도를 명확히 해야 합니다.


패턴 6) 메서드 체이닝/클로저가 빌림을 “생각보다 오래” 잡는 경우 (E0502)

NLL(Non-Lexical Lifetimes) 덕분에 많은 경우 자동으로 수명이 줄어들지만, 클로저/이터레이터 체이닝은 빌림이 예상보다 길어질 수 있습니다.

문제가 되는 코드(전형적 형태)

fn main() {
    let mut s = String::from("abc");

    let f = || s.len(); // 클로저가 s를 캡처(불변 빌림처럼 동작)

    s.push('d'); // E0502가 날 수 있음(상황/컴파일러 추론에 따라)

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

해결: 필요한 값을 미리 계산해 캡처를 제거

fn main() {
    let mut s = String::from("abc");

    let len = s.len();
    s.push('d');

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

또는 클로저에 데이터를 넘기되 참조가 아니라 소유 값으로 넘깁니다.

fn main() {
    let mut s = String::from("abc");

    let snapshot = s.clone();
    let f = move || snapshot.len();

    s.push('d');
    println!("{}", f());
}

실무 팁: “클로저가 캡처하는 값”은 빌림 추론을 복잡하게 만듭니다. 성능/메모리보다 먼저, 구조를 단순하게 만드는 게 디버깅 시간을 줄입니다.


패턴 7) 자기 참조 구조/동일 객체 내부 필드 간 교차 참조 (E0499/E0502)

구조체의 한 필드를 빌린 상태에서, 같은 구조체의 다른 필드를 가변으로 빌리려다 충돌하는 케이스입니다. Rust는 기본적으로 “같은 객체 전체”를 빌린 것으로 보는 경우가 많습니다(필드 분해가 항상 가능한 건 아님).

문제가 되는 코드(단순화)

struct State {
    buf: Vec<u8>,
    pos: usize,
}

impl State {
    fn read_and_advance(&mut self) -> Option<u8> {
        let b = self.buf.get(self.pos)?; // self.buf 불변 빌림
        self.pos += 1;                   // self 가변 사용 -> E0502 가능
        Some(*b)
    }
}

해결 1: 필요한 값을 먼저 복사하고(또는 cloned) 빌림을 즉시 끝내기

struct State {
    buf: Vec<u8>,
    pos: usize,
}

impl State {
    fn read_and_advance(&mut self) -> Option<u8> {
        let out = self.buf.get(self.pos).copied()?;
        self.pos += 1;
        Some(out)
    }
}

해결 2: 필드 분해로 “서로 다른 빌림”임을 명확히 하기

struct State {
    buf: Vec<u8>,
    pos: usize,
}

impl State {
    fn read_and_advance(&mut self) -> Option<u8> {
        let State { buf, pos } = self;
        let out = buf.get(*pos).copied()?;
        *pos += 1;
        Some(out)
    }
}

실무 팁: 구조체 메서드에서 빌림 충돌이 나면, self를 통째로 다루지 말고 let State { ... } = self;처럼 필드 단위로 분해하면 해결되는 경우가 많습니다.


정리: 빌림 충돌을 줄이는 리팩터링 체크리스트

  • 참조(&T, &mut T)를 오래 들고 있지 말고, 스코프를 짧게 유지한다
  • 컬렉션(Vec, HashMap)에서 참조를 들고 구조를 바꾸지 않는다
  • 동시에 두 군데를 바꿔야 하면 split_at_mut, swap, entry 같은 전용 API를 먼저 찾는다
  • 반복 중 수정은 2단계(수집 후 반영)로 바꾼다
  • 클로저/체이닝으로 캡처가 길어질 때는 중간 변수로 끊어 수명을 단순화한다
  • self 내부 충돌은 필드 분해로 해결 실마리를 찾는다

빌림 검사기는 “안 되는 걸 막는 도구”라기보다, 데이터 경계를 더 명확하게 만들도록 강제하는 설계 파트너에 가깝습니다. 위 7가지 패턴을 몸에 익히면, E0502/E0499는 디버깅 공포가 아니라 구조 개선 신호로 보이기 시작합니다.