- Published on
Rust Iterator map/filter 체인 성능튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치 코드에서 Iterator 체인은 가독성과 안전성 측면에서 거의 정답에 가깝습니다. 하지만 데이터 규모가 커지거나 핫패스가 되면, map/filter가 연달아 붙는 형태가 생각보다 쉽게 병목이 됩니다. Rust의 Iterator는 “원칙적으로” zero-cost 추상화를 지향하지만, 현실에서는 캡처된 클로저, 분기 예측 실패, 불필요한 중간 할당, 경계 검사, trait 객체화 같은 요소가 성능에 영향을 줍니다.
이 글은 map/filter 체인을 유지하되, 어디를 어떻게 바꿔야 실제로 빨라지는지(그리고 언제는 바꾸지 말아야 하는지)를 정리합니다.
소유권/빌림 때문에 체인 리팩터링이 막히는 경우는 아래 글도 같이 보면 좋습니다. Rust 소유권 에러 E0502·E0499 한방 해결 패턴
1) 먼저 확인: 정말 Iterator 체인이 병목인가
성능튜닝의 첫 단계는 “의심”이 아니라 “측정”입니다. Rust에서는 최소한 다음 2가지를 권합니다.
- 마이크로 벤치마크:
criterion으로 특정 함수/체인을 반복 측정 - 프로파일링:
perf,flamegraph로 실제 핫스팟 확인
예시로, 문자열 배열에서 숫자만 파싱해 변환 후 합산하는 체인을 벤치마크해봅니다.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn iter_chain(input: &[&str]) -> i64 {
input
.iter()
.filter_map(|s| s.parse::<i64>().ok())
.map(|n| n * 2)
.filter(|n| n % 3 != 0)
.sum()
}
fn bench(c: &mut Criterion) {
let data: Vec<&str> = (0..100_000)
.map(|i| if i % 10 == 0 { "x" } else { "123" })
.collect();
c.bench_function("iter_chain", |b| {
b.iter(|| iter_chain(black_box(&data)))
});
}
criterion_group!(benches, bench);
criterion_main!(benches);
이 시점에서 중요한 건 “체인이 느리다”가 아니라, 어떤 비용이 큰지입니다. 예를 들어 parse가 압도적으로 비싸면 체인 최적화가 체감이 없고, 반대로 아주 단순한 연산만 있는 체인이라면 오버헤드가 눈에 띌 수 있습니다.
2) map + filter는 filter_map으로 합치기
가장 흔한 개선 포인트는 map으로 Option/Result를 만들고, 이어서 filter로 거르는 패턴입니다. 이는 대개 filter_map 또는 flat_map으로 합칠 수 있습니다.
2-1) 나쁜 예: 불필요한 분기와 중간 값
let out: Vec<i64> = input
.iter()
.map(|s| s.parse::<i64>().ok())
.filter(|x| x.is_some())
.map(|x| x.unwrap() * 2)
.collect();
Option을 만들고is_some로 한 번 분기하고unwrap로 또 한 번 분기합니다
2-2) 개선: filter_map으로 한 번에
let out: Vec<i64> = input
.iter()
.filter_map(|s| s.parse::<i64>().ok().map(|n| n * 2))
.collect();
여기서 포인트는 “코드가 짧아졌다”가 아니라, 분기와 상태가 줄어들어 최적화 기회가 늘어난다는 점입니다.
3) collect를 중간에 하지 말기 (중간 Vec는 거의 항상 손해)
다음 패턴은 매우 흔한 성능 함정입니다.
let tmp: Vec<_> = input.iter().map(f).collect();
let out: Vec<_> = tmp.iter().filter(g).map(h).collect();
중간 Vec는 다음 비용을 만듭니다.
- 할당 및 재할당
- 메모리 대역폭 사용 증가
- 캐시 미스 가능성 증가
가능하면 끝까지 스트리밍으로 흘려보내세요.
let out: Vec<_> = input.iter().map(f).filter(g).map(h).collect();
단, 예외도 있습니다.
- 이후 단계가 여러 번 재사용되면(두 번 이상 순회)
- 랜덤 액세스가 필요하면
- 멀티스레딩 분할을 위해 구간이 필요하면
이때는 중간 collect가 “손해”가 아니라 “전략”이 될 수 있습니다.
4) sum, count, fold로 단일 패스 만들기
map/filter 체인의 최종 목적이 집계라면, collect 대신 집계 메서드를 쓰는 것이 유리합니다.
4-1) 나쁜 예: Vec로 모아놓고 다시 합산
let v: Vec<i64> = input.iter().filter_map(parse).map(transform).collect();
let s: i64 = v.iter().sum();
4-2) 개선: 바로 sum
let s: i64 = input.iter().filter_map(parse).map(transform).sum();
4-3) 더 강한 제어: fold
fold는 상태를 명시적으로 들고 가며, 조건 분기와 누적을 한 함수에 모을 수 있습니다.
let s: i64 = input.iter().fold(0i64, |acc, s| {
let Ok(n) = s.parse::<i64>() else { return acc };
let n = n * 2;
if n % 3 == 0 { acc } else { acc + n }
});
이 형태는 컴파일러가 인라이닝/분기 정리를 하기에 유리한 경우가 많습니다. 다만 가독성이 떨어질 수 있으니, 핫패스에서만 선택적으로 쓰는 편이 좋습니다.
5) for 루프가 더 빠를 때: 분기/캡처/인라이닝의 현실
Rust의 Iterator는 많은 경우 for로 풀어쓴 코드와 동일한 성능이 나오지만, 다음 상황에서는 for가 더 유리할 수 있습니다.
- 클로저 캡처가 많아 인라이닝이 잘 안 되는 경우
- 체인이 매우 길어 LLVM이 최적화에 실패하는 경우
try_fold/fold로도 표현이 복잡해지는 경우
예시: 동일 로직을 for로 풀어쓰기
fn loop_version(input: &[&str]) -> i64 {
let mut acc = 0i64;
for s in input {
let Ok(mut n) = s.parse::<i64>() else { continue };
n *= 2;
if n % 3 == 0 { continue }
acc += n;
}
acc
}
튜닝 관점에서의 결론은 단순합니다.
- 기본은
Iterator체인 - 측정 결과 체인이 핫스팟이면
fold또는for로 전환
6) cloned/to_owned 남발 줄이기: 복사 비용은 체인에서 증폭된다
체인 도중에 소유권을 맞추려고 cloned, to_string, to_owned를 넣는 순간 비용이 커집니다.
let out: Vec<String> = input
.iter()
.filter(|s| s.len() > 3)
.map(|s| s.to_string())
.collect();
이게 필요하다면 어쩔 수 없지만, 많은 경우는 참조를 유지한 채로 처리할 수 있습니다.
let out: Vec<&str> = input
.iter()
.copied()
.filter(|s| s.len() > 3)
.collect();
혹은 최종 단계에서만 소유화하세요.
let out: Vec<String> = input
.iter()
.copied()
.filter(|s| s.len() > 3)
.map(str::to_owned)
.collect();
소유권 때문에 체인을 못 이어가는 경우가 많으니, 관련 패턴은 위의 소유권 글을 같이 참고하는 것을 권합니다.
7) Vec를 만든다면 with_capacity로 재할당 줄이기
최종 결과가 Vec라면, 용량 추정이 가능한 경우가 많습니다.
let mut out = Vec::with_capacity(input.len());
for s in input {
if s.len() > 3 {
out.push(s.to_owned());
}
}
Iterator 체인에서도 collect는 내부적으로 성장 전략을 쓰지만, 필터링 비율이 높지 않거나 크기를 예측할 수 있으면 with_capacity가 확실히 이득일 때가 있습니다.
8) by_ref, inspect로 디버깅하되, 릴리즈에서 제거하기
체인 중간 상태를 보고 싶을 때 inspect는 편하지만, 핫패스에서는 제거해야 합니다.
let s: i64 = input
.iter()
.filter_map(|s| s.parse::<i64>().ok())
.inspect(|n| {
// 디버깅용: 성능 측정 전에는 반드시 제거하거나 feature로 감싸기
let _ = n;
})
.sum();
릴리즈 빌드에서 inspect가 완전히 사라질 수도 있지만(컴파일러가 제거), 부작용이 있으면 제거되지 않습니다. 성능 측정 시에는 의도적으로 빼고 비교하세요.
9) 동적 디스패치 피하기: Box<dyn Iterator>는 비용이 있다
타입을 숨기기 위해 Box<dyn Iterator>를 쓰면, 각 next 호출이 가상 호출이 됩니다.
fn make_iter(flag: bool) -> Box<dyn Iterator<Item = i32>> {
if flag {
Box::new((0..1_000).filter(|x| x % 2 == 0))
} else {
Box::new((0..1_000).filter(|x| x % 3 == 0))
}
}
핫패스에서는 아래 대안을 고려하세요.
- 제네릭으로 반환(가능한 경우)
impl Iterator반환 + 분기 최소화enum으로 두 이터레이터 타입을 감싸 정적 디스패치 유지
간단 예시로 impl Iterator는 조건 분기 반환이 직접 안 되므로, 분기를 바깥으로 빼는 구조가 필요합니다.
fn sum_even(n: i32) -> i32 {
(0..n).filter(|x| x % 2 == 0).sum()
}
fn sum_mod3(n: i32) -> i32 {
(0..n).filter(|x| x % 3 == 0).sum()
}
10) par_iter로 해결하려다 더 느려지는 케이스
병렬 이터레이션(rayon)은 강력하지만, 다음이면 오히려 손해입니다.
- 각 원소 처리 비용이 매우 작음(오버헤드가 더 큼)
- 데이터가 작음
- 공유 상태 락이 많음
핫패스 체인 튜닝은 보통 다음 순서가 안전합니다.
- 단일 스레드에서 불필요한 할당/분기 제거
- 프로파일링으로 연산 비용이 충분히 큰지 확인
- 그 다음에 병렬화 검토
11) 체크리스트: map/filter 체인 성능을 올리는 실전 규칙
map으로Option만들고filter/unwrap하는 패턴은filter_map으로 합치기- 중간
collect는 원칙적으로 금지(재사용/랜덤액세스/분할이 필요할 때만) - 최종 목적이 집계면
sum/count/fold로 단일 패스 - 소유화(
to_owned,to_string,cloned)는 가능한 뒤로 미루기 - 결과가
Vec라면with_capacity로 재할당 줄이기 Box<dyn Iterator>로 타입 지우기는 핫패스에서 피하기- 체인이 너무 길면
fold또는for로 전환하고 측정으로 확인
12) 마무리: “Iterator가 느리다”가 아니라 “구성이 느리다”
Rust Iterator 자체는 대부분의 경우 매우 빠릅니다. 문제는 체인 구성에서 생기는 불필요한 중간 상태, 분기 중복, 소유화로 인한 할당, 동적 디스패치 같은 요소가 누적된 결과입니다.
핫패스에서 map/filter 체인을 튜닝할 때는 “함수형 스타일을 버리자”가 아니라, 동일한 의미를 더 적은 비용으로 표현하는 방향이 좋습니다. 그리고 항상 criterion과 프로파일링으로 “실제로 빨라졌는지” 확인하세요.
추가로, 운영 환경에서 성능 이슈는 종종 애플리케이션 코드가 아니라 시스템 리소스/파일 디스크립터 같은 곳에서 터지기도 합니다. 이럴 때는 다음 글도 함께 참고할 만합니다.