Published on

Rust 소유권 - E0502·E0499 borrow checker 해결

Authors

Rust를 쓰다 보면 소유권 규칙 자체는 이해했는데도 컴파일러가 E0502 또는 E0499로 막아서는 순간이 자주 옵니다. 특히 컬렉션을 순회하면서 동시에 수정하거나, 참조를 변수에 오래 붙잡아 두는 코드에서 폭발합니다.

이 글은 “왜 안 되는지”를 규칙으로만 설명하지 않고, 해결 가능한 형태로 코드를 바꾸는 패턴을 중심으로 정리합니다. (참고로 async 환경에서는 대여가 await를 넘어가며 더 복잡해질 수 있는데, 그 경우는 Rust async에서 Send/Sync 컴파일 오류 해결도 함께 보면 좋습니다.)

E0502와 E0499를 한 문장으로 이해하기

E0502: 불변 대여가 살아있는데 가변 대여를 하려고 함

  • 상황: 어떤 값에 대해 &T를 들고 있는 동안, 같은 값에 대해 &mut T가 필요해짐
  • 핵심: 불변 참조의 생존 범위가 생각보다 길다

E0499: 가변 대여를 동시에 2번 하려고 함

  • 상황: 어떤 값에 대해 &mut T를 이미 빌렸는데, 같은 값에 대해 또 &mut T가 필요해짐
  • 핵심: Rust는 동시에 두 개의 가변 참조를 허용하지 않음 (데이터 레이스/aliasing 방지)

둘 다 결론은 같습니다.

  • 같은 데이터에 대해 “읽기 참조”와 “쓰기 참조”가 겹치면 안 됨
  • “쓰기 참조”는 동시에 하나만 가능

먼저 확인할 것: 참조의 생존 범위가 길어지는 흔한 패턴

Rust의 NLL(Non-Lexical Lifetimes) 덕분에 예전보다 생존 범위는 줄어들었지만, 아래 패턴은 여전히 자주 문제를 만듭니다.

  • let r = &x;처럼 참조를 변수에 바인딩하고, 그 변수를 나중에까지 사용
  • println!/format! 등으로 참조를 뒤에서 사용
  • iterator 체인에서 클로저가 참조를 캡처

즉, “이미 끝났다고 생각한 불변 대여”가 컴파일러 입장에서는 아직 끝나지 않은 상태가 됩니다.

E0502 대표 케이스 1: 읽고 나서 같은 값 수정하기

아래 코드는 s를 불변으로 빌린 다음, 같은 s를 가변으로 빌리려 해서 E0502가 납니다.

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

    let len = s.len(); // 불변 접근(대여)

    // 여기서 s를 수정하려고 하면 충돌이 날 수 있음
    s.push('!');

    println!("{len} {s}");
}

해결 1: 불변 대여를 “값”으로 끊기

len()usize를 반환하므로, 실제로는 참조를 오래 들고 있을 이유가 없습니다. 문제는 종종 다른 형태에서 생깁니다. 예를 들어 let r = &s;를 오래 들고 있거나, r을 뒤에서 쓰는 경우입니다.

가장 안전한 처방은 불변 참조 대신 필요한 값을 복사/소유하게 만드는 겁니다.

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

    let snapshot = s.clone(); // 비용은 있지만 소유로 끊는다
    s.push('!');

    println!("before={snapshot}, after={s}");
}
  • 장점: 가장 단순하고 확실
  • 단점: clone() 비용

해결 2: 스코프로 생존 범위 줄이기

참조가 필요한 구간을 블록으로 감싸면, 블록이 끝나는 시점에 대여도 끝납니다.

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

    {
        let first = &v[0];
        println!("first={first}");
    } // 여기서 first의 대여가 종료

    v.push(4);
    println!("v={v:?}");
}

이 패턴은 특히 “로그 출력 때문에 불변 참조가 뒤까지 살아남는” 케이스를 정리할 때 유용합니다.

E0502 대표 케이스 2: iterator로 순회하며 push/insert

다음 코드는 매우 흔합니다.

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

    for x in v.iter() {
        if *x == 2 {
            v.push(99); // E0502: iter()가 불변 대여 중인데 push는 가변 대여 필요
        }
    }
}

v.iter()v 전체를 불변으로 빌린 상태에서 루프가 도는 동안, push()v 전체를 가변으로 빌려야 해서 충돌합니다.

해결 1: “수정 계획”을 먼저 수집하고 나중에 적용

읽기 단계와 쓰기 단계를 분리합니다.

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

    let mut should_push = false;
    for x in v.iter() {
        if *x == 2 {
            should_push = true;
        }
    }

    if should_push {
        v.push(99);
    }

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

조건이 여러 개면 “추가할 값 목록”을 Vec로 모아두고 마지막에 extend() 하는 방식이 일반적입니다.

해결 2: 인덱스로 순회하거나 while로 제어

인덱스 기반 순회는 불변 iterator 대여를 피할 수 있습니다. 다만 push()로 길이가 바뀌는 상황에서는 루프 조건을 조심해야 합니다.

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

    let mut i = 0;
    while i < v.len() {
        if v[i] == 2 {
            v.push(99);
        }
        i += 1;
    }

    println!("{v:?}");
}
  • 장점: 단순
  • 단점: 로직 실수로 무한 루프/의도치 않은 재처리 가능

E0499 대표 케이스 1: 같은 컬렉션에서 두 원소를 동시에 &mut로 빌리기

다음은 “서로 다른 인덱스”인데도 실패하는 전형적인 E0499 예시입니다.

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

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

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

컴파일러는 v[0]v[1]이 서로 다르다는 걸 일반적으로 증명하지 못합니다. 그래서 “같은 v에 대한 두 개의 가변 대여”로 보고 막습니다.

해결 1: split_at_mut로 안전하게 분할

표준 라이브러리가 “서로 겹치지 않는 두 슬라이스”를 보장해주는 API를 제공합니다.

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(k)[..k][k..]가 겹치지 않음을 타입 시스템에 알려줍니다.
  • 두 포인터가 aliasing 하지 않으니 &mut 두 개가 동시에 가능해집니다.

해결 2: 접근 순서를 바꾸고 대여를 짧게 만들기

동시에 들고 있을 필요가 없다면, 스코프를 나눠서 하나씩 처리합니다.

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

    {
        let a = &mut v[0];
        *a += 1;
    }

    {
        let b = &mut v[1];
        *b += 1;
    }

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

이 방식은 “두 값을 동시에 비교/스왑” 같은 경우에는 불편하므로 그땐 split_at_mut가 더 적합합니다.

E0499 대표 케이스 2: HashMap에서 get과 insert를 섞을 때

HashMap에서 값을 읽고(혹은 참조를 잡고) 같은 맵에 다시 쓰려고 하면, 대여가 겹치며 오류가 납니다.

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");
    if v.is_some() {
        m.insert("b".to_string(), 2); // E0502 또는 E0499 계열로 막히는 패턴
    }
}

해결: Entry API로 “한 번의 가변 접근”으로 끝내기

entry()는 내부적으로 필요한 대여 규칙을 만족하는 형태로 설계되어 있습니다.

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<String, i32> = HashMap::new();

    *m.entry("a".to_string()).or_insert(0) += 1;
    *m.entry("b".to_string()).or_insert(0) += 10;

    println!("{m:?}");
}
  • 읽기와 쓰기를 분리하지 않고도 안전하게 갱신 가능
  • 카운팅/집계 로직에서 사실상 정답 패턴

실전 리팩터링 체크리스트

E0502/E0499를 보면 아래 순서대로 점검하면 해결 속도가 빨라집니다.

  1. 참조를 변수에 오래 저장했는가
    • let r = &x;를 만들었다면, 정말 그 참조가 뒤까지 필요했는지 확인
  2. 읽기 단계와 쓰기 단계를 섞었는가
    • 순회하며 수정하는 코드면, 수정할 것들을 먼저 모으고 나중에 적용
  3. 동시에 두 개의 &mut가 필요한가
    • 필요 없다면 스코프 분리
    • 필요하다면 split_at_mut, chunks_exact_mut, Entry 같은 “겹치지 않음을 증명하는 API” 사용
  4. clone이 가장 싸게 끝나는가
    • 성능이 중요한 경로가 아니라면 clone()은 생산성을 크게 올립니다

보너스: async에서 더 자주 터지는 이유

async 함수에서는 await가 끼는 순간, 로컬 변수(참조 포함)가 상태 머신에 캡처될 수 있습니다. 그 결과 “참조가 생각보다 오래 살아있다” 문제가 더 자주 발생합니다. 이때는

  • await 전에 필요한 값을 소유로 만들어 두기
  • Mutex/RwLock 가드가 await를 넘어가지 않게 스코프를 끊기

같은 원칙이 그대로 적용됩니다. async 관련 컴파일 오류 맥락은 Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking와 함께 보면, “안전 규칙을 지키면서 구조를 바꾸는 감각”을 더 빨리 잡을 수 있습니다.

정리

  • E0502는 “불변 대여와 가변 대여가 겹침”
  • E0499는 “가변 대여가 동시에 2개 이상”
  • 해결의 본질은 대여 생존 범위를 줄이거나, 읽기/쓰기 단계를 분리하거나, 겹치지 않음을 보장하는 표준 API로 구조를 바꾸는 것입니다.

borrow checker는 귀찮게 막는 존재가 아니라, 런타임 버그를 컴파일 타임으로 옮기는 장치입니다. 위 패턴들을 손에 익히면, E0502/E0499는 “원인 파악이 쉬운 리팩터링 신호”로 바뀝니다.