Published on

Rust E0502·E0499 빌림 충돌 6패턴 해결

Authors

서로 다른 기능을 안전하게 조합하려는 순간, Rust의 빌림 검사기는 종종 E0502(불변 빌림 중 가변 빌림)와 E0499(가변 빌림의 중복)로 개발자를 멈춰 세웁니다. 하지만 이 에러들은 “Rust가 까다롭다”가 아니라, 참조의 수명과 별칭(aliasing) 범위가 의도보다 넓게 잡힌 상태를 알려주는 신호에 가깝습니다.

이 글에서는 실무에서 반복적으로 등장하는 빌림 충돌을 6가지 패턴으로 묶고, 각 패턴을 컴파일러가 이해할 수 있는 형태로 좁히는 방법(스코프 축소, 분리 빌림, 소유권 이동, 내부 가변성, 데이터 구조 변경)을 코드 중심으로 정리합니다.

참고: 러스트 빌림 문제를 푸는 과정은 성능 튜닝에서 병목을 잘라내는 사고와도 비슷합니다. 프런트엔드에서 Long Task를 쪼개 INP를 개선하는 접근과 결이 닮아 있습니다: Chrome INP 개선 - Long Task 분해 실전 가이드

E0502·E0499를 빠르게 구분하는 법

  • E0502: 이미 &T(불변 참조)를 잡고 있는 동안 &mut T(가변 참조)를 만들려고 할 때
  • E0499: 이미 &mut T를 잡고 있는 동안 또 다른 &mut T를 만들려고 할 때(동일 대상에 대한 중복 가변 빌림)

둘 다 핵심 원인은 같습니다.

  • 참조가 생각보다 오래 살아있다(스코프가 넓다)
  • 한 구조체/컨테이너에서 서로 다른 필드/원소를 동시에 빌리고 싶은데, 컴파일러가 “겹치지 않는다”를 증명할 수 없다

이제 6가지 패턴으로 쪼개서 해결합니다.

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

가장 흔한 형태는 “읽고 난 뒤 그 값에 기반해 수정”입니다.

fn bump_if_exists(map: &mut std::collections::HashMap<String, i32>, key: &str) {
    let v = map.get(key); // &i32 (불변 빌림)

    if let Some(x) = v {
        // 여기서 map을 가변으로 다시 빌리려 하며 충돌 가능
        map.insert(key.to_string(), x + 1);
    }
}

해결 1: 필요한 값만 복사/복제해서 빌림을 끊기

fn bump_if_exists(map: &mut std::collections::HashMap<String, i32>, key: &str) {
    let next = map.get(key).copied().map(|x| x + 1);

    if let Some(v) = next {
        map.insert(key.to_string(), v);
    }
}
  • copied()i32를 값으로 가져오면 &i32 참조가 남지 않아 이후 &mut가 가능

해결 2: 표준 API로 의도를 표현하기 (entry)

fn bump(map: &mut std::collections::HashMap<String, i32>, key: &str) {
    use std::collections::hash_map::Entry;

    match map.entry(key.to_string()) {
        Entry::Occupied(mut e) => {
            *e.get_mut() += 1;
        }
        Entry::Vacant(e) => {
            e.insert(1);
        }
    }
}
  • 읽기와 쓰기를 분리하지 않고, 한 번의 가변 접근으로 처리

패턴 2) iter()로 순회하면서 같은 컬렉션을 수정 (E0502)

fn remove_negatives(v: &mut Vec<i32>) {
    for x in v.iter() { // v를 불변으로 빌림
        if *x < 0 {
            v.retain(|y| *y >= 0); // v를 가변으로 빌리려 함
        }
    }
}

해결 1: 인덱스 기반 루프 또는 retain 단독 사용

fn remove_negatives(v: &mut Vec<i32>) {
    v.retain(|x| *x >= 0);
}

해결 2: 수정이 필요하면 “수정할 대상 목록”을 먼저 만들기

fn zero_out_selected(v: &mut Vec<i32>) {
    let idxs: Vec<usize> = v.iter()
        .enumerate()
        .filter_map(|(i, x)| (*x > 10).then_some(i))
        .collect();

    for i in idxs {
        v[i] = 0;
    }
}
  • 1단계에서 불변 순회로 인덱스만 수집
  • 2단계에서 가변 수정
  • 빌림의 “동시성”을 시간적으로 분리

패턴 3) 같은 벡터에서 두 원소를 동시에 &mut로 잡기 (E0499)

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

컴파일러 입장에서는 i == j일 수도 있으니, 동일 원소를 두 번 가변 빌림했다고 판단합니다.

해결 1: split_at_mut로 비겹침을 증명

fn swap_ok(v: &mut [i32], i: usize, j: usize) {
    assert!(i != j);

    let (lo, hi) = if i < j { (i, j) } else { (j, i) };
    let (left, right) = v.split_at_mut(hi);

    let a = &mut left[lo];
    let b = &mut right[0];

    std::mem::swap(a, b);
}
  • 슬라이스를 둘로 쪼개면 두 가변 참조가 서로 다른 영역임을 타입 시스템이 알 수 있음

해결 2: 표준 라이브러리의 swap 사용

fn swap_std(v: &mut [i32], i: usize, j: usize) {
    v.swap(i, j);
}
  • 가장 안전하고 의도가 명확한 해법

패턴 4) 구조체의 서로 다른 필드를 동시에 빌리는데 충돌 (E0499/E0502)

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

fn push_and_advance(s: &mut State, b: u8) {
    let buf = &mut s.buf;
    let c = &mut s.cursor; // 같은 s에서 두 번째 &mut

    buf.push(b);
    *c += 1;
}

해결 1: 한 번에 구조 분해로 빌림 분리

fn push_and_advance(s: &mut State, b: u8) {
    let State { buf, cursor } = s;

    buf.push(b);
    *cursor += 1;
}
  • 필드 단위로 분해하면 컴파일러가 “서로 다른 필드”임을 더 잘 추론

해결 2: 로컬 스코프를 더 짧게

fn push_and_advance(s: &mut State, b: u8) {
    s.buf.push(b);
    s.cursor += 1;
}
  • 불필요한 참조 바인딩을 만들지 않으면 빌림 범위가 자동으로 줄어듦

패턴 5) 메서드 체인/클로저가 빌림 수명을 길게 만들어 충돌

NLL(Non-Lexical Lifetimes) 덕분에 많이 좋아졌지만, 여전히 “클로저가 캡처한 참조”나 “체인 중간 결과” 때문에 빌림이 길어질 수 있습니다.

fn update(v: &mut Vec<String>) {
    let first = v.first().unwrap();

    v.push(first.clone()); // first가 v를 불변 빌림 중이라 E0502 가능
}

해결 1: 필요한 값만 먼저 소유로 만들기

fn update(v: &mut Vec<String>) {
    let first = v.first().cloned().unwrap();
    v.push(first);
}

해결 2: 클로저 캡처를 피하고 단계 분리

fn append_len(v: &mut Vec<String>) {
    let n = v.len();
    let s = format!("len={}", n);
    v.push(s);
}
  • len()&self이지만 결과를 값으로 즉시 저장해 빌림을 끝냄

패턴 6) 참조를 반환/보관하려다 자기참조 구조를 만들고 충돌

예를 들어 컨테이너 내부 데이터를 가리키는 참조를 구조체에 저장하려 하면, “자기참조(self-referential)” 문제가 생기며 빌림 충돌이 연쇄적으로 발생합니다.

struct Cache<'a> {
    current: Option<&'a str>,
}

fn set_current<'a>(cache: &mut Cache<'a>, s: &'a String) {
    cache.current = Some(s.as_str());
}

위 자체는 단순하지만, 실무에서는 보통 “소유하는 String”과 “그 내부를 가리키는 참조”를 한 구조체에 같이 두려다가 막힙니다.

해결 1: 참조 대신 인덱스/키를 저장

struct Cache {
    current_idx: Option<usize>,
}

fn set_current(cache: &mut Cache, idx: usize) {
    cache.current_idx = Some(idx);
}
  • 데이터는 Vec<String> 같은 외부 저장소가 소유
  • 캐시는 참조 대신 위치(인덱스)만 보관

해결 2: Rc/Arc로 소유권을 공유하고 참조 수명 문제를 제거

use std::rc::Rc;

struct Cache {
    current: Option<Rc<String>>,
}

fn set_current(cache: &mut Cache, s: Rc<String>) {
    cache.current = Some(s);
}
  • “빌림” 대신 “공유 소유”로 모델을 변경
  • 멀티스레드라면 Arc<String> 사용

해결 3: 내부 가변성(RefCell)은 최후의 수단으로

use std::cell::RefCell;

struct Store {
    items: RefCell<Vec<i32>>,
}

fn push_twice(store: &Store) {
    store.items.borrow_mut().push(1);
    store.items.borrow_mut().push(2);
}
  • 컴파일 타임 대신 런타임에 빌림 규칙을 검사
  • 성능/패닉 가능성/설계 복잡도를 감수해야 하므로, 먼저 스코프 축소나 데이터 구조 변경을 검토

디버깅 체크리스트: “빌림 범위를 눈으로 줄이기”

  1. 참조(&/&mut)를 로컬 변수에 오래 들고 있지 않은가
  2. 불변 참조로 읽은 값을 Copy/Clone해서 “값”으로 만들 수 없는가
  3. 한 컨테이너에서 두 곳을 동시에 바꾸려는가
    • 슬라이스면 split_at_mut
    • 맵이면 entry
  4. 반복 중 수정이 필요하면, 2패스로 나눌 수 없는가(수집 후 수정)
  5. 참조를 구조체에 저장하려는가
    • 인덱스/키 저장, Rc/Arc, 혹은 아키텍처 재설계 고려

이런 접근은 Rust에만 국한되지 않습니다. 예를 들어 배포 파이프라인에서 원인과 범위를 좁혀가는 방식은 캐시 미스 디버깅과도 유사합니다: GitHub Actions 캐시 안 먹을 때 key·restore-keys 디버깅

마무리: 에러 메시지는 “동시성”이 아니라 “범위” 문제를 말한다

E0502와 E0499는 결국 “같은 데이터에 대해 동시에 존재할 수 없는 참조 조합이 만들어졌다”는 뜻입니다. 해결의 본질은 참조의 생존 범위를 줄이거나, 비겹침을 증명하거나, 아예 소유 모델을 바꾸는 것입니다.

처음에는 빌림 규칙이 발목을 잡는 것처럼 보이지만, 익숙해지면 오히려 설계 결함(숨은 공유 상태, 애매한 책임 경계)을 초기에 드러내는 강력한 도구가 됩니다. 소유권 모델을 명확히 하는 과정은 예외 없는 에러 전파/소유권 설계와도 통합니다: C++23 std - -expected로 예외 없애기 - 소유권·에러전파

다음에 E0502·E0499를 만나면, 에러를 억지로 피해가기보다 “내 참조가 어디서 시작해서 어디서 끝나야 하는가”를 먼저 자로 재듯 그려보면 해결 속도가 확 올라갑니다.