Published on

Rust borrow checker E0502·E0499 에러 해결

Authors
Binance registration banner

서론

Rust를 쓰다 보면 컴파일러가 코드를 “안전하지 않다”고 판단해 빌드를 막는 순간이 옵니다. 그중에서도 E0502(immutable borrow와 mutable borrow 충돌), E0499(동시에 두 개 이상의 mutable borrow 시도)는 초반 러스트 학습 곡선을 가파르게 만드는 대표 에러입니다.

하지만 이 에러들은 단순히 “러스트가 까다롭다”가 아니라, 데이터 레이스·use-after-free·iterator invalidation 같은 버그를 컴파일 타임에 제거하기 위한 규칙이 코드 구조에 반영되지 않았다는 신호입니다. 이 글에서는 에러 메시지를 해석하는 법, 자주 발생하는 패턴, 그리고 실제로 팀 코드베이스에서 가장 많이 쓰는 해결 전략을 코드로 정리합니다.

문제 원인을 “경고를 무시하고 우회”하는 방식이 아니라, 소유권/대여(ownership/borrowing) 모델에 맞게 설계를 바꾸는 방식으로 접근하겠습니다. (다른 언어의 경고 해결 글을 좋아한다면, 문제를 원인별로 쪼개서 해결하는 방식은 Pandas SettingWithCopyWarning 완전 해결법 같은 글과 결이 비슷합니다.)

E0502: immutable borrow 중에 mutable borrow를 하려는 경우

에러 의미

E0502는 “이미 &T로 빌려서 읽고 있는데, 같은 값에 대해 &mut T로 쓰기 빌림을 하려 한다”는 뜻입니다. Rust 규칙은 간단합니다.

  • 읽기 빌림(&T)은 여러 개 가능
  • 쓰기 빌림(&mut T)은 동시에 딱 하나만 가능
  • 그리고 읽기 빌림이 살아있는 동안에는 쓰기 빌림을 만들 수 없음

핵심은 “동시에”의 기준이 개발자가 생각하는 “이 줄이 끝났으니 끝난 것”이 아니라, 빌림의 스코프(lifetime) 라는 점입니다. NLL(Non-Lexical Lifetimes) 덕분에 과거보다 많이 완화됐지만, 여전히 변수에 참조를 담아두면 스코프가 길어져 충돌이 납니다.

대표 패턴 1: 참조를 변수에 담아두고, 이후에 수정

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

    let first = &v[0]; // immutable borrow

    v.push(4); // mutable borrow -> E0502

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

왜 문제일까요? push는 내부 버퍼가 재할당(realloc)될 수 있어 &v[0]가 가리키는 주소가 무효가 될 수 있습니다. Rust는 이를 컴파일 타임에 막습니다.

해결 1: 값을 복사/클론해서 참조 수명을 줄이기

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

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

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

Copy가 아닌 타입이라면 clone을 고려합니다.

fn main() {
    let mut v = vec![String::from("a"), String::from("b")];

    let first = v[0].clone();
    v.push(String::from("c"));

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

해결 2: 참조 사용 범위를 블록으로 제한

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

    {
        let first = &v[0];
        println!("{}", first);
    } // 여기서 immutable borrow 종료

    v.push(4);
}

대표 패턴 2: iter()로 읽고 있는데 같은 컬렉션을 수정

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

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

해결 1: 두 단계로 나누기(읽기 단계와 쓰기 단계 분리)

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

    let should_push = v.iter().any(|x| *x == 2);

    if should_push {
        v.push(4);
    }
}

해결 2: 인덱스 기반 루프 + 필요한 값만 복사

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

    let mut i = 0;
    while i < v.len() {
        let x = v[i]; // Copy로 값만 가져오기
        if x == 2 {
            v.push(4);
        }
        i += 1;
    }
}

이 방식은 “순회 중 push로 길이가 변한다”는 논리적 문제도 생길 수 있으니, 의도한 동작인지 꼭 확인해야 합니다.

E0499: mutable borrow가 동시에 두 번 발생

에러 의미

E0499는 “이미 &mut로 빌린 상태에서, 같은 값에 대해 또 다른 &mut를 만들려 한다”는 뜻입니다. Rust는 동시 두 개의 mutable 참조를 금지합니다. 이유는 간단합니다.

  • 두 개의 &mut가 같은 메모리를 가리키면, 한쪽 변경이 다른 쪽에서 예측 불가능
  • aliasing + mutation 조합을 원천 차단

대표 패턴 1: 같은 벡터에서 두 원소를 동시에 &mut로 얻기

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

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

    *a += 1;
    *b += 1;
}

서로 다른 인덱스인데 왜 안 될까요? 컴파일러는 일반적인 인덱싱 연산만으로는 “서로 다른 위치”임을 증명하지 못합니다.

해결 1: split_at_mut로 슬라이스를 분할해 불변식 증명

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

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

    *a += 1;
    *b += 1;

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

split_at_mut는 “왼쪽과 오른쪽이 겹치지 않는다”는 사실을 API가 보장하므로, 컴파일러가 안전을 증명할 수 있습니다.

해결 2: 인덱스가 런타임에 결정된다면 get_many_mut 고려

Rust 버전에 따라 slice::get_many_mut 같은 API를 사용할 수 있습니다. 다만 안정화/버전 제약이 있을 수 있어, 팀 표준 버전에 맞춰 선택하세요.

대표 패턴 2: 구조체 메서드에서 self를 두 번 &mut로 빌림

struct App {
    a: i32,
    b: i32,
}

impl App {
    fn bump_both(&mut self) {
        let x = &mut self.a;
        let y = &mut self.b; // E0499 (상황에 따라)
        *x += 1;
        *y += 1;
    }
}

NLL로 많은 경우 통과하지만, 중간에 함수 호출이 끼거나 참조가 더 오래 살아남으면 실패할 수 있습니다.

해결 1: 동시에 참조를 들고 있지 않게 순서를 바꾸기

impl App {
    fn bump_both(&mut self) {
        self.a += 1;
        self.b += 1;
    }
}

해결 2: 필드를 분해(destructure)해서 분리된 mutable 참조로 만들기

impl App {
    fn bump_both(&mut self) {
        let App { a, b } = self;
        *a += 1;
        *b += 1;
    }
}

이 패턴은 “서로 다른 필드”라는 사실을 컴파일러가 더 잘 이해하도록 돕습니다.

실전 해결 전략: 빌림 스코프를 줄이고, 단계를 분리하라

E0502/E0499는 대부분 아래 전략 중 하나로 정리됩니다.

1) 참조를 오래 들고 있지 않기

  • 참조를 변수에 저장하는 대신, 필요한 순간에만 사용
  • 블록을 만들어 스코프를 강제로 종료
  • 가능하면 값 복사(Copy) 또는 clone으로 소유권을 가져오기
fn main() {
    let mut s = String::from("hello");

    // 나쁜 예: 참조를 오래 들고 감
    let r = s.as_str();
    // s.push('!'); // 여기서 E0502 가능
    println!("{}", r);

    // 좋은 예: 필요한 시점에만 빌림
    s.push('!');
    println!("{}", s.as_str());
}

2) “읽기-계산-쓰기”를 한 번에 하지 말고 두 단계로

특히 컬렉션을 순회하면서 수정하려는 욕구가 강할수록, 단계를 나누면 해결이 쉬워집니다.

fn remove_negatives(v: &mut Vec<i32>) {
    // 1단계: 제거할 인덱스 수집(읽기)
    let to_remove: Vec<usize> = v
        .iter()
        .enumerate()
        .filter_map(|(i, x)| if *x < 0 { Some(i) } else { None })
        .collect();

    // 2단계: 뒤에서부터 제거(쓰기)
    for i in to_remove.into_iter().rev() {
        v.remove(i);
    }
}

3) API를 “빌림 친화적”으로 설계

함수 시그니처가 불필요하게 &mut를 오래 요구하면 호출부에서 충돌이 납니다. 가능한 한:

  • 입력은 &T 또는 소유권(T)으로 받고
  • 출력은 필요한 데이터만 반환
  • 내부에서만 짧게 &mut를 사용

예를 들어, 아래처럼 “조회 + 수정”을 한 함수에서 다 하려 하면 호출부에서 빌림 충돌이 잘 납니다.

fn get_and_update(map: &mut std::collections::HashMap<String, i32>, key: &str) -> i32 {
    let v = map.get(key).unwrap();
    // map.insert(key.to_string(), *v + 1); // E0502 가능
    *v
}

대신 entry API로 한 번에 처리하면 깔끔합니다.

use std::collections::HashMap;

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

자주 쓰는 “정석” 도구들

split_at_mut: 같은 슬라이스에서 두 mutable 참조가 필요할 때

  • 정렬, 파티션, 투 포인터 알고리즘에서 자주 등장
  • 두 구간이 겹치지 않음을 API가 보장

std::mem::take / std::mem::replace: 소유권을 잠시 빼서 작업

구조체 필드를 빌린 채로 다른 필드를 수정해야 하는 상황에서 유용합니다.

use std::mem;

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

impl State {
    fn flush(&mut self) {
        // buf를 통째로 꺼내서(소유권 이동) 처리
        let buf = mem::take(&mut self.buf);
        self.total += buf.len();
        // buf는 여기서 drop
    }
}

RefCell / RwLock은 “최후의 수단”으로

컴파일 타임 빌림 규칙을 런타임 체크로 바꾸는 도구들입니다.

  • 단일 스레드 내부 가변성: RefCell
  • 멀티 스레드 공유: Mutex, RwLock

다만 이는 borrow checker 에러를 “해결”한다기보다 “검사를 런타임으로 미룬다”에 가깝습니다. 성능/데드락/패닉 가능성을 감수해야 하므로, 먼저 설계와 스코프 정리로 풀 수 있는지 확인하는 게 좋습니다.

컴파일러 메시지 읽는 요령

에러 메시지에서 중요한 건 보통 두 가지입니다.

  1. “첫 번째 빌림이 발생한 위치”
  2. “두 번째 빌림이 발생한 위치”

그리고 종종 “첫 번째 빌림이 여기까지 사용된다” 같은 라인이 함께 나옵니다. 이 지점이 실제로는 println! 같은 단순 사용일 수도 있고, 함수 인자로 참조가 전달되면서 수명이 길어진 것일 수도 있습니다.

이 디버깅 방식은 관찰 지점을 늘려 원인을 좁혀가는 점에서, 운영 환경에서 원인별로 분류해 해결하는 접근과 유사합니다. 예를 들어 인증 에러를 invalid_grant 원인별로 쪼개는 방식은 OAuth PKCE invalid_grant 6가지 원인·해결 같은 글이 참고가 됩니다.

결론: E0502/E0499는 “코드 구조를 개선하라”는 신호

E0502와 E0499는 러스트 개발자가 반드시 통과해야 하는 관문이지만, 익숙해지면 오히려 설계를 더 명확하게 만드는 가이드가 됩니다.

  • E0502: 읽기 참조가 살아있는 동안 쓰기를 시도했다
  • E0499: 동시에 두 개의 쓰기 참조를 만들려 했다

해결의 대부분은 다음으로 귀결됩니다.

  • 참조 수명을 짧게 만들기(블록, 즉시 사용, 값 복사/클론)
  • 읽기와 쓰기를 단계로 분리하기
  • split_at_mut, entry, mem::take 같은 “안전함을 증명하는 API”로 표현하기

이 원칙으로 코드를 재구성하면, 단순히 에러를 없애는 수준을 넘어 유지보수성과 동시성 안전성까지 함께 얻을 수 있습니다.