Published on

Rust Iterator로 for 제거 - map·fold·try_fold

Authors

서버/CLI/데이터 파이프라인을 Rust로 짜다 보면, 처음에는 for 루프가 가장 자연스럽습니다. 하지만 로직이 커질수록 for 내부에 변환, 필터링, 집계, 에러 처리, 조기 종료가 뒤섞이면서 “무엇을 하는 코드인지”가 흐려집니다. Rust의 Iterator는 이런 문제를 데이터 흐름(변환 파이프라인) 형태로 드러내 주고, 특히 try_foldResult/Option 기반 에러 전파와 조기 종료를 깔끔하게 정리해줍니다.

이 글은 for를 무조건 없애자는 글이 아니라, 의도가 명확해지는 지점에서 map, fold, try_fold로 루프를 치환하는 실전 패턴을 다룹니다.

  • 변환: map
  • 집계: fold
  • 실패 가능 + 조기 종료: try_fold

관련해서 Rust 설계 관점(의존성 역전, 레이어 분리)을 함께 고민한다면 Rust로 헥사고날 아키텍처 구현 - 의존성 역전도 같이 보면 좋습니다. Iterator 체인은 도메인 로직을 “데이터 변환”으로 드러내기 좋아 레이어 분리에 유리합니다.

for가 나빠지는 순간: 상태와 분기 누적

다음은 흔한 패턴입니다. 입력을 파싱하고, 조건에 따라 누적하고, 중간에 실패하면 종료합니다.

use std::num::ParseIntError;

fn sum_positive_numbers(lines: &[String]) -> Result<i64, ParseIntError> {
    let mut sum = 0i64;

    for line in lines {
        let n: i64 = line.trim().parse()?;
        if n > 0 {
            sum += n;
        }
    }

    Ok(sum)
}

이 정도는 괜찮습니다. 하지만 요구사항이 늘면 for 내부가 금방 비대해집니다.

  • 파싱 실패 시, 어떤 라인에서 실패했는지 메시지 보강
  • 특정 조건이면 조기 종료
  • 변환/정규화 단계 추가
  • 통계(카운트, 최소/최대)까지 같이 구함

이때 Iterator로 바꾸면 “단계”가 분리되어 읽기 쉬워집니다.

map: 원소 단위 변환을 선언적으로

mapTU로 바꾸는 변환입니다. for에서 값을 가공해 다른 컬렉션에 넣는 코드를 자주 대체합니다.

예: 문자열 리스트를 정수로 변환

fn parse_all(lines: &[String]) -> Result<Vec<i64>, std::num::ParseIntError> {
    lines
        .iter()
        .map(|s| s.trim().parse::<i64>())
        .collect()
}

여기서 핵심은 collect()가 타입에 따라 동작이 달라진다는 점입니다.

  • collect::<Vec<_>>()Iterator<Item = T>Vec<T>
  • collect::<Result<Vec<_>, _>>()Iterator<Item = Result<T, E>>Result<Vec<T>, E>

즉, map으로 Result를 만들고 collect로 한 번에 “에러 전파 + 벡터 수집”이 됩니다.

map만으로 충분하지 않은 경우

  • 조건부로 건너뛰기: filter 또는 filter_map
  • 누적(합/카운트/해시맵): fold
  • 에러가 나오면 즉시 중단: try_fold

fold: 누적 상태를 명시적으로 모델링

fold는 “초기 상태 + 누적 함수”로 결과를 만듭니다. for에서 mut 변수를 두고 누적하는 패턴을 치환합니다.

예: 양수 합계

fn sum_positive(nums: &[i64]) -> i64 {
    nums.iter()
        .copied()
        .filter(|n| *n > 0)
        .fold(0i64, |acc, n| acc + n)
}
  • filter로 조건을 분리
  • fold로 누적 로직을 한 줄로 고정

예: 여러 통계를 한 번에(튜플 누적)

fold의 강점은 누적 상태를 구조체/튜플로 자유롭게 만들 수 있다는 점입니다.

#[derive(Debug, Default, Clone, Copy)]
struct Stats {
    count: usize,
    sum: i64,
    min: i64,
    max: i64,
}

fn stats(nums: &[i64]) -> Option<Stats> {
    nums.iter().copied().fold(None, |acc, n| {
        Some(match acc {
            None => Stats { count: 1, sum: n, min: n, max: n },
            Some(s) => Stats {
                count: s.count + 1,
                sum: s.sum + n,
                min: s.min.min(n),
                max: s.max.max(n),
            },
        })
    })
}

for로도 가능하지만, fold는 “상태 전이”가 함수로 고정되어 테스트/리팩터링이 쉽습니다.

fold의 한계: 실패/조기 종료가 어색해짐

fold는 기본적으로 모든 원소를 끝까지 돈다는 전제가 강합니다. 중간에 Result/Option으로 실패할 수 있고, 실패 시 즉시 멈추고 싶다면 try_fold가 더 적합합니다.

try_fold: 실패 가능 누적 + 조기 종료

try_foldfold의 “실패 가능 버전”입니다.

  • 누적 함수가 ResultOption 같은 Try 계열을 반환
  • 실패가 발생하면 즉시 중단(short-circuit)
  • 성공이면 누적 결과를 반환

예: 파싱하면서 조건부 합산, 실패 시 즉시 종료

use std::num::ParseIntError;

fn sum_positive_parsed(lines: &[String]) -> Result<i64, ParseIntError> {
    lines
        .iter()
        .try_fold(0i64, |acc, s| {
            let n: i64 = s.trim().parse()?;
            Ok(if n > 0 { acc + n } else { acc })
        })
}

이 코드는 for 버전과 동등하지만, 중요한 차이가 있습니다.

  • 누적 상태 acc가 클로저 인자로 들어와서 변이(mut)가 사라짐
  • 실패 경로가 ?로 자연스럽게 연결
  • “파싱하고 양수만 더한다”는 파이프라인이 더 직접적으로 보임

예: 검증이 섞인 누적(도메인 룰에서 자주 등장)

예를 들어, 결제 금액 리스트를 합산하는데 음수가 나오면 실패로 처리한다고 가정해봅시다.

#[derive(Debug)]
enum SumError {
    NegativeAmount(i64),
}

fn sum_non_negative(nums: &[i64]) -> Result<i64, SumError> {
    nums.iter().copied().try_fold(0i64, |acc, n| {
        if n < 0 {
            return Err(SumError::NegativeAmount(n));
        }
        Ok(acc + n)
    })
}

for로 작성하면 break/return/에러 생성이 섞여 복잡해지기 쉬운데, try_fold는 실패를 “반환 타입”으로 고정해 제어 흐름을 단순화합니다.

try_fold vs collect::<Result<...>>()

둘 다 에러 전파를 우아하게 처리합니다. 차이는 목적입니다.

  • collect는 “전부 변환해서 모으기”에 최적
  • try_fold는 “변환하면서 누적/검증/조기 종료”에 최적

예를 들어, 파싱된 값을 전부 Vec로 모을 필요가 없고 합계만 필요하면 try_fold가 메모리 측면에서도 낫습니다.

for를 완전히 버리면 안 되는 경우

Iterator 체인은 강력하지만, 다음 상황에서는 for가 더 명확할 수 있습니다.

  1. 복잡한 상태 머신: 여러 개의 가변 상태가 서로 얽혀 전이될 때
  2. 성능 미세 튜닝: 인라이닝/분기 예측/메모리 접근을 아주 구체적으로 다듬어야 할 때
  3. 디버깅 편의: 중간 단계마다 로그/브레이크포인트를 촘촘히 넣어야 할 때

다만 이 경우에도, 큰 흐름은 Iterator로 두고 “핵심 루프”만 for로 내리는 식의 타협이 가능합니다.

실전 변환 레시피: for에서 Iterator로 옮기는 순서

for 루프를 한 번에 체인으로 바꾸려 하면 오히려 가독성이 떨어질 수 있습니다. 아래 순서가 안전합니다.

  1. 입력 이터레이터 확정: iter()/iter_mut()/into_iter() 중 무엇인지 결정
  2. 변환 단계 분리: map으로 “원소 단위 변환”을 먼저 밖으로 꺼내기
  3. 필터링 분리: filter/filter_map으로 조건부 로직 분리
  4. 최종 목적 결정
    • 모으기면 collect
    • 집계면 fold
    • 실패 가능 집계면 try_fold

예: 단계적으로 리팩터링

초기 for:

fn total_len_of_nonempty(lines: &[String]) -> usize {
    let mut total = 0usize;
    for s in lines {
        let t = s.trim();
        if !t.is_empty() {
            total += t.len();
        }
    }
    total
}

Iterator 버전:

fn total_len_of_nonempty(lines: &[String]) -> usize {
    lines
        .iter()
        .map(|s| s.trim())
        .filter(|t| !t.is_empty())
        .map(|t| t.len())
        .fold(0usize, |acc, n| acc + n)
}

중간 변수를 없애는 게 목표가 아니라, “트림한다 → 비어있지 않은 것만 → 길이를 구한다 → 합친다”가 코드 구조로 보이는 게 핵심입니다.

성능 관점: Iterator는 느리다?

Rust Iterator는 제로 코스트 추상화를 목표로 설계되어, 단순한 체인은 최적화 시 루프와 유사하게 컴파일되는 경우가 많습니다. 하지만 다음은 유의해야 합니다.

  • 클로저가 너무 복잡하거나 캡처가 많으면 최적화가 어려워질 수 있음
  • inspect 같은 디버깅용 어댑터를 남겨두면 성능/가독성 모두 악화
  • collect로 불필요한 중간 Vec를 만들면 메모리/CPU 낭비

실무에서는 “중간 컬렉션을 만들지 않고 fold/try_fold로 끝내기”가 체감 성능에 더 큰 영향을 주는 경우가 많습니다.

에러 처리 품질 올리기: 어떤 원소에서 실패했는지

try_fold를 쓰면 실패 시점에서 즉시 중단되므로, 인덱스나 원본 값을 에러에 담기 좋습니다.

use std::num::ParseIntError;

#[derive(Debug)]
enum ParseSumError {
    Parse { index: usize, input: String, source: ParseIntError },
}

fn sum_with_context(lines: &[String]) -> Result<i64, ParseSumError> {
    lines
        .iter()
        .enumerate()
        .try_fold(0i64, |acc, (i, s)| {
            let n: i64 = s.trim().parse().map_err(|e| ParseSumError::Parse {
                index: i,
                input: s.clone(),
                source: e,
            })?;
            Ok(acc + n)
        })
}

for에서도 가능하지만, try_fold는 “에러 컨텍스트를 붙여 반환한다”가 누적 함수 내부에 집중되어, 제어 흐름이 덜 산만해집니다.

정리: 언제 무엇을 쓰나

  • map: 원소를 다른 형태로 바꾸는 변환이 핵심일 때
  • fold: 실패 없이 누적/집계가 목적일 때(합계, 카운트, 통계, 맵 구성)
  • try_fold: 누적 중 실패 가능성이 있고, 실패 시 즉시 중단하고 싶을 때(파싱, 검증, 도메인 룰)

Iterator 체인은 단순히 for를 “없애는 기술”이 아니라, 로직을 데이터 흐름으로 드러내는 설계 도구입니다. 특히 도메인 로직을 함수형 파이프라인으로 표현하면 테스트와 레이어 분리가 쉬워지고, 결과적으로 유지보수 비용이 내려갑니다.

다음 단계로는 scan, flat_map, take_while, map_while 같은 어댑터까지 확장하면, 많은 for 루프가 더 작은 조합으로 쪼개져 코드의 의도가 더 선명해집니다.