- Published on
Rust 반복문을 Iterator·fold로 바꾸는 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
반복문은 읽기 쉽고 디버깅도 편하지만, Rust에서는 Iterator 기반으로 로직을 구성하면 소유권/빌림 규칙을 더 자연스럽게 만족시키고, 중간 상태(가변 변수)를 줄이며, 조합 가능한 파이프라인으로 만들 수 있습니다. 특히 누적 계산은 fold가 정석에 가깝습니다.
이 글은 “반복문을 무조건 없애자”가 아니라, 반복문의 의도를 분해해 Iterator 조합으로 옮기는 방법을 패턴으로 정리합니다. 마지막에는 fold를 중심으로 try_fold, scan, take_while 같은 도구로 확장하는 기준도 제시합니다.
관련해서 운영 환경에서의 문제를 줄이는 습관(실패를 빨리 터뜨리기, 조기 종료 조건 명확화)은 쉘 스크립트의 set -euo pipefail 철학과도 닮았습니다. 필요하면 bash set -euo pipefail로 스크립트 터질 때 대처법도 같이 보면 좋습니다.
반복문을 Iterator로 치환하는 사고 순서
반복문을 Iterator로 옮길 때는 아래 질문을 순서대로 던지면 빠릅니다.
- 입력 시퀀스는 무엇인가:
Vec, 슬라이스, 맵, 범위(0..n), 스트림 등 - 각 원소를 변환하는가:
map - 조건으로 걸러내는가:
filter/filter_map - 누적 결과가 필요한가:
fold/reduce - 중간에 멈추는가:
find,any,all,take_while,try_fold - 에러를 전파하는가:
collect로Result/Option수집, 혹은try_fold
이제 가장 흔한 반복문들을 Iterator와 fold로 바꿔봅니다.
패턴 1: 합계/카운트 같은 누적값
기존 반복문
fn sum_positive(nums: &[i32]) -> i32 {
let mut acc = 0;
for &x in nums {
if x > 0 {
acc += x;
}
}
acc
}
Iterator + fold
fn sum_positive(nums: &[i32]) -> i32 {
nums.iter()
.copied()
.filter(|&x| x > 0)
.fold(0, |acc, x| acc + x)
}
핵심 포인트:
iter()는&i32를 내놓으니 값이 필요하면copied()또는cloned()- 누적 초기값이 명확하면
fold(init, f)가 가장 직관적 sum()도 가능하지만, 로직이 커지면fold가 확장성 좋음
패턴 2: 최소/최대/Reduce 계열
반복문으로 최소값을 찾는 코드는 보통 초기값 처리 때문에 지저분해집니다.
반복문
fn min_value(nums: &[i32]) -> Option<i32> {
if nums.is_empty() {
return None;
}
let mut m = nums[0];
for &x in &nums[1..] {
if x < m {
m = x;
}
}
Some(m)
}
Iterator
fn min_value(nums: &[i32]) -> Option<i32> {
nums.iter().copied().min()
}
min/max/min_by_key/max_by_key는 반복문 치환의 대표적인 “즉시 이득” 케이스입니다.
추가로 reduce는 fold에서 초기값이 없는 형태입니다.
fn product(nums: &[i64]) -> Option<i64> {
nums.iter().copied().reduce(|a, b| a * b)
}
패턴 3: 컬렉션 변환(필터링 + 매핑)
반복문
fn squares_of_even(nums: &[i32]) -> Vec<i32> {
let mut out = Vec::new();
for &x in nums {
if x % 2 == 0 {
out.push(x * x);
}
}
out
}
Iterator
fn squares_of_even(nums: &[i32]) -> Vec<i32> {
nums.iter()
.copied()
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.collect()
}
만약 “조건에 맞는 것만 변환하되, 변환 실패는 버린다”라면 filter_map이 더 적합합니다.
fn parse_ints(inputs: &[&str]) -> Vec<i32> {
inputs.iter().filter_map(|s| s.parse::<i32>().ok()).collect()
}
여기서 제네릭 표기 parse::<i32>()는 <>가 포함되므로 코드 블록 안에서만 사용해야 안전합니다.
패턴 4: 누적하면서 동시에 결과를 만들기
반복문에서 흔히 “누적 상태를 업데이트하면서 출력도 만든다”를 합니다. 이때 fold의 누적 타입을 튜플로 두면 깔끔합니다.
반복문
fn prefix_sums(nums: &[i32]) -> Vec<i32> {
let mut out = Vec::with_capacity(nums.len());
let mut acc = 0;
for &x in nums {
acc += x;
out.push(acc);
}
out
}
fold로 튜플 누적
fn prefix_sums(nums: &[i32]) -> Vec<i32> {
let (_acc, out) = nums.iter().copied().fold(
(0, Vec::with_capacity(nums.len())),
|(acc, mut out), x| {
let acc = acc + x;
out.push(acc);
(acc, out)
},
);
out
}
이 패턴은 강력하지만, 클로저에서 mut out을 들고 다니는 게 번거로울 수 있습니다. 그런 경우 scan이 더 자연스럽습니다.
scan 사용
fn prefix_sums(nums: &[i32]) -> Vec<i32> {
nums.iter()
.copied()
.scan(0, |state, x| {
*state += x;
Some(*state)
})
.collect()
}
정리하면:
- “마지막에 하나만 필요”하면
fold - “중간 결과 시퀀스가 필요”하면
scan
패턴 5: 조기 종료가 있는 반복문
fold는 기본적으로 끝까지 돕니다. 반복문에서 break가 있다면 아래 중 하나로 옮기는 게 일반적입니다.
5-1. 조건을 만족하는 첫 원소 찾기: find
fn first_large(nums: &[i32], threshold: i32) -> Option<i32> {
nums.iter().copied().find(|&x| x >= threshold)
}
5-2. 어떤 조건이라도 만족하면 끝: any / all
fn has_negative(nums: &[i32]) -> bool {
nums.iter().any(|&x| x < 0)
}
5-3. 특정 조건까지 처리: take_while
fn sum_until_negative(nums: &[i32]) -> i32 {
nums.iter()
.copied()
.take_while(|&x| x >= 0)
.fold(0, |acc, x| acc + x)
}
“조건을 어기는 원소를 만났을 때 즉시 종료”가 의도라면 take_while이 반복문의 break와 1:1로 대응됩니다.
패턴 6: 에러 전파가 있는 반복문은 try_fold
반복문에서 ?를 쓰며 누적하다가 실패하면 즉시 반환하는 코드는 try_fold가 가장 자연스럽습니다.
반복문
fn sum_parsed(inputs: &[&str]) -> Result<i32, std::num::ParseIntError> {
let mut acc = 0;
for s in inputs {
let x: i32 = s.parse()?;
acc += x;
}
Ok(acc)
}
try_fold
fn sum_parsed(inputs: &[&str]) -> Result<i32, std::num::ParseIntError> {
inputs.iter().try_fold(0, |acc, s| {
let x: i32 = s.parse()?;
Ok(acc + x)
})
}
장점:
- 실패 시 즉시 중단(불필요한 파싱 진행 없음)
- 누적과 에러 전파가 한 곳에 모임
운영 관점에서도 이런 “실패를 빨리 드러내는” 구조는 디버깅 비용을 줄입니다. 비슷한 철학의 사례로 Spring Boot HikariCP 커넥션 고갈 원인·해결 9가지처럼, 문제가 커지기 전에 조기 탐지/조기 차단하는 접근이 중요합니다.
패턴 7: 인덱스가 필요한 반복문
반복문에서 인덱스가 필요하면 enumerate가 기본입니다.
반복문
fn first_mismatch(a: &[u8], b: &[u8]) -> Option<usize> {
let n = a.len().min(b.len());
for i in 0..n {
if a[i] != b[i] {
return Some(i);
}
}
None
}
Iterator
fn first_mismatch(a: &[u8], b: &[u8]) -> Option<usize> {
a.iter()
.zip(b.iter())
.enumerate()
.find_map(|(i, (x, y))| if x != y { Some(i) } else { None })
}
여기서 zip은 길이가 짧은 쪽에 맞춰 자동으로 종료됩니다.
fold를 쓸 때 자주 하는 실수와 기준
1) fold에 가변 참조를 누적자로 들고 가기
예를 들어 누적자에 &mut Vec 같은 것을 넣으면 빌림 수명이 꼬이기 쉽습니다. 대개는 다음 중 하나로 해결합니다.
- 누적자에 소유 값을 넣기:
(acc, Vec)같은 형태 - 혹은
collect로 먼저 모으고 후처리 - 중간 결과 스트림이면
scan
2) 너무 긴 체인은 가독성을 해친다
Iterator 체인은 “짧을수록 아름답다”가 아니라 “의도가 한 줄로 읽히면 좋다”에 가깝습니다. 변환 단계가 많아지면 중간에 let iter = ...;로 끊거나, 의미 있는 헬퍼 함수를 만들어 이름을 주는 게 낫습니다.
fn normalize(x: i32) -> i32 {
x.abs().min(100)
}
fn score(nums: &[i32]) -> i32 {
nums.iter().copied().map(normalize).fold(0, |acc, x| acc + x)
}
3) 성능은 대부분 비슷하지만, 병목은 다른 곳에 있다
Rust의 Iterator는 최적화가 잘 되는 편이라 단순 for와 큰 차이가 없는 경우가 많습니다. 다만 다음 상황에서는 확인이 필요합니다.
- 클로저가 너무 복잡해 인라이닝이 깨질 때
- 박싱된 트레이트 객체(
Box로 감싼dyn Iterator)를 쓸 때 - 불필요한
clone이 숨어 있을 때
성능 의심이 들면 cargo bench, perf, flamegraph로 실제 병목을 확인하는 게 우선입니다. 추적/관측의 중요성은 Go의 누수 추적 사례인 Go goroutine 누수 추적 - pprof+trace로 잡기 같은 글과도 맥락이 같습니다.
실전 예제: 반복문 기반 집계를 fold로 리팩터링
요구사항:
- 로그 라인 배열이 있고, 형식은
level:message level이INFO인 것만 세고, 메시지 길이 합을 구한다- 파싱이 실패하면 즉시 에러 반환
반복문
#[derive(Debug)]
enum ParseLogError {
BadFormat,
}
fn info_stats(lines: &[&str]) -> Result<(usize, usize), ParseLogError> {
let mut count = 0usize;
let mut total_len = 0usize;
for line in lines {
let (level, msg) = line.split_once(':').ok_or(ParseLogError::BadFormat)?;
if level == "INFO" {
count += 1;
total_len += msg.len();
}
}
Ok((count, total_len))
}
try_fold로 치환
#[derive(Debug)]
enum ParseLogError {
BadFormat,
}
fn info_stats(lines: &[&str]) -> Result<(usize, usize), ParseLogError> {
lines.iter().try_fold((0usize, 0usize), |(count, total_len), line| {
let (level, msg) = line.split_once(':').ok_or(ParseLogError::BadFormat)?;
if level == "INFO" {
Ok((count + 1, total_len + msg.len()))
} else {
Ok((count, total_len))
}
})
}
이 형태의 장점:
- 누적 상태
(count, total_len)가 타입으로 고정되어 추론이 안정적 - 실패 시 즉시 중단되어 불필요한 작업을 하지 않음
- 로직이 “입력 시퀀스에서 통계를 접는다”라는 의도로 읽힘
언제는 반복문이 더 낫나
Iterator가 항상 정답은 아닙니다. 다음 경우는 for가 더 명료할 때가 많습니다.
- 중첩 루프가 복잡하고,
continue/break가 여러 단계로 얽힐 때 - 상태 머신처럼 분기와 상태 갱신이 많은 로직
- 디버깅 중간에 브레이크포인트를 촘촘히 찍어야 할 때
그럼에도 “단순 누적/변환/필터/조기 종료”는 Iterator로 옮길수록 코드 표면적이 줄고, 테스트도 쉬워지는 편입니다.
정리: 반복문의 의도를 fold로 매핑하기
- 누적만 한다:
fold(init, f) - 초기값이 없다:
reduce(f)또는min/max - 필터 후 누적:
filter+fold - 중간 결과 스트림이 필요:
scan - 조기 종료:
find/any/all/take_while - 실패 시 즉시 중단하며 누적:
try_fold
반복문을 Iterator로 치환하는 핵심은 문법 트릭이 아니라, 데이터 흐름을 “변환 파이프라인 + 누적”으로 재구성하는 습관입니다. 이 관점이 자리 잡으면 Rust의 소유권 규칙도 오히려 설계 가이드처럼 작동하기 시작합니다.