Published on

Rust borrow checker E0502·E0499 에러 해결

Authors

서론

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”로 표현하기

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