Published on

Rust Iterator·Result로 함수형 에러처리 최적화

Authors

서버나 배치에서 입력을 파싱하고, 검증하고, 변환하고, IO를 수행하는 흐름은 대부분 Iterator 파이프라인으로 모델링할 수 있습니다. 문제는 중간 어딘가에서 실패할 수 있다는 점이고, 그 실패를 어떻게 전파하느냐에 따라 코드 가독성, 성능, 그리고 운영 시 디버깅 난이도가 크게 갈립니다.

Rust는 예외 대신 Result를 기본으로 삼기 때문에, IteratorResult를 잘 결합하면 함수형 스타일로 에러 처리를 “구조화”하면서도 비용을 최소화할 수 있습니다. 이 글은 다음에 집중합니다.

  • map/filter/flat_map 체인에서 Result를 자연스럽게 흘려보내는 법
  • 조기 실패, 에러 수집, 부분 성공 등 요구사항별 최적 패턴
  • 불필요한 collect와 임시 Vec를 줄이는 방법
  • 에러 타입 설계와 컨텍스트 부여(운영 친화성)

관련해서 Rust의 소유권과 핀 고급 주제는 별도 글에서 다뤘으니, 필요하면 Rust self-referential 구조체가 불가능한 이유와 Pin도 참고하면 좋습니다.

IteratorResult 조합이 중요한가

명령형 코드에서는 보통 아래처럼 작성합니다.

  • 루프를 돌며 파싱
  • 실패하면 return Err(...)
  • 성공한 값은 push

이 방식은 직관적이지만, 변환 단계가 늘어날수록 if let/match가 중첩되거나, 에러 처리 분기가 여기저기 흩어집니다. 반면 Iterator 체인은 “데이터 흐름”을 위에서 아래로 읽게 해주고, Result는 “실패 가능성”을 타입으로 고정합니다.

핵심은 다음 등식에 가깝습니다.

  • Iterator<Item = T>는 “여러 개의 T
  • Result<T, E>는 “성공 또는 실패”
  • Iterator<Item = Result<T, E>>는 “여러 개의 성공 또는 실패”

이때 우리가 원하는 동작은 상황별로 달라집니다.

  • 첫 실패에서 즉시 중단하고 싶다
  • 모든 에러를 모아서 리포팅하고 싶다
  • 에러는 로깅만 하고 성공만 계속 처리하고 싶다

각각에 맞는 조합을 알면, 성능과 가독성을 동시에 얻을 수 있습니다.

기본 패턴 1: map에서 Result 만들고 한 번에 collect

가장 흔한 요구는 “입력을 모두 변환하되, 중간에 하나라도 실패하면 전체를 실패로”입니다. Rust에서는 collectFromIterator를 통해 Result<Vec<T>, E>로 자동 변환해줍니다.

use std::num::ParseIntError;

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

fn main() {
    let input = vec!["1", " 2", "x", "3"];
    let out = parse_all(&input);
    println!("{out:?}");
}

이 패턴의 장점

  • 조기 실패를 자동으로 수행합니다. 내부적으로는 첫 Err에서 더 이상 진행하지 않습니다.
  • 중간 Vec를 만들지 않고 최종 결과만 만듭니다.
  • 코드가 짧고 읽기 쉽습니다.

주의점

  • 에러에 “어느 줄에서 실패했는지” 같은 컨텍스트가 없습니다. 운영에서는 이 정보가 매우 중요합니다.

기본 패턴 2: 인덱스·원본을 포함한 에러 컨텍스트 추가

컨텍스트를 넣으려면 에러 타입을 감싸는 구조체를 만들고, map_err로 변환합니다.

use std::num::ParseIntError;

#[derive(Debug)]
struct LineParseError {
    line_no: usize,
    raw: String,
    source: ParseIntError,
}

fn parse_all_with_context(lines: &[String]) -> Result<Vec<i32>, LineParseError> {
    lines
        .iter()
        .enumerate()
        .map(|(i, s)| {
            s.trim().parse::<i32>().map_err(|e| LineParseError {
                line_no: i + 1,
                raw: s.clone(),
                source: e,
            })
        })
        .collect()
}

여기서 raw: s.clone()은 비용이 있습니다. 하지만 실패 케이스에서만 복사가 일어나도록 만들 수도 있습니다.

use std::num::ParseIntError;

#[derive(Debug)]
struct LineParseError<'a> {
    line_no: usize,
    raw: &'a str,
    source: ParseIntError,
}

fn parse_all_with_borrow<'a>(lines: &'a [&'a str]) -> Result<Vec<i32>, LineParseError<'a>> {
    lines
        .iter()
        .enumerate()
        .map(|(i, s)| {
            s.trim().parse::<i32>().map_err(|e| LineParseError {
                line_no: i + 1,
                raw: s,
                source: e,
            })
        })
        .collect()
}

최적화 포인트

  • 성공 경로에서 불필요한 String 할당을 하지 않습니다.
  • 에러가 발생했을 때만 컨텍스트를 들고 나옵니다.
  • 라이프타임이 늘어나지만, 배치 처리나 파이프라인에서는 오히려 명확합니다.

기본 패턴 3: and_then으로 단계적 변환을 평탄화

파싱 이후 검증, 이후 변환 같은 단계가 이어지면 and_then이 깔끔합니다.

#[derive(Debug)]
enum MyError {
    Parse,
    OutOfRange,
}

fn parse_i32(s: &str) -> Result<i32, MyError> {
    s.trim().parse::<i32>().map_err(|_| MyError::Parse)
}

fn validate(v: i32) -> Result<i32, MyError> {
    if (0..=100).contains(&v) {
        Ok(v)
    } else {
        Err(MyError::OutOfRange)
    }
}

fn parse_and_validate_all(lines: &[&str]) -> Result<Vec<i32>, MyError> {
    lines
        .iter()
        .map(|s| parse_i32(s).and_then(validate))
        .collect()
}

and_then이 중요한가

  • Result<Result<T, E>, E> 같은 중첩을 만들지 않습니다.
  • 단계별 함수를 테스트하기 쉬워집니다.

최적화 시나리오 1: 성공만 계속 처리하고 에러는 스킵

로그 수집, 부분 집계 같은 경우 “실패한 레코드는 버리고 성공만 처리”가 더 유용합니다. 이때는 filter_map(Result::ok)가 간단합니다.

fn sum_valid_ints(lines: &[&str]) -> i32 {
    lines
        .iter()
        .map(|s| s.trim().parse::<i32>())
        .filter_map(Result::ok)
        .sum()
}

운영 관점 보완

스킵만 하면 문제를 놓칩니다. “스킵하되 관측 가능하게” 만들려면 inspect로 로깅을 끼울 수 있습니다.

fn sum_valid_ints_with_log(lines: &[&str]) -> i32 {
    lines
        .iter()
        .map(|s| (s, s.trim().parse::<i32>()))
        .inspect(|(raw, r)| {
            if r.is_err() {
                eprintln!("skip invalid value: {raw}");
            }
        })
        .filter_map(|(_, r)| r.ok())
        .sum()
}

이 패턴은 크래시 대신 품질 저하를 선택하는 시스템에서 유용합니다. 장애 진단은 결국 “관측 가능성”이 좌우하므로, 트러블슈팅 글 스타일이 궁금하면 Kubernetes CrashLoopBackOff 원인별 10분 진단처럼 원인별 분기 기준을 정리하는 방식도 참고할 만합니다.

최적화 시나리오 2: 모든 에러를 모아서 한 번에 보고

데이터 품질 리포트나 대량 마이그레이션에서는 “최대한 많이 처리하고, 에러는 모아서 리포트”가 필요합니다. 표준 라이브러리만으로도 partition을 활용해 성공과 실패를 분리할 수 있습니다.

fn parse_partition(lines: &[&str]) -> (Vec<i32>, Vec<String>) {
    let (oks, errs): (Vec<_>, Vec<_>) = lines
        .iter()
        .map(|s| s.trim().parse::<i32>().map_err(|_| format!("invalid: {s}")))
        .partition(Result::is_ok);

    let values = oks.into_iter().map(Result::unwrap).collect();
    let errors = errs.into_iter().map(Result::unwrap_err).collect();
    (values, errors)
}

주의점과 개선

  • unwrap 계열은 안전하지만 “논리적으로만 안전”합니다. 리팩터링 중 조건이 바뀌면 위험해질 수 있습니다.
  • 더 안전하게 하려면 if let으로 풀거나, 별도 헬퍼 함수를 두는 편이 낫습니다.

또한 에러 문자열을 즉시 String으로 만들면 비용이 커질 수 있습니다. 가능하면 에러 구조체에 &str을 빌려 담고, 최종 출력 단계에서만 문자열화하세요.

최적화 시나리오 3: try_fold로 조기 실패와 누적을 동시에

collect가 만능은 아닙니다. 누적 로직이 Vec가 아니라 커스텀 상태 머신인 경우 try_fold가 가장 깔끔하고 빠릅니다.

#[derive(Debug)]
enum AggError {
    Empty,
    Parse,
}

fn avg(lines: &[&str]) -> Result<f64, AggError> {
    let (sum, cnt) = lines
        .iter()
        .try_fold((0i64, 0i64), |(sum, cnt), s| {
            let v = s.trim().parse::<i64>().map_err(|_| AggError::Parse)?;
            Ok((sum + v, cnt + 1))
        })?;

    if cnt == 0 {
        return Err(AggError::Empty);
    }
    Ok(sum as f64 / cnt as f64)
}

try_fold의 장점

  • 조기 실패를 유지하면서도 중간 컬렉션이 필요 없습니다.
  • 누적 상태가 튜플이든 구조체든 자유롭습니다.
  • ?를 클로저 안에서 자연스럽게 사용할 수 있어, 에러 경로가 짧습니다.

flat_map을 쓸 때의 함정: 에러가 사라지기 쉽다

flat_map은 “여러 개로 펼치기”에 좋지만, Result와 섞이면 에러를 무심코 버리기 쉽습니다.

나쁜 예는 대략 이런 형태입니다.

  • 파싱 실패는 빈 이터레이터로 대체
  • 결과적으로 실패가 관측되지 않음

대신 “에러를 유지한 채 펼치기”가 필요하면, Result를 먼저 처리한 뒤 성공 값만 펼치거나, 에러를 별도 채널로 모으는 구조를 택하세요.

fn split_words_all_or_fail(lines: &[&str]) -> Result<Vec<&str>, &'static str> {
    lines
        .iter()
        .map(|s| {
            if s.trim().is_empty() {
                Err("empty line")
            } else {
                Ok(s.split_whitespace().collect::<Vec<_>>())
            }
        })
        .collect::<Result<Vec<Vec<&str>>, _>>()
        .map(|v| v.into_iter().flatten().collect())
}

여기서는 한 번 Vec<Vec<_>>가 생깁니다. 대용량이면 부담이 될 수 있으니, 요구사항에 따라 try_fold로 바로 누적하거나, itertoolsprocess_results 같은 도구를 고려할 수 있습니다.

에러 타입 설계: 성능보다 중요한 것은 디버깅 가능성

함수형 체인을 최적화하다 보면 “에러를 얇게” 만들고 싶어집니다. 하지만 운영에서 중요한 건 다음입니다.

  • 어떤 입력이 실패했는가
  • 어느 단계에서 실패했는가
  • 재현 가능한 정보가 있는가

따라서 다음 가이드가 실전에서 균형이 좋습니다.

  • 라이브러리 경계에서는 Result<T, E>E를 구체적으로
  • 애플리케이션 경계에서는 에러에 컨텍스트를 붙여서 로깅 친화적으로
  • 성공 경로에서 할당을 늘리지 않도록, 가능하면 참조를 보관하고 최종 출력에서 문자열화

대규모 시스템에서 이런 “관측 가능성 우선” 접근은 성능 트러블슈팅에서도 동일하게 통합니다. 프론트엔드 쪽이지만 원인-증상-검증 루프를 잘 정리한 글로 Next.js 14 RSC 캐시·라우터 성능 트러블슈팅을 함께 보면, Rust 서버 코드에서도 같은 방식으로 진단 체계를 만들 수 있습니다.

실전 예제: 파일 라인을 파싱해 도메인 객체로 변환

아래는 “id,name CSV 비슷한 라인”을 파싱해 구조체로 만드는 예제입니다.

요구사항:

  • 한 줄이라도 실패하면 전체 실패
  • 실패 시 줄 번호와 원문을 포함
  • 성공 경로에서 불필요한 복사를 최소화
use std::num::ParseIntError;

#[derive(Debug)]
struct User {
    id: u64,
    name: String,
}

#[derive(Debug)]
enum UserParseReason {
    BadFormat,
    BadId(ParseIntError),
    EmptyName,
}

#[derive(Debug)]
struct UserParseError<'a> {
    line_no: usize,
    raw: &'a str,
    reason: UserParseReason,
}

fn parse_user(line: &str) -> Result<User, UserParseReason> {
    let mut it = line.split(',');
    let id_s = it.next().ok_or(UserParseReason::BadFormat)?;
    let name_s = it.next().ok_or(UserParseReason::BadFormat)?;

    let id = id_s.trim().parse::<u64>().map_err(UserParseReason::BadId)?;
    let name = name_s.trim();
    if name.is_empty() {
        return Err(UserParseReason::EmptyName);
    }

    Ok(User {
        id,
        name: name.to_string(),
    })
}

fn parse_users<'a>(lines: &'a [&'a str]) -> Result<Vec<User>, UserParseError<'a>> {
    lines
        .iter()
        .enumerate()
        .map(|(i, raw)| {
            parse_user(raw).map_err(|reason| UserParseError {
                line_no: i + 1,
                raw,
                reason,
            })
        })
        .collect()
}

이 코드는 다음 특성을 가집니다.

  • parse_user는 순수 함수에 가깝고 단위 테스트가 쉽습니다.
  • parse_users는 파이프라인 형태로 읽히며, 첫 실패에서 즉시 중단합니다.
  • 에러는 줄 번호와 원문을 빌려 들고 있어, 실패 경로에서만 정보가 커집니다.

마무리: 요구사항별로 “에러 흐름”을 먼저 고정하라

IteratorResult를 섞을 때 가장 흔한 실수는, 구현을 먼저 하고 나중에 “에러를 어떻게 할지”를 고민하는 것입니다. 반대로 아래 순서로 결정하면 코드가 단단해집니다.

  1. 첫 실패에서 중단할 것인가, 계속 진행할 것인가
  2. 에러를 버릴 것인가, 모을 것인가, 즉시 리포트할 것인가
  3. 에러에 어떤 컨텍스트가 필요한가(줄 번호, 키, 단계)
  4. 그 다음에 collect/try_fold/partition 중 무엇이 맞는지 고른다

이 순서대로 설계하면 함수형 체인의 장점인 “짧고 예측 가능한 에러 전파”를 유지하면서도, 불필요한 할당과 중간 컬렉션을 줄여 성능까지 챙길 수 있습니다.