Published on

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

Authors

Rust를 쓰다 보면 컴파일러가 가장 자주 던지는 경고등이 E0502E0499입니다. 둘 다 “빌림 규칙을 동시에 만족하지 못했다”는 신호지만, 실제로는 반복해서 등장하는 몇 가지 전형적인 코드 모양이 있습니다.

  • E0502: 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들려고 할 때, 혹은 그 반대 흐름이 섞일 때
  • E0499: 같은 스코프에서 동일한 값에 대한 &mut를 2개 이상 동시에 만들려고 할 때

이 글은 에러 메시지를 해석하는 데서 멈추지 않고, 현장에서 바로 써먹는 “7가지 패턴”으로 정리해 해결책을 제공합니다. (예시는 std만 사용합니다)

참고로 이런 류의 문제는 런타임 장애가 아니라 “컴파일 타임에 데이터 경쟁 가능성을 제거”하는 과정이라, 관점만 바꾸면 성능/안정성 모두 이득입니다. 성능 이슈를 구조적으로 추적하는 방식은 Chrome INP 낮추기 - Long Task 원인추적·해결처럼 원인-패턴-해결을 묶어 보는 접근과도 닮아 있습니다.

에러를 읽는 핵심: ‘참조의 생존 범위’

Rust의 빌림 충돌은 대부분 “참조가 생각보다 오래 살아있다”에서 시작합니다.

  • 스코프(중괄호) 기준으로 끝나지 않습니다.
  • “마지막 사용 지점”까지 살아있을 수 있습니다(NLL, non-lexical lifetimes).
  • 하지만 클로저 캡처, 반복자 체인, match 바인딩 등에서는 여전히 길게 잡히는 경우가 많습니다.

이제부터는 실전에서 자주 만나는 7패턴으로 나눠 봅니다.

패턴 1) 읽고 나서 같은 컨테이너를 수정한다 (E0502)

문제 코드

Vec에서 값을 읽은 다음, 같은 Vec를 수정하려는 코드가 흔합니다.

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

    let first = &v[0];
    v.push(40); // error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable

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

해결 1: 값을 복사/복제해서 참조를 끊기

Copy 타입이면 가장 단순합니다.

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

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

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

해결 2: 사용을 앞당겨 참조 생존 범위를 줄이기

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

    {
        let first = &v[0];
        println!("{}", first); // 여기서 마지막 사용
    }

    v.push(40);
}

핵심은 “불변 참조가 살아있는 동안 구조를 바꾸는 작업”을 분리하는 것입니다.

패턴 2) 인덱스로 두 번 &mut를 뽑는다 (E0499)

문제 코드

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

    let a = &mut v[0];
    let b = &mut v[1]; // error[E0499]: cannot borrow `v` as mutable more than once at a time

    *a += *b;
}

해결 1: split_at_mut로 서로 다른 슬라이스로 분리

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

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

    *a += *b;
}

해결 2: get_many_mut 사용(버전에 따라 제공)

Rust 버전에 따라 slice::get_many_mut가 유용합니다. 인덱스가 서로 다름을 런타임에서 확인하고, 안전하게 여러 &mut를 꺼냅니다.

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

    let [a, b] = v.get_many_mut([0, 1]).unwrap();
    *a += *b;
}

패턴 3) HashMap에서 getget_mut을 섞는다 (E0502)

문제 코드

use std::collections::HashMap;

fn main() {
    let mut m = HashMap::from([("a".to_string(), 1)]);

    let cur = m.get("a");
    if let Some(x) = m.get_mut("a") { // error[E0502]
        *x += 1;
    }

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

해결 1: entry API로 읽기+쓰기 흐름을 하나로

use std::collections::HashMap;

fn main() {
    let mut m = HashMap::new();

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

해결 2: 읽은 값을 복제해서 참조를 끊기

use std::collections::HashMap;

fn main() {
    let mut m = HashMap::from([("a".to_string(), 1)]);

    let cur = m.get("a").copied();
    if let Some(x) = m.get_mut("a") {
        *x += 1;
    }

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

HashMap류는 entry를 기본 패턴으로 잡아두면 빌림 충돌이 눈에 띄게 줄어듭니다.

패턴 4) 반복 중에 같은 컬렉션을 수정한다 (E0502 또는 E0499)

문제 코드

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

    for x in v.iter() {
        if *x == 2 {
            v.push(4); // error[E0502]
        }
    }
}

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

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

    let mut to_add = vec![];
    for x in v.iter() {
        if *x == 2 {
            to_add.push(4);
        }
    }

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

해결 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(4);
        }
        i += 1;
    }
}

이 패턴은 런타임에서 작업 큐를 돌리며 상태를 갱신하는 코드에서 자주 나옵니다.

패턴 5) match/바인딩이 참조를 길게 잡는다 (E0502)

문제 코드

match로 참조를 바인딩하면, 의도보다 참조가 오래 살아있는 것처럼 느껴질 때가 있습니다.

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

    let r = match s.as_str() {
        "hello" => &s, // 불변 참조를 만들어 둠
        _ => &s,
    };

    s.push('!'); // error[E0502]
    println!("{}", r);
}

해결: 필요한 값만 뽑아서 소유하거나, 참조 사용을 먼저 끝내기

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

    let is_hello = s == "hello"; // bool만 남김
    if is_hello {
        // ...
    }

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

또는 match가 반환하는 값을 “참조”가 아니라 “복제된 값”으로 바꾸는 것이 핵심입니다. String이면 to_string()이나 clone()이 비용이 될 수 있으니, 설계 단계에서 데이터 흐름을 재고하는 게 좋습니다.

패턴 6) 클로저가 &mut self와 동시에 필드를 캡처한다 (E0502/E0499)

문제 코드

메서드 내부에서 클로저를 만들고, 그 클로저가 self 또는 self의 필드를 캡처한 상태로 다시 self를 가변으로 쓰면 충돌이 납니다.

struct App {
    buf: Vec<i32>,
}

impl App {
    fn run(&mut self) {
        let f = || {
            // self.buf를 캡처(불변)
            self.buf.len()
        };

        self.buf.push(1); // error[E0502]
        println!("{}", f());
    }
}

해결 1: 필요한 값만 로컬로 빼서 캡처

struct App {
    buf: Vec<i32>,
}

impl App {
    fn run(&mut self) {
        let len_before = self.buf.len();
        let f = move || len_before;

        self.buf.push(1);
        println!("{}", f());
    }
}

해결 2: 스코프를 쪼개 캡처 수명을 제한

struct App {
    buf: Vec<i32>,
}

impl App {
    fn run(&mut self) {
        {
            let f = || self.buf.len();
            println!("{}", f());
        }

        self.buf.push(1);
    }
}

클로저는 “언제 호출되느냐” 때문에 컴파일러가 보수적으로 수명을 잡는 경우가 많습니다. 따라서 캡처를 최소화하는 쪽이 정답인 경우가 많습니다.

패턴 7) self의 두 필드를 동시에 가변으로 빌리려 한다 (E0499)

문제 코드

struct State {
    a: Vec<i32>,
    b: Vec<i32>,
}

impl State {
    fn move_one(&mut self) {
        let x = self.a.pop().unwrap();
        self.b.push(x);
    }

    fn bad(&mut self) {
        let a = &mut self.a;
        let b = &mut self.b; // error[E0499]
        if let Some(x) = a.pop() {
            b.push(x);
        }
    }
}

Rust는 “서로 다른 필드”라도 &mut self에서 동시에 두 개의 &mut를 꺼내는 상황을 보수적으로 막을 수 있습니다(특히 접근 방식에 따라). 최근 컴파일러는 많은 경우 필드 분리를 잘 해주지만, 여전히 실패하는 형태가 있습니다.

해결 1: 동시에 잡지 말고 순서를 만들기

struct State {
    a: Vec<i32>,
    b: Vec<i32>,
}

impl State {
    fn ok(&mut self) {
        if let Some(x) = self.a.pop() {
            self.b.push(x);
        }
    }
}

해결 2: 구조 분해로 필드 분리(동시 &mut 명시)

struct State {
    a: Vec<i32>,
    b: Vec<i32>,
}

impl State {
    fn ok2(&mut self) {
        let State { a, b } = self;
        if let Some(x) = a.pop() {
            b.push(x);
        }
    }
}

이 방식은 컴파일러에게 “서로 다른 필드에 대한 분리된 빌림”임을 더 명확히 전달합니다.

디버깅 체크리스트: 빌림 충돌을 빠르게 푸는 순서

현장에서 E0502/E0499가 뜨면 아래 순서로 보면 해결이 빨라집니다.

  1. 참조(&/&mut)가 마지막으로 사용되는 지점을 찾는다
  2. 그 참조가 필요한지, 값 복사/소유로 바꿀 수 있는지 본다
  3. “읽기 단계”와 “쓰기 단계”를 분리할 수 있는지 본다
  4. 컬렉션이면 entry, split_at_mut, 2단계 처리(수집 후 반영) 같은 정형 패턴을 적용한다
  5. 클로저/이터레이터 체인이 있으면 캡처를 최소화하거나 스코프를 쪼갠다

이런 식의 원인 추적은 서버 메모리 이슈를 단계적으로 좁혀가는 Spring Boot OutOfMemoryError 덤프 분석·튜닝 7단계 같은 접근과도 유사합니다. “증상”이 아니라 “패턴”을 찾는 게 핵심입니다.

마무리: 빌림 규칙은 제약이 아니라 설계 가이드

E0502/E0499는 처음엔 발목을 잡지만, 반복되는 7패턴을 익히면 오히려 코드 구조가 더 명확해집니다.

  • 참조 수명을 짧게
  • 읽기/쓰기 단계 분리
  • 컬렉션은 표준 API(entry, split_at_mut) 활용
  • 클로저 캡처 최소화

이 원칙만 습관화해도 빌림 충돌의 대부분은 “컴파일러와 싸우는 문제”가 아니라 “데이터 흐름을 정리하는 리팩터링”으로 바뀝니다.

다음 단계로는, 복잡한 그래프 구조나 캐시를 다루면서 Rc, RefCell, Arc, Mutex 같은 내부 가변성까지 확장되는 케이스를 다루게 되는데, 그때도 결국 핵심은 동일합니다. 참조의 생존 범위를 통제하고, 동시에 필요한 가변 접근을 구조적으로 분리하는 것입니다.