- Published on
Rust Iterator로 for 루프 제거하고 성능 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치 코드를 Rust로 작성하다 보면, 데이터 변환 파이프라인이 for 루프에 점점 쌓이면서 가독성과 성능이 동시에 흔들리는 순간이 옵니다. Rust의 Iterator는 이런 문제를 해결하기 위해 설계된 도구입니다. 단순히 함수형 스타일로 예쁘게 만드는 수준이 아니라, 컴파일러가 최적화하기 쉬운 형태로 바꿔 불필요한 메모리 할당과 분기 비용을 줄이고, 실수하기 쉬운 상태 변수를 제거할 수 있습니다.
이 글에서는 for 루프를 Iterator로 치환하는 대표 패턴과, 성능에 직접 영향을 주는 포인트(할당, 중간 컬렉션, 조기 종료, 에러 전파)를 코드로 정리합니다.
Iterator가 for 루프보다 느리다는 오해
Rust에서 Iterator는 기본적으로 지연 평가(lazy) 입니다. map이나 filter를 여러 번 붙여도, 실제로는 collect나 sum 같은 소비자(consumer)를 만나기 전까지 실행되지 않습니다. 그리고 LLVM 최적화가 잘 먹히는 경우가 많아, 단순한 루프는 인라이닝과 루프 최적화로 for 루프와 동급이거나 더 나은 결과가 나기도 합니다.
중요한 건 “Iterator가 무조건 빠르다”가 아니라, 아래 조건을 만족시키면 성능을 챙기기 쉽다는 점입니다.
- 중간
Vec를 만들지 않고 한 번에 소비한다 - 조기 종료가 필요하면
try_fold·find·any·all등을 쓴다 - 필요 이상으로
clone하거나to_string같은 할당을 하지 않는다 collect가 필요하면 용량을 예측해with_capacity나size_hint를 활용한다
패턴 1: push 루프를 map + collect로 치환
가장 흔한 형태는 변환 후 Vec에 push하는 루프입니다.
fn squares_for(xs: &[i32]) -> Vec<i32> {
let mut out = Vec::new();
for &x in xs {
out.push(x * x);
}
out
}
fn squares_iter(xs: &[i32]) -> Vec<i32> {
xs.iter().map(|&x| x * x).collect()
}
여기서 주의할 점은 xs.iter()가 &i32를 내놓기 때문에 |&x|로 역참조를 한 번 해줬다는 것입니다.
성능 팁: 용량을 아는 경우 with_capacity
collect는 내부적으로 size_hint를 활용하지만, 상황에 따라 보수적으로 잡힐 수 있습니다. 길이가 확실하면 직접 잡아주는 게 더 낫습니다.
fn squares_iter_prealloc(xs: &[i32]) -> Vec<i32> {
let mut out = Vec::with_capacity(xs.len());
out.extend(xs.iter().map(|&x| x * x));
out
}
extend는 Iterator를 그대로 받아서 채워 넣기 때문에, “중간 Vec 생성” 없이 한 번에 처리합니다.
패턴 2: 조건 분기 누적 루프를 filter로 정리
if로 걸러서 push하는 루프는 filter로 치환하면 의도가 선명해집니다.
fn even_squares(xs: &[i32]) -> Vec<i32> {
xs.iter()
.copied()
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.collect()
}
여기서 copied()는 &i32를 i32로 복사해줍니다. i32처럼 Copy 타입이면 비용이 거의 없고 코드가 깔끔해집니다.
패턴 3: 누적 합·카운트를 fold로 치환
상태 변수가 2개 이상으로 늘어나면 for 루프는 실수하기 쉬워집니다. fold는 누적 로직을 한 곳에 모아줍니다.
fn sum_and_count_positive(xs: &[i32]) -> (i32, usize) {
xs.iter().copied().filter(|x| *x > 0).fold((0, 0), |(sum, cnt), x| {
(sum + x, cnt + 1)
})
}
fold는 결과 타입이 무엇이든 될 수 있어서, 튜플 누적도 자연스럽습니다.
패턴 4: 중첩 루프를 flat_map으로 펴기
리스트 안의 리스트, 혹은 각 원소에서 여러 값을 생성하는 경우 중첩 루프가 생깁니다. flat_map은 이를 “한 줄짜리 파이프라인”으로 바꿔줍니다.
fn all_chars(lines: &[String]) -> Vec<char> {
lines.iter()
.flat_map(|s| s.chars())
.collect()
}
흔한 실수: map 뒤 flatten보다 flat_map
아래처럼 해도 되지만,
let v: Vec<char> = lines.iter().map(|s| s.chars()).flatten().collect();
대부분은 flat_map이 더 읽기 쉽습니다.
패턴 5: 조기 종료가 있는 루프는 find·any·all
for 루프에서 break로 빠져나오는 로직은 Iterator에서도 가능합니다.
fn first_over_limit(xs: &[i32], limit: i32) -> Option<i32> {
xs.iter().copied().find(|x| *x > limit)
}
fn has_negative(xs: &[i32]) -> bool {
xs.iter().any(|&x| x < 0)
}
fn all_non_zero(xs: &[i32]) -> bool {
xs.iter().all(|&x| x != 0)
}
이런 메서드들은 내부적으로 조건을 만족하면 즉시 종료합니다. 즉, “Iterator 체인은 항상 끝까지 돈다”는 오해는 틀렸습니다.
패턴 6: 에러 전파가 있는 루프는 try_fold 또는 collect로
for 루프에서 Result를 다루면 보통 이런 형태가 됩니다.
fn parse_all_for(xs: &[String]) -> Result<Vec<i32>, std::num::ParseIntError> {
let mut out = Vec::with_capacity(xs.len());
for s in xs {
out.push(s.parse::<i32>()?);
}
Ok(out)
}
Iterator로는 두 가지가 대표적입니다.
1) collect로 Result 모으기
Rust는 Iterator<Item = Result<T, E>>를 collect::<Result<Vec<T>, E>>()로 모을 수 있습니다.
fn parse_all_collect(xs: &[String]) -> Result<Vec<i32>, std::num::ParseIntError> {
xs.iter().map(|s| s.parse::<i32>()).collect()
}
첫 에러에서 즉시 종료하고 에러를 반환합니다.
2) 누적이 복잡하면 try_fold
누적 과정에서 부가 상태가 필요하면 try_fold가 더 직접적입니다.
fn parse_sum_until_error(xs: &[String]) -> Result<i32, std::num::ParseIntError> {
xs.iter().try_fold(0, |acc, s| {
let v = s.parse::<i32>()?;
Ok(acc + v)
})
}
성능 포인트 1: 중간 컬렉션을 만들지 말기
아래 코드는 겉보기엔 깔끔하지만 중간 Vec를 만들 가능성이 큽니다.
fn bad_pipeline(xs: &[i32]) -> i32 {
let tmp: Vec<i32> = xs.iter().map(|&x| x * 2).collect();
tmp.iter().filter(|&&x| x % 3 == 0).sum()
}
한 번에 소비하면 중간 할당을 피할 수 있습니다.
fn good_pipeline(xs: &[i32]) -> i32 {
xs.iter()
.map(|&x| x * 2)
.filter(|x| x % 3 == 0)
.sum()
}
이 차이는 데이터 크기가 커질수록 크게 체감됩니다.
성능 포인트 2: iter·iter_mut·into_iter 선택
Iterator로 바꾸면서 성능이 흔들리는 이유 중 하나는 “소유권 이동과 복사”를 잘못 선택했기 때문입니다.
xs.iter()는&T를 생산합니다. 원본을 유지하면서 읽기 전용 처리.xs.iter_mut()는&mut T를 생산합니다. 제자리(in-place) 수정.xs.into_iter()는T를 생산합니다. 컬렉션을 소비(소유권 이동)하며 재할당을 줄일 수 있음.
예를 들어 문자열을 가공해서 새 컬렉션을 만들 때, 소유권을 넘길 수 있으면 into_iter가 불필요한 clone을 줄여줍니다.
fn trim_owned(mut xs: Vec<String>) -> Vec<String> {
xs.into_iter().map(|s| s.trim().to_string()).collect()
}
여기서 to_string()은 새 할당이므로 비용이 있습니다. 가능하면 결과를 String이 아니라 &str로 유지하거나, 입력을 애초에 슬라이스로 설계하는 것이 더 큰 최적화입니다.
성능 포인트 3: enumerate로 인덱스 루프 제거
인덱스 기반 루프는 범위 체크나 실수 가능성이 늘어납니다. Iterator의 enumerate는 인덱스와 값을 함께 제공합니다.
fn positions_of_zero(xs: &[i32]) -> Vec<usize> {
xs.iter()
.enumerate()
.filter_map(|(i, &x)| if x == 0 { Some(i) } else { None })
.collect()
}
filter_map은 filter + map을 한 번에 합친 형태라, 의도가 명확하고 체인도 짧아집니다.
성능 포인트 4: 슬라이스 윈도우는 windows·chunks
연속 구간을 다루는 루프는 수동 인덱싱 대신 표준 Iterator를 쓰면 안전성과 성능을 동시에 챙기기 쉽습니다.
fn moving_sum_2(xs: &[i32]) -> Vec<i32> {
xs.windows(2).map(|w| w[0] + w[1]).collect()
}
fn sum_by_chunk(xs: &[i32], n: usize) -> Vec<i32> {
xs.chunks(n).map(|c| c.iter().sum()).collect()
}
Iterator 체인 디버깅: inspect로 관찰
체인이 길어지면 중간 값을 보고 싶을 때가 있습니다. 이때 inspect가 유용합니다.
fn debug_pipeline(xs: &[i32]) -> i32 {
xs.iter()
.copied()
.inspect(|x| println!("in: {x}"))
.map(|x| x * 2)
.inspect(|x| println!("mapped: {x}"))
.filter(|x| x % 3 == 0)
.sum()
}
inspect는 값을 바꾸지 않고 관찰만 합니다. 운영 코드에서는 로그 비용이 크니 제한적으로 사용하세요.
언제 for 루프가 더 나은가
Iterator가 항상 정답은 아닙니다. 아래 경우엔 for가 더 명확하거나 빠를 수 있습니다.
- 내부에서 복잡한 상태 머신을 돌리며 여러 곳에서
continue·break가 난무하는 경우 - 성능 핫스팟에서 분기 예측, 메모리 접근 패턴을 아주 세밀하게 제어해야 하는 경우
unsafe최적화나 SIMD를 직접 적용하는 경우
다만 대부분의 비즈니스 로직에서는, Iterator로 바꿨을 때 코드 길이는 줄고, 버그 표면적도 줄며, 성능도 충분히 잘 나오는 경우가 많습니다.
다른 언어의 지연 평가와 비교해 이해하기
Iterator의 지연 평가는 Kotlin의 Sequence나 Python의 제너레이터와 비슷한 감각으로 이해할 수 있습니다. 특히 “부작용이 있는 map이 중복 실행되는 문제”는 지연 평가에서 자주 마주치는 함정인데, Kotlin Sequence 사례가 좋은 참고가 됩니다: Kotlin Sequence에서 map이 두 번 실행될 때
Rust에서도 clone이나 로깅 같은 부작용을 체인 중간에 넣으면, 소비자가 여러 번 실행될 때 예상치 못한 동작이 나올 수 있으니(예: 같은 Iterator를 다시 만들거나, collect를 두 번 하는 경우) “소비 지점이 어디인지”를 항상 의식하는 습관이 중요합니다.
또한 대규모 시스템에서는 성능 문제를 단독으로 보기보다, 타임아웃·데드라인 같은 상위 레벨의 실패 전파와 함께 진단해야 합니다. 분산 환경에서의 진단 관점은 gRPC 데드라인 전파 실패, 원인과 진단법도 같이 보면 연결해서 사고하기 좋습니다.
정리: for를 없애는 게 목적이 아니라, 비용을 없애는 것
push루프는map+collect또는extend로 바꾼다- 조건 분기는
filter, 변환+필터는filter_map을 우선 고려한다 - 누적은
fold, 에러를 포함한 누적은try_fold가 깔끔하다 - 조기 종료는
find·any·all로 표현하면 의도가 드러난다 - 성능은 “중간 컬렉션 제거”와 “불필요한 할당 제거”에서 가장 크게 갈린다
다음에 기존 for 루프를 리팩터링할 때는, 먼저 “이 루프가 하는 일이 변환인지, 필터링인지, 누적인지, 조기 종료인지”를 분류한 뒤, 그 역할에 맞는 Iterator 소비자를 고르는 방식으로 접근해보면 코드 품질과 성능을 함께 끌어올리기 쉽습니다.