Rust 기초: Iterator 트레이트로 컬렉션 순회하기

Rust 기초: Iterator 트레이트로 컬렉션 순회하기

Rust로 컬렉션을 다루다 보면 iter(), map(), filter(), collect() 같은 메서드를 자연스럽게 쓰게 됩니다. 이 메서드들 뒤에 있는 게 바로 Iterator 트레이트인데요.

Iterator는 Rust의 함수형 코드를 떠받치는 핵심 추상화입니다. 한 번 익숙해지면 for 루프와 임시 변수를 늘어놓던 코드가 짧고 우아하게 바뀌어요. 이 글에서는 Iterator 트레이트가 어떻게 동작하는지, 어댑터와 소비자의 차이가 무엇인지, collect()의 다양한 활용까지 알아보겠습니다. Vec을 먼저 익혀두면 예제를 따라가기 한결 수월할 거예요.

Iterator 트레이트의 핵심

Iterator의 핵심은 단 하나의 메서드, next()입니다. 호출할 때마다 다음 값을 Some(value)로 돌려주고, 더 이상 값이 없으면 None을 반환하죠.

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ... 수십 개의 기본 메서드들
}

Item은 연관 타입(associated type)으로 이 iterator가 어떤 값을 만들어내는지를 정의합니다. 나머지 map, filter, collect 같은 메서드는 모두 next() 위에 기본 구현되어 있어요. 즉, 어떤 타입next()만 구현해주면 자동으로 iterator의 모든 기능을 누릴 수 있는 거죠.

직접 한번 만들어볼까요?

struct Counter {
    count: u32,
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<u32> {
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

fn main() {
    let counter = Counter { count: 0 };
    let sum: u32 = counter.sum();
    println!("합계: {sum}");
}
결과
합계: 15

next() 하나만 구현했는데 sum()이 바로 동작합니다. 이런 게 트레이트의 위력이에요.

지연 평가

iterator는 기본적으로 지연 평가(lazy)됩니다. 선언만 해서는 아무 일도 일어나지 않고, 소비자가 호출될 때 비로소 동작하죠.

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

    let mapped = nums.iter().map(|x| {
        println!("처리 중: {x}");
        x * 2
    });

    println!("여기까지는 아무 출력도 없습니다");

    let doubled: Vec<i32> = mapped.collect();
    println!("결과: {doubled:?}");
}
결과
여기까지는 아무 출력도 없습니다
처리 중: 1
처리 중: 2
처리 중: 3
처리 중: 4
처리 중: 5
결과: [2, 4, 6, 8, 10]

map을 호출한 시점이 아니라 collect를 호출한 시점에 실제 처리가 일어나죠. 이렇게 지연 평가되기 때문에 무한 시퀀스를 다루거나 큰 데이터를 효율적으로 처리할 수 있습니다.

어댑터와 소비자

Iterator 메서드는 크게 두 종류로 나뉩니다. 어댑터(adapter)는 새 iterator를 반환하고, 소비자(consumer)는 iterator를 실제로 돌려서 값을 만들어내요.

대표적인 어댑터부터 볼게요.

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

    let result: Vec<i32> = nums
        .iter()
        .map(|x| x * 2)        // 어댑터: 변환
        .filter(|x| x > &4)    // 어댑터: 거름
        .take(2)               // 어댑터: 앞에서 N개
        .collect();            // 소비자

    println!("{result:?}");
}
결과
[6, 8]

map, filter, take 모두 새 iterator를 반환할 뿐 아직 실행되지 않습니다. collect()가 호출되어야 비로소 체인 전체가 돌아가요.

자주 쓰는 소비자도 정리해볼게요.

  • collect() — 컬렉션으로 모음
  • sum() / product() — 합 / 곱
  • count() — 개수
  • find() — 조건 맞는 첫 값을 Option으로 반환
  • any() / all() — 하나라도 만족 / 전부 만족
  • fold() — 누적값 만들기
  • for_each() — 부수 효과 실행
fn main() {
    let nums = vec![1, 2, 3, 4, 5];

    let sum: i32 = nums.iter().sum();
    let has_even = nums.iter().any(|x| x % 2 == 0);
    let first_big = nums.iter().find(|&&x| x > 3);

    println!("합: {sum}, 짝수 있음: {has_even}, 처음 3 초과: {first_big:?}");
}
결과
합: 15, 짝수 있음: true, 처음 3 초과: Some(4)

for 루프의 정체

for 루프는 사실 iterator를 감싼 문법 설탕인데요.

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

    for n in &nums {
        println!("{n}");
    }
}

위 코드는 컴파일러 입장에서 대략 이렇게 풀립니다.

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

    let mut iter = (&nums).into_iter();
    while let Some(n) = iter.next() {
        println!("{n}");
    }
}

이 변환을 가능하게 하는 게 IntoIterator 트레이트입니다. for 루프에 쓰는 모든 타입은 IntoIterator를 구현하고 있어야 해요. 역으로 말하면 자기 타입에 IntoIterator를 구현하면 사용자가 for 루프로 순회할 수 있게 됩니다.

IteratorIntoIterator가 헷갈리기 쉬운데요. 차이는 단순합니다. Iterator는 “값을 하나씩 꺼낼 수 있는 능력”이고, IntoIterator는 “나를 iterator로 바꿔주는 능력”이에요. Vec 같은 컬렉션은 IntoIterator를 구현해서 iterator를 만들어내고, 만들어진 iterator가 Iterator를 구현해서 next()로 값을 꺼낼 수 있는 거죠.

iter, iter_mut, into_iter

같은 Vec을 순회하는 데도 세 가지 방법이 있는데요. 세 가지의 차이는 소유권을 어떻게 다루느냐예요.

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

    // iter(): 불변 참조로 순회
    for n in nums.iter() {
        println!("{n}"); // n: &i32
    }

    // iter_mut(): 가변 참조로 순회 (수정 가능)
    let mut nums2 = vec![1, 2, 3];
    for n in nums2.iter_mut() {
        *n *= 10; // n: &mut i32
    }
    println!("{nums2:?}");

    // into_iter(): 소유권을 가져감
    for n in nums.into_iter() {
        println!("{n}"); // n: i32
    }
    // nums는 이제 사용 불가
}
결과
1
2
3
[10, 20, 30]
1
2
3

iter()&T를, iter_mut()&mut T를, into_iter()T를 만들어내는 iterator입니다. for n in &numsnums.iter(), for n in &mut numsnums.iter_mut(), for n in numsnums.into_iter()와 같아요. 대부분 iter()를 쓰지만 컬렉션을 더 이상 쓰지 않을 거면 into_iter()로 소유권을 가져가서 불필요한 복사를 피할 수 있습니다.

collect와 타입 추론

collect()는 iterator를 컬렉션으로 변환하는 가장 흔한 소비자인데요. 어떤 컬렉션으로 모을지 명시해줘야 한다는 게 특이한 점입니다.

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

    // 변수에 타입 명시
    let evens: Vec<i32> = nums.iter().filter(|&&x| x % 2 == 0).copied().collect();

    // turbofish ::<>로 명시
    let evens2 = nums.iter().filter(|&&x| x % 2 == 0).copied().collect::<Vec<i32>>();

    println!("{evens:?}, {evens2:?}");
}
결과
[2, 4], [2, 4]

collect()Vec, HashMap, String 등 다양한 타입으로 변환할 수 있어서 컴파일러가 어디로 모을지 추론하기 어렵거든요. 그래서 타입을 명시해주는 게 필수입니다.

Result를 다룰 때 특히 유용한 사용법이 있는데요. Iterator<Item = Result<T, E>>Result<Vec<T>, E>로 한 번에 모을 수 있습니다.

fn main() {
    let strs = vec!["1", "2", "3"];
    let ok: Result<Vec<i32>, _> = strs.iter().map(|s| s.parse::<i32>()).collect();
    println!("{ok:?}");

    let bad = vec!["1", "x", "3"];
    let err: Result<Vec<i32>, _> = bad.iter().map(|s| s.parse::<i32>()).collect();
    println!("{err:?}");
}
결과
Ok([1, 2, 3])
Err(ParseIntError { kind: InvalidDigit })

중간에 하나라도 Err이 나오면 즉시 멈추고 그 에러를 돌려줘요. 하나하나 unwrap하는 대신 이 패턴으로 한 방에 처리할 수 있죠.

마치며

Iterator 트레이트는 Rust 함수형 코드의 척추 같은 존재입니다. 처음에는 메서드가 너무 많아 보이지만 결국 next()라는 단순한 인터페이스 위에 모든 게 쌓여 있어요.

핵심을 정리하면 이렇습니다. Iteratornext() 하나만 구현하면 수십 개의 메서드를 공짜로 얻을 수 있습니다. 어댑터(map, filter 등)는 새 iterator를 만들 뿐이고, 소비자(collect, sum 등)가 호출되어야 실제로 동작합니다. for 루프는 IntoIterator를 호출하는 문법 설탕이고요. iter() / iter_mut() / into_iter()는 소유권을 어떻게 다루는지에 따라 골라 씁니다.

Iterator에 익숙해지면 함수 체이닝으로 의도가 명확한 코드를 짤 수 있게 됩니다. Vec이나 HashMap 같은 컬렉션을 다룰 때 특히 진가가 발휘되니까 함께 익혀두면 좋아요.

더 자세한 내용은 std::iter::Iterator - Rust 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord