- Published on
Rust iterator에서 borrow checker 에러 7가지 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust에서 iterator는 선언적으로 깔끔한 코드를 만들 수 있지만, 클로저 캡처와 참조 수명, 가변/불변 대여가 얽히면 borrow checker 에러가 빠르게 등장합니다. 특히 map/filter/for_each 같은 어댑터는 “짧아 보이지만 실제로는 대여가 길게 유지되는” 상황을 만들기 쉽습니다.
이 글은 iterator 문맥에서 반복적으로 등장하는 borrow checker 에러를 7가지 패턴으로 분류하고, 각 패턴마다 통과 가능한 형태로 고치는 방법을 제시합니다. (에러 메시지는 컴파일러 버전과 코드에 따라 약간 달라질 수 있습니다.)
참고로 Rust에서 데이터 흐름을 안정적으로 설계하는 관점은 RAG/벡터 파이프라인에서도 매우 중요합니다. 관련해서는 Rust+Qdrant RAG에서 벡터 드리프트 잡는 법도 같이 보면, “상태와 불변성”을 다루는 감각을 넓히는 데 도움이 됩니다.
1) 같은 컬렉션을 순회하면서 동시에 수정하려는 패턴
증상
iter()로 불변 대여를 잡은 상태에서 같은 컬렉션에 push/remove 같은 가변 작업을 하려 하면 충돌합니다.
문제가 되는 코드
fn main() {
let mut v = vec![1, 2, 3];
v.iter().for_each(|x| {
if *x % 2 == 1 {
v.push(*x); // 불변 대여 중 가변 대여 시도
}
});
}
해결 1: 변경할 내용을 별도로 모은 뒤 반영
fn main() {
let mut v = vec![1, 2, 3];
let to_add: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 1).collect();
v.extend(to_add);
}
해결 2: 인덱스 기반으로 분리 (가능한 경우)
fn main() {
let mut v = vec![1, 2, 3];
let mut i = 0;
while i < v.len() {
let x = v[i];
if x % 2 == 1 {
v.push(x);
}
i += 1;
}
}
핵심은 “순회에 쓰는 대여”와 “수정에 쓰는 대여”가 같은 스코프에 겹치지 않도록 끊는 것입니다.
2) iter() 중에 iter_mut() 또는 다른 가변 접근을 섞는 패턴
증상
한 컬렉션에 대해 불변 이터레이터를 유지한 채, 중간에 get_mut/iter_mut/인덱스 가변 접근을 하면 immutable borrow와 mutable borrow가 충돌합니다.
문제가 되는 코드
fn main() {
let mut v = vec![10, 20, 30];
for (i, x) in v.iter().enumerate() {
if *x == 20 {
*v.get_mut(i).unwrap() = 200; // 불변 순회 중 가변 접근
}
}
}
해결: 처음부터 iter_mut()로 설계를 바꾸기
fn main() {
let mut v = vec![10, 20, 30];
v.iter_mut().for_each(|x| {
if *x == 20 {
*x = 200;
}
});
}
iter()로는 읽기만, iter_mut()로는 그 자리에서 수정만 한다는 규칙을 지키면 대부분의 충돌이 사라집니다.
3) collect()로 참조를 모으고, 원본을 변형하는 패턴
증상
Vec<&T>처럼 원본을 참조하는 결과를 만든 뒤 원본을 수정하려 하면 참조가 무효화될 수 있으므로 컴파일러가 막습니다.
문제가 되는 코드
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
let refs: Vec<&String> = v.iter().collect();
v.push(String::from("c")); // refs가 v를 빌린 상태
println!("{}", refs[0]);
}
해결 1: 참조 대신 소유값을 복제/이동해 모으기
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
let owned: Vec<String> = v.iter().cloned().collect();
v.push(String::from("c"));
println!("{}", owned[0]);
}
해결 2: 변형을 먼저 하고, 참조는 나중에 만든다
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
v.push(String::from("c"));
let refs: Vec<&String> = v.iter().collect();
println!("{}", refs[0]);
}
iterator에서 collect()가 만들어내는 “대여의 범위”가 생각보다 길어질 수 있다는 점을 기억하세요.
4) 클로저가 외부 가변 참조를 캡처한 상태에서, 같은 값을 또 빌리는 패턴
증상
map/filter 클로저에서 외부의 &mut 상태를 캡처하면, 그 상태의 가변 대여가 클로저의 수명 동안 유지됩니다. 그 사이에 같은 값을 다른 방식으로 접근하면 충돌합니다.
문제가 되는 코드
use std::collections::HashMap;
fn main() {
let mut counts: HashMap<String, usize> = HashMap::new();
let words = vec!["a", "b", "a"];
let lens: Vec<usize> = words
.iter()
.map(|w| {
*counts.entry((*w).to_string()).or_insert(0) += 1; // counts를 가변 캡처
w.len()
})
.collect();
// 여기서 counts를 또 쓰는 건 보통은 괜찮지만,
// 더 복잡한 체인에서 counts의 대여가 길게 유지되며 문제가 커질 수 있음
println!("{:?} {:?}", lens, counts);
}
위 코드는 상황에 따라 컴파일되기도 하지만, 비슷한 형태에서 counts를 다른 대여로 엮으면 쉽게 깨집니다. 특히 “iterator 체인을 더 이어 붙이거나”, “다른 클로저가 중첩되거나”, “임시값이 길게 살아남는” 순간에 자주 터집니다.
해결: 부수효과를 for 루프로 분리하거나, fold로 상태를 명시화
해결 1: 명시적으로 분리
use std::collections::HashMap;
fn main() {
let mut counts: HashMap<String, usize> = HashMap::new();
let words = vec!["a", "b", "a"];
let mut lens = Vec::with_capacity(words.len());
for w in &words {
*counts.entry((*w).to_string()).or_insert(0) += 1;
lens.push(w.len());
}
println!("{:?} {:?}", lens, counts);
}
해결 2: fold로 상태를 한 덩어리로
use std::collections::HashMap;
fn main() {
let words = vec!["a", "b", "a"];
let (counts, lens) = words.iter().fold(
(HashMap::<String, usize>::new(), Vec::<usize>::new()),
|(mut counts, mut lens), w| {
*counts.entry((*w).to_string()).or_insert(0) += 1;
lens.push(w.len());
(counts, lens)
},
);
println!("{:?} {:?}", lens, counts);
}
iterator는 “순수 변환”에 강하고, 외부 상태를 건드리는 순간 borrow 관계가 복잡해집니다. 부수효과가 핵심이면 차라리 루프로 빼는 게 유지보수에 유리합니다.
5) flat_map/map에서 임시값을 만들고 그 참조를 반환하는 패턴
증상
클로저 내부에서 String/Vec 같은 임시 소유값을 만든 뒤 &str/&T 참조로 내보내면, 클로저가 끝날 때 임시값이 drop되므로 참조가 살아남을 수 없습니다.
문제가 되는 코드
fn main() {
let nums = vec![1, 2, 3];
let parts: Vec<&str> = nums
.iter()
.map(|n| n.to_string())
.map(|s| s.as_str()) // 임시 String의 &str을 반환하려 함
.collect();
println!("{:?}", parts);
}
해결 1: 참조가 아니라 소유값을 수집
fn main() {
let nums = vec![1, 2, 3];
let parts: Vec<String> = nums.iter().map(|n| n.to_string()).collect();
println!("{:?}", parts);
}
해결 2: 반드시 &str가 필요하면, 소유 컨테이너를 먼저 만들고 참조는 그 다음
fn main() {
let nums = vec![1, 2, 3];
let strings: Vec<String> = nums.iter().map(|n| n.to_string()).collect();
let parts: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
println!("{:?}", parts);
}
&str는 “어딘가에 실제 문자열 소유자가 존재하고, 그 소유자가 더 오래 살아있다”는 전제가 필요합니다.
6) split_at_mut 없이 같은 슬라이스에서 두 개의 &mut를 만들려는 패턴
증상
iterator로 인덱스를 돌면서 v[i]와 v[j]를 동시에 가변 참조로 잡고 싶을 때가 있습니다. 하지만 Rust는 동일 슬라이스에서 두 개의 &mut를 임의로 만드는 것을 금지합니다(서로 다른 인덱스라는 것을 컴파일러가 일반적으로 증명 못 함).
문제가 되는 코드
fn main() {
let mut v = vec![1, 2, 3, 4];
(0..v.len() - 1).for_each(|i| {
let a = &mut v[i];
let b = &mut v[i + 1]; // 같은 v에서 두 번째 &mut
*a += *b;
});
}
해결: split_at_mut로 슬라이스를 둘로 쪼개 서로 다른 영역임을 증명
fn main() {
let mut v = vec![1, 2, 3, 4];
for i in 0..v.len() - 1 {
let (left, right) = v.split_at_mut(i + 1);
let a = &mut left[i];
let b = &mut right[0];
*a += *b;
}
println!("{:?}", v);
}
iterator 스타일로 유지하고 싶다면 for_each 대신 for를 쓰는 게 더 읽히는 경우가 많습니다. 핵심은 split_at_mut 같은 API로 “서로 겹치지 않는 가변 대여”를 모델링하는 것입니다.
7) iterator 결과가 원본을 빌린 상태로 남아 있는데, 그 사이에 원본을 이동(move)하는 패턴
증상
iterator는 종종 “지연 평가”입니다. map/filter로 만든 iterator를 변수에 저장해 두면, 그 iterator가 원본을 빌린 채로 남아 있을 수 있습니다. 그 상태에서 원본을 drop하거나 다른 곳으로 이동시키면 에러가 납니다.
문제가 되는 코드
fn main() {
let v = vec![1, 2, 3];
let it = v.iter().map(|x| x * 2); // it가 v를 빌림
let moved = v; // v를 이동하려 함
let out: Vec<i32> = it.collect();
println!("{:?} {:?}", moved, out);
}
해결 1: iterator를 먼저 소비해서 대여를 끝내기
fn main() {
let v = vec![1, 2, 3];
let out: Vec<i32> = v.iter().map(|x| x * 2).collect();
let moved = v;
println!("{:?} {:?}", moved, out);
}
해결 2: 애초에 소유 iterator로 만들기 (into_iter)
fn main() {
let v = vec![1, 2, 3];
let out: Vec<i32> = v.into_iter().map(|x| x * 2).collect();
println!("{:?}", out);
}
iter()는 빌리고, into_iter()는 소유권을 가져갑니다. iterator를 변수에 담아두는 순간, “대여가 길어지는” 효과가 생긴다는 점을 항상 의식하세요.
정리: 에러를 줄이는 설계 규칙
- 순회와 수정을 한 스코프에 겹치지 말기: 필요하면 “수집 후 반영”으로 2단계 처리.
- 읽기는
iter/ 쓰기는iter_mut: 혼합하면 충돌 확률이 급상승. - 참조를
collect로 오래 들고 있지 말기: 이후 원본 수정 계획이 있다면 소유값으로 수집. - 임시값의 참조를 반환하지 말기:
&str는 소유자가 더 오래 살아야 함. - 두 개의
&mut가 필요하면 표준 API로 분할:split_at_mut같은 함수로 비겹침을 증명. - 지연 iterator는 대여를 길게 만든다: 저장하지 말고 빨리 소비하거나 소유 iterator로 전환.
빌드/테스트 파이프라인에서 이런 borrow 이슈는 “로컬에선 고쳤는데 CI에서 러스트 버전 차이로 다시 터지는” 형태로도 나타납니다. 캐시나 환경 차이로 재현이 흔들릴 때는 GitHub Actions 캐시가 안 먹을 때 키·경로 9분 점검처럼 CI 설정을 함께 점검해두면 디버깅 시간이 줄어듭니다.
부록: 디버깅을 빠르게 하는 팁 3가지
1) iterator 체인을 끊고 타입을 고정하기
중간에 collect::<Vec<_>>()를 넣어 대여 범위를 끊으면 에러가 훨씬 단순해집니다.
fn main() {
let v = vec![1, 2, 3, 4];
let tmp: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 0).collect();
let out: Vec<i32> = tmp.into_iter().map(|x| x * 10).collect();
println!("{:?}", out);
}
2) for 루프로 바꿔서 대여 스코프를 눈에 보이게 하기
iterator는 멋지지만, 대여 스코프가 “표현식 단위”로 숨습니다. 복잡해지면 루프로 바꾸는 게 가장 빠른 해결책인 경우가 많습니다.
3) “참조를 반환할 건지, 소유값을 반환할 건지”를 먼저 결정하기
&T/&str를 반환하는 순간 수명 제약이 붙습니다. 성능 최적화가 목표가 아니라면, 초반에는 소유값(String, Vec<T>)으로 단순화한 뒤 병목에서만 참조 최적화를 고려하는 편이 안전합니다.