- Published on
Rust E0502 빌림 충돌, NLL로 고치는 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 언어의 오류와 달리 Rust의 E0502는 “컴파일러가 너무 엄격해서”가 아니라, 내 코드가 실제로 동시에 만족할 수 없는 참조 조건을 만들었기 때문에 발생합니다. 핵심은 간단합니다.
- 같은 값에 대해 불변 빌림(
&T)이 살아있는 동안 - 가변 빌림(
&mut T)을 만들려고 했기 때문에
E0502: cannot borrow ... as mutable because it is also borrowed as immutable가 뜹니다.
다행히 Rust는 NLL(Non-Lexical Lifetimes) 덕분에 “스코프 끝까지”가 아니라 “마지막 사용 지점까지”로 빌림 수명을 줄여 추론합니다. 즉, 코드를 조금만 재배치하거나, 임시값을 분리하거나, API를 바꾸면 대부분 깔끔하게 해결됩니다.
아래 7가지 패턴은 현업에서 E0502를 가장 자주 만나는 형태를 기준으로, NLL 관점에서 어떻게 고치는지 정리한 것입니다.
E0502를 이해하는 최소 기준: 빌림 규칙과 NLL
Rust의 규칙을 한 줄로 요약하면 다음과 같습니다.
- 어떤 값
x에 대해&x가 살아있으면 그 동안&mut x는 만들 수 없다 - 어떤 값
x에 대해&mut x가 살아있으면 그 동안&x도 만들 수 없다
NLL은 여기서 “살아있다”를 블록 스코프 기준이 아니라 마지막 사용 지점 기준으로 줄여줍니다. 그래서 해결책은 대개 “불변 참조를 더 빨리 끝내기” 또는 “가변 참조를 더 늦게 만들기”로 귀결됩니다.
패턴 1) let r = &x;를 오래 들고 있다가 x를 수정
가장 흔한 형태입니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // E0502
println!("{}", first);
}
문제는 first가 println!까지 살아있다고 컴파일러가 판단하기 때문입니다. NLL이라도 first를 실제로 마지막에 사용하니 수명이 길어집니다.
해결 1: 사용을 앞당겨 빌림을 끝내기
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // 값 복사 (i32는 Copy)
v.push(4);
println!("{}", first);
}
Copy 타입이면 참조 대신 값을 복사하는 것이 가장 간단합니다.
해결 2: 블록으로 수명을 끊기
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 불변 빌림 종료
v.push(4);
}
NLL이 있어도 “마지막 사용”이 뒤에 있으면 수명이 안 줄어듭니다. 이럴 때 블록은 확실한 해법입니다.
패턴 2) if let이나 match에서 불변 빌림을 잡고 분기 안에서 수정
예를 들어 Option을 확인하면서 동시에 컨테이너를 수정하고 싶은 경우가 많습니다.
fn main() {
let mut v = vec![10, 20, 30];
if let Some(x) = v.get(0) {
// x는 &i32 (불변 빌림)
v.push(*x); // E0502
}
}
v.get(0)이 만든 불변 빌림이 if let 본문 끝까지 살아있어서 push가 막힙니다.
해결: 필요한 값만 먼저 뽑아두기
fn main() {
let mut v = vec![10, 20, 30];
let x0 = v.get(0).copied();
if let Some(x) = x0 {
v.push(x);
}
}
핵심은 get으로 얻은 참조를 들고 있지 말고, 필요한 데이터만 값으로 분리하는 것입니다.
패턴 3) 반복문에서 요소 참조를 들고 같은 컬렉션을 수정
아래는 직관적으로 “가능해 보이지만” Rust에서는 막히는 코드입니다.
fn main() {
let mut v = vec![1, 2, 3];
for x in &v {
if *x % 2 == 1 {
v.push(*x); // E0502
}
}
}
for x in &v 자체가 v 전체에 대한 불변 빌림을 루프 동안 유지합니다. 그 상태에서 push는 가변 빌림이므로 충돌합니다.
해결 1: 두 단계로 처리 (스냅샷 후 수정)
fn main() {
let mut v = vec![1, 2, 3];
let odds: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 1).collect();
v.extend(odds);
}
불변 순회와 가변 변경을 단계 분리하면 빌림 충돌이 사라집니다.
해결 2: 인덱스 기반으로 범위를 고정
fn main() {
let mut v = vec![1, 2, 3];
let n = v.len();
for i in 0..n {
let x = v[i];
if x % 2 == 1 {
v.push(x);
}
}
}
len()을 먼저 고정해두면 “순회 대상”과 “성장하는 부분”을 분리할 수 있습니다.
패턴 4) 같은 구조체에서 필드를 읽으면서 다른 필드를 수정
구조체 내부 필드 간에도 빌림 규칙이 적용됩니다. 특히 메서드에서 자주 터집니다.
struct State {
cache: Vec<i32>,
sum: i32,
}
impl State {
fn update(&mut self) {
let first = self.cache.first(); // self.cache 불변 빌림
self.sum += first.copied().unwrap_or(0); // 여기까지는 OK
self.cache.push(1); // E0502가 나는 형태가 종종 발생
}
}
상황에 따라 first의 사용이 뒤에 남아있거나, 더 복잡한 참조가 얽히면 self 전체가 불변으로 빌린 것으로 간주될 수 있습니다.
해결: 필요한 값은 로컬로 떼어내고, 가변 변경은 나중에
impl State {
fn update(&mut self) {
let first_val = self.cache.first().copied().unwrap_or(0);
self.sum += first_val;
self.cache.push(1);
}
}
NLL이 가장 잘 먹히는 형태가 “참조를 들고 있지 않고 값으로 변환”입니다.
추가로, 서로 다른 필드를 동시에 다루고 싶다면 let (a, b) = (...)로 분해하거나, 표준 라이브러리의 split_at_mut 같은 API를 활용해 “서로 다른 가변 참조”를 안전하게 만들 수도 있습니다.
패턴 5) HashMap에서 get으로 읽고 같은 키를 insert로 갱신
캐시나 카운터 구현에서 흔합니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".to_string(), 1);
let v = m.get("a");
m.insert("a".to_string(), v.copied().unwrap_or(0) + 1); // E0502
}
get이 만든 불변 빌림이 살아있는데 insert가 가변 빌림을 요구해서 충돌합니다.
해결 1: copied()로 값만 먼저 추출
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".to_string(), 1);
let next = m.get("a").copied().unwrap_or(0) + 1;
m.insert("a".to_string(), next);
}
해결 2: entry API로 읽기와 쓰기를 한 번에
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
*m.entry("a".to_string()).or_insert(0) += 1;
}
entry는 내부적으로 필요한 빌림을 안전하게 구성해 주기 때문에 E0502를 원천적으로 피할 수 있습니다.
패턴 6) 슬라이스에서 한 요소를 참조한 채 같은 슬라이스를 가변으로 또 빌림
특히 정렬, 파티셔닝, 교환(swap) 류 로직에서 자주 발생합니다.
fn main() {
let mut a = [1, 2, 3, 4];
let x = &a[0];
a[1] = *x + 10; // E0502가 나는 변형이 흔함
}
겉보기엔 “서로 다른 인덱스니까 괜찮지 않나” 싶지만, Rust는 기본적으로 배열 전체에 대한 빌림으로 해석할 수 있습니다.
해결: 값을 먼저 복사하거나, 분할 API 사용
fn main() {
let mut a = [1, 2, 3, 4];
let x0 = a[0];
a[1] = x0 + 10;
}
또는 더 일반적으로는 split_at_mut로 “서로 겹치지 않는 가변 슬라이스”를 만들 수 있습니다.
fn main() {
let mut a = [1, 2, 3, 4];
let (left, right) = a.split_at_mut(1);
let x0 = left[0];
right[0] = x0 + 10; // right[0]은 원래 a[1]
}
이 패턴은 “겹치지 않음”을 컴파일러에게 증명하는 대표적인 방법입니다.
패턴 7) 메서드 체이닝/클로저가 빌림을 예상보다 길게 잡는 경우
이터레이터 체인이나 클로저 캡처는 빌림 수명을 길게 만드는 원인이 됩니다.
fn main() {
let mut v = vec![1, 2, 3];
let pos = v.iter().position(|x| *x == 2);
// 여기서 iter() 불변 빌림이 끝났다고 생각하기 쉽지만
if pos.is_some() {
v.push(4); // 상황에 따라 E0502로 이어지는 패턴이 나옴
}
}
특히 “참조를 반환하는 어댑터”를 중간에 끼워 넣거나, 클로저가 외부 참조를 캡처하면 빌림이 더 오래 살아남을 수 있습니다.
해결 1: 중간 결과를 값으로 강제 수집/복사
fn main() {
let mut v = vec![1, 2, 3];
let has_two = v.iter().any(|x| *x == 2);
if has_two {
v.push(4);
}
}
해결 2: 클로저가 참조를 캡처하지 않게 구조 변경
- 클로저 바깥에서 필요한 값은 미리 계산
- 체인을 끊고
let tmp = ...;로 단계화
이 방식은 Rust뿐 아니라 다른 환경에서도 “디버깅 가능한 형태로 단순화”하는 데 효과적입니다. 예를 들어 운영 환경에서 원인 분리를 위해 체크리스트처럼 단계화하는 접근은 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트 같은 글에서 다루는 방식과도 결이 같습니다.
실전 리팩터링 체크리스트
E0502를 만났을 때 아래 순서로 보면 대부분 빠르게 해결됩니다.
- 불변 참조(
&)를 오래 들고 있는let r = ...이 있는가 if let/match에서 참조를 바인딩한 뒤, 같은 소유자를 수정하는가for x in &collection중에 같은 컬렉션을 수정하려는가HashMap은get다음insert대신entry로 합칠 수 있는가- “서로 다른 인덱스”를 다룬다면
split_at_mut등으로 비겹침을 증명할 수 있는가 - 체이닝/클로저가 참조를 캡처해 빌림이 길어지지 않았는가
- 최후의 수단으로, 값을 복사하거나(
Copy),clone()으로 소유권을 끊어낼 수 있는가
이런 접근은 데이터 처리에서도 동일하게 유효합니다. 예를 들어 판다스에서 뷰/복사 경계가 애매하면 경고가 터지는데, 그때도 “중간 결과를 명확히 분리”하는 것이 해법인 경우가 많습니다. 관련해서는 pandas SettingWithCopyWarning 완전정복 - 안전한 대입을 같이 참고하면 사고방식이 잘 연결됩니다.
마무리: NLL은 만능이 아니라, 힌트다
NLL 덕분에 Rust는 과거보다 훨씬 많은 코드를 “사람이 기대하는 방식”으로 컴파일해 줍니다. 하지만 NLL이 해결해 주는 것은 어디까지나 컴파일러가 수명을 더 정확히 추론할 수 있는 여지이고, 코드가 실제로 “읽으면서 동시에 쓰는” 구조라면 여전히 E0502는 정당하게 발생합니다.
정리하면, E0502를 고치는 가장 강력한 방법은 다음 두 가지입니다.
- 참조를 들고 있지 말고 필요한 값만 먼저 추출해서 빌림을 끝낸다
- 읽기 단계와 쓰기 단계를 명시적으로 분리한다
이 7패턴을 손에 익히면, E0502는 공포의 에러가 아니라 “코드 구조를 더 안전하게 만들라는 신호”로 바뀝니다.