Published on

Rust 반복문을 Iterator·fold로 바꾸는 패턴

Authors

반복문은 읽기 쉽고 디버깅도 편하지만, Rust에서는 Iterator 기반으로 로직을 구성하면 소유권/빌림 규칙을 더 자연스럽게 만족시키고, 중간 상태(가변 변수)를 줄이며, 조합 가능한 파이프라인으로 만들 수 있습니다. 특히 누적 계산은 fold가 정석에 가깝습니다.

이 글은 “반복문을 무조건 없애자”가 아니라, 반복문의 의도를 분해해 Iterator 조합으로 옮기는 방법을 패턴으로 정리합니다. 마지막에는 fold를 중심으로 try_fold, scan, take_while 같은 도구로 확장하는 기준도 제시합니다.

관련해서 운영 환경에서의 문제를 줄이는 습관(실패를 빨리 터뜨리기, 조기 종료 조건 명확화)은 쉘 스크립트의 set -euo pipefail 철학과도 닮았습니다. 필요하면 bash set -euo pipefail로 스크립트 터질 때 대처법도 같이 보면 좋습니다.

반복문을 Iterator로 치환하는 사고 순서

반복문을 Iterator로 옮길 때는 아래 질문을 순서대로 던지면 빠릅니다.

  1. 입력 시퀀스는 무엇인가: Vec, 슬라이스, 맵, 범위(0..n), 스트림 등
  2. 각 원소를 변환하는가: map
  3. 조건으로 걸러내는가: filter / filter_map
  4. 누적 결과가 필요한가: fold / reduce
  5. 중간에 멈추는가: find, any, all, take_while, try_fold
  6. 에러를 전파하는가: collectResult/Option 수집, 혹은 try_fold

이제 가장 흔한 반복문들을 Iteratorfold로 바꿔봅니다.

패턴 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는 반복문 치환의 대표적인 “즉시 이득” 케이스입니다.

추가로 reducefold에서 초기값이 없는 형태입니다.

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
  • levelINFO인 것만 세고, 메시지 길이 합을 구한다
  • 파싱이 실패하면 즉시 에러 반환

반복문

#[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의 소유권 규칙도 오히려 설계 가이드처럼 작동하기 시작합니다.