Published on

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

Authors

서로 다른 언어에서 런타임에 터질 버그가 Rust에서는 컴파일 타임 에러로 나타납니다. 그중에서도 E0502E0499 는 초반에 가장 자주 마주치는 “빌림 충돌” 계열입니다.

  • E0502: 불변 빌림(&T)이 살아있는 동안 가변 빌림(&mut T)을 만들려고 할 때
  • E0499: 동일한 값에 대해 가변 빌림(&mut T)을 동시에 2개 이상 만들려고 할 때

핵심 규칙은 단순합니다.

  • 어떤 값에 대해 가변 참조는 동시에 하나만 존재할 수 있다
  • 불변 참조가 존재하는 동안 가변 참조를 만들 수 없다

하지만 실제 코드는 “참조가 언제까지 살아있는지(스코프)”가 눈에 잘 안 보여서 충돌이 발생합니다. 아래는 실무에서 반복적으로 등장하는 6가지 패턴과 해결 전략입니다.

참고: 비동기/런타임에서의 패닉도 결국 “어떤 작업이 언제까지 점유되는가”가 핵심인 경우가 많습니다. Tokio 런타임 점유 이슈를 다룬 글도 함께 보면 감이 빨리 옵니다: Tokio runtime 패닉 - blocking_in_place 원인·해결

1) 불변 참조를 잡아둔 채로 수정하려는 경우 (E0502)

가장 기본적인 형태입니다.

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

    let r = &s;          // 불변 빌림 시작
    s.push('!');         // 여기서 가변 접근 시도
    println!("{}", r);   // 불변 빌림이 아직 사용됨
}

왜 막히나

rprintln! 에서 사용되므로, 컴파일러는 r 의 생존 범위를 println! 까지로 봅니다. 그 사이에 s.pushs 를 가변으로 빌려야 하므로 충돌입니다.

해결법 A: 불변 참조의 사용을 먼저 끝내기

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

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

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

해결법 B: 필요한 값만 복사/복제하기

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

    let snapshot = s.clone();
    s.push('!');

    println!("before={snapshot}, after={s}");
}

복제 비용이 부담되면 &str 슬라이스로 필요한 부분만 잡거나, 이후 패턴에서 다루는 스코프 쪼개기를 적용합니다.

2) Vec 인덱싱으로 같은 벡터를 두 번 가변 빌림 (E0499)

v[i] 는 내부적으로 IndexMut 를 통해 &mut 를 반환할 수 있어서, 같은 Vec 에서 2개의 &mut 를 만들면 바로 충돌합니다.

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

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

    *a += *b;
}

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

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

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

    *a += *b;
    println!("{:?}", v);
}

split_at_mut 는 “서로 겹치지 않는 두 구간”임을 라이브러리 레벨에서 보장하므로, 컴파일러가 2개의 &mut 를 허용합니다.

3) HashMap/BTreeMap 에서 getget_mut 를 섞는 경우 (E0502)

불변 조회를 한 뒤, 같은 맵을 가변으로 수정하려는 패턴입니다.

use std::collections::HashMap;

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

    let v = m.get("a");      // 불변 빌림
    let w = m.get_mut("a");  // 가변 빌림 시도

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

해결법 A: 값을 먼저 복사해 스코프를 끊기

use std::collections::HashMap;

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

    let old = *m.get("a").unwrap(); // i32는 Copy

    *m.get_mut("a").unwrap() = old + 10;
    println!("{:?}", m.get("a"));
}

해결법 B: entry API로 한 번에 처리

use std::collections::HashMap;

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

    let x = m.entry("a".into()).or_insert(0);
    *x += 1;

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

entry 는 “조회와 삽입/수정”을 하나의 가변 빌림으로 묶어, 불변/가변 혼용을 피하게 해줍니다.

4) 루프에서 원소를 참조한 채 컬렉션을 수정 (E0502/E0499)

반복 중인 컬렉션을 동시에 수정하는 패턴입니다.

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

    for x in &v {        // v를 불변으로 빌림
        if *x == 2 {
            v.push(4);   // 가변 수정 시도
        }
    }
}

해결법 A: 인덱스 기반으로 순회하되, push 대상은 따로 모으기

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

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

해결법 B: retain/drain/partition 같은 “소유권 기반” API 활용

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

    // 2를 제거하면서 카운트
    let mut removed = 0;
    v.retain(|x| {
        let keep = *x != 2;
        if !keep { removed += 1; }
        keep
    });

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

Rust 컬렉션은 “빌림을 오래 들고 있지 않게” 만드는 고수준 메서드가 많습니다. 가능하면 이런 API로 문제를 구조적으로 없애는 게 가장 깔끔합니다.

5) 메서드 체인/클로저가 참조를 예상보다 오래 잡는 경우 (E0502)

NLL(Non-Lexical Lifetimes) 덕분에 많은 경우 참조 생존 범위가 줄어들지만, 클로저/이터레이터 체인에서는 여전히 “참조가 캡처되어 오래 살아있는” 형태가 나오기 쉽습니다.

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

    let pred = |c: char| s.contains(c); // s를 불변으로 캡처
    s.push('d');                         // 가변 수정 시도

    println!("{}", pred('a'));
}

해결법 A: 캡처 대신 필요한 데이터만 분리

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

    let snapshot = s.clone();
    let pred = move |c: char| snapshot.contains(c);

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

해결법 B: 클로저를 짧은 스코프로 제한

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

    {
        let pred = |c: char| s.contains(c);
        println!("{}", pred('a'));
    } // pred 드랍, 불변 빌림 종료

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

클로저가 환경을 어떻게 캡처하는지(move 여부 포함)를 의식하면, E0502의 상당수를 “스코프 설계”로 해결할 수 있습니다.

6) 자기 참조 구조/동일 구조 내 두 필드 동시 가변 빌림 (E0499)

구조체에서 self 를 가변으로 빌린 상태에서, 다시 self 의 다른 필드를 빌리려다 충돌하는 패턴이 나옵니다. 특히 “한 필드를 빌린 참조를 로컬 변수로 들고” 다른 필드를 수정하려 할 때 자주 발생합니다.

#[derive(Debug)]
struct State {
    buf: Vec<u8>,
    pos: usize,
}

impl State {
    fn bump_and_read(&mut self) -> u8 {
        let b = &mut self.buf[self.pos]; // self.buf 가변 빌림
        self.pos += 1;                   // self 전체를 다시 가변 사용
        *b
    }
}

fn main() {
    let mut st = State { buf: vec![10, 20], pos: 0 };
    println!("{}", st.bump_and_read());
}

해결법 A: 먼저 필요한 인덱스/값을 계산하고, 빌림을 늦게 시작

#[derive(Debug)]
struct State {
    buf: Vec<u8>,
    pos: usize,
}

impl State {
    fn bump_and_read(&mut self) -> u8 {
        let i = self.pos;
        self.pos += 1;
        self.buf[i]
    }
}

fn main() {
    let mut st = State { buf: vec![10, 20], pos: 0 };
    println!("{}", st.bump_and_read());
}

여기서는 u8Copy 라서 더 간단합니다. 만약 큰 타입이라면 clone 이나 mem::take 같은 패턴을 고려합니다.

해결법 B: 필드 단위로 “동시에 빌려도 안전함”을 표현하기

두 필드를 동시에 가변으로 다루어야 한다면, 로직을 재구성하거나 표준 라이브러리 도구로 “서로 다른 부분”임을 드러내야 합니다. 예를 들어 split_at_mut 처럼요. 구조체에서는 보통 다음 중 하나로 갑니다.

  • 연산 순서를 바꿔 한쪽 빌림을 먼저 끝낸다
  • 임시 변수로 값을 빼서 처리한 뒤 다시 넣는다(std::mem::take, std::mem::replace)
use std::mem;

#[derive(Debug)]
struct State {
    buf: Vec<String>,
    log: Vec<String>,
}

impl State {
    fn move_first_to_log(&mut self) {
        // buf를 통째로 빼서(소유권 이동) 빌림 충돌을 제거
        let mut buf = mem::take(&mut self.buf);
        if let Some(first) = buf.get(0).cloned() {
            self.log.push(first);
        }
        self.buf = buf;
    }
}

fn main() {
    let mut st = State {
        buf: vec!["a".into(), "b".into()],
        log: vec![],
    };
    st.move_first_to_log();
    println!("{:?}", st);
}

이 방식은 약간 우회처럼 보이지만, “동시에 두 군데를 가변으로 잡아야 하는 구조” 자체를 깨뜨리는 실전적인 해결책입니다.

에러 메시지 읽는 요령: 진짜 문제는 ‘빌림이 끝나지 않음’

E0502/E0499 를 해결할 때는 보통 아래 질문으로 정리됩니다.

  1. 이 참조(& 또는 &mut)가 언제까지 살아있다고 컴파일러가 판단하는가
  2. 그 생존 범위 안에서 같은 대상에 대한 또 다른 빌림이 생기는가
  3. 생존 범위를 줄이거나(스코프/순서 변경), 구조적으로 분리할 수 있는가(split_at_mut, entry, 소유권 이동)

이 관점은 네트워크 타임아웃/데드라인 문제를 디버깅할 때 “어떤 리소스가 언제까지 점유되는가”를 보는 것과 비슷합니다. 관심 있다면 Go gRPC context deadline exceeded 원인 7가지 도 같은 사고방식을 훈련하는 데 도움이 됩니다.

정리: 6가지 패턴별 치트시트

  • 패턴 1: 불변 참조 유지 중 수정 E0502
    • 해결: 불변 참조 사용을 먼저 끝내기, 스냅샷 복제
  • 패턴 2: Vec 원소 2개를 동시에 &mut E0499
    • 해결: split_at_mut
  • 패턴 3: 맵에서 getget_mut E0502
    • 해결: entry, 값 복사/스코프 단축
  • 패턴 4: 순회 중 컬렉션 수정 E0502/E0499
    • 해결: 변경 사항 분리 후 extend, 또는 retain/drain
  • 패턴 5: 클로저/체인이 참조를 오래 캡처 E0502
    • 해결: 스코프 제한, move + 스냅샷
  • 패턴 6: 구조체 내부에서 필드 빌림과 다른 필드 수정 E0499
    • 해결: 연산 순서 변경, mem::take/replace, 로직 재구성

Rust의 빌림 규칙은 “불편한 제약”이라기보다, 데이터 레이스/유즈애프터프리 같은 결함을 설계 단계에서 제거하는 장치입니다. 위 6가지 패턴을 손에 익히면, 에러를 만났을 때도 코드를 더 단순한 소유권 흐름으로 재구성하는 방향이 자연스럽게 떠오를 겁니다.