Published on

Rust Iterator 체이닝으로 Side Effect 제거 리팩터링

Authors

서버/배치 코드에서 흔히 보는 패턴이 있습니다. for 루프 안에서 push 하고, 조건에 따라 continue 하고, 중간중간 로그를 찍고, 에러를 누적하다가 마지막에 한 번에 반환하는 방식입니다. 처음엔 빠르게 구현되지만, 시간이 지나면 부수효과(side effect) 가 로직의 중심이 되어 버립니다.

Rust는 기본적으로 불변(immutable)과 소유권(ownership) 모델을 강제하기 때문에, 이런 코드를 억지로 작성하면 mut 가 늘어나고, 스코프가 꼬이고, 에러 전파가 지저분해지기 쉽습니다. 이때 Iterator 체이닝은 “데이터 흐름”을 전면으로 끌어올려서 부수효과를 가장자리로 밀어내는 강력한 리팩터링 도구가 됩니다.

이 글에서는 다음을 목표로 합니다.

  • for + mut + 중간 상태 누적 로직을 Iterator 파이프라인으로 바꾸기
  • filter/map/flat_map/filter_map/try_fold/collect 를 상황별로 선택하기
  • 로깅, 메트릭, 캐시 갱신 같은 “필요한 부수효과”를 격리하는 방법
  • 성능과 가독성 사이에서 어떤 지점을 택해야 하는지 기준 세우기

관련해서 Rust의 구조화/의존성 관리 관점은 Rust로 헥사고날 아키텍처 구현 - 의존성 역전 글도 함께 보면, “핵심 로직은 순수하게, I/O는 바깥으로”라는 관점이 더 선명해집니다.

Side Effect가 늘어나는 전형적인 코드

예시로 “사용자 목록을 받아 유효한 사용자만 변환하고, 실패는 수집하며, 결과는 정렬 후 반환” 같은 배치성 로직을 가정해보겠습니다.

#[derive(Debug, Clone)]
struct User {
    id: u64,
    email: String,
    active: bool,
}

#[derive(Debug)]
struct UserDto {
    id: u64,
    domain: String,
}

#[derive(Debug)]
enum ConvertError {
    Inactive(u64),
    InvalidEmail(u64),
}

fn convert_user(u: &User) -> Result<UserDto, ConvertError> {
    if !u.active {
        return Err(ConvertError::Inactive(u.id));
    }

    let parts: Vec<&str> = u.email.split('@').collect();
    if parts.len() != 2 {
        return Err(ConvertError::InvalidEmail(u.id));
    }

    Ok(UserDto {
        id: u.id,
        domain: parts[1].to_string(),
    })
}

fn legacy(users: &[User]) -> (Vec<UserDto>, Vec<ConvertError>) {
    let mut ok = Vec::new();
    let mut errs = Vec::new();

    for u in users {
        // 부수효과: 로깅
        // println!("processing {}", u.id);

        match convert_user(u) {
            Ok(dto) => ok.push(dto),
            Err(e) => errs.push(e),
        }
    }

    ok.sort_by_key(|d| d.id);
    (ok, errs)
}

문제는 이 코드가 “나쁘다”가 아니라, 요구사항이 조금만 늘어나도 부수효과가 더 섞인다는 점입니다.

  • 특정 도메인은 제외
  • 중복 제거
  • 변환 성공 건수/실패 건수 메트릭
  • 에러가 N개 넘으면 중단
  • 변환 과정에서 외부 조회(I/O) 추가

이런 요구가 들어오면 mut 상태가 늘고, break/continue 분기가 늘고, 테스트에서 특정 분기를 재현하기가 어려워집니다.

1단계: partition 으로 성공/실패 분리

성공/실패를 동시에 모으는 케이스는 partition 이 깔끔합니다. 다만 partitionbool 기준이라 Result 를 그대로 쓰려면 한 번 형태를 맞춰야 합니다.

fn refactor_partition(users: &[User]) -> (Vec<UserDto>, Vec<ConvertError>) {
    let (oks, errs): (Vec<_>, Vec<_>) = users
        .iter()
        .map(convert_user)
        .partition(Result::is_ok);

    let mut ok: Vec<UserDto> = oks.into_iter().map(Result::unwrap).collect();
    let errs: Vec<ConvertError> = errs.into_iter().map(Result::unwrap_err).collect();

    ok.sort_by_key(|d| d.id);
    (ok, errs)
}

장점은 “흐름”이 보인다는 것입니다.

  • 입력: iter()
  • 변환: map(convert_user)
  • 분리: partition
  • 수집: collect

단점은 unwrap 이 들어가면서 심리적 부담이 생긴다는 점입니다. 여기서는 partition(Result::is_ok) 로 이미 분리했기 때문에 안전하지만, 팀 컨벤션에 따라 꺼릴 수 있습니다.

2단계: fold 로 단일 패스에서 누적하기

unwrap 을 피하고 싶고, 성공/실패를 한 번에 누적하고 싶다면 fold 가 정석입니다.

fn refactor_fold(users: &[User]) -> (Vec<UserDto>, Vec<ConvertError>) {
    let (mut ok, errs) = users
        .iter()
        .map(convert_user)
        .fold((Vec::new(), Vec::new()), |(mut ok, mut errs), r| {
            match r {
                Ok(dto) => ok.push(dto),
                Err(e) => errs.push(e),
            }
            (ok, errs)
        });

    ok.sort_by_key(|d| d.id);
    (ok, errs)
}

여전히 내부적으로는 push 라는 부수효과가 있지만, 중요한 차이는 부수효과의 범위가 fold 클로저로 격리되어 전체 함수의 제어 흐름을 오염시키지 않는다는 점입니다.

또한 이 패턴은 이후에 “에러가 N개 넘으면 중단” 같은 요구에 대응하기 쉽습니다.

3단계: 실패 누적이 아니라 “중단”이 목표라면 try_fold

일정 조건에서 즉시 중단하고 싶다면 try_fold 가 강력합니다. 예를 들어 “변환 중 에러가 나오면 바로 반환”은 collect 로도 되지만, 중간에 카운팅/메트릭/리소스 정리를 함께 하고 싶으면 try_fold 가 더 유연합니다.

fn convert_all_or_fail(users: &[User]) -> Result<Vec<UserDto>, ConvertError> {
    let mut out = users
        .iter()
        .try_fold(Vec::new(), |mut acc, u| {
            let dto = convert_user(u)?;
            acc.push(dto);
            Ok(acc)
        })?;

    out.sort_by_key(|d| d.id);
    Ok(out)
}

여기서 중요한 포인트는 ? 를 통해 에러 전파가 “흐름”으로 합쳐진다는 점입니다. for 루프에서도 가능하지만, try_fold 는 “중간 누적 + 조기 종료”를 표준 라이브러리 모델로 표현합니다.

4단계: filter_map 은 “조건부 변환”에만 쓰기

filter_map 은 편리하지만, 에러를 버리는 순간 디버깅이 어려워집니다. 따라서 다음처럼 “실패는 관심 없고, 성공만 필요”한 상황에서만 쓰는 게 안전합니다.

fn only_valid_dtos(users: &[User]) -> Vec<UserDto> {
    users
        .iter()
        .filter_map(|u| convert_user(u).ok())
        .collect()
}

실무에서는 실패를 버리면 나중에 운영 이슈에서 근거가 사라집니다. 실패를 반드시 관측해야 한다면, 최소한 로깅/메트릭을 별도 단계로 분리하는 편이 낫습니다.

Side Effect를 “가장자리”로 밀어내는 패턴

부수효과를 완전히 없애는 것이 목표가 아니라, 핵심 변환 파이프라인을 순수하게 유지하고, 부수효과를 경계로 몰아내는 게 목표입니다.

1) 로깅은 inspect 로, 단 최종 소비가 있어야 함

inspect 는 중간 값 관찰용으로 유용하지만, Iterator는 lazy라서 소비(consume) 가 없으면 실행되지 않습니다.

fn with_logging(users: &[User]) -> Vec<UserDto> {
    users
        .iter()
        .inspect(|u| {
            // tracing::debug!(user_id = u.id, "processing");
        })
        .map(convert_user)
        .filter_map(Result::ok)
        .collect()
}

운영에서 “로그가 안 찍힌다”는 이슈는 대부분 lazy 평가를 잊어서 생깁니다. collect/for_each/count 같은 터미널 연산이 있어야 합니다.

2) 메트릭/카운터는 fold 로 구조화

성공/실패 카운트는 inspect 로 글로벌 mutable 카운터를 만지기보다, fold 누적으로 표현하는 편이 테스트하기 쉽습니다.

#[derive(Default, Debug)]
struct Stats {
    ok: usize,
    err: usize,
}

fn with_stats(users: &[User]) -> (Vec<UserDto>, Stats) {
    users
        .iter()
        .map(convert_user)
        .fold((Vec::new(), Stats::default()), |(mut out, mut st), r| {
            match r {
                Ok(dto) => {
                    st.ok += 1;
                    out.push(dto);
                }
                Err(_) => {
                    st.err += 1;
                }
            }
            (out, st)
        })
}

이렇게 하면 “카운터 업데이트”라는 부수효과가 외부 상태 변경이 아니라 데이터로 모델링된 누적이 됩니다.

3) I/O가 섞이면 Iterator 체이닝을 과신하지 않기

DB 조회나 HTTP 호출처럼 I/O가 들어오면, Iterator 체이닝이 오히려 가독성을 해칠 수 있습니다. 이때는 다음 중 하나를 택하는 게 좋습니다.

  • 변환 함수 자체를 Result 로 유지하고, 바깥에서 collect 로 모으기
  • 비동기라면 futures::stream 계열로 전환하기
  • 아예 명시적 for 루프를 쓰되, “부수효과와 순수 로직”을 함수로 분리하기

Iterator는 “순수 변환 + 수집”에 가장 강합니다. I/O가 주인공인 코드까지 억지로 체이닝하면 디버깅 비용이 커집니다.

실전 리팩터링 레시피 5가지

레시피 1: map + collectResult<Vec<_>, _> 만들기

성공만 필요하고 하나라도 실패하면 중단하는 경우, 가장 간단한 형태입니다.

fn all_or_error(users: &[User]) -> Result<Vec<UserDto>, ConvertError> {
    users.iter().map(convert_user).collect()
}

collect 는 타입 힌트에 따라 Result<Vec<T>, E> 로 자동 수집됩니다.

레시피 2: “성공/실패 둘 다 필요”하면 fold

앞서 본 패턴이 가장 안정적입니다. partition 도 가능하지만, foldunwrap 을 피할 수 있습니다.

레시피 3: 중첩 컬렉션 펼치기는 flat_map

예를 들어 사용자 하나가 여러 개의 이벤트를 만든다면, mapflatten 또는 flat_map 으로 평탄화합니다.

fn domains(users: &[User]) -> Vec<String> {
    users
        .iter()
        .flat_map(|u| u.email.split('@').skip(1))
        .map(str::to_string)
        .collect()
}

레시피 4: 중복 제거는 HashSet 누적으로 명시

정렬 기반 중복 제거보다 의도가 분명합니다.

use std::collections::HashSet;

fn unique_domains(users: &[User]) -> HashSet<String> {
    users
        .iter()
        .filter_map(|u| u.email.split('@').nth(1))
        .map(str::to_string)
        .collect()
}

레시피 5: 조건이 복잡해지면 “작은 순수 함수”로 쪼개기

Iterator 체이닝이 길어질수록, 한 줄에 모든 걸 넣기보다 단계별로 이름을 붙여야 유지보수가 됩니다.

fn is_interesting(u: &User) -> bool {
    u.active && u.email.ends_with(".com")
}

fn to_dto(u: &User) -> Result<UserDto, ConvertError> {
    convert_user(u)
}

fn pipeline(users: &[User]) -> Vec<UserDto> {
    users
        .iter()
        .filter(|u| is_interesting(u))
        .filter_map(|u| to_dto(u).ok())
        .collect()
}

흔한 함정: Iterator는 lazy, 그리고 borrow는 전염된다

함정 1: inspect 를 붙였는데 실행이 안 된다

앞서 언급했듯이 터미널 연산이 없으면 실행되지 않습니다. 디버깅 중에는 임시로 count() 를 붙여도 됩니다.

let n = users.iter().inspect(|u| {
    // println!("{}", u.id);
}).count();

함정 2: 체이닝 중간에 참조가 길게 살아남아 mut 접근이 막힌다

Iterator는 참조를 길게 끌고 가는 경향이 있어, 같은 스코프에서 users 를 수정하려 하면 borrow checker가 막습니다. 해결책은 보통 다음 중 하나입니다.

  • 필요한 값만 cloned() 또는 to_owned() 로 소유권을 가져오기
  • 중간 결과를 collect 로 끊어 스코프를 종료하기
  • 애초에 “읽기 파이프라인”과 “쓰기 단계”를 분리하기

정리: Side Effect 제거의 핵심은 “데이터 흐름을 먼저 세우기”

Iterator 체이닝 리팩터링의 본질은 문법적 멋이 아니라, 다음 설계를 강제한다는 데 있습니다.

  • 변환은 map 으로 표현
  • 선택/제거는 filter 로 표현
  • 실패는 Result 로 모델링하고, 필요하면 fold 로 관측 가능하게 누적
  • 로깅/메트릭 같은 부수효과는 inspect 또는 누적 구조로 “가장자리”에 배치

이 방식은 테스트 전략에도 유리합니다. 파이프라인의 각 단계가 순수 함수로 분리되면, 입력-출력 기반으로 단위 테스트가 쉬워지고, 운영에서 필요한 관측(로그/메트릭)은 별도 레이어로 이동합니다. 이런 “핵심 로직을 순수하게 유지”하는 접근은 아키텍처 관점에서도 유효하며, 더 큰 구조로 확장할 때는 Rust로 헥사고날 아키텍처 구현 - 의존성 역전 같은 패턴과 결합하면 효과가 커집니다.

마지막으로, Iterator 체이닝이 항상 정답은 아닙니다. I/O 중심이거나 디버깅이 최우선인 구간은 명시적 for 루프가 더 읽기 쉬울 수 있습니다. 중요한 것은 “부수효과를 중심에 두지 말고, 데이터 변환의 흐름을 중심에 두는 것”입니다. Iterator는 그 목표를 달성하기 위한 Rust 표준 도구 상자라고 보면 됩니다.