- Published on
Rust Iterator로 for 루프 지우기 - map·fold·scan
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/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 가장 흔한 for → map + 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
})
}
단, 이 경우 반환된 HashMap은 words가 살아 있는 동안만 유효합니다(빌린 키).
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()
}
state는scan내부에 캡슐화됩니다.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 - 상태는 필요하지만 출력은 필터링/조건부:
scan에filter_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_each는 for 루프의 함수형 버전처럼 보이지만, 부수효과가 많아질수록 디버깅이 어려워질 수 있습니다.
items.iter().for_each(|x| {
// 로깅, 메트릭, 외부 호출 등 부수효과
});
이 경우는 for가 더 명료한 경우도 많습니다.
6) 예제: for 기반 데이터 처리 파이프라인을 Iterator로 재구성
상황: 로그 라인에서 숫자를 파싱해
- 음수는 버리고
- 누적합(prefix)을 만들고
- 누적합이
limit을 넘기면 중단 - 마지막 누적합을 반환
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로 표현 scan의None으로 조기 종료
파싱까지 완전히 스트리밍으로 처리하고 싶다면, try_fold 하나로도 가능합니다. 다만 “중간 누적합을 방출”하지 않으니 scan이 더 직관적인 경우가 많습니다.
7) 마무리: map·fold·scan으로 루프를 ‘의미’로 바꾸기
정리하면 다음과 같습니다.
map: 요소를 다른 형태로 변환(루프의push제거)fold/try_fold: 누적/집계(루프의acc제거, 에러 전파를 구조화)scan: 상태를 가진 변환(루프의acc 갱신 + 매 단계 push/break제거)
Iterator는 “짧게 쓰기 위한 트릭”이 아니라, 변환/집계/상태를 분리해 코드의 의도를 더 정확히 전달하는 도구입니다. 다만 체인이 과도하게 길어지거나, 부수효과가 많은 경우는 for가 더 유지보수에 유리할 수 있으니, 위의 체크리스트 기준으로 균형 있게 적용하는 것을 권합니다.