Published on

Rust Iterator로 for 루프 지우기 - map·fold·scan

Authors

서버/CLI/데이터 처리 코드에서 for 루프는 가장 흔한 도구지만, Rust에서는 Iterator 체인으로 더 안전하고 예측 가능한 형태로 바꾸는 경우가 많습니다. 특히 변이 가능한 상태(mut)가 루프 밖으로 새어 나가거나, 중간 컬렉션이 불필요하게 생성되거나, 에러 처리가 중첩되기 쉬운 코드에서 Iterator는 “의도”를 더 선명하게 드러냅니다.

이 글은 map, fold, scan을 축으로 for 루프를 단계적으로 없애는 방법을 다룹니다. 단순 치환이 아니라, 소유권/라이프타임/성능(할당/복사)/에러 전파 관점에서 어떤 선택이 좋은지도 같이 정리합니다.

스트림/이터레이션 기반 사고는 언어를 가리지 않습니다. Java에서 병렬 Stream이 생각만큼 빠르지 않은 이유를 파고든 글도 참고하면 “무조건 함수형이 빠르다”는 오해를 줄이는 데 도움이 됩니다: Java Stream 병렬화가 느린 6가지 이유와 해결

1) Rust Iterator의 핵심 모델: “지연 평가 + 조합”

Rust Iterator는 기본적으로 지연(lazy) 입니다. map, filter, scan 같은 어댑터는 바로 실행되지 않고, 최종적으로 collect, for_each, fold, sum 같은 소비자(consumer) 가 호출될 때 실행됩니다.

이 모델이 주는 이점은 다음과 같습니다.

  • 중간 Vec 생성 회피: map(...).filter(...).take(...) 같은 연산이 한 번의 순회로 합쳐질 수 있음
  • 소유권을 정교하게 제어: iter()(빌림), iter_mut()(가변 빌림), into_iter()(소유권 이동)
  • 의도 표현: “변환(map)”, “누적(fold)”, “상태 포함 변환(scan)” 같은 의미가 코드에 드러남

다만, 모든 for를 억지로 없애는 게 정답은 아닙니다. 복잡한 제어 흐름(여러 continue/break), 다중 상태 갱신, 디버깅 편의 때문에 for가 더 명료한 경우도 많습니다. 이 글의 목표는 “삭제”가 아니라 더 좋은 표현이 가능한 구간을 정확히 골라 바꾸는 것입니다.

2) map: 변환은 루프가 아니라 변환으로

2.1 가장 흔한 formap + collect

예를 들어 문자열 리스트에서 길이를 뽑아 Vec로 만들고 싶다고 합시다.

fn lengths_for_loop(words: &[String]) -> Vec<usize> {
    let mut out = Vec::with_capacity(words.len());
    for w in words {
        out.push(w.len());
    }
    out
}

fn lengths_map(words: &[String]) -> Vec<usize> {
    words.iter().map(|w| w.len()).collect()
}

여기서 중요한 포인트는 words.iter() 입니다.

  • iter()&String을 순회합니다(빌림).
  • into_iter()를 쓰면 String 소유권이 이동할 수 있습니다(상황에 따라 달라짐).

2.2 map만으로 끝내지 말고, “중간 컬렉션”을 만들지 않기

map 다음에 또 collect하고 다시 순회하는 패턴은 성능/가독성 모두 손해일 수 있습니다.

// 안 좋은 예: 중간 Vec 생성
let tmp: Vec<_> = nums.iter().map(|x| x * 2).collect();
let sum: i32 = tmp.iter().sum();

// 더 나은 예: 한 번에 소비
let sum: i32 = nums.iter().map(|x| x * 2).sum();

Iterator의 강점은 “체인 끝에서 한 번만 소비”하는 데 있습니다.

2.3 map에서 소유권 다루기: clone 남발을 피하기

다음은 흔히 만나는 실수입니다.

// 필요 이상으로 clone
let upper: Vec<String> = words.iter().map(|w| w.clone().to_uppercase()).collect();

to_uppercase()&str만 있으면 되므로 clone이 필요 없습니다.

let upper: Vec<String> = words.iter().map(|w| w.to_uppercase()).collect();

소유권을 옮겨야 하는 경우(예: Vec<String>을 소비해서 다른 Vec<String>을 만들기)에는 into_iter()가 더 자연스럽습니다.

fn normalize(mut words: Vec<String>) -> Vec<String> {
    words
        .into_iter()
        .map(|w| w.trim().to_lowercase())
        .collect()
}

3) fold: 누적/집계를 위한 루프 제거

fold는 “초기값 + 누적 함수”로 결과를 한 번에 만듭니다. 합계, 곱, 문자열 결합, 맵 구성, 통계 계산 등 대부분의 집계가 대상입니다.

3.1 합계/곱 같은 기본 집계

sum()이 있으면 sum()이 더 읽기 쉽지만, 원리를 이해하려면 fold가 좋습니다.

fn sum_fold(nums: &[i64]) -> i64 {
    nums.iter().fold(0, |acc, x| acc + x)
}

3.2 HashMap 구성: for에서 fold

예: 단어 빈도수를 만들기.

use std::collections::HashMap;

fn word_count_fold(words: &[String]) -> HashMap<String, usize> {
    words.iter().fold(HashMap::new(), |mut acc, w| {
        *acc.entry(w.clone()).or_insert(0) += 1;
        acc
    })
}

여기서 w.clone()이 들어간 이유는 HashMap<String, usize>의 키가 String 소유권을 필요로 하기 때문입니다. 만약 입력의 라이프타임이 충분히 길고, 키를 빌려도 된다면 HashMap<&str, usize> 같은 형태로 clone을 제거할 수도 있습니다.

use std::collections::HashMap;

fn word_count_borrowed(words: &[String]) -> HashMap<&str, usize> {
    words.iter().fold(HashMap::new(), |mut acc, w| {
        *acc.entry(w.as_str()).or_insert(0) += 1;
        acc
    })
}

단, 이 경우 반환된 HashMapwords가 살아 있는 동안만 유효합니다(빌린 키).

3.3 try_fold: 에러 전파가 있는 누적

루프에서 ?를 쓰며 누적하는 패턴은 try_fold로 자주 바꿀 수 있습니다.

fn parse_and_sum(nums: &[String]) -> Result<i64, std::num::ParseIntError> {
    nums.iter().try_fold(0i64, |acc, s| {
        let v: i64 = s.parse()?;
        Ok(acc + v)
    })
}

이 패턴은 “중간에 실패하면 즉시 중단”이라는 의미가 명확합니다.

4) scan: 상태를 가진 변환(“누적 과정을 출력”)

fold가 최종 결과만 만든다면, scan누적 상태를 유지하면서 각 스텝의 출력을 만들어냅니다. 즉, 루프에서 acc를 갱신하고 매번 push하는 코드를 깔끔하게 대체합니다.

4.1 prefix sum(누적합) 만들기

fn prefix_sums(nums: &[i64]) -> Vec<i64> {
    nums.iter()
        .scan(0i64, |state, x| {
            *state += x;
            Some(*state)
        })
        .collect()
}
  • statescan 내부에 캡슐화됩니다.
  • Some(value)를 반환하면 그 값이 다음 아이템으로 방출됩니다.
  • None을 반환하면 순회가 종료됩니다(조기 종료).

4.2 “조건이 깨지면 중단” 같은 조기 종료

예: 누적합이 임계치를 넘으면 그 시점까지만 결과를 만들기.

fn prefix_until_limit(nums: &[i64], limit: i64) -> Vec<i64> {
    nums.iter()
        .scan(0i64, |state, x| {
            *state += x;
            if *state > limit {
                None
            } else {
                Some(*state)
            }
        })
        .collect()
}

루프에서 break하는 로직이 scan에서는 None으로 자연스럽게 표현됩니다.

4.3 scan vs fold 선택 기준

  • 최종 결과만 필요: fold 또는 try_fold
  • 중간 과정(각 단계 결과)이 필요: scan
  • 상태는 필요하지만 출력은 필터링/조건부: scanfilter_map을 결합하거나, scan에서 Option을 활용

5) for를 없애기 전에 체크할 5가지(실전 기준)

Iterator로 바꾸는 게 항상 이득은 아닙니다. 아래 기준으로 판단하면 실전에서 시행착오가 줄어듭니다.

5.1 가독성: 체인이 4~5개를 넘어가면 분리

체인이 길어지면 중간에 let 바인딩으로 끊는 게 낫습니다.

let iter = items
    .iter()
    .filter(|x| x.enabled)
    .map(|x| x.value);

let sum: i64 = iter.clone().count() as i64; // clone 불가한 iter도 많음
let total: i64 = iter.sum();

위처럼 “재사용”이 필요해지면 Iterator는 한 번 소비된다는 제약이 문제가 됩니다. 이때는 중간 결과를 collect하는 편이 더 명확할 수 있습니다.

5.2 성능: collect는 비용이 아니라 “의도”

collect를 무조건 악으로 보면 안 됩니다.

  • 이후 여러 번 순회해야 한다면 collect가 오히려 이득
  • 랜덤 접근이 필요하면 Vec가 필요
  • 디버깅/로깅을 위해 중간 결과를 보고 싶으면 collect가 편리

5.3 소유권: iter/iter_mut/into_iter를 먼저 고르기

  • 읽기만: iter()
  • 제자리 수정: iter_mut()
  • 소비해서 변환: into_iter()

이 선택이 흔들리면, clone이 늘어나거나 라이프타임이 꼬입니다.

5.4 조기 종료: take_while, find, scan(None) 활용

break가 핵심인 루프는 Iterator로 바꾸기 어렵다고 느끼기 쉽지만,

  • find는 “조건 만족 첫 요소”
  • take_while은 “조건 깨질 때까지”
  • scan은 “상태 기반 조기 종료”

로 대부분 대체 가능합니다.

5.5 부수효과: for_each는 신중하게

for_eachfor 루프의 함수형 버전처럼 보이지만, 부수효과가 많아질수록 디버깅이 어려워질 수 있습니다.

items.iter().for_each(|x| {
    // 로깅, 메트릭, 외부 호출 등 부수효과
});

이 경우는 for가 더 명료한 경우도 많습니다.

6) 예제: for 기반 데이터 처리 파이프라인을 Iterator로 재구성

상황: 로그 라인에서 숫자를 파싱해

  1. 음수는 버리고
  2. 누적합(prefix)을 만들고
  3. 누적합이 limit을 넘기면 중단
  4. 마지막 누적합을 반환

6.1 for 루프 버전

fn process_for(lines: &[String], limit: i64) -> Result<i64, std::num::ParseIntError> {
    let mut acc = 0i64;

    for line in lines {
        let v: i64 = line.parse()?;
        if v < 0 {
            continue;
        }

        acc += v;
        if acc > limit {
            break;
        }
    }

    Ok(acc)
}

6.2 Iterator 버전(try_fold + scan 조합)

여기서는 “파싱 실패 시 즉시 종료”가 필요하므로 try_fold 또는 map에서 Result를 다뤄야 합니다. 한 가지 실용적인 방식은 map으로 Result 스트림을 만들고, collect로 한 번에 파싱을 끝낸 뒤(파싱이 핵심 실패 지점이라면), 그 다음을 순수 Iterator로 처리하는 것입니다.

fn process_iter(lines: &[String], limit: i64) -> Result<i64, std::num::ParseIntError> {
    let nums: Vec<i64> = lines.iter().map(|s| s.parse()).collect::<Result<_, _>>()?;

    let last = nums
        .into_iter()
        .filter(|v| *v >= 0)
        .scan(0i64, |state, v| {
            *state += v;
            if *state > limit { None } else { Some(*state) }
        })
        .last()
        .unwrap_or(0);

    Ok(last)
}
  • 파싱을 collect::<Result<Vec<_>, _>>()?로 한 번에 처리해 에러 흐름을 단순화
  • 비즈니스 로직은 filter + scan + last로 표현
  • scanNone으로 조기 종료

파싱까지 완전히 스트리밍으로 처리하고 싶다면, try_fold 하나로도 가능합니다. 다만 “중간 누적합을 방출”하지 않으니 scan이 더 직관적인 경우가 많습니다.

7) 마무리: map·fold·scan으로 루프를 ‘의미’로 바꾸기

정리하면 다음과 같습니다.

  • map: 요소를 다른 형태로 변환(루프의 push 제거)
  • fold/try_fold: 누적/집계(루프의 acc 제거, 에러 전파를 구조화)
  • scan: 상태를 가진 변환(루프의 acc 갱신 + 매 단계 push/break 제거)

Iterator는 “짧게 쓰기 위한 트릭”이 아니라, 변환/집계/상태를 분리해 코드의 의도를 더 정확히 전달하는 도구입니다. 다만 체인이 과도하게 길어지거나, 부수효과가 많은 경우는 for가 더 유지보수에 유리할 수 있으니, 위의 체크리스트 기준으로 균형 있게 적용하는 것을 권합니다.