- Published on
Rust E0502 충돌 해결 - NLL·split_at_mut·RefCell
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 언어에서의 충돌은 런타임에서 터지는 경우가 많지만, Rust의 E0502는 컴파일 타임에 문제를 정확히 드러냅니다. 에러 메시지는 대개 cannot borrow ... as mutable because it is also borrowed as immutable 형태로 나오며, 요지는 “이미 불변으로 빌려 둔 참조가 살아있는 동안 같은 값을 가변으로 빌릴 수 없다”입니다.
이 글에서는 E0502가 왜 생기는지, 그리고 실제 코드에서 어떻게 풀어내는지 세 갈래로 정리합니다.
- NLL(Non-Lexical Lifetimes)로 “참조의 생존 범위”를 줄여 해결
- 슬라이스/컨테이너를 안전하게 분할하는
split_at_mut패턴 - 설계상 공유 가변성이 필요할 때
RefCell로 내부 가변성 적용
동시성/락 이슈를 추적하듯 원인을 쪼개는 접근이 도움이 됩니다. 비슷한 문제를 다른 스택에서 디버깅하는 관점은 MySQL InnoDB 데드락 추적 - deadlock.log 읽기 같은 글과도 닮아 있습니다.
E0502가 발생하는 전형적인 패턴
가장 흔한 케이스는 “한 줄에서 불변 참조를 만든 뒤, 같은 스코프에서 가변 참조를 만들려고 하는 경우”입니다.
fn main() {
let mut v = vec![10, 20, 30];
let first = &v[0]; // 불변 빌림
v.push(40); // 가변 빌림 시도 (재할당/재배치 가능)
println!("{}", first);
}
여기서 push는 벡터의 capacity가 부족하면 재할당이 일어나고, 그 과정에서 기존 요소들의 메모리 위치가 바뀔 수 있습니다. first가 가리키는 주소가 무효화될 수 있으니 Rust는 이를 허용하지 않습니다.
핵심은 “불변 빌림이 살아있는 동안 가변 빌림을 만들지 말라”이며, 해결은 보통 다음 중 하나입니다.
- 불변 참조를 더 빨리 끝내기(NLL 활용)
- 구조를 분할해서 서로 다른 영역을 가변으로 빌리기(
split_at_mut) - 공유 가변성이 꼭 필요하면 설계를 바꾸기(
RefCell,Mutex등)
1) NLL로 해결하기: 참조의 생존 범위를 줄여라
Rust 2018 이후 NLL 덕분에 “변수의 스코프”와 “참조의 실제 생존 범위”가 분리됩니다. 즉, 참조를 만든 변수가 존재하더라도, 그 참조가 더 이상 사용되지 않으면 빌림은 끝난 것으로 간주될 수 있습니다.
사용을 먼저 끝내는 방식
fn main() {
let mut v = vec![10, 20, 30];
let first = v[0]; // 값 복사 (i32는 Copy)
v.push(40); // 이제 안전
println!("{}", first);
}
여기서는 참조가 아니라 값을 복사했기 때문에 빌림 자체가 없습니다.
참조가 필요하다면 “출력/사용을 먼저”
fn main() {
let mut v = vec![10, 20, 30];
let first_ref = &v[0];
println!("{}", first_ref); // 여기서 참조 사용이 끝남
v.push(40); // NLL이 빌림 종료를 인지하면 통과
}
이 패턴은 단순하지만 매우 자주 통합니다. 다만 참조를 이후에도 써야 한다면 근본 해결이 아니며, 다음 패턴이 필요합니다.
스코프를 강제로 줄이기
fn main() {
let mut v = vec![10, 20, 30];
{
let first_ref = &v[0];
println!("{}", first_ref);
} // 여기서 불변 빌림 종료
v.push(40);
}
스코프 블록은 “빌림을 끝내고 싶다”는 의도를 코드로 명확히 표현하는 좋은 방법입니다.
2) split_at_mut로 해결하기: 한 컨테이너를 두 개의 가변 슬라이스로
E0502는 “같은 대상에 대한 불변/가변 충돌”뿐 아니라, “같은 슬라이스에서 서로 다른 인덱스에 대해 가변 참조를 두 개 만들려는 경우”에도 자주 발생합니다.
예를 들어, 버블 정렬이나 인접 원소 스왑을 직접 구현하려다 다음과 같은 코드가 막힙니다.
fn swap_adjacent(v: &mut [i32], i: usize) {
let a = &mut v[i];
let b = &mut v[i + 1];
std::mem::swap(a, b);
}
컴파일러는 i와 i+1이 다르다는 것을 “일반적인 경우”에 증명하지 못합니다. 그래서 같은 슬라이스 v에서 두 개의 &mut를 동시에 꺼내는 행위를 금지합니다.
이때 split_at_mut는 “서로 겹치지 않는 두 슬라이스”를 안전하게 만들어 줍니다.
안전한 스왑 구현
fn swap_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];
std::mem::swap(a, b);
}
split_at_mut(i + 1)은
left:0..=i범위right:i+1..범위
로 쪼개며, 두 슬라이스가 절대 겹치지 않음을 표준 라이브러리가 보장합니다. 결과적으로 a와 b는 서로 다른 메모리 구간을 가리키는 &mut가 되어 규칙을 위반하지 않습니다.
Vec에서도 동일하게 적용
Vec는 내부적으로 슬라이스를 제공하므로 v.as_mut_slice() 혹은 &mut v[..]로 동일하게 쓸 수 있습니다.
fn main() {
let mut v = vec![1, 2, 3, 4];
swap_adjacent(&mut v[..], 1);
assert_eq!(v, vec![1, 3, 2, 4]);
}
언제 split_at_mut가 정답인가
- 같은 컬렉션 내부의 “서로 다른 구간”을 동시에 수정해야 할 때
- 인덱스 기반 알고리즘(정렬, DP 테이블 일부 갱신 등)에서 빌림 충돌이 날 때
unsafe없이 안전하게 “비겹침”을 증명하고 싶을 때
반대로, 트리/그래프처럼 노드 간 참조가 얽혀 있고 “비겹침”을 슬라이스 분할로 증명하기 어려운 구조라면 다음의 RefCell류가 더 현실적인 선택이 됩니다.
3) RefCell로 해결하기: 공유 가변성(Interior Mutability)
E0502는 Rust가 “컴파일 타임에” 빌림 규칙을 지키도록 강제하기 때문에 생깁니다. 그런데 어떤 구조는 설계상 공유 참조를 여러 곳에 뿌려야 하고, 특정 시점에만 내부를 수정해야 합니다.
대표적으로
- 그래프에서 여러 노드가 같은 노드를 참조
- 캐시/메모이제이션 테이블을 여러 곳에서 읽고 가끔 갱신
- 콜백/클로저가 외부 상태를 캡처하고 갱신
같은 경우입니다.
이때 RefCell은 “단일 스레드” 환경에서 빌림 검사를 런타임으로 옮겨 줍니다.
borrow()는 불변 빌림(Ref)borrow_mut()은 가변 빌림(RefMut)- 규칙 위반 시 컴파일 에러가 아니라 런타임 패닉
간단한 예제: 공유 참조로 내부 카운터 갱신
use std::cell::RefCell;
struct Counter {
value: RefCell<i32>,
}
impl Counter {
fn new() -> Self {
Self { value: RefCell::new(0) }
}
fn inc(&self) {
*self.value.borrow_mut() += 1;
}
fn get(&self) -> i32 {
*self.value.borrow()
}
}
fn main() {
let c = Counter::new();
c.inc();
c.inc();
println!("{}", c.get());
}
여기서 inc는 &self만 받습니다. 즉, 외부에서는 Counter를 불변으로 들고 있어도 내부 상태는 바뀔 수 있습니다. 이게 내부 가변성 패턴입니다.
RefCell을 쓸 때의 트레이드오프
- 장점: 빌림 제약을 설계적으로 풀 수 있고, API가 유연해짐
- 단점: 런타임 패닉 가능(테스트/리뷰로 관리 필요), 비용 증가
- 제약:
RefCell은Sync가 아니므로 멀티스레드 공유에는 부적합
멀티스레드라면 보통 Mutex나 RwLock으로 넘어가야 합니다. 이 또한 “런타임에서의 배타/공유”를 관리한다는 점에서 RefCell과 철학이 유사합니다.
실전 디버깅 체크리스트: E0502를 빠르게 푸는 순서
1) 불변 참조가 정말 필요한가
Copy타입이면 참조 대신 값 복사로 끝나는지 확인clone()이 비용 괜찮다면 소유권을 복제해 빌림을 제거할 수 있는지 확인
2) 참조를 더 빨리 끝낼 수 있는가
- 참조 사용을 앞당기기
- 스코프 블록으로 생존 범위 축소
- 불변 참조를 반환값/임시로만 쓰고 보관하지 않기
3) “서로 다른 부분”을 동시에 수정하는가
- 슬라이스라면
split_at_mut - 맵/벡터에서 두 원소를 동시에 수정해야 하면, 인덱스/키로 두 번 빌리는 대신 분할/재구성 패턴 고려
4) 자료구조가 본질적으로 공유 가변성을 요구하는가
- 단일 스레드:
Rc+RefCell조합 고려 - 멀티 스레드:
Arc+Mutex또는RwLock고려
이런 선택은 “컴파일 타임 안전”과 “런타임 유연성” 사이의 균형 문제입니다. 예외 없는 에러 처리와 자원 관리를 설계로 강제하는 관점은 C++23 std - -expected로 예외 없는 에러처리+RAII에서 다룬 접근과도 비교해볼 만합니다.
자주 만나는 케이스별 처방전
케이스 A: Vec에서 참조 잡고 push/insert
- 참조 대신 인덱스를 저장하고, 나중에 다시 접근
- 혹은 값을 복사/복제
- 혹은 작업 순서를 바꿔 참조 사용을 먼저 끝냄
fn main() {
let mut v = vec![10, 20, 30];
let idx = 0;
v.push(40);
println!("{}", v[idx]);
}
케이스 B: 한 슬라이스에서 두 원소를 동시에 &mut
split_at_mut로 비겹침을 증명
케이스 C: 그래프/트리에서 부모-자식 참조가 얽힘
- 소유권을 명확히 재설계(인덱스 기반 arena, 노드 풀)
- 단일 스레드면
Rc+RefCell로 공유 가변성
use std::cell::RefCell;
use std::rc::Rc;
type NodeRef = Rc<RefCell<Node>>;
#[derive(Debug)]
struct Node {
value: i32,
next: Option<NodeRef>,
}
fn main() {
let a: NodeRef = Rc::new(RefCell::new(Node { value: 1, next: None }));
let b: NodeRef = Rc::new(RefCell::new(Node { value: 2, next: None }));
a.borrow_mut().next = Some(Rc::clone(&b));
b.borrow_mut().value += 10;
println!("{:?}", a.borrow().next.as_ref().unwrap().borrow().value);
}
이 패턴은 강력하지만, borrow_mut()을 중첩 호출하면 런타임 패닉이 날 수 있으니 빌림 범위를 짧게 유지해야 합니다.
정리
E0502는 “불변과 가변 빌림이 동시에 존재할 수 없다”는 Rust의 핵심 규칙이 실제 코드에서 드러나는 순간입니다. 해결은 요령이 아니라 구조화된 선택의 문제로 보는 게 좋습니다.
- NLL을 활용해 참조 생존 범위를 줄일 수 있으면 가장 깔끔합니다.
- 같은 컬렉션의 서로 다른 부분을 동시에 수정해야 한다면
split_at_mut같은 분할 API가 정답입니다. - 공유 가변성이 본질이라면
RefCell로 빌림 검사를 런타임으로 옮기되, 패닉 가능성과 비용을 감수하고 빌림 범위를 엄격히 관리해야 합니다.
이 세 가지를 손에 익히면, E0502는 “막히는 에러”가 아니라 “설계를 더 안전하게 만드는 가이드”로 바뀝니다.