- Published on
Rust E0502 해결 - split_at_mut 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 컴파일러가 가장 먼저 가르쳐주는 교훈이 있습니다. "같은 데이터에 대해 동시에 여러 방식으로 빌리지 마라" 입니다. 그 교훈이 에러 코드로 튀어나오는 대표 사례가 E0502입니다.
E0502는 보통 다음 상황에서 발생합니다.
- 동일한 컬렉션(특히
Vec/슬라이스)에서 - 한쪽은 불변 참조(
&T)로 빌린 상태에서 - 다른 한쪽을 가변 참조(
&mut T)로 빌리거나 - 혹은 같은 슬라이스 내 서로 다른 원소를 각각 가변으로 빌리려다
Rust의 대여 규칙은 "논리적으로 겹치지 않는다"는 개발자 직감을 그대로 믿지 않습니다. 컴파일 타임에 겹치지 않음을 증명해야 합니다. 이때 가장 강력하고 표준적인 해법이 split_at_mut 패턴입니다.
E0502가 나는 전형적인 코드
슬라이스에서 두 원소를 동시에 수정하고 싶을 때, 가장 먼저 떠올리는 코드는 대개 이런 형태입니다.
fn swap_like(v: &mut [i32], i: usize, j: usize) {
let a = &mut v[i];
let b = &mut v[j];
*a += 1;
*b += 1;
}
이 코드는 많은 경우 컴파일 에러가 납니다. 에러 메시지는 상황에 따라 조금씩 다르지만, 핵심은 같습니다.
v[i]를&mut로 빌린 순간v전체가 가변 대여 상태로 간주되고- 그 상태에서 다시
v[j]를&mut로 빌리려 하니 - 같은 슬라이스에 대한 두 개의 가변 대여가 겹칠 수 있다고 판단합니다.
개발자는 i != j라면 겹치지 않는다고 알지만, 컴파일러는 위 코드만 보고는 그걸 증명할 수 없습니다.
왜 Rust는 이렇게 엄격할까
Rust의 목표는 "런타임 비용 없이" 메모리 안전을 보장하는 것입니다. 그래서 별도의 GC나 런타임 borrow 체크 대신, 컴파일러가 참조의 생명주기와 aliasing을 정적으로 추론합니다.
문제는 인덱싱 연산 v[i]가 컴파일러 입장에서:
i가 무엇인지j가 무엇인지- 둘이 다른지
를 일반적으로 확정할 수 없다는 점입니다. 따라서 안전을 위해 보수적으로 막습니다.
이 패턴은 대규모 시스템에서의 "충돌" 문제를 떠올리면 이해가 쉽습니다. 예컨대 배포 파이프라인에서 캐시가 안 먹히는 문제도 결국은 상태가 겹치거나(충돌) 의도와 다르게 공유되는 것이 원인인 경우가 많습니다. 비슷한 관점에서 참고로 GitHub Actions에서 node_modules 캐시가 안 먹힐 때 같은 글도 같이 읽어두면 "상태 공유"에 대한 감각이 좋아집니다.
split_at_mut: 겹치지 않음을 컴파일러에게 증명하기
split_at_mut는 슬라이스를 서로 겹치지 않는 두 조각으로 나눕니다.
- 왼쪽 조각:
&mut [T] - 오른쪽 조각:
&mut [T]
이 둘은 Rust 표준 라이브러리가 "겹치지 않는다"는 것을 보장해주기 때문에, 각 조각에서 다시 가변 참조를 꺼내도 안전합니다.
i, j 두 원소를 안전하게 동시에 수정하기
가장 흔한 해결 패턴은 i와 j를 정렬한 뒤, 작은 인덱스를 기준으로 슬라이스를 자르는 방식입니다.
fn add_two(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])
};
*a += 1;
*b += 1;
}
fn main() {
let mut v = vec![10, 20, 30, 40];
add_two(&mut v, 1, 3);
assert_eq!(v, vec![10, 21, 30, 41]);
}
핵심은 다음입니다.
split_at_mut(k)는0..k와k..len으로 나눕니다.- 예를 들어
j를 기준으로 나누면,j는 오른쪽 슬라이스의 첫 원소가 되어right[0]로 접근할 수 있습니다. - 이렇게 하면
left[i]와right[0]는 서로 다른 슬라이스에서 나온&mut이므로 aliasing이 불가능합니다.
이 패턴은 단순한 swap, 동시에 증가, 두 포인터 업데이트 등에서 매우 자주 등장합니다.
swap은 사실 표준 함수가 더 낫다
두 원소를 바꾸는 목적이라면, 직접 참조를 두 개 만들기보다 표준 라이브러리의 swap을 쓰는 것이 가장 깔끔합니다.
fn swap(v: &mut [i32], i: usize, j: usize) {
v.swap(i, j);
}
swap 내부는 안전하게 구현되어 있으며(필요 시 unsafe를 사용하더라도 외부 API는 안전), E0502를 피하도록 설계되어 있습니다.
하지만 실무에서는 swap보다 더 복잡한 "두 위치를 동시에 갱신"하는 로직이 많기 때문에 split_at_mut를 이해해두는 게 중요합니다.
패턴 확장: 인접 원소를 동시에 다루는 경우
예를 들어 버블 정렬처럼 i와 i+1을 동시에 만지는 경우도 split_at_mut가 자연스럽습니다.
fn bump_adjacent(v: &mut [i32], i: usize) {
assert!(i + 1 < v.len());
let (left, right) = v.split_at_mut(i + 1);
let a = &mut left[i];
let b = &mut right[0];
*a += 10;
*b += 20;
}
인접 원소는 특히 "겹치지 않는다"가 명백하지만, 컴파일러는 인덱싱 두 번으로는 그걸 확정하지 못합니다. split_at_mut(i+1)은 그 사실을 타입 시스템으로 고정해줍니다.
split_at_mut가 안 먹히는 케이스와 대안
1) 인덱스가 여러 개(예: 3개 이상)일 때
두 조각으로 나누는 split_at_mut만으로는 참조 3개를 한 번에 만들기 어렵습니다. 이때는 전략이 필요합니다.
- 인덱스를 정렬하고
- 여러 번
split_at_mut를 중첩해서 더 작은 조각으로 쪼개거나 - 아예 로직을 "한 번에 하나씩" 처리하도록 바꾸거나
- 데이터 구조를 바꿔서(예: 각 원소를 독립 소유하도록) aliasing 가능성을 제거
중첩 예시는 다음처럼 접근할 수 있습니다.
fn add_three(v: &mut [i32], a: usize, b: usize, c: usize) {
let mut idx = [a, b, c];
idx.sort_unstable();
assert!(idx[0] != idx[1] && idx[1] != idx[2]);
let (s0, rest) = v.split_at_mut(idx[1]);
let x = &mut s0[idx[0]];
let (s1, s2) = rest.split_at_mut(idx[2] - idx[1]);
let y = &mut s1[0];
let z = &mut s2[0];
*x += 1;
*y += 1;
*z += 1;
}
포인트는 "각 참조가 서로 다른 슬라이스 조각에서 나오도록" 만드는 것입니다.
2) 성능 때문에 반복 분할이 부담일 때
split_at_mut 자체는 O(1)이며 슬라이스 포인터/길이만 조정합니다. 보통 성능 부담은 거의 없습니다.
다만 루프 안에서 매우 복잡한 분할을 반복한다면, 로직을 재구성하는 편이 더 나을 수 있습니다. 예를 들어 인덱스 기반 랜덤 업데이트라면, 업데이트를 정렬해서 한 번에 처리하거나, 작업 단위를 파티셔닝하는 방법을 고려합니다.
이런 "작업 파티셔닝"은 분산 시스템에서도 동일한 해법으로 등장합니다. 충돌을 줄이기 위해 작업을 분리하고 경계를 명확히 하는 방식인데, 장애 상황 분석 관점에서는 K8s CrashLoopBackOff 원인 10가지·즉시 진단법 같은 글이 문제를 구조화하는 데 도움이 됩니다.
실무 팁: E0502를 줄이는 코드 스타일
1) 참조의 스코프를 줄여라
Rust는 참조가 "언제까지 살아있는지"를 기준으로 충돌을 판단합니다. 따라서 불변 참조를 오래 들고 있으면 가변 참조를 만들 수 없습니다.
fn example(v: &mut Vec<i32>) {
let first = v.first();
// first를 여기서 계속 쓰고 있으면 이후 v.push(...) 같은 변경이 막힐 수 있음
if let Some(x) = first {
println!("{x}");
}
// first 사용이 끝난 뒤에 변경
v.push(42);
}
핵심은 "필요한 만큼만 빌리고 빨리 놓기"입니다.
2) 인덱싱 두 번 대신 분할/반복자 조합을 고려
- 두 원소면
split_at_mut - swap이면
slice::swap - 윈도우 단위면
windows_mut같은 API(안정화 여부는 버전에 따라 다를 수 있음)
표준 라이브러리는 aliasing 문제를 피하도록 설계된 API가 많습니다.
정리
E0502는 대개 "같은 슬라이스에서 동시에 여러 참조를 만들었다"는 신호입니다.- 컴파일러가 겹치지 않음을 증명할 수 없으면, 개발자 직감과 무관하게 막습니다.
split_at_mut는 슬라이스를 겹치지 않는 두 조각으로 나눠서, 두 개의&mut참조를 안전하게 얻는 가장 대표적인 패턴입니다.- 두 원소를 동시에 수정해야 한다면, 먼저 인덱스를 정렬하거나 분기해서
split_at_mut경계를 올바르게 잡는 것이 핵심입니다.
Rust에서 이런 패턴을 익혀두면, 단순히 에러를 "우회"하는 것이 아니라 타입 시스템이 원하는 방식으로 코드를 구조화하게 되어 결과적으로 유지보수성과 안전성이 함께 올라갑니다.