Published on

Rust E0502 빌림 충돌 해결 - split_at_mut 패턴

Authors

서로 다른 원소를 다루는 것처럼 보이는데도 Rust가 E0502를 내며 컴파일을 막는 순간이 있습니다. 특히 Vec나 슬라이스에서 한쪽은 읽고(불변), 다른 쪽은 쓰려(가변) 할 때, 혹은 같은 컨테이너에서 두 개의 가변 참조를 동시에 만들려 할 때 흔히 터집니다.

이 글에서는 split_at_mut을 핵심 도구로 삼아, 빌림 검사기가 “겹치지 않는다”는 사실을 증명하도록 코드를 구조화하는 패턴을 정리합니다. 실무에서 자주 나오는 인덱스 기반 업데이트, 인접 원소 스왑, 두 구간 동시 수정 같은 문제를 단계별로 해결해봅니다.

문제 해결 접근은 다른 디버깅 글과도 결이 비슷합니다. 증상이 아니라 “제약을 만족하는 구조”로 바꾸는 게 핵심이죠. 시스템 레벨에서 원인을 파고드는 방식이 궁금하다면 리눅스 OOM Killer로 프로세스 죽을 때 진단법도 함께 참고하면 좋습니다.

E0502는 왜 발생하나

E0502는 보통 다음 상황에서 발생합니다.

  • 같은 값에 대해 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들려고 함
  • 혹은 그 반대(가변 참조가 살아있는 동안 불변 참조 생성)

Rust의 규칙을 한 문장으로 줄이면 이렇습니다.

  • 불변 참조는 여러 개 가능
  • 가변 참조는 오직 하나만 가능
  • 그리고 가변 참조가 존재하는 동안 다른 참조는 금지

문제는 “인덱스가 다르면 서로 다른 원소이니 안전하다”라는 사람의 직관을, 컴파일러가 항상 추론할 수는 없다는 점입니다. 특히 슬라이스 인덱싱으로 참조를 두 개 꺼내면, 컴파일러는 두 참조가 겹치지 않는다는 것을 일반적으로 증명하지 못합니다.

실패 예제: 한쪽을 읽고 다른 쪽을 수정

예를 들어 다음 코드는 직관적으로 안전해 보이지만, E0502가 발생할 수 있는 전형적인 형태입니다.

fn bump_based_on_other(v: &mut [i32], i: usize, j: usize) {
    let x = &v[i];      // 불변 빌림
    v[j] += *x;         // 가변 접근 시도
}

여기서 핵심은 xv의 일부를 불변으로 빌린 상태에서, v를 다시 가변으로 접근한다는 점입니다. 사람은 ij가 다르다고 가정하지만, 컴파일러는 호출자가 i == j를 넘길 수도 있다고 보고 보수적으로 막습니다.

그렇다면 해법은 두 가지 방향입니다.

  1. ij가 다르다는 것을 코드 구조로 증명하기
  2. 애초에 동시에 참조를 잡지 않도록 값을 복사하거나 스코프를 줄이기

split_at_mut은 1번을 깔끔하게 달성하는 대표 도구입니다.

핵심 도구: split_at_mut이 보장하는 것

split_at_mut(mid)는 하나의 슬라이스를 겹치지 않는 두 개의 가변 슬라이스로 나눠 반환합니다.

  • 왼쪽: 인덱스 0..mid
  • 오른쪽: 인덱스 mid..len

중요한 점은 “두 슬라이스가 절대 겹치지 않는다”는 사실을 함수 시그니처 수준에서 보장한다는 것입니다. 즉, 컴파일러가 안전성을 증명할 수 있는 형태로 데이터를 분할해주는 도구입니다.

split_at_mut 패턴 1: 두 인덱스가 다를 때 안전하게 동시에 다루기

두 인덱스 i, j의 원소를 동시에 수정하거나, 한쪽을 읽고 다른 쪽을 수정하려면 다음 패턴을 씁니다.

  • min(i, j)를 기준으로 슬라이스를 나누고
  • 한쪽에서는 마지막/특정 위치를, 다른 쪽에서는 오프셋을 적용해 접근

예시로, v[j] += v[i]를 안전하게 구현해봅니다.

fn add_from_to(v: &mut [i32], i: usize, j: usize) {
    assert!(i < v.len() && j < v.len());
    assert!(i != j);

    if i < j {
        let (left, right) = v.split_at_mut(j);
        // right[0] == v[j]
        let src = left[i];       // 값 복사(불변 참조 오래 유지하지 않음)
        right[0] += src;
    } else {
        let (left, right) = v.split_at_mut(i);
        let src = right[0];      // right[0] == v[i]
        left[j] += src;
    }
}

포인트는 다음과 같습니다.

  • split_at_mut으로 “서로 다른 구간”임을 컴파일러에게 알려줌
  • srcCopy 타입(i32)이라 값 복사로 참조 수명을 짧게 만듦
  • assert!(i != j)로 동일 인덱스 케이스를 명시적으로 제거

실무에서는 assert! 대신 if i == j { return; } 같은 방어 코드를 둘 수도 있습니다.

자주 하는 실수: 참조를 오래 잡고 있기

다음처럼 let src_ref = &left[i];를 잡아두면, 그 참조의 수명 때문에 다시 빌림 충돌이 날 수 있습니다.

fn bad(v: &mut [i32], i: usize, j: usize) {
    let (left, right) = v.split_at_mut(j);
    let src_ref = &left[i];
    right[0] += *src_ref;
}

이 코드는 케이스에 따라 컴파일되기도 하지만, 패턴적으로는 “참조 대신 값으로 뽑아오기”가 더 안전합니다. Copy가 아닌 타입이면 clone() 또는 계산을 먼저 끝내고 참조를 드롭시키는 방식으로 스코프를 줄여야 합니다.

split_at_mut 패턴 2: 인접 원소 스왑, 버블/삽입 류 로직

정렬이나 로컬 스왑 로직에서 인접한 두 원소를 동시에 가변으로 잡고 싶을 때가 많습니다. 인덱싱으로는 다음이 막힙니다.

fn swap_adjacent(v: &mut [i32], k: usize) {
    // v[k]와 v[k+1]을 동시에 가변으로 빌리기 어렵다
    let a = &mut v[k];
    let b = &mut v[k + 1];
    std::mem::swap(a, b);
}

이때도 split_at_mut이 정석입니다.

fn swap_adjacent(v: &mut [i32], k: usize) {
    assert!(k + 1 < v.len());

    let (left, right) = v.split_at_mut(k + 1);
    let a = &mut left[k];   // 왼쪽의 마지막
    let b = &mut right[0];  // 오른쪽의 첫 번째
    std::mem::swap(a, b);
}

이 패턴은 버블 정렬의 한 단계, 로컬 최적화, 구간 내 인접 교환 등에서 매우 자주 쓰입니다.

참고로 단순 스왑이라면 표준 라이브러리의 slice::swap이 더 간단합니다.

fn swap_adjacent_std(v: &mut [i32], k: usize) {
    v.swap(k, k + 1);
}

하지만 swap으로 해결되지 않는 “두 원소를 동시에 들고 복잡한 연산을 수행”해야 하는 경우에는 split_at_mut 방식이 여전히 필요합니다.

split_at_mut 패턴 3: 두 구간을 동시에 수정하기

예를 들어, 슬라이스의 앞부분과 뒷부분을 다른 규칙으로 동시에 업데이트하는 로직을 생각해봅시다.

  • 앞 구간: 스케일 적용
  • 뒤 구간: 클램프 적용
fn transform_two_regions(v: &mut [i32], mid: usize) {
    let mid = mid.min(v.len());
    let (a, b) = v.split_at_mut(mid);

    for x in a.iter_mut() {
        *x *= 2;
    }

    for x in b.iter_mut() {
        *x = (*x).clamp(-10, 10);
    }
}

이 코드는 단순해 보이지만, 핵심은 “두 가변 반복자”를 안전하게 얻으려면 슬라이스가 분할되어야 한다는 점입니다. 분할 없이 동일 슬라이스에서 두 개의 iter_mut()를 만들면 바로 빌림 규칙 위반입니다.

언제 split_at_mut이 최선이고, 언제 다른 선택이 나은가

split_at_mut은 강력하지만 만능은 아닙니다. 상황별로 선택지를 정리해보면 다음과 같습니다.

1) 값 복사로 해결 가능하면 가장 간단

스칼라나 Copy 타입이면, 참조를 잡지 말고 값으로 뽑아 계산한 뒤 수정하세요.

fn add_self(v: &mut [i32], i: usize) {
    let x = v[i];
    v[i] = x + 1;
}

2) 두 원소만 필요하면 split_at_mut 또는 표준 API

  • 스왑: swap(i, j)
  • 두 원소 동시 수정: split_at_mut로 분할 후 접근

3) 임의 개수의 원소를 동시에 가변 참조로 잡아야 하면 구조를 바꿔야 함

예를 들어 여러 인덱스를 받아 해당 원소들을 동시에 수정하려면, 인덱스를 정렬하고 구간을 순차적으로 분할하는 등 더 큰 설계가 필요합니다. 또는 내부 가변성(RefCell, Mutex) 같은 다른 모델로 전환해야 할 수도 있습니다. 다만 내부 가변성은 런타임 비용과 패닉 가능성을 가져오므로, 먼저 split_at_mut로 해결 가능한지 확인하는 편이 좋습니다.

실전 팁: E0502를 빠르게 푸는 사고 순서

  1. 에러가 난 줄에서 “불변 참조가 살아있는 상태에서 가변 접근”인지, 반대인지 확인
  2. 참조를 오래 잡고 있지 않은지 확인하고, 가능하면 값으로 복사해서 참조 수명 단축
  3. 같은 컨테이너에서 두 곳을 동시에 만지는 로직이면 슬라이스를 분할할 수 있는지 검토
  4. 분할 기준 mid를 잡고 split_at_mut(mid)로 두 가변 슬라이스를 확보
  5. 오른쪽 슬라이스는 인덱스가 mid만큼 이동했다는 점을 코드에서 명확히 반영

이 과정은 “빌드 에러를 없애는 요령”이 아니라, Rust가 요구하는 안전성 증명을 코드 구조로 표현하는 방법입니다. 프론트엔드 성능에서 병목을 Long Task 단위로 분해해 다루는 것과 유사한 접근인데, 관심 있다면 Chrome INP 급락? Long Task 5가지 해결도 같은 맥락으로 읽힙니다.

마무리

E0502는 초반에는 불친절해 보이지만, 실제로는 “동시에 존재할 수 없는 참조 관계”를 조기에 차단해주는 안전장치입니다. 문제는 우리가 알고 있는 비겹침 조건을 컴파일러가 추론할 수 있도록 코드 형태를 바꿔야 한다는 점이고, 그때 가장 많이 쓰이는 정석이 split_at_mut 패턴입니다.

  • 두 인덱스가 다르면, 분할 후 각각 다른 슬라이스에서 접근
  • 인접 스왑이나 로컬 교환은 split_at_mut(k + 1)로 좌우를 나눠 처리
  • 두 구간 동시 변환은 분할 후 각각 iter_mut()로 독립 처리

이 패턴에 익숙해지면, “빌림 검사기와 싸우는 느낌”이 줄고 “안전성을 설계로 표현한다”는 감각이 빠르게 잡힙니다.