Published on

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

Authors
Binance registration banner

서로 다른 인덱스의 원소를 동시에 갱신하는 로직을 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 없이도 성능을 지키며 해결됩니다.