- Published on
Rust E0502/E0499 빌림 충돌, NLL 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 언어에서 가장 시간을 잡아먹는 버그가 경계 조건이라면, Rust에서는 빌림 규칙이 그 역할을 합니다. 특히 E0502(불변 빌림과 가변 빌림의 충돌), E0499(가변 빌림의 중복)는 초반 학습뿐 아니라 리팩터링 과정에서도 반복적으로 튀어나옵니다.
핵심은 “컴파일러가 언제까지 빌림이 살아있다고 판단하느냐”입니다. Rust 2018부터 도입된 NLL(Non-Lexical Lifetimes)은 빌림의 생존 범위를 블록 단위가 아니라 실제로 마지막으로 사용되는 지점까지로 줄여줍니다. 덕분에 예전에는 불가능했던 코드가 가능해졌지만, 여전히 자주 막히는 패턴은 남아있습니다.
이 글은 E0502/E0499를 NLL 관점에서 재해석하고, 실무에서 바로 적용 가능한 7가지 해결 패턴을 예제로 정리합니다. (디버깅 관점은 데이터프레임 경고를 잡는 방식과도 닮았습니다. 문제를 “규칙 위반”이 아니라 “범위 추론 실패”로 읽어내는 게 포인트입니다. 유사한 접근으로는 Pandas SettingWithCopyWarning 완전 정복 - 버그 7패턴 같은 글이 도움이 됩니다.)
E0502/E0499를 NLL로 읽는 법
E0502: 이미&T가 살아있는 동안&mut T를 만들려고 함E0499: 이미&mut T가 살아있는 동안 또 다른&mut T를 만들려고 함
NLL이 있어도 해결되지 않는 경우는 대개 다음 중 하나입니다.
- 빌림이 “생각보다 늦게” 마지막으로 사용됨(예:
println!에서 뒤늦게 사용) - 동일 컨테이너에서 두 개의 가변 참조를 동시에 만들려 함(인덱싱, 반복자)
- 반환값/클로저/이터레이터가 참조를 잡고 있어 수명이 길어짐
아래 7패턴은 이 세 가지 원인을 각각 다른 방식으로 끊어내는 방법입니다.
패턴 1) 불변 참조를 먼저 “끝내기”: 사용 순서 재배치
가장 흔한 E0502는 불변 참조를 만든 뒤, 같은 값에 가변 접근을 시도할 때 발생합니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
// v.push(4); // E0502: `v` is borrowed as immutable
println!("{}", first);
}
해결은 단순합니다. 불변 참조의 마지막 사용을 앞당기거나, 가변 작업을 뒤로 미룹니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // 복사(또는 clone)로 참조 자체를 없애기
v.push(4);
println!("{}", first);
}
포인트:
Copy타입이면 값 복사로 참조를 제거하는 게 가장 깔끔합니다.String같은 타입이면clone이 비용이 될 수 있어 다른 패턴을 고려합니다.
패턴 2) 스코프를 인위적으로 줄이기: 중괄호 블록
NLL이 있어도 “마지막 사용”이 늦게 잡히면 빌림이 길어집니다. 그럴 땐 스코프를 분리해 빌림을 강제로 종료시킵니다.
fn main() {
let mut s = String::from("hello");
{
let r = &s;
println!("{}", r);
} // 여기서 r drop
s.push('!');
println!("{}", s);
}
포인트:
- “빌림이 끝나야 하는 지점”을 블록으로 명시하면 컴파일러 추론이 단순해집니다.
- 리팩터링 중에 특히 유용합니다(디버그 출력 하나 때문에 빌림이 길어지는 경우가 많음).
패턴 3) 컨테이너를 쪼개기: split_at_mut로 두 개의 &mut 만들기
E0499의 대표 사례는 같은 Vec에서 서로 다른 인덱스에 대해 가변 참조 두 개를 만들려는 경우입니다.
fn main() {
let mut v = vec![10, 20, 30];
// let a = &mut v[0];
// let b = &mut v[1];
// E0499: cannot borrow `v` as mutable more than once
// 해결: split_at_mut
let (left, right) = v.split_at_mut(1);
let a = &mut left[0];
let b = &mut right[0];
*a += 1;
*b += 2;
println!("{:?}", v);
}
포인트:
split_at_mut는 “서로 겹치지 않는 슬라이스”임을 타입 레벨에서 보장합니다.- 인덱스가 동적으로 결정되면, 작은 쪽을 먼저
min/max로 정규화한 뒤 split하는 식으로 확장 가능합니다.
패턴 4) 읽기와 쓰기를 분리: iter()로 계산 후 iter_mut()로 반영
한 번의 루프에서 읽고 쓰려다 빌림 충돌이 나는 경우가 많습니다. 해결은 두 단계로 나누는 것입니다.
fn main() {
let mut v = vec![1, 2, 3, 4];
// 1) 읽기 전용으로 필요한 값 계산
let sum: i32 = v.iter().sum();
// 2) 그 다음 가변 반복자로 반영
for x in v.iter_mut() {
*x += sum;
}
println!("{:?}", v);
}
포인트:
- 성능이 걱정되면 “중간 결과”를 최소화하세요(합, 최대값, 인덱스 목록 등).
- 이 패턴은 데이터 파이프라인에서도 자주 쓰입니다. (대규모 처리에서 메모리 폭주를 피하는 관점은 Elixir Stream으로 대용량 ETL 메모리 폭주 막기 같은 글과 연결됩니다.)
패턴 5) 소유권 이동으로 참조를 끊기: mem::take / replace
구조체의 필드를 빌린 상태에서 같은 구조체를 다시 가변으로 만지려 하면 충돌이 자주 납니다. 이럴 때는 필드를 “꺼내서” 작업하고 다시 넣는 방식이 강력합니다.
use std::mem;
#[derive(Debug)]
struct State {
buf: String,
}
fn main() {
let mut st = State { buf: "hello".to_string() };
// buf를 통째로 꺼내서(빈 문자열로 대체) 소유권 기반으로 처리
let mut tmp = mem::take(&mut st.buf);
tmp.push('!');
st.buf = tmp;
println!("{:?}", st);
}
포인트:
take는 해당 타입이Default일 때만 가능(예:String,Vec).Option<T>필드라면take()메서드로 더 자연스럽게 처리 가능합니다.
패턴 6) HashMap 동시 접근: entry()로 한 번에 끝내기
HashMap에서 “존재 확인 후 삽입/수정”을 두 번 접근으로 하면 빌림이 꼬입니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
// 나쁜 예(상황에 따라 E0502/E0499로 이어질 수 있음)
// if m.contains_key("a") { ... }
// 좋은 예: entry로 한 번에
let v = m.entry("a".to_string()).or_insert(0);
*v += 1;
println!("{:?}", m);
}
포인트:
entry는 내부적으로 필요한 빌림을 한 번만 잡고, 그 범위도 명확합니다.or_insert_with로 비용 큰 초기화도 지연시킬 수 있습니다.
패턴 7) “참조를 반환”하지 말고 “인덱스/키를 반환”: 수명 전파 차단
함수에서 참조를 반환하면 호출자 스코프까지 수명이 전파되어, 이후 가변 작업을 막는 일이 잦습니다. 그럴 땐 참조 대신 인덱스나 키 같은 “값”을 반환해 빌림을 끊습니다.
fn find_pos(v: &Vec<String>, needle: &str) -> Option<usize> {
v.iter().position(|s| s == needle)
}
fn main() {
let mut v = vec!["a".to_string(), "b".to_string()];
if let Some(i) = find_pos(&v, "a") {
// 여기서는 가변 빌림 가능
v[i].push_str("-x");
}
println!("{:?}", v);
}
포인트:
- “참조를 오래 들고 있지 말라”는 Rust의 설계 철학을 가장 직접적으로 반영합니다.
- 특히 API 설계에서 효과가 큽니다. 반환 타입이
&T인지usize인지에 따라 호출자 코드의 자유도가 크게 달라집니다.
보너스: NLL이 있어도 막히는 대표 함정 3가지
1) 디버그 출력이 빌림을 연장
println!에서 참조를 사용하면 그 지점까지 불변 빌림이 유지됩니다. 디버그용 출력은 종종 “원인”이 아니라 “수명 연장 트리거”입니다. 패턴 1, 2로 해결합니다.
2) 이터레이터/클로저가 참조를 캡처
iter() 결과를 변수에 담아두면 그 이터레이터가 원본을 빌린 채로 남습니다. 가능한 한 체이닝으로 끝내거나, 중간 결과를 값으로 수집(collect)해 빌림을 끊습니다.
3) 인덱싱 두 번은 컴파일러가 겹침을 증명 못함
사람이 보기엔 i와 j가 다르더라도, 컴파일러는 일반적으로 “다르다”를 증명하지 못합니다. 이때는 split_at_mut 같은 안전한 분할 API를 쓰는 게 정석입니다.
정리: 에러 메시지를 “범위 문제”로 번역하라
E0502/E0499를 만났을 때의 실전 체크리스트는 다음과 같습니다.
- 참조의 마지막 사용 지점이 어디인지 찾기(NLL 관점)
- 불변/가변 작업을 순서 재배치하거나 스코프를 분리하기
- 동일 컨테이너의 다중 가변 참조가 필요하면 분할 API(
split_at_mut,entry)로 증명 가능하게 만들기 - 장기 참조를 반환하는 API라면 인덱스/키/소유권 반환으로 설계를 바꾸기
Rust의 빌림 에러는 “컴파일러가 까다롭다”가 아니라 “내가 의도한 데이터 접근 범위를 타입 시스템이 증명할 수 있느냐”의 문제입니다. NLL 덕분에 증명 부담이 줄었지만, 위 7패턴을 습관화하면 빌림 충돌은 대부분 기계적으로 풀립니다.