Published on

Rust Iterator로 for 루프 제거·성능 최적화

Authors

서버 사이드 Rust를 쓰다 보면 처음에는 for 루프로 빠르게 구현하고, 이후 병목이 보이거나 코드가 커지면서 “반복 로직을 더 선언적으로, 더 안전하게, 더 빠르게” 바꾸고 싶어집니다. 이때 Rust의 Iterator는 단순한 문법 설탕이 아니라, 제로 코스트 추상화를 목표로 설계된 핵심 도구입니다.

이 글에서는 for 루프를 Iterator로 제거하는 과정에서 자주 나오는 패턴(필터링, 맵핑, 누적, 조기 종료, 인덱스 필요, 에러 전파, 할당 최소화)을 성능 관점까지 포함해 정리합니다.

참고로 Iterator로 리팩터링하다 보면 빌림 규칙에서 막히는 경우가 있는데, 그때는 Rust E0502/E0499 빌림 충돌 6가지 패턴도 함께 보면 도움이 됩니다.

for 루프를 Iterator로 바꾸는 기준

무조건 for를 없애는 게 정답은 아닙니다. 아래 기준으로 판단하면 좋습니다.

  • 반복이 “변환 파이프라인” 성격이면 Iterator가 유리합니다. 예: filtermapsum.
  • 중간 상태(mut 변수)가 많아질수록 Iterator가 가독성과 버그 방지에 유리합니다.
  • 조기 종료(break)나 복잡한 분기가 핵심이면 for가 더 명확할 수 있습니다. 단, find, any, try_fold 등으로 대체 가능한지 먼저 확인합니다.
  • 성능은 대체로 Iterator가 불리하지 않습니다. 다만 불필요한 collect, 클로저 캡처로 인한 대형 상태 이동, 동적 디스패치 같은 함정은 피해야 합니다.

패턴 1: 누적(sum)과 조건(filter) 조합

for 루프 버전

fn sum_even(nums: &[i32]) -> i32 {
    let mut acc = 0;
    for &n in nums {
        if n % 2 == 0 {
            acc += n;
        }
    }
    acc
}

Iterator 버전

fn sum_even(nums: &[i32]) -> i32 {
    nums.iter()
        .copied()
        .filter(|n| n % 2 == 0)
        .sum()
}

핵심 포인트는 copied()입니다. iter()&i32를 내보내므로 sum()을 바로 쓰면 타입이 꼬일 수 있습니다. copied()cloned()로 값으로 바꾸면 깔끔해집니다.

성능 관점에서 filtersum은 보통 인라이닝되며, LLVM이 루프로 잘 최적화합니다. 중요한 건 중간 Vec를 만들지 않는 것입니다.

패턴 2: map + collect에서 할당 최소화

문자열 변환이나 구조체 변환을 할 때 흔히 mapcollect를 합니다. 이때 할당이 성능 병목이 되기 쉽습니다.

기본적인 Iterator 변환

fn to_strings(nums: &[i32]) -> Vec<String> {
    nums.iter().map(|n| n.to_string()).collect()
}

이 코드는 간결하지만, Vec가 커질수록 리사이즈 비용이 발생할 수 있습니다. ExactSizeIterator라면 collect가 어느 정도 최적화하지만, 변환 소스가 슬라이스처럼 길이가 확실한 경우엔 의도를 더 명확히 할 수 있습니다.

with_capacity로 리사이즈 최소화

fn to_strings(nums: &[i32]) -> Vec<String> {
    let mut out = Vec::with_capacity(nums.len());
    out.extend(nums.iter().map(|n| n.to_string()));
    out
}

extend는 Iterator를 받아서 내부적으로 push를 수행합니다. 길이가 확실한 입력에서 with_capacity를 주면 재할당을 줄일 수 있어요.

패턴 3: 조기 종료를 find/any/all로 치환

for + break

fn contains_negative(nums: &[i32]) -> bool {
    for &n in nums {
        if n < 0 {
            return true;
        }
    }
    false
}

Iterator

fn contains_negative(nums: &[i32]) -> bool {
    nums.iter().any(|&n| n < 0)
}

any는 조건을 만족하면 즉시 종료합니다. find도 마찬가지로 조기 종료가 가능합니다.

성능적으로도 any는 루프와 동일한 형태로 최적화되는 경우가 많고, 코드 의도가 더 명확해집니다.

패턴 4: 인덱스가 필요할 때 enumerate

인덱스 기반 로직 때문에 Iterator를 포기하는 경우가 많은데, enumerate()로 대부분 해결됩니다.

fn first_over_limit(nums: &[i32], limit: i32) -> Option<usize> {
    nums.iter()
        .enumerate()
        .find_map(|(i, &n)| (n > limit).then_some(i))
}

find_map은 “찾기 + 변환”을 한 번에 처리합니다. then_some은 조건이 참일 때만 Some을 반환하는 패턴입니다.

패턴 5: 중첩 루프를 flat_map으로 평탄화

for 중첩

fn all_pairs(a: &[i32], b: &[i32]) -> Vec<(i32, i32)> {
    let mut out = Vec::new();
    for &x in a {
        for &y in b {
            out.push((x, y));
        }
    }
    out
}

Iterator

fn all_pairs(a: &[i32], b: &[i32]) -> Vec<(i32, i32)> {
    a.iter()
        .copied()
        .flat_map(|x| b.iter().copied().map(move |y| (x, y)))
        .collect()
}

여기서 movex를 내부 클로저로 캡처하기 위해 필요합니다. 이 패턴은 익숙해지면 강력하지만, 캡처하는 값이 큰 구조체라면 복사/이동 비용을 의식해야 합니다. 큰 값이면 &T를 캡처하거나, 필요한 필드만 추출해 캡처하는 식으로 조정합니다.

패턴 6: 에러 전파를 try_fold/collect로 단순화

에러가 섞인 반복은 for에서 ?를 쓰며 처리하곤 합니다. Iterator에서는 두 가지가 대표적입니다.

collectResult<Vec<T>, E> 만들기

fn parse_all(input: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
    input.iter().map(|s| s.parse::<i32>()).collect()
}

collectIterator<Item = Result<T, E>>를 모아 Result<Vec<T>, E>로 바꿀 수 있습니다. 중간에 에러가 나오면 즉시 종료합니다.

누적 로직이 필요하면 try_fold

fn sum_parsed(input: &[&str]) -> Result<i32, std::num::ParseIntError> {
    input.iter().try_fold(0, |acc, s| {
        let n = s.parse::<i32>()?;
        Ok(acc + n)
    })
}

try_fold는 “에러가 나면 중단”이라는 조기 종료 의미까지 포함해 for + ?를 자연스럽게 치환합니다.

성능 함정 1: 불필요한 collect는 비용을 만든다

Iterator 체인을 쓰다 보면 중간 결과를 확인하려고 무심코 collect::<Vec<_>>()를 끼워 넣는 경우가 많습니다.

let tmp: Vec<_> = nums.iter().filter(|&&n| n > 0).collect();
let sum: i32 = tmp.iter().copied().sum();

위 코드는 중간 Vec 할당과 메모리 접근 비용이 생깁니다. 대부분은 아래처럼 한 번에 끝낼 수 있습니다.

let sum: i32 = nums.iter().copied().filter(|n| *n > 0).sum();

디버깅 목적이라면 inspect를 활용할 수 있습니다.

let sum: i32 = nums.iter()
    .copied()
    .inspect(|n| eprintln!("n={n}"))
    .filter(|n| *n > 0)
    .sum();

성능 함정 2: 동적 디스패치(Box<dyn Iterator>) 남발

런타임에 Iterator 타입을 숨기려고 Box<dyn Iterator<Item = T>>를 쓰면 동적 디스패치와 힙 할당이 들어갈 수 있습니다.

가능하면 제네릭과 impl Iterator 반환을 우선 고려하세요.

fn positives(nums: &[i32]) -> impl Iterator<Item = i32> + '_ {
    nums.iter().copied().filter(|n| *n > 0)
}

impl Iterator는 정적 디스패치로 최적화 여지가 큽니다.

성능 함정 3: 클로저에서 큰 값을 캡처하지 않기

map(move |x| ...)가 편하다고 큰 구조체를 통째로 캡처하면, 복사/이동이 누적될 수 있습니다. 해결책은 보통 아래 중 하나입니다.

  • 필요한 필드만 미리 빼서 캡처
  • 참조를 캡처(&T)하고 라이프타임을 맞추기
  • 반복 외부에서 계산 가능한 값은 미리 계산

빌림 충돌이 발생하면 Iterator 자체가 문제가 아니라 “동시에 가변/불변 참조를 잡는 구조”가 문제인 경우가 많습니다. 이런 경우는 Rust E0502/E0499 빌림 충돌 6가지 패턴에서 소개하는 우회 패턴(스코프 분리, split_at_mut, 인덱싱 대신 iter_mut 등)이 그대로 적용됩니다.

실전 리팩터링 예: for 루프 3개를 파이프라인으로

요구사항:

  • 로그 라인 목록에서
  • WARN 이상만 남기고
  • 특정 키워드가 포함된 라인의 길이를 합산

for 루프 버전

fn sum_len(lines: &[String], keyword: &str) -> usize {
    let mut total = 0;
    for line in lines {
        if !(line.contains("WARN") || line.contains("ERROR")) {
            continue;
        }
        if !line.contains(keyword) {
            continue;
        }
        total += line.len();
    }
    total
}

Iterator 버전

fn sum_len(lines: &[String], keyword: &str) -> usize {
    lines.iter()
        .filter(|line| line.contains("WARN") || line.contains("ERROR"))
        .filter(|line| line.contains(keyword))
        .map(|line| line.len())
        .sum()
}

이 형태는 “데이터 흐름”이 한눈에 보입니다. 또한 중간 상태가 없어 테스트하기도 쉽습니다.

벤치마크는 이렇게: criterion으로 확인

Iterator가 빠를지, for가 빠를지는 케이스마다 다를 수 있으니, 중요한 경로라면 벤치마크로 확인하세요.

Cargo.toml 예시:

[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "iter_vs_for"
harness = false

벤치 코드 예시:

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn sum_even_for(nums: &[i32]) -> i32 {
    let mut acc = 0;
    for &n in nums {
        if n % 2 == 0 {
            acc += n;
        }
    }
    acc
}

fn sum_even_iter(nums: &[i32]) -> i32 {
    nums.iter().copied().filter(|n| n % 2 == 0).sum()
}

fn bench(c: &mut Criterion) {
    let nums: Vec<i32> = (0..100_000).collect();

    c.bench_function("sum_even_for", |b| {
        b.iter(|| sum_even_for(black_box(&nums)))
    });

    c.bench_function("sum_even_iter", |b| {
        b.iter(|| sum_even_iter(black_box(&nums)))
    });
}

criterion_group!(benches, bench);
criterion_main!(benches);

여기서 black_box는 컴파일러가 “결과를 안 써도 되네?”라고 판단해 코드를 지워버리는 것을 방지합니다.

언제는 for 루프가 더 낫나

Iterator가 항상 더 좋은 건 아닙니다.

  • 복잡한 상태 머신(여러 단계의 가변 상태 전이)
  • 루프 내부에서 다양한 break/continue 분기가 난무
  • 성능 미세 튜닝이 필요하고, 컴파일러가 최적화하기 어려운 형태

이런 경우 for가 더 읽기 쉽고 디버깅도 편합니다. 중요한 건 “Iterator로 바꾸는 것”이 목적이 아니라, 의도를 명확히 하고 불필요한 비용을 줄이는 것입니다.

정리: Iterator로 for를 제거하며 성능까지 챙기는 체크리스트

  • filter/map/sum/collect는 파이프라인으로 묶고 중간 collect를 피한다
  • 입력 길이를 알면 with_capacity + extend로 재할당을 줄인다
  • 조기 종료는 any/find/find_map/try_fold를 먼저 떠올린다
  • Box<dyn Iterator> 대신 impl Iterator로 정적 디스패치를 우선한다
  • 클로저 캡처 비용(큰 값 move)과 빌림 충돌을 의식한다

반복 로직이 쌓일수록 Rust Iterator는 코드의 “잡음을 줄이고”, 동시에 “할당과 분기 비용을 줄이는 방향”으로 설계를 유도합니다. 그리고 그 과정에서 빌림 규칙과 만나면, 문제는 대개 Iterator가 아니라 소유권/참조 구조에 있으니, 필요하면 위에서 언급한 빌림 충돌 가이드를 함께 확인해 보세요.