Published on

Rust E0502 빌림 충돌, split_at_mut로 해결하기

Authors

서로 다른 인덱스의 원소를 동시에 갱신하는 로직을 Rust로 옮기다 보면, 컴파일러가 E0502를 던지며 길을 막는 순간이 자주 옵니다. 특히 Vec나 슬라이스에서 한쪽은 읽고(불변), 다른 한쪽은 쓰고(가변) 싶거나, 두 원소를 동시에 가변으로 잡고 싶을 때가 대표적입니다.

이 에러는 “러스트가 까다롭다”라기보다, 동일한 메모리 영역에 대한 aliasing(별칭) 가능성을 컴파일 타임에 차단하기 때문에 생깁니다. 그리고 그 차단을 우회하는 것이 아니라, 컴파일러가 이해할 수 있는 방식으로 ‘서로 겹치지 않는 두 구간’임을 증명해 주는 것이 핵심입니다. 그때 가장 자주 쓰는 도구가 split_at_mut입니다.

이번 글에서는 E0502가 왜 나는지, 흔한 오해(예: 인덱스가 다르니 안전하지 않나), 그리고 split_at_mut로 해결하는 정석 패턴을 다룹니다. 러스트 런타임 문제를 진단하는 글로는 이전에 Rust Tokio task 누수로 RAM 폭증 진단법도 참고하면 좋습니다.

E0502는 어떤 상황에서 터지나

E0502는 대체로 다음 형태에서 발생합니다.

  • 같은 값(예: vec)에 대해
    • 이미 불변으로 빌린 상태에서(&T)
    • 다시 가변으로 빌리려 할 때(&mut T)

슬라이스/벡터에서 “서로 다른 인덱스”를 잡는 경우에도, 컴파일러는 기본적으로 두 참조가 겹치지 않는다는 사실을 자동으로 증명하지 못하는 경우가 많습니다.

재현 코드: 서로 다른 원소를 동시에 다루려다 실패

아래는 아주 흔한 형태입니다. i번째와 j번째 값을 동시에 갱신(스왑, 합산, 두 원소 업데이트 등)하려는 코드죠.

fn add_pair(v: &mut [i32], i: usize, j: usize) {
    let a = &mut v[i];
    let b = &mut v[j];
    *a += *b;
}

이 코드는 보통 E0499류(동일 값의 가변 빌림 2회)로 막히지만, 비슷한 맥락에서 불변/가변을 섞으면 E0502도 쉽게 만납니다.

예를 들어, 한쪽을 불변으로 잡아두고 다른 한쪽을 가변으로 잡는 순간입니다.

fn bump_using_other(v: &mut [i32], i: usize, j: usize) {
    let other = &v[j];     // 불변 빌림
    let target = &mut v[i]; // 가변 빌림 (여기서 충돌 가능)
    *target += *other;
}

사람 눈에는 ij가 다르면 안전해 보이지만, 컴파일러는 i != j라는 조건을 코드만 보고 항상 알 수 있는 게 아닙니다. 또한 설령 런타임에 다르더라도, 컴파일 타임 규칙은 “가능성”만으로도 거부합니다.

왜 인덱스가 다른데도 안 되는가

Rust의 참조 규칙은 크게 두 가지로 요약됩니다.

  • 같은 스코프에서
    • 여러 개의 불변 참조는 가능
    • 가변 참조는 오직 하나만 가능
    • 가변 참조가 존재하면 불변 참조와 공존 불가

문제는 v[i]v[j]가 “서로 다른 원소”라는 사실이 코드 구조상 명확히 드러나지 않으면, 컴파일러 입장에서는 둘이 같은 원소를 가리킬 수도 있다고 가정해야 한다는 점입니다.

따라서 해법은 단순합니다.

  • “이 두 참조가 절대 겹치지 않는다”를
  • 컴파일러가 이해할 수 있는 API로 표현한다

그 대표가 split_at_mut입니다.

split_at_mut: 겹치지 않는 두 가변 슬라이스로 쪼개기

split_at_mut(mid)는 하나의 &mut [T]를 **서로 겹치지 않는 두 개의 &mut [T]**로 분리합니다.

  • 왼쪽: 0..mid
  • 오른쪽: mid..len

이때 중요한 보장이 있습니다.

  • 두 슬라이스는 메모리 상에서 겹치지 않는다
  • 따라서 왼쪽에서 &mut를 뽑고, 오른쪽에서 &mut를 뽑아도 aliasing이 아니다

두 인덱스의 원소를 동시에 가변으로 얻기

가장 많이 쓰는 패턴은 “ij 중 작은 쪽을 기준으로 split” 입니다.

fn two_mut<T>(s: &mut [T], i: usize, j: usize) -> (&mut T, &mut T) {
    assert!(i != j);

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

fn demo() {
    let mut v = vec![10, 20, 30, 40];
    let (a, b) = two_mut(&mut v, 1, 3);
    *a += 1; // v[1] = 21
    *b += 2; // v[3] = 42
    assert_eq!(v, vec![10, 21, 30, 42]);
}

핵심은 split_at_mut의 기준을 큰 인덱스로 잡는 것입니다.

  • i < j이면 split_at_mut(j)j를 경계로 나누면
    • i는 왼쪽 슬라이스에 존재
    • j는 오른쪽 슬라이스의 첫 원소(0)가 됨

이렇게 하면 컴파일러는 “서로 다른 슬라이스에서 나온 두 &mut”임을 확실히 알 수 있습니다.

E0502 케이스: 한쪽은 읽고, 한쪽은 쓰기

사실 “읽기와 쓰기”도 위 패턴으로 동일하게 해결할 수 있습니다. 두 원소를 각각 다른 슬라이스로 분리한 뒤, 읽을 값은 복사하거나(예: Copy), 혹은 스코프를 분리해 불변 참조의 수명을 줄이면 됩니다.

방법 1: 값 복사로 불변 참조 수명 제거

i32처럼 Copy인 타입이면 가장 간단합니다.

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

    if i < j {
        let (left, right) = v.split_at_mut(j);
        let other = right[0];      // 값 복사 (참조 아님)
        left[i] += other;
    } else {
        let (left, right) = v.split_at_mut(i);
        let other = right[0];
        left[j] += other;
    }
}

other&v[j]로 잡지 않고 “값 자체”로 가져오면, 불변 참조가 남아 있지 않으니 가변 빌림과 충돌할 여지가 크게 줄어듭니다.

방법 2: 스코프를 줄여 불변 빌림을 먼저 끝내기

Copy가 아닌 타입이면, 참조 수명을 스코프로 끊는 방식도 자주 씁니다.

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

    let other_len = {
        let other = &v[j];
        other.len()
    }; // 여기서 other의 불변 빌림이 끝남

    v[i].push_str(&"!".repeat(other_len));
}

다만 이 방식은 v[i]v[j]가 같은 컨테이너에서 파생된 참조라는 사실 때문에, 상황에 따라 여전히 막힐 수 있습니다. 그럴 때는 애초에 split_at_mut로 컨테이너를 둘로 나눠 “서로 다른 구간”임을 증명하는 편이 안정적입니다.

split_at_mut로 스왑 구현하기

표준 라이브러리에 이미 slice::swap(i, j)가 있지만, 학습용으로 직접 구현하면 split_at_mut의 감각이 빠르게 잡힙니다.

fn swap_manual<T>(s: &mut [T], i: usize, j: usize) {
    assert!(i != j);

    if i < j {
        let (left, right) = s.split_at_mut(j);
        std::mem::swap(&mut left[i], &mut right[0]);
    } else {
        let (left, right) = s.split_at_mut(i);
        std::mem::swap(&mut right[0], &mut left[j]);
    }
}

여기서 중요한 포인트는 right[0]입니다.

  • split_at_mut(j)를 했을 때
  • right는 원래 슬라이스의 j.. 구간
  • 따라서 right[0]이 곧 원래의 s[j]

split_at_mut가 적용되지 않는 경우와 대안

split_at_mut는 “한 슬라이스를 두 구간으로 나눌 수 있을 때” 강력합니다. 하지만 다음과 같은 경우는 다른 접근이 필요합니다.

1) 두 원소가 같은 쪽 구간에 몰리는 경우

예를 들어 split_at_mut(j)로 나눴는데 ij가 둘 다 왼쪽에 있으면 의미가 없습니다. 그래서 위에서처럼 항상 큰 인덱스를 경계로 split하는 패턴을 씁니다.

2) 그래프/트리처럼 인덱스 기반이 아닌 참조 구조

노드들이 서로를 참조하는 구조에서는 슬라이스 분할만으로 해결이 안 됩니다.

  • Rc/Arc + 내부 가변성(RefCell, Mutex, RwLock)
  • 인덱스 기반 arena(예: Vec<Node>에 id로 접근)

같은 설계가 필요할 수 있습니다. 다만 내부 가변성은 런타임 체크/락 비용이 생기므로, 가능한 범위에서는 split_at_mut 같은 정적 보장을 먼저 고려하는 편이 좋습니다.

3) 정말로 안전함을 알고 있고, 성능 때문에 우회하고 싶은 경우

unsafeget_unchecked_mut 등을 조합해 두 포인터를 만들 수도 있습니다. 하지만 이는 “겹치지 않음”을 개발자가 직접 증명해야 하고, 잘못되면 UB로 직행합니다. 대부분의 실무 로직에서는 split_at_mut로 충분합니다.

실전 팁: E0502를 빨리 푸는 사고 순서

  1. 에러가 난 줄에서 “이미 살아 있는 참조”가 무엇인지 찾기
  2. 불변 참조가 길게 살아 있다면, 값 복사 또는 스코프 축소로 수명 줄이기
  3. 같은 컨테이너에서 두 원소를 다뤄야 한다면, split_at_mut로 구간을 분리하기
  4. 반복문 안이라면, 참조를 루프 밖으로 끌고 나가지 않았는지 확인하기

이런 식으로 접근하면, 컴파일러가 요구하는 “명확한 비겹침(non-overlap) 증명”을 코드 구조로 제공할 수 있습니다.

마무리

E0502는 Rust가 메모리 안전을 유지하기 위해 “참조의 공존”을 엄격히 제한하면서 생기는 대표적인 컴파일 에러입니다. 핵심은 컴파일러가 납득할 수 있게 서로 겹치지 않는 영역에서 참조가 나왔음을 보여 주는 것이고, 슬라이스/벡터에서는 split_at_mut가 그 역할을 가장 깔끔하게 해냅니다.

두 인덱스의 원소를 동시에 갱신해야 한다면, 먼저 split_at_mut로 문제를 구조적으로 풀어보세요. 대다수의 빌림 충돌은 이 패턴 하나로 코드가 단순해지고, unsafe 없이도 성능을 지키며 해결됩니다.