Published on

Rust 이터레이터·클로저로 반복문 제거 12선

Authors

반복문은 명령형 코드의 기본 도구지만, Rust에서는 이터레이터와 클로저 조합이 가독성, 버그 방지, 성능(지연 평가, 불필요한 할당 감소) 측면에서 더 나은 선택이 되는 경우가 많습니다. 특히 Iterator 트레이트는 map, filter, fold 같은 고수준 연산을 제공하고, 클로저는 상태 캡처까지 가능해 “반복 + 조건 + 누적 + 조기 종료” 같은 패턴을 선언적으로 표현할 수 있습니다.

이 글에서는 반복문을 제거하는 12가지 대표 패턴을 실전 코드로 정리합니다. 각 패턴은 for/while로 작성하던 로직을 이터레이터 체인으로 치환하는 방식이며, 중간에 Result/Option 처리, 인덱싱, 그룹화, 조합 탐색 같은 자주 나오는 문제를 함께 다룹니다.

참고로, 반복이 과도하게 쌓이면 어디서 비용이 발생하는지 추적하기 어려워집니다. 성능 분석·폭증 디버깅 관점은 프론트엔드 사례지만 사고방식은 동일하니 React 렌더링 폭증, Chrome Profiler로 잡는 법도 함께 보면 도움이 됩니다.

준비: 예제 데이터

아래 예제들은 공통적으로 다음 같은 데이터를 가정합니다.

#[derive(Debug, Clone)]
struct Item {
    id: u64,
    name: String,
    price: i64,
}

fn sample_items() -> Vec<Item> {
    vec![
        Item { id: 1, name: "apple".into(), price: 1200 },
        Item { id: 2, name: "banana".into(), price: 800 },
        Item { id: 3, name: "avocado".into(), price: 2500 },
        Item { id: 4, name: "blueberry".into(), price: 3000 },
    ]
}

1) map으로 변환: 값 가공 루프 제거

가장 기본적인 “각 원소를 변환해 새 컬렉션 생성” 패턴입니다.

let items = sample_items();

let names: Vec<String> = items
    .iter()
    .map(|it| it.name.clone())
    .collect();

assert_eq!(names, vec!["apple", "banana", "avocado", "blueberry"]);
  • iter()는 빌림 반복자
  • map은 원소를 다른 타입으로 변환
  • collect는 원하는 컨테이너로 수집

2) filter로 조건 분기 제거

if로 분기하면서 push하던 루프를 filter로 치환합니다.

let items = sample_items();

let expensive: Vec<&Item> = items
    .iter()
    .filter(|it| it.price >= 2000)
    .collect();

assert_eq!(expensive.len(), 2);

3) filter_map으로 “조건 + 변환” 합치기

filtermap을 두 번 쓰기보다, “조건을 만족하면 변환, 아니면 버림”은 filter_map이 깔끔합니다.

let items = sample_items();

let maybe_discounted: Vec<i64> = items
    .iter()
    .filter_map(|it| {
        if it.price >= 2000 {
            Some(it.price - 200) // 할인
        } else {
            None
        }
    })
    .collect();

assert_eq!(maybe_discounted, vec![2300, 2800]);

4) find/position으로 조기 종료

반복 중 조건을 만족하면 즉시 탈출하는 패턴은 find가 정석입니다.

let items = sample_items();

let first_b: Option<&Item> = items.iter().find(|it| it.name.starts_with('b'));
assert_eq!(first_b.unwrap().name, "banana");

let idx: Option<usize> = items.iter().position(|it| it.id == 3);
assert_eq!(idx, Some(2));
  • find는 원소를 반환
  • position은 인덱스를 반환

5) any/all로 플래그 누적 제거

루프에서 bool 플래그를 갱신하던 코드를 단일 표현식으로 바꿉니다.

let items = sample_items();

let has_free = items.iter().any(|it| it.price == 0);
let all_positive = items.iter().all(|it| it.price > 0);

assert!(!has_free);
assert!(all_positive);

6) fold로 누적(합계·해시·통계) 루프 제거

fold는 “초기값 + 누적 함수”로 모든 누적 로직을 표현합니다.

let items = sample_items();

let total: i64 = items.iter().fold(0, |acc, it| acc + it.price);
assert_eq!(total, 1200 + 800 + 2500 + 3000);

fold는 강력하지만 과용하면 읽기 어려울 수 있습니다. 단순 합계는 sum이 더 명확합니다.

let total2: i64 = sample_items().iter().map(|it| it.price).sum();
assert_eq!(total2, 7500);

7) scan으로 “상태를 가진 변환” 만들기

반복 중 상태(누적값, 이전 값 등)를 유지하며 결과를 내보내려면 scan이 유용합니다.

예: 가격 누적합 시퀀스 생성

let items = sample_items();

let prefix_sums: Vec<i64> = items
    .iter()
    .map(|it| it.price)
    .scan(0, |state, x| {
        *state += x;
        Some(*state)
    })
    .collect();

assert_eq!(prefix_sums, vec![1200, 2000, 4500, 7500]);

8) take_while/skip_while로 구간 처리

정렬된/의미 있는 순서를 가진 데이터에서 “조건이 깨질 때까지” 처리하는 루프를 제거합니다.

let items = sample_items();

let until_expensive: Vec<&Item> = items
    .iter()
    .take_while(|it| it.price < 2000)
    .collect();

assert_eq!(until_expensive.len(), 2);

let after_cheap: Vec<&Item> = items
    .iter()
    .skip_while(|it| it.price < 2000)
    .collect();

assert_eq!(after_cheap.len(), 2);

9) enumerate로 인덱스 루프 제거

인덱스 기반 for i in 0..len 대신 enumerate를 사용하면 범위 오류를 줄이고 의도가 선명해집니다.

let items = sample_items();

let pairs: Vec<(usize, String)> = items
    .iter()
    .enumerate()
    .map(|(i, it)| (i, it.name.clone()))
    .collect();

assert_eq!(pairs[0].0, 0);
assert_eq!(pairs[0].1, "apple");

10) zip으로 병렬 순회(두 배열 동시 처리)

두 컬렉션을 같은 인덱스로 동시에 순회할 때 zip이 깔끔합니다.

let names = vec!["a", "b", "c"];
let prices = vec![10, 20, 30];

let joined: Vec<String> = names
    .iter()
    .zip(prices.iter())
    .map(|(n, p)| format!("{}:{}", n, p))
    .collect();

assert_eq!(joined, vec!["a:10", "b:20", "c:30"]);

길이가 다르면 짧은 쪽에 맞춰 종료됩니다. 이 성질을 “안전한 병렬 순회”로 활용할 수 있습니다.

11) flat_map으로 중첩 루프 평탄화

중첩 루프에서 내부 컬렉션을 펼쳐 하나의 스트림으로 만들고 싶다면 flat_map이 정답입니다.

let groups = vec![vec![1, 2], vec![3], vec![4, 5]];

let flattened: Vec<i32> = groups
    .into_iter()
    .flat_map(|g| g.into_iter())
    .collect();

assert_eq!(flattened, vec![1, 2, 3, 4, 5]);

또는 map으로 Option을 만든 뒤 flatten하는 방식도 있습니다.

let xs = vec![Some(1), None, Some(3)];
let ys: Vec<i32> = xs.into_iter().flatten().collect();
assert_eq!(ys, vec![1, 3]);

12) try_fold/collect로 에러 전파 루프 제거

반복 중 하나라도 실패하면 즉시 중단하고 에러를 반환하는 패턴은 Result와 이터레이터가 특히 잘 맞습니다.

12-1) collectVec<Result>Result<Vec>

fn parse_all(input: &[&str]) -> Result<Vec<i64>, std::num::ParseIntError> {
    input.iter().map(|s| s.parse::<i64>()).collect()
}

let ok = parse_all(&["1", "2", "3"]).unwrap();
assert_eq!(ok, vec![1, 2, 3]);

12-2) try_fold로 누적하면서 실패 시 중단

fn sum_parsed(input: &[&str]) -> Result<i64, std::num::ParseIntError> {
    input.iter().try_fold(0_i64, |acc, s| {
        let x = s.parse::<i64>()?;
        Ok(acc + x)
    })
}

assert_eq!(sum_parsed(&["10", "20"]).unwrap(), 30);

이 패턴은 네트워크/파일/파싱처럼 실패 가능성이 있는 작업에서 반복문을 크게 단순화합니다. 분산 환경에서 오류 전파가 꼬이면 추적이 어려워지는데, 그런 경우엔 gRPC Interceptor로 분산 트레이싱 전파 오류 잡기처럼 “전파 경로를 표준화”하는 접근이 통합니다.

이터레이터 체인 설계 팁: 읽기 쉬운 경계 만들기

이터레이터는 강력하지만, 체인이 길어지면 디버깅이 어려워질 수 있습니다. 다음 규칙을 권합니다.

  1. 한 줄에 한 변환: map/filter를 과도하게 중첩하지 말고 줄을 나눕니다.
  2. 중간 결과 이름 붙이기: 복잡해지면 let iter = ...;로 끊어줍니다.
  3. 할당 시점을 의식하기: collect는 경계입니다. 정말 필요할 때만 Vec로 만드세요.
  4. 빌림 vs 소유권 선택: iter()(빌림), iter_mut()(가변 빌림), into_iter()(소유 이동)를 의도에 맞게 고릅니다.

예: 불필요한 clone을 줄이기 위해 소유권 이동을 활용

let items = sample_items();

let names: Vec<String> = items
    .into_iter() // Item을 이동
    .map(|it| it.name) // name을 그대로 꺼냄 (clone 없음)
    .collect();

assert_eq!(names.len(), 4);

언제 반복문이 더 나은가

모든 반복을 이터레이터로 바꾸는 것이 정답은 아닙니다.

  • 복잡한 상태 머신: 분기와 상태가 많으면 loop/while이 더 명료할 수 있습니다.
  • 초고성능 핫패스: 이터레이터가 대부분 최적화되지만, 특정 상황에서 명시적 루프가 더 예측 가능할 때가 있습니다.
  • 조기 break/continue가 난무: 이 경우는 오히려 로직을 함수로 쪼개거나, find_map, try_fold, take_while 같은 연산으로 “의도를 가진 종료”로 바꾸는 게 좋습니다.

성능이나 동작이 의심될 때는 CI에서 측정과 캐싱을 안정화하는 것도 중요합니다. 예를 들어 벤치마크/빌드 시간이 들쭉날쭉하면 GitHub Actions 캐시 미적중 원인 - key·restore-keys·락파일 같은 체크리스트가 도움이 됩니다.

마무리

Rust에서 반복문 제거는 단순한 “코드 골프”가 아니라, 데이터 흐름을 선언적으로 만들고 실패 전파를 표준화하는 방법입니다. 이 글의 12패턴을 기준으로 다음 순서로 적용해보면 좋습니다.

  • 변환은 map
  • 필터는 filter/filter_map
  • 조기 종료는 find/any/take_while
  • 누적은 sum/fold/scan
  • 에러 전파는 collect/try_fold

한 번에 모든 코드를 바꾸기보다, 가장 버그가 잦은 루프(인덱스 범위, 에러 처리, 중첩 루프)부터 이터레이터로 치환하면 체감 효과가 큽니다.