Published on

Rust Iterator·Option·Result로 FP 파이프라인

Authors

서버 사이드에서 데이터를 받아 파싱하고, 검증하고, 변환하고, 저장하는 흐름은 본질적으로 “단계(stage)의 연속”입니다. Rust는 예외(throw) 대신 Option/Result로 실패를 타입에 올려두고, Iterator로 대량 데이터를 스트리밍 처리할 수 있게 해줍니다. 이 세 가지를 잘 엮으면 FP(함수형) 파이프라인처럼 읽히면서도, 런타임 오류보다 컴파일 타임 안전성에 기대는 코드를 만들 수 있습니다.

이 글은 다음을 목표로 합니다.

  • Iterator 파이프라인에서 Option/Result를 자연스럽게 흘려보내는 방법
  • map, filter_map, and_then, ok_or, collect, try_fold, transpose의 실전 조합
  • “중간에 하나라도 실패하면 중단” vs “실패를 모아 보고 계속 진행” 두 전략

참고로, 파이프라인 설계는 장애 원인 추적에도 큰 도움이 됩니다. 예를 들어 서비스가 계속 재시작될 때도 “단계별 실패 지점”을 로깅/구조화하면 원인 규명이 쉬워집니다. 관련해서는 systemd 서비스가 계속 재시작될 때 원인 추적법도 함께 보면 좋습니다.

1) 파이프라인의 기본: Iterator는 “게으른(lazy) 스트림”

Rust의 Iterator는 기본적으로 lazy입니다. map/filter를 아무리 붙여도, 마지막에 collect, for_each, count 같은 소비(terminal) 연산을 호출하기 전까지는 실제로 실행되지 않습니다.

이 특성 덕분에 다음이 가능합니다.

  • 큰 입력을 한 번에 메모리에 올리지 않고 처리
  • take, find 등으로 조기 종료
  • 실패가 발생하면 즉시 중단하는 흐름을 자연스럽게 구성

간단한 예시입니다.

fn main() {
    let nums = vec![1, 2, 3, 4, 5];

    let out: Vec<i32> = nums
        .into_iter()
        .map(|x| x * 2)
        .filter(|x| x % 3 != 0)
        .collect();

    println!("{out:?}");
}

여기까진 “실패가 없는” 순수 변환입니다. 이제 실패가 끼어드는 순간, Option/Result가 등장합니다.

2) Option 파이프라인: 값이 “없을 수 있음”을 흐름에 포함

Option은 값이 없을 수 있음을 표현합니다. 파이프라인에서 자주 쓰는 패턴은 다음입니다.

  • filter_map: Option을 반환하는 변환 + None 제거
  • and_then: Option을 반환하는 함수를 체이닝(모나딕 바인딩)

2.1 filter_map으로 “파싱 가능한 것만” 통과시키기

문자열 리스트에서 정수로 파싱되는 값만 모읍니다.

fn main() {
    let raw = vec!["10", "x", "20", "-", "30"];

    let nums: Vec<i32> = raw
        .into_iter()
        .filter_map(|s| s.parse::<i32>().ok())
        .collect();

    assert_eq!(nums, vec![10, 20, 30]);
}

여기서 핵심은 parseResult를 반환하므로 ok()Option으로 낮춘 뒤, filter_mapNone을 제거한다는 점입니다.

2.2 and_then으로 Option 단계 연결

예를 들어 “문자열에서 정수를 파싱하고, 양수면 역수를 구한다” 같은 흐름입니다.

fn parse_i32(s: &str) -> Option<i32> {
    s.parse::<i32>().ok()
}

fn reciprocal_pos(x: i32) -> Option<f64> {
    if x > 0 { Some(1.0 / x as f64) } else { None }
}

fn main() {
    let v = parse_i32("10").and_then(reciprocal_pos);
    assert_eq!(v, Some(0.1));

    let v2 = parse_i32("-3").and_then(reciprocal_pos);
    assert_eq!(v2, None);
}

and_then은 “다음 단계도 Option”일 때 자연스럽게 이어줍니다.

3) Result 파이프라인: 실패 이유를 보존하고 전파

운영 코드에서는 “왜 실패했는지”가 중요합니다. 이때 Result가 중심이 됩니다.

  • map: 성공 값만 변환
  • map_err: 에러만 변환
  • and_then: 다음 단계도 Result인 경우 체이닝
  • ?: 현재 함수의 Result로 에러를 즉시 전파

3.1 단계별 검증을 and_then으로 연결

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

fn parse_non_empty(s: &str) -> Result<&str, MyError> {
    if s.trim().is_empty() { Err(MyError::Empty) } else { Ok(s) }
}

fn parse_u8(s: &str) -> Result<u8, MyError> {
    s.parse::<u16>()
        .map_err(|_| MyError::NotANumber)
        .and_then(|n| {
            if n <= 255 { Ok(n as u8) } else { Err(MyError::OutOfRange) }
        })
}

fn main() {
    let out = parse_non_empty("200").and_then(parse_u8);
    assert_eq!(out.unwrap(), 200);
}

에러를 “정보로서” 유지하면서 파이프라인을 구성할 수 있습니다.

4) Iterator + Result: collect가 “첫 에러에서 멈추는” 파이프라인을 만든다

많은 사람들이 Rust에서 FP 파이프라인이 강력하다고 느끼는 지점이 여기입니다.

Iterator<Item = Result<T, E>>collect::<Result<Vec<T>, E>>() 하면,

  • 모두 성공이면 Ok(Vec<T>)
  • 하나라도 실패하면 “첫 번째 에러”로 Err(E)

가 됩니다.

#[derive(Debug)]
enum ParseError {
    BadInt(String),
}

fn parse_one(s: &str) -> Result<i32, ParseError> {
    s.parse::<i32>().map_err(|_| ParseError::BadInt(s.to_string()))
}

fn main() {
    let raw = vec!["1", "2", "x", "3"];

    let out: Result<Vec<i32>, ParseError> = raw
        .iter()
        .map(|s| parse_one(s))
        .collect();

    // 여기서는 "x"에서 즉시 Err
    println!("{out:?}");
}

이 패턴은 “원자적 처리”에 잘 맞습니다.

  • 배치 입력을 처리하지만, 하나라도 깨지면 전체를 실패로 보고 롤백해야 하는 경우
  • 설정 파일 로딩처럼, 일부만 성공해도 의미가 없는 경우

비슷한 맥락에서 데이터 파이프라인에서는 “차원 불일치 같은 입력 오류를 초기에 강하게 막기”가 중요합니다. 벡터 검색/RAG 쪽이라면 Pinecone·Milvus 임베딩 차원 불일치 해결 가이드처럼 입력 검증을 앞단에 두는 전략이 효과적입니다.

5) “실패를 모아서 계속 진행”하기: partition 또는 수동 fold

현업에서는 “실패한 레코드만 따로 모으고, 성공한 것만 저장”해야 할 때가 많습니다. collect는 첫 실패에서 멈추므로, 다른 전략이 필요합니다.

대표적으로 두 가지가 있습니다.

  • partition을 사용해 Ok/Err를 분리
  • fold로 직접 누적
#[derive(Debug, Clone)]
enum E { Bad(String) }

fn parse_one(s: &str) -> Result<i32, E> {
    s.parse::<i32>().map_err(|_| E::Bad(s.to_string()))
}

fn main() {
    let raw = vec!["1", "x", "2", "y", "3"];

    let (oks, errs): (Vec<_>, Vec<_>) = raw
        .iter()
        .map(|s| parse_one(s))
        .partition(|r| r.is_ok());

    let values: Vec<i32> = oks.into_iter().map(|r| r.unwrap()).collect();
    let errors: Vec<E> = errs.into_iter().map(|r| r.unwrap_err()).collect();

    println!("values={values:?}");
    println!("errors={errors:?}");
}

주의할 점은 unwrap이지만, 여기서는 partition으로 이미 분리했기 때문에 안전한 unwrap입니다(논리적으로 Ok/Err가 보장됨).

6) try_fold로 “누적 중 에러면 즉시 중단”을 더 명시적으로

collect는 편하지만, 누적 로직이 복잡해지면 try_fold가 더 읽기 좋습니다.

예: 숫자 목록을 파싱하면서 합계를 구하되, 하나라도 실패하면 중단.

#[derive(Debug)]
enum E { BadInt(String) }

fn main() {
    let raw = vec!["10", "20", "x", "30"];

    let sum: Result<i32, E> = raw
        .iter()
        .try_fold(0i32, |acc, s| {
            let n = s.parse::<i32>().map_err(|_| E::BadInt(s.to_string()))?;
            Ok(acc + n)
        });

    println!("{sum:?}");
}

try_fold는 “중간 상태(accumulator)가 있는 파이프라인”에서 특히 강력합니다.

7) OptionResult 사이 변환: ok_or, ok, transpose

실전에서는 OptionResult가 섞입니다.

  • OptionResult로: ok_or, ok_or_else
  • ResultOption으로: ok, err
  • Option<Result<T, E>> 또는 Result<Option<T>, E> 정리: transpose

7.1 OptionResult로 승격: “없음도 에러로 취급”

#[derive(Debug)]
enum E { MissingEnv }

fn main() {
    let v: Option<String> = std::env::var("HOME").ok();

    let home: Result<String, E> = v.ok_or(E::MissingEnv);
    println!("{home:?}");
}

7.2 transpose로 중첩 구조 평탄화

예: Iterator에서 “선택적 필드가 있으면 파싱하고, 없으면 그냥 통과”

  • 입력: Option<&str>
  • 파싱: Result<i32, E>
  • 결과: Option<Result<i32, E>>
  • 원하는 형태: Result<Option<i32>, E>
#[derive(Debug)]
enum E { Bad }

fn parse_opt(v: Option<&str>) -> Result<Option<i32>, E> {
    v.map(|s| s.parse::<i32>().map_err(|_| E::Bad))
        .transpose()
}

fn main() {
    assert_eq!(parse_opt(Some("10")).unwrap(), Some(10));
    assert_eq!(parse_opt(None).unwrap(), None);
}

transpose는 FP 파이프라인에서 “중첩된 컨테이너를 정리하는 도구”로 자주 등장합니다.

8) 파이프라인을 함수로 쪼개면 테스트와 관측 가능성이 좋아진다

Iterator 체이닝을 한 줄로 길게 쓰면 멋있어 보이지만, 운영 관점에서는 다음이 더 중요합니다.

  • 단계별 책임 분리(파싱, 검증, 변환, 저장)
  • 단계별 에러 타입/메시지 정교화
  • 로깅/메트릭 훅을 끼우기 쉬움

예를 들어 “입력 라인들을 파싱해서 유효한 레코드만 DB에 넣는다”를 생각해보면, 다음처럼 구성할 수 있습니다.

#[derive(Debug)]
struct Record {
    user_id: u64,
    score: i32,
}

#[derive(Debug)]
enum E {
    BadFormat(String),
    BadUserId(String),
    BadScore(String),
}

fn parse_line(line: &str) -> Result<Record, E> {
    let mut it = line.split(',');
    let user = it.next().ok_or_else(|| E::BadFormat(line.to_string()))?;
    let score = it.next().ok_or_else(|| E::BadFormat(line.to_string()))?;

    let user_id = user.trim().parse::<u64>().map_err(|_| E::BadUserId(user.to_string()))?;
    let score = score.trim().parse::<i32>().map_err(|_| E::BadScore(score.to_string()))?;

    Ok(Record { user_id, score })
}

fn main() {
    let lines = vec!["1,10", "2,20", "x,30", "3,zzz"]; 

    // 1) 실패 시 즉시 중단하고 싶다면 collect
    let all: Result<Vec<Record>, E> = lines.iter().map(|l| parse_line(l)).collect();
    println!("all={all:?}");

    // 2) 실패를 모아 계속 진행하고 싶다면 partition
    let (oks, errs): (Vec<_>, Vec<_>) = lines
        .iter()
        .map(|l| parse_line(l))
        .partition(|r| r.is_ok());

    let records: Vec<Record> = oks.into_iter().map(|r| r.unwrap()).collect();
    let errors: Vec<E> = errs.into_iter().map(|r| r.unwrap_err()).collect();

    println!("records={records:?}");
    println!("errors={errors:?}");
}

이 구조는 장애 대응에도 유리합니다. “어떤 단계에서 어떤 입력이 어떤 이유로 실패했는지”가 에러 타입에 남기 때문입니다. 성능 이슈를 다룰 때도 파이프라인 각 단계의 비용을 분리해 측정하기 쉬운데, Java 진영에서 스트림 병렬 처리로 성능이 무너지는 사례를 보면(병렬화가 만능이 아님) 파이프라인 설계가 얼마나 중요한지 감이 옵니다. 관련해서는 Java Stream 병렬 처리 성능 폭망 원인 6가지도 참고할 만합니다.

9) 자주 쓰는 조합 레시피

실무에서 반복되는 “레시피”를 정리하면 다음과 같습니다.

9.1 Iterator에서 Option 제거

  • filter_map(|x| ...)
  • flat_map(Option::into_iter)도 가능하지만 filter_map이 보통 더 직관적
let out: Vec<i32> = vec![Some(1), None, Some(2)]
    .into_iter()
    .flatten()
    .collect();

9.2 Iterator에서 Result를 한 번에 수집

let out: Result<Vec<i32>, _> = vec!["1", "2"]
    .into_iter()
    .map(|s| s.parse::<i32>())
    .collect();

9.3 OptionResult로 바꿔 “없음도 실패”로 통일

fn must(v: Option<i32>) -> Result<i32, &'static str> {
    v.ok_or("missing")
}

9.4 “선택적 파싱”은 transpose

fn parse_opt(s: Option<&str>) -> Result<Option<u32>, std::num::ParseIntError> {
    s.map(|x| x.parse::<u32>()).transpose()
}

10) 마무리: Rust FP 파이프라인의 핵심은 “실패를 타입으로 모델링”

Rust에서 Iterator는 데이터 흐름을, Option은 “없을 수 있음”을, Result는 “실패 이유”를 표현합니다. 이 셋을 조합하면 다음을 동시에 얻습니다.

  • 파이프라인이 선언적으로 읽힘(단계가 체이닝으로 드러남)
  • 실패가 예외가 아니라 타입으로 강제됨(처리 누락이 줄어듦)
  • 대량 처리에서 스트리밍/조기 종료/에러 전파가 자연스럽게 구현됨

정리하면,

  • “하나라도 실패하면 전체 실패”는 map + collect::<Result<Vec<_>, _>>()
  • “실패를 모아 계속 진행”은 partition 또는 수동 fold
  • Option/Result가 섞이면 ok_ortranspose

이 패턴들을 몸에 익히면, Rust로 ETL, 로그 처리, 설정 로딩, API 입력 검증 같은 영역에서 FP 파이프라인을 안정적으로 구축할 수 있습니다.