- Published on
Rust E0502 빌림 충돌, split_at_mut로 해결하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 인덱스의 원소를 동시에 갱신하는 로직을 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;
}
사람 눈에는 i와 j가 다르면 안전해 보이지만, 컴파일러는 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이 아니다
두 인덱스의 원소를 동시에 가변으로 얻기
가장 많이 쓰는 패턴은 “i와 j 중 작은 쪽을 기준으로 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)로 나눴는데 i와 j가 둘 다 왼쪽에 있으면 의미가 없습니다. 그래서 위에서처럼 항상 큰 인덱스를 경계로 split하는 패턴을 씁니다.
2) 그래프/트리처럼 인덱스 기반이 아닌 참조 구조
노드들이 서로를 참조하는 구조에서는 슬라이스 분할만으로 해결이 안 됩니다.
Rc/Arc+ 내부 가변성(RefCell,Mutex,RwLock)- 인덱스 기반 arena(예:
Vec<Node>에 id로 접근)
같은 설계가 필요할 수 있습니다. 다만 내부 가변성은 런타임 체크/락 비용이 생기므로, 가능한 범위에서는 split_at_mut 같은 정적 보장을 먼저 고려하는 편이 좋습니다.
3) 정말로 안전함을 알고 있고, 성능 때문에 우회하고 싶은 경우
unsafe로 get_unchecked_mut 등을 조합해 두 포인터를 만들 수도 있습니다. 하지만 이는 “겹치지 않음”을 개발자가 직접 증명해야 하고, 잘못되면 UB로 직행합니다. 대부분의 실무 로직에서는 split_at_mut로 충분합니다.
실전 팁: E0502를 빨리 푸는 사고 순서
- 에러가 난 줄에서 “이미 살아 있는 참조”가 무엇인지 찾기
- 불변 참조가 길게 살아 있다면, 값 복사 또는 스코프 축소로 수명 줄이기
- 같은 컨테이너에서 두 원소를 다뤄야 한다면,
split_at_mut로 구간을 분리하기 - 반복문 안이라면, 참조를 루프 밖으로 끌고 나가지 않았는지 확인하기
이런 식으로 접근하면, 컴파일러가 요구하는 “명확한 비겹침(non-overlap) 증명”을 코드 구조로 제공할 수 있습니다.
마무리
E0502는 Rust가 메모리 안전을 유지하기 위해 “참조의 공존”을 엄격히 제한하면서 생기는 대표적인 컴파일 에러입니다. 핵심은 컴파일러가 납득할 수 있게 서로 겹치지 않는 영역에서 참조가 나왔음을 보여 주는 것이고, 슬라이스/벡터에서는 split_at_mut가 그 역할을 가장 깔끔하게 해냅니다.
두 인덱스의 원소를 동시에 갱신해야 한다면, 먼저 split_at_mut로 문제를 구조적으로 풀어보세요. 대다수의 빌림 충돌은 이 패턴 하나로 코드가 단순해지고, unsafe 없이도 성능을 지키며 해결됩니다.