Published on

Rust E0502·E0499 빌림 충돌 5분 해결

Authors

서로 다른 에러처럼 보이지만, E0502E0499는 본질적으로 “동시에 성립할 수 없는 빌림이 겹쳤다”는 같은 문제에서 출발합니다. 이 글은 러스트 빌림 검사기( borrow checker )의 관점에서 문제를 5분 안에 분류하고, 가장 흔한 코드 패턴을 바로 고치는 실전 레시피를 제공합니다.

특히 실무에서는 “컴파일러가 왜 화내는지”보다 “어떻게 빨리 고치는지”가 중요합니다. 아래 체크리스트와 예제를 그대로 적용하면 대부분의 충돌은 즉시 정리됩니다.

E0502·E0499를 30초에 이해하기

E0502: 불변 빌림과 가변 빌림이 겹침

  • 이미 어떤 값이 불변으로 빌려진 상태에서, 같은 값에 대해 가변 빌림을 시도하면 발생합니다.
  • 핵심은 불변 빌림이 “생각보다 오래 살아있다”는 점입니다. (특히 참조를 변수에 담거나, 이터레이터/클로저가 캡처할 때)

E0499: 가변 빌림이 2개 이상 겹침

  • 같은 값에 대해 가변 참조를 동시에 두 개 만들려고 하면 발생합니다.
  • 예: let a = &mut v[i]; let b = &mut v[j]; 같은 형태

5분 해결 체크리스트

  1. 에러 메시지에서 “첫 번째 빌림이 시작한 위치”와 “두 번째 빌림이 시도된 위치”를 찾습니다.
  2. 첫 번째 빌림이 끝나야 하는데 끝나지 않는 이유를 봅니다.
    • 참조를 변수에 저장했는가
    • 이터레이터가 살아있는가
    • 클로저가 캡처했는가
    • match/if let 바인딩이 스코프를 늘렸는가
  3. 아래 레시피 중 하나를 적용합니다.

문제 해결의 감각은 운영 장애 대응과 비슷합니다. 증상을 패턴으로 분류하고, 검증된 처방을 적용하는 식이죠. 같은 맥락에서 재시도/백오프 패턴을 정리한 글도 참고할 만합니다: OpenAI API 429 Rate Limit 재시도·백오프 설계

레시피 1: 빌림 스코프를 “더 짧게” 만들기 (가장 많이 씀)

E0502의 절반은 이걸로 끝납니다. 참조를 오래 들고 있지 말고, 필요한 순간에만 잠깐 빌리고 바로 사용하세요.

문제 코드

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

    let first = &v[0];      // 불변 빌림 시작
    v.push(40);             // 가변 빌림 필요 -> E0502

    println!("{first}");
}

해결 1: 값을 복사해서 들고 있기

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];

    println!("{}", v[0]); // 여기서만 잠깐 빌림
    v.push(40);
}

레시피 2: 블록으로 스코프를 강제로 끊기

참조를 변수에 담아야 한다면, 빌림이 끝나는 지점을 블록으로 명확히 만들어줍니다.

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

    {
        let r = &v[0];
        println!("{r}");
    } // 여기서 r 드랍 -> 불변 빌림 종료

    v.push(4);
}

이 방식은 “컴파일러가 추론하는 수명”을 사람이 의도한 수명으로 맞춰주는 가장 단순한 방법입니다.

레시피 3: split_at_mut로 “서로 다른 영역”임을 증명하기

E0499에서 가장 자주 쓰는 정석입니다. 같은 벡터에서 서로 다른 인덱스를 동시에 &mut로 빌리려면, 컴파일러가 두 참조가 겹치지 않는다는 사실을 증명할 수 있어야 합니다.

문제 코드

fn swap(v: &mut Vec<i32>, i: usize, j: usize) {
    let a = &mut v[i];
    let b = &mut v[j];
    std::mem::swap(a, b); // E0499
}

해결: split_at_mut

fn swap(v: &mut [i32], i: usize, j: usize) {
    assert!(i != j);

    let (lo, hi) = if i < j {
        let (lo, hi) = v.split_at_mut(j);
        (&mut lo[i], &mut hi[0])
    } else {
        let (lo, hi) = v.split_at_mut(i);
        (&mut hi[0], &mut lo[j])
    };

    std::mem::swap(lo, hi);
}

핵심은 split_at_mut(k)가 슬라이스를 두 조각으로 나누고, 두 조각이 절대 겹치지 않는다는 것을 타입 시스템으로 보장한다는 점입니다.

레시피 4: drain/retain/remove 대신 “인덱스 수집 후 처리”

반복하면서 동시에 수정하려다 E0502 또는 E0499로 터지는 전형적인 패턴입니다.

문제 코드: 순회 중 push

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

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

해결: 먼저 필요한 데이터를 모아서, 그 다음에 수정

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

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

    v.extend(to_add);
}

이 패턴은 성능에도 유리한 경우가 많습니다. 빌림 충돌을 피하려고 억지로 clone을 남발하기보다, “읽기 단계”와 “쓰기 단계”를 분리하면 깔끔해집니다.

레시피 5: Option::take로 필드를 “꺼내서” 작업하기

구조체의 특정 필드를 가변으로 만지면서 동시에 다른 필드를 읽거나, 자기 자신을 참조하는 형태가 되면 충돌이 자주 납니다. 이때 Option으로 감싸고 take로 소유권을 잠깐 빼내면 해결되는 경우가 많습니다.

예시: 캐시 갱신 중 자기 참조 충돌을 피하기

#[derive(Default)]
struct Cache {
    buf: Option<Vec<u8>>,
    hits: usize,
}

impl Cache {
    fn update(&mut self) {
        // buf를 잠깐 꺼내오면, self에 대한 빌림 충돌이 줄어든다
        let mut buf = self.buf.take().unwrap_or_default();

        buf.push(1);
        self.hits += 1;

        self.buf = Some(buf);
    }
}

fn main() {
    let mut c = Cache::default();
    c.update();
}

핵심은 “필드에 대한 가변 빌림을 길게 유지하지 않고”, 소유권을 로컬 변수로 옮겨 작업한 뒤 다시 넣는 것입니다.

레시피 6: RefCell은 최후의 수단, 대신 설계를 먼저 바꾸기

RefCell은 런타임에 빌림 규칙을 검사합니다. 컴파일 타임 에러를 런타임 패닉 가능성으로 바꾸는 것이므로, 다음 조건일 때만 고려하세요.

  • 단일 스레드이고
  • 내부 가변성이 설계상 자연스럽고
  • 빌림이 짧고 명확하게 관리되는 경우
use std::cell::RefCell;

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

    // 런타임에 borrow 규칙 검사
    v.borrow_mut().push(4);
    println!("{:?}", v.borrow());
}

다만 RefCell로 가기 전에, 위 레시피들(스코프 단축, 단계 분리, split_at_mut, take)로 해결 가능한지 먼저 확인하는 편이 좋습니다.

컴파일러 메시지 읽는 요령: “어디서 시작했고, 왜 아직 살아있나”

에러 메시지의 핵심은 보통 다음 두 줄입니다.

  • immutable borrow occurs here 또는 first mutable borrow occurs here
  • mutable borrow occurs here 또는 second mutable borrow occurs here

그리고 그 사이에 “첫 빌림이 아직 사용된다”는 단서가 붙습니다.

  • 참조 변수가 이후에 println!에서 쓰인다
  • 이터레이터가 루프 끝까지 살아있다
  • 클로저가 환경을 캡처한다

이 지점을 찾으면, 해결은 대개 “그 사용을 더 앞당기거나”, “값을 복사/복제하거나”, “스코프를 끊거나”, “읽기와 쓰기를 분리”로 귀결됩니다.

자주 나오는 Q&A

Q1. clone으로 해결하면 안 되나?

가능하지만 습관이 되면 성능과 설계가 망가집니다. 작은 Copy 타입은 값 복사로 끝내고, 큰 데이터는 레시피 4처럼 읽기/쓰기 단계를 분리하는 쪽이 보통 더 낫습니다.

Q2. 왜 컴파일러는 ij가 다르다는 걸 모르나?

알더라도 “같은 컬렉션에서 두 개의 &mut를 동시에 만드는 것”은 매우 위험한 연산이라, 러스트는 안전한 API(split_at_mut)를 통해서만 겹치지 않음을 증명하게 설계되어 있습니다.

Q3. 이게 실무에서 어떤 감각과 비슷한가?

장애 대응에서 병목을 빠르게 좁히는 것과 유사합니다. 예를 들어 리눅스 FD 고갈을 진단할 때도 “어디서 열고, 왜 닫히지 않는가”가 핵심인데, 빌림 충돌도 “어디서 빌렸고, 왜 아직 살아있나”가 핵심입니다. 관련 글: 리눅스 Too many open files 즉시 진단·해결

마무리: 5분 안에 끝내는 실전 결론

  • E0502는 대개 “불변 빌림이 생각보다 길다”가 원인입니다. 먼저 스코프를 줄이세요.
  • E0499는 “가변 빌림 2개”가 원인입니다. 같은 컬렉션이면 split_at_mut 같은 안전한 분할 API를 쓰세요.
  • 반복 중 수정이 필요하면 “읽기 단계”와 “쓰기 단계”를 분리하세요.
  • 구조체 필드 충돌은 Option::take로 소유권을 잠깐 꺼내 작업하면 깔끔해집니다.

이 6개 레시피를 손에 익히면, 대부분의 빌림 충돌은 디버깅이 아니라 리팩터링 루틴으로 처리할 수 있습니다.