Published on

Rust E0502/E0499 빌림 충돌 5패턴 해결

Authors

Rust를 쓰다 보면 컴파일러가 가장 먼저 “선 넘었다”고 알려주는 에러가 E0502(불변 빌림과 가변 빌림의 충돌), E0499(가변 빌림의 중복)입니다. 둘 다 핵심은 같습니다. 동일한 데이터에 대해, 동시에(같은 라이프타임 구간에) 규칙에 어긋나는 빌림이 존재한다는 뜻입니다.

이 글에서는 실무에서 반복적으로 등장하는 충돌을 5가지 패턴으로 분류하고, 각 패턴별로 “왜 발생하는지”와 “가장 안전하고 읽기 좋은 해결법”을 코드로 정리합니다.

참고로 이런 류의 문제는 언어는 달라도 디버깅 패턴이 비슷합니다. 예를 들어 Pandas의 뷰/복사 혼동이 경고로 드러나는 것처럼, Rust는 빌림 규칙을 컴파일 타임에 강제합니다. 유사한 진단-해결 글로는 Pandas SettingWithCopyWarning 원인·해결 7가지도 함께 보면 “원인 분해” 관점에 도움이 됩니다.


E0502와 E0499를 빠르게 해석하는 법

  • E0502: 이미 &T(불변 참조)를 잡아둔 상태에서 같은 대상에 &mut T를 만들려고 함
  • E0499: 이미 &mut T를 잡아둔 상태에서 같은 대상에 또 &mut T를 만들려고 함

중요한 포인트는 **“동시에”의 기준이 실행 순서가 아니라 “스코프(라이프타임)”**라는 점입니다. Rust는 참조가 마지막으로 사용되는 지점까지 라이프타임을 연장할 수 있고(비-어휘적 라이프타임, NLL), 그 결과 “내 눈에는 끝난 것 같은데” 컴파일러는 아직 참조가 살아있다고 판단할 수 있습니다.


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

전형적인 실패 코드

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

    let first = &v[0];
    v.push(4); // E0502: immutable borrow occurs here

    println!("{first}");
}

firstv를 불변으로 빌린 상태인데, push는 재할당(reallocation) 가능성이 있어 v 전체를 가변으로 빌려야 합니다. 그래서 충돌합니다.

해결 1: 값 복사(또는 클론)로 참조 라이프타임 제거

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

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

    println!("{first}");
}

Copy가 아닌 타입이면 clone() 또는 필요한 데이터만 추출하는 방식으로 해결합니다.

해결 2: 스코프를 줄여 참조를 빨리 끝내기

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

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

    v.push(4);
}

언제 이 패턴을 쓰나

  • 로깅/검증 때문에 잠깐 참조가 필요할 때
  • 참조를 오래 들고 있을 이유가 없는데 무심코 변수로 빼둔 경우

패턴 2) 같은 컨테이너에서 “읽고” 결과로 “쓰기”를 하는 루프 (E0502)

전형적인 실패 코드: iter()로 읽으면서 같은 벡터를 수정

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

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

v.iter()가 루프 전체 동안 v를 불변으로 빌립니다. 그 상태에서 push는 가변 빌림이 필요하니 충돌합니다.

해결 1: 2단계 처리(읽기 단계와 쓰기 단계 분리)

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

    let to_add: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 1).collect();
    v.extend(to_add);

    assert_eq!(v, vec![1, 2, 3, 1, 3]);
}

읽기 단계에서는 불변 참조만, 쓰기 단계에서는 가변 참조만 사용하게 만들면 규칙이 깔끔해집니다.

해결 2: 인덱스로 순회하되, 길이 변화에 주의

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

    let original_len = v.len();
    for i in 0..original_len {
        let x = v[i];
        if x % 2 == 1 {
            v.push(x);
        }
    }
}

언제 이 패턴을 쓰나

  • “기존 데이터 기반으로 추가 append” 같은 작업
  • 스트리밍처럼 보이지만 실제로는 배치로 나눌 수 있는 작업

패턴 3) 같은 슬라이스에서 서로 다른 두 요소를 &mut로 동시에 잡기 (E0499)

전형적인 실패 코드

fn swap_first_two(v: &mut [i32]) {
    let a = &mut v[0];
    let b = &mut v[1]; // E0499
    std::mem::swap(a, b);
}

컴파일러는 v[0]v[1]이 서로 다른 메모리라는 걸 “일반적인 인덱싱 연산”만으로는 증명하지 못합니다. 그래서 보수적으로 막습니다.

해결 1: 표준 라이브러리의 split_at_mut 사용

fn swap_first_two(v: &mut [i32]) {
    let (left, right) = v.split_at_mut(1);
    let a = &mut left[0];
    let b = &mut right[0];
    std::mem::swap(a, b);
}

split_at_mut는 “두 슬라이스가 겹치지 않는다”는 것을 타입 시스템에 반영해줍니다.

해결 2: 전용 API 사용

이미 제공되는 메서드가 있으면 그게 가장 안전합니다.

fn swap_first_two(v: &mut [i32]) {
    v.swap(0, 1);
}

언제 이 패턴을 쓰나

  • 배열/슬라이스에서 두 지점을 동시에 갱신해야 하는 알고리즘
  • 그래프/DP/정렬처럼 “서로 다른 인덱스의 동시 가변 참조”가 필요한 코드

패턴 4) HashMap에서 항목을 읽고 같은 HashMap을 다시 수정 (E0502/E0499)

HashMap은 내부적으로 버킷 재배치가 일어날 수 있어, 엔트리 참조를 들고 있는 동안 맵을 다시 가변 접근하면 충돌이 자주 납니다.

전형적인 실패 코드: 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").unwrap();
    m.insert("b".to_string(), *v + 1); // E0502
}

해결 1: 필요한 값만 복사해 참조를 끊기

use std::collections::HashMap;

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

    let a_val = *m.get("a").unwrap();
    m.insert("b".to_string(), a_val + 1);
}

해결 2: entry API로 “읽기+쓰기”를 한 번에

카운팅/누적처럼 전형적인 패턴은 entry가 정답인 경우가 많습니다.

use std::collections::HashMap;

fn main() {
    let mut counts: HashMap<String, usize> = HashMap::new();
    let key = "apple".to_string();

    *counts.entry(key).or_insert(0) += 1;
}

해결 3: 두 키를 동시에 &mut로 잡아야 한다면 구조를 바꾸기

서로 다른 키에 대해 동시에 &mut를 얻는 것은 일반적으로 불가능합니다(동일 맵에 대한 2개의 가변 빌림). 이때는 다음 중 하나로 설계를 조정합니다.

  • 값을 복사한 뒤 다시 넣기
  • 업데이트를 순차적으로 수행
  • 데이터 구조를 분리(예: 두 HashMap으로 분리하거나, 값에 내부 가변성 부여)

언제 이 패턴을 쓰나

  • 캐시 갱신, 카운터, 집계 로직
  • 하나의 맵을 “상태 저장소”로 쓰면서 여러 곳에서 업데이트하는 코드

패턴 5) 구조체 메서드에서 self의 한 필드를 빌린 채 다른 필드를 수정 (E0502/E0499)

실무에서 체감 난이도가 높은 케이스입니다. 특히 self.field에 대한 참조를 로컬 변수로 빼두면, 그 참조가 살아있는 동안 self를 다시 가변으로 쓰기 어렵습니다.

전형적인 실패 코드

struct App {
    buf: String,
    len: usize,
}

impl App {
    fn update_len_and_append(&mut self, s: &str) {
        let b = &self.buf;          // 불변 빌림
        self.len = b.len();         // 여기까지는 OK처럼 보이지만
        self.buf.push_str(s);       // E0502가 나는 형태로 자주 변형됨
    }
}

위 예시는 컴파일러/NLL에 따라 통과할 수도 있지만, 실제로는 b를 더 뒤에서 쓰거나, 다른 로직이 끼면 쉽게 충돌합니다. 핵심은 필드 참조를 오래 들고 있지 말고, 필요한 값만 뽑아 쓰거나, 필드 단위로 분리 빌림을 유도하는 것입니다.

해결 1: 필요한 값만 먼저 계산해서 저장

struct App {
    buf: String,
    len: usize,
}

impl App {
    fn update_len_and_append(&mut self, s: &str) {
        let current_len = self.buf.len();
        self.len = current_len;
        self.buf.push_str(s);
    }
}

해결 2: “필드 분해”로 서로 다른 필드에 대한 빌림을 분리

struct App {
    buf: String,
    len: usize,
}

impl App {
    fn append_and_refresh(&mut self, s: &str) {
        let App { buf, len } = self;
        buf.push_str(s);
        *len = buf.len();
    }
}

이 방식은 self 전체를 다시 빌리지 않고, 서로 다른 필드를 명시적으로 다루게 해 빌림 충돌을 줄입니다.

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

컴파일 타임 빌림 규칙을 런타임 체크로 바꾸는 방법입니다. 단, 런타임 패닉(예: RefCell의 중복 가변 대여) 가능성이 생기므로 “정말 구조적으로 필요할 때”만 씁니다.

use std::cell::RefCell;

struct App {
    buf: RefCell<String>,
}

impl App {
    fn append(&self, s: &str) {
        self.buf.borrow_mut().push_str(s);
    }
}

언제 이 패턴을 쓰나

  • self 내부에 캐시를 두고, 논리적으로는 불변 메서드에서 캐시만 갱신해야 할 때
  • 콜백/클로저로 인해 빌림 스코프를 깔끔히 자르기 어려울 때

디버깅 체크리스트: 빌림 충돌을 빠르게 줄이는 6가지 질문

  1. 지금 참조(& 또는 &mut)를 변수로 “저장”해두고 있나
  2. 그 참조가 마지막으로 사용되는 지점이 어디인가(로그 한 줄 때문에 라이프타임이 늘어나지 않았나)
  3. 읽기와 쓰기를 한 루프에서 섞고 있나(2단계로 분리 가능한가)
  4. 컨테이너에서 동시에 두 군데를 가변 참조해야 하나(슬라이스면 split_at_mut, 맵이면 설계 변경)
  5. self 전체가 아니라 특정 필드만 다루도록 분해할 수 있나
  6. 정말로 내부 가변성이 필요한가(필요하다면 RefCell의 런타임 비용과 패닉 가능성을 감수할 가치가 있나)

마무리: “스코프를 줄이거나, 단계를 나누거나, 구조를 분리”

E0502/E0499는 Rust가 안전성을 지키기 위해 강하게 거는 제약이지만, 반복해서 겪다 보면 해결 전략이 패턴화됩니다.

  • 참조를 오래 들고 있지 않게 스코프를 줄이기
  • 읽기와 쓰기를 단계로 분리하기
  • 동시에 두 곳을 바꿔야 한다면 표준 도구(split_at_mut, swap, entry)를 쓰거나 데이터 구조를 분리하기
  • 그래도 안 되면 내부 가변성은 의도적으로 도입하기

이 5패턴을 기준으로 에러 메시지를 분류하면, 컴파일러가 요구하는 형태로 코드를 재구성하는 속도가 확실히 빨라집니다.