Published on

Rust E0502/E0499 소유권 에러 7분 해결

Authors

Rust를 쓰다 보면 생산성을 가장 많이 끊는 에러가 E0502E0499입니다. 둘 다 “빌림(borrow) 규칙” 위반인데, 원인을 정확히 분류하면 해결은 생각보다 빠릅니다. 이 글은 디버깅 순서를 고정해 7분 안에 해결하는 것을 목표로 합니다.

  • E0502: 불변으로 빌린 상태에서 가변으로 빌리려 함(또는 그 반대의 충돌)
  • E0499: 가변 참조를 동시에 2개 이상 만들려 함

핵심은 하나입니다. 참조의 “동시성(overlap)”을 없애라. 즉, 빌림이 겹치지 않게 범위를 줄이거나, 안전한 분할 API를 쓰거나, 구조를 바꾸면 됩니다.

문제 유형을 더 깊게 보고 싶다면 아래 글도 함께 참고하세요.

0. 30초 진단: 에러 메시지에서 이것만 본다

컴파일러는 보통 다음 정보를 줍니다.

  1. borrowed here (어디서 빌렸는지)
  2. borrow later used here (그 빌림이 어디까지 살아있는지)
  3. cannot borrow ... as mutable because it is also borrowed as immutable 같은 충돌 문장

여기서 “later used here”가 가리키는 줄이 빌림 범위의 끝입니다. 해결은 대부분 이 범위를 줄이는 쪽으로 갑니다.

1. E0502 1분 해결: 빌림 범위를 줄여라(스코프/임시값/복사)

패턴 A: 먼저 값을 꺼내서 빌림을 끝낸 뒤 수정

아래는 흔한 E0502 예시입니다.

fn bump_if_positive(v: &mut Vec<i32>) {
    let first = &v[0];
    if *first > 0 {
        v.push(1); // E0502: immutable borrow still active
    }
}

해결은 first를 참조로 들고 있지 말고 값을 복사하거나, 최소한 빌림이 끝나게 만듭니다.

fn bump_if_positive(v: &mut Vec<i32>) {
    let first = v[0]; // i32는 Copy
    if first > 0 {
        v.push(1);
    }
}

Copy가 아닌 타입이면 clone()이 필요할 수 있습니다.

fn bump_if_starts_with_a(v: &mut Vec<String>) {
    let first = v[0].clone();
    if first.starts_with('a') {
        v.push("added".to_string());
    }
}

패턴 B: 블록으로 스코프를 강제로 끊기

fn bump_if_positive(v: &mut Vec<i32>) {
    {
        let first = &v[0];
        if *first <= 0 {
            return;
        }
    } // 여기서 first의 borrow가 종료

    v.push(1);
}

이 방식은 “참조를 꼭 써야 하는데” 범위만 좁히고 싶을 때 가장 빠릅니다.

2. E0499 2분 해결: 동시에 두 개의 &mut를 만들지 마라

E0499는 보통 “같은 컬렉션에서 서로 다른 원소를 동시에 가변 참조”하려다 발생합니다.

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

패턴 A: 표준 라이브러리 기능부터 찾기

위 케이스는 그냥 Vec::swap이 정답입니다.

fn swap_ok(v: &mut Vec<i32>, i: usize, j: usize) {
    v.swap(i, j);
}

패턴 B: split_at_mut로 안전하게 분할

서로 다른 인덱스를 동시에 가변 참조해야 한다면 split_at_mut가 가장 정석입니다.

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

    let (a, b) = if i < j {
        let (left, right) = v.split_at_mut(j);
        (&mut left[i], &mut right[0])
    } else {
        let (left, right) = v.split_at_mut(i);
        (&mut right[0], &mut left[j])
    };

    std::mem::swap(a, b);
}

포인트는 split_at_mut(k)가 슬라이스를 겹치지 않는 두 조각으로 나눠주기 때문에, 컴파일러가 aliasing이 없음을 증명할 수 있다는 점입니다.

3. “빌림이 너무 길다”의 정체: NLL이 있어도 길어지는 경우

Rust의 NLL(Non-Lexical Lifetimes) 덕분에 많은 케이스가 자동으로 해결되지만, 다음 패턴에서는 여전히 빌림이 길어질 수 있습니다.

  • 참조가 이후 로직에서 다시 사용됨
  • 클로저가 참조를 캡처함
  • 반복문에서 참조를 변수에 들고 다음 반복으로 넘어감

예: 반복문에서 참조를 들고 수정하려는 경우

fn mark_and_push(v: &mut Vec<i32>) {
    for i in 0..v.len() {
        let x = &v[i];
        if *x % 2 == 0 {
            v.push(0); // E0502
        }
    }
}

해결은 “참조”가 아니라 “값”으로 판단하거나, 루프 구조를 바꿉니다.

fn mark_and_push(v: &mut Vec<i32>) {
    let original_len = v.len();
    for i in 0..original_len {
        let x = v[i];
        if x % 2 == 0 {
            v.push(0);
        }
    }
}

push로 길이가 바뀌면 인덱싱 안정성이 깨질 수 있으니, 위처럼 original_len을 고정하는 습관이 중요합니다.

4. 구조를 바꾸면 3분 절약: “읽기 단계”와 “쓰기 단계”를 분리

실무에서 가장 강력한 해결책은 로직을 2단계로 나누는 것입니다.

  • 1단계: 불변 참조로 필요한 정보 수집
  • 2단계: 가변 참조로 일괄 적용

예: 조건에 맞는 인덱스를 모아서 나중에 수정

fn add_one_to_negatives(v: &mut Vec<i32>) {
    // 1) 읽기: 수정할 위치만 수집
    let idxs: Vec<usize> = v
        .iter()
        .enumerate()
        .filter_map(|(i, &x)| if x < 0 { Some(i) } else { None })
        .collect();

    // 2) 쓰기: 가변으로 일괄 적용
    for i in idxs {
        v[i] += 1;
    }
}

이 접근은 borrow 충돌을 원천 차단하고, 로직도 명확해져서 디버깅 시간이 크게 줄어듭니다.

5. 그래도 안 되면: RefCell/RwLock는 “최후의 선택”으로

컴파일 타임 borrow가 너무 빡빡해서 구조 변경이 어려울 때 RefCell 같은 interior mutability를 고려할 수 있습니다. 다만 이는 컴파일 타임 에러를 런타임 패닉 가능성으로 바꾸는 것이므로, 정말 필요한 경우에만 씁니다.

use std::cell::RefCell;

fn push_if_needed(v: &RefCell<Vec<i32>>) {
    let need_push = {
        let borrowed = v.borrow();
        borrowed.first().copied().unwrap_or(0) > 0
    }; // 여기서 immutable borrow 종료

    if need_push {
        v.borrow_mut().push(1);
    }
}

RefCell을 쓸 때는 반드시 “불변 빌림 블록”과 “가변 빌림 블록”이 겹치지 않게 스코프를 분리하세요.

멀티스레드라면 Mutex/RwLock이 대안이지만, 그건 소유권 에러 해결이라기보다 동시성 설계 영역입니다.

6. 7분 체크리스트: E0502/E0499를 빠르게 끝내는 순서

  1. 에러에서 later used here 위치를 찾고, 그 줄까지가 빌림 범위임을 확인
  2. 참조를 꼭 들고 있어야 하는지 점검
    • 가능하면 Copy 값으로 바꾸기
    • 아니면 clone()로 소유권 확보
  3. 참조가 필요하면 스코프를 줄이기
    • { ... } 블록으로 borrow 종료 지점 명확화
  4. 동시에 &mut가 필요하면 표준 API 확인
    • swap, get_mut, retain, drain
  5. 서로 다른 원소를 동시에 수정해야 하면 split_at_mut/chunks_exact_mut 등 분할 API 사용
  6. 로직이 복잡하면 읽기/쓰기 2단계로 재구성
  7. 최후의 수단으로 RefCell 등 interior mutability 고려(런타임 검증 비용/패닉 가능성 인지)

7. 자주 나오는 미니 레시피 모음

HashMap에서 조회 후 삽입/수정하려다 충돌

use std::collections::HashMap;

fn upsert(m: &mut HashMap<String, i32>, key: String) {
    // entry API로 한 번에 처리
    *m.entry(key).or_insert(0) += 1;
}

슬라이스에서 두 위치를 동시에 수정

fn add_both(a: &mut [i32], i: usize, j: usize) {
    assert!(i != j);
    let (x, y) = if i < j {
        let (l, r) = a.split_at_mut(j);
        (&mut l[i], &mut r[0])
    } else {
        let (l, r) = a.split_at_mut(i);
        (&mut r[0], &mut l[j])
    };

    *x += 1;
    *y += 1;
}

반복 중 push/insert가 필요할 때

fn filter_then_extend(v: &mut Vec<i32>) {
    let to_add: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 0).collect();
    v.extend(to_add);
}

iter()로 읽는 단계와 extend()로 쓰는 단계를 분리하면 borrow 충돌이 사라집니다.

마무리

E0502E0499는 “Rust가 까다롭다”의 상징처럼 보이지만, 실제로는 참조가 겹치는 구간을 제거하는 게임입니다. 스코프를 줄이거나, 분할 API를 쓰거나, 읽기/쓰기를 분리하는 3가지만 익숙해지면 대부분은 7분 안에 끝납니다.

추가로, split_at_mut나 NLL 관점에서 더 많은 예제가 필요하다면 위 내부 링크 글(Rust E0502 충돌 해결 - NLL·split_at_mut·RefCell)을 함께 보며 본문 패턴을 확장해보세요.