Published on

Rust iterator에서 borrow checker 에러 7가지 패턴

Authors

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 borrowmutable 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를 변수에 담아두는 순간, “대여가 길어지는” 효과가 생긴다는 점을 항상 의식하세요.


정리: 에러를 줄이는 설계 규칙

  1. 순회와 수정을 한 스코프에 겹치지 말기: 필요하면 “수집 후 반영”으로 2단계 처리.
  2. 읽기는 iter / 쓰기는 iter_mut: 혼합하면 충돌 확률이 급상승.
  3. 참조를 collect로 오래 들고 있지 말기: 이후 원본 수정 계획이 있다면 소유값으로 수집.
  4. 임시값의 참조를 반환하지 말기: &str는 소유자가 더 오래 살아야 함.
  5. 두 개의 &mut가 필요하면 표준 API로 분할: split_at_mut 같은 함수로 비겹침을 증명.
  6. 지연 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>)으로 단순화한 뒤 병목에서만 참조 최적화를 고려하는 편이 안전합니다.