어떤 배열의 원소를 순회하며 짝수만 골라 제곱한 뒤 합산하는 작업을 생각해 봅시다. C라면 인덱스 변수, 조건문, 누적 변수가 뒤엉킨 for 반복문을 작성해야 하지만, 러스트에서는 numbers.iter().filter(|x| *x % 2 == 0).map(|x| x * x).sum()처럼 데이터 변환 파이프라인을 한 줄로 압축할 수 있습니다. 이것이 가능한 이유는 러스트에 반복자Iterator라는 개념이 있기 때문입니다. 이번 글에서는 러스트의 반복자에 대해 알아봅니다.
Iterator 트레이트
러스트에서 반복자는 Iterator 트레이트를 구현한 타입이다. 이 트레이트의 핵심은 연관 타입 Item과 메서드 next()다.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// 75개 이상의 기본 메서드가 여기에 정의되어 있다
}
next()는 호출할 때마다 원소를 하나씩 Some(value)로 반환하고, 원소가 소진되면 None을 반환한다. 이 단순한 프로토콜 위에 map, filter, fold 등 75개 이상의 기본 메서드1가 구축되어 있다. Iterator를 구현할 때 직접 작성해야 하는 것은 next() 하나뿐이다.
let v = vec![1, 2, 3];
let mut iter = v.iter();
assert_eq!(iter.next(), Some(&1));
assert_eq!(iter.next(), Some(&2));
assert_eq!(iter.next(), Some(&3));
assert_eq!(iter.next(), None);
next()가 &mut self를 받는다는 점에 주목하자. 반복자는 내부 상태(현재 위치)를 변경하며 진행하므로, 가변 참조가 필요하다.
반복자를 만드는 세 가지 방법
컬렉션에서 반복자를 만드는 방법은 세 가지이며, 각각 소유권을 다루는 방식이 다르다.
| 메서드 | 산출 타입 | 소유권 |
|---|---|---|
.iter() | &T | 불변 대여 — 원본 유지 |
.iter_mut() | &mut T | 가변 대여 — 원본 수정 가능 |
.into_iter() | T | 소유권 이전 — 원본 소비 |
let names = vec![String::from("Alice"), String::from("Bob")];
// .iter() — 불변 참조를 산출, names는 계속 사용 가능
for name in names.iter() {
println!("{name}");
}
println!("names 여전히 유효: {names:?}");
// .into_iter() — 소유권을 이전, names는 이후 사용 불가
for name in names.into_iter() {
println!("{name}");
}
// println!("{names:?}"); // 컴파일 오류: names가 이동됨
.iter_mut()은 원소를 제자리에서 수정할 때 사용한다.
let mut scores = vec![80, 65, 90];
for score in scores.iter_mut() {
*score += 5; // 모든 점수에 5점 가산
}
assert_eq!(scores, vec![85, 70, 95]);
for 반복문과 IntoIterator
러스트의 for 반복문은 반복자를 사용하는 조금 더 쉬운 방법syntatic sugar일 뿐이다. for item in collection은 컴파일러에 의해 다음과 동등한 코드로 변환된다.
// for item in collection { body }
// 는 다음과 같다:
let mut iter = IntoIterator::into_iter(collection);
loop {
match iter.next() {
Some(item) => { /* body */ }
None => break,
}
}
이 변환의 핵심은 IntoIterator 트레이트다. 이 트레이트를 구현한 타입은 for 반복문에서 직접 사용할 수 있다.
pub trait IntoIterator {
type Item;
type IntoIter: Iterator<Item = Self::Item>;
fn into_iter(self) -> Self::IntoIter;
}
표준 라이브러리의 컬렉션들은 IntoIterator를 세 가지 형태로 구현한다.
let v = vec![1, 2, 3];
for x in &v { } // impl IntoIterator for &Vec<T> → .iter()
for x in &mut v { } // impl IntoIterator for &mut Vec<T> → .iter_mut()
for x in v { } // impl IntoIterator for Vec<T> → .into_iter()
&v로 대여하면 .iter()가, v를 직접 넘기면 .into_iter()가 호출된다. 따라서 for x in &v와 for x in v.iter()는 의미가 같다.
반복자 어댑터
반복자 어댑터iterator adaptor는 반복자를 입력으로 받아 새로운 반복자를 반환하는 메서드다. 핵심 특성은 지연 평가lazy evaluation다. 어댑터를 호출하는 것만으로는 아무 계산도 일어나지 않지만, 소비 어댑터가 next()를 호출할 때 비로소 원소가 하나씩 처리된다.
let v = vec![1, 2, 3];
// 이 줄만으로는 아무 일도 일어나지 않는다
let mapped = v.iter().map(|x| x * 2);
// 소비해야 실행된다
let result: Vec<_> = mapped.collect();
assert_eq!(result, vec![2, 4, 6]);
컴파일러는 어댑터만 호출하고 소비하지 않으면 iterators are lazy and do nothing unless consumed와 같은 경고를 발생시킨다.
주요 어댑터
map
각 원소를 클로저로 변환한다.
let names = vec!["alice", "bob", "carol"];
let upper: Vec<String> = names.iter().map(|s| s.to_uppercase()).collect();
// ["ALICE", "BOB", "CAROL"]
filter
조건을 만족하는 원소만 통과시킨다. 클로저는 원소의 참조를 받으므로 && 패턴이 등장할 수 있다.
let numbers = vec![1, 2, 3, 4, 5, 6];
let evens: Vec<&i32> = numbers.iter().filter(|x| *x % 2 == 0).collect();
// [2, 4, 6]
enumerate
인덱스와 원소의 쌍 (usize, T)을 산출한다.
let fruits = vec!["apple", "banana", "cherry"];
for (i, fruit) in fruits.iter().enumerate() {
println!("{i}: {fruit}");
}
zip
두 반복자를 쌍으로 묶는다. 짧은 쪽이 소진되면 종료한다.
let keys = vec!["a", "b", "c"];
let values = vec![1, 2, 3];
let pairs: Vec<_> = keys.iter().zip(values.iter()).collect();
// [("a", 1), ("b", 2), ("c", 3)]
take, skip
앞에서 n개를 취하거나 건너뛴다.
let numbers = 1..=100;
let first_five: Vec<i32> = numbers.clone().take(5).collect(); // [1, 2, 3, 4, 5]
let after_ten: Vec<i32> = numbers.skip(10).take(3).collect(); // [11, 12, 13]
chain
두 반복자를 이어 붙인다.
let a = vec![1, 2];
let b = vec![3, 4];
let chained: Vec<&i32> = a.iter().chain(b.iter()).collect();
// [1, 2, 3, 4]
flat_map
각 원소를 반복자로 변환한 뒤 평탄화한다. map + flatten의 축약이다.
let sentences = vec!["hello world", "foo bar"];
let words: Vec<&str> = sentences.iter().flat_map(|s| s.split_whitespace()).collect();
// ["hello", "world", "foo", "bar"]
peekable
다음 원소를 소비하지 않고 미리 엿볼 수 있는 peek() 메서드를 제공한다.
let mut iter = vec![1, 2, 3].into_iter().peekable();
assert_eq!(iter.peek(), Some(&1)); // 소비하지 않음
assert_eq!(iter.next(), Some(1)); // 이제 소비
이 어댑터들은 자유롭게 붙여서 사용할 수 있다. 중간에 임시 컬렉션이 생성되지 않으므로, 파이프라인이 아무리 길어도 메모리 할당은 최종 collect 한 번뿐이다.
소비 어댑터
소비 어댑터consuming adaptor는 반복자의 next()를 끝까지 호출하여 최종 결과를 생산한다. 호출 후 반복자는 소진된다.
collect
반복자의 원소를 컬렉션으로 모은다. 터보피시나 타입 어노테이션으로 대상 컬렉션을 지정한다.
let doubled: Vec<i32> = (1..=5).map(|x| x * 2).collect();
// HashMap으로도 수집 가능
use std::collections::HashMap;
let map: HashMap<&str, i32> = vec![("a", 1), ("b", 2)].into_iter().collect();
sum, product
원소의 합 또는 곱을 계산한다.
let total: i32 = (1..=100).sum(); // 5050
let factorial: u64 = (1..=10).product(); // 3628800
fold
초기값과 누적 클로저로 원소를 하나의 값으로 축약한다. sum, product, count 등 많은 소비 어댑터가 내부적으로 fold로 구현되어 있다.
let sum = (1..=5).fold(0, |acc, x| acc + x); // 15
let csv = vec!["a", "b", "c"]
.iter()
.fold(String::new(), |acc, s| {
if acc.is_empty() { s.to_string() }
else { format!("{acc},{s}") }
});
// "a,b,c"
find
조건을 만족하는 첫 번째 원소를 Option으로 반환한다.
let first_even = (1..=10).find(|x| x % 2 == 0);
assert_eq!(first_even, Some(2));
any, all
조건을 만족하는 원소가 하나라도 있는지, 또는 모두 만족하는지를 검사한다. 단축 평가2를 수행하므로, 조건이 확정되면 나머지 원소를 순회하지 않는다.
let has_negative = vec![1, -2, 3].iter().any(|x| *x < 0); // true
let all_positive = vec![1, 2, 3].iter().all(|x| *x > 0); // true
count, min, max
원소의 개수, 최솟값, 최댓값을 반환한다.
let count = (0..100).filter(|x| x % 3 == 0).count(); // 34
let min = vec![3, 1, 4, 1, 5].iter().min(); // Some(&1)
let max = vec![3, 1, 4, 1, 5].iter().max(); // Some(&5)
커스텀 반복자 구현
Iterator 트레이트를 직접 구현하면 어떤 시퀀스든 반복자로 표현할 수 있다. 구현해야 할 메서드는 next() 하나뿐이다.
struct Counter {
count: u32,
max: u32,
}
impl Counter {
fn new(max: u32) -> Counter {
Counter { count: 0, max }
}
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.count < self.max {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
next()만 구현하면 map, filter, sum, collect 등 Iterator 트레이트의 모든 기본 메서드를 자동으로 사용할 수 있다.
let total: u32 = Counter::new(5).sum();
assert_eq!(total, 15); // 1 + 2 + 3 + 4 + 5
let pairs: Vec<_> = Counter::new(5)
.zip(Counter::new(5).skip(1))
.collect();
// [(1, 2), (2, 3), (3, 4), (4, 5)]
let sum_of_products: u32 = Counter::new(5)
.zip(Counter::new(5).skip(1))
.map(|(a, b)| a * b)
.filter(|x| x % 2 == 0)
.sum();
이 예시에서 Counter는 단순한 정수 시퀀스이지만, 파일의 줄, 네트워크 패킷, 피보나치 수열 등 임의의 시퀀스를 동일한 패턴으로 표현할 수 있다. 커스텀 반복자가 기존의 모든 어댑터 및 소비자와 즉시 호환된다는 점이 Iterator 트레이트 설계의 강점이다.
성능
반복자 체이닝은 읽기 좋은 추상화지만, 런타임 비용은 어떨까? 러스트 컴파일러는 반복자 파이프라인을 단형화하여 수작업 루프와 동일한 기계어를 생성한다.
The Rust Programming Language의 벤치마크에 따르면, 오디오 디코더에서 반복자 체이닝과 직접 인덱싱 루프의 성능이 동일했다. 반복자가 더 나은 경우도 있는데, 컴파일러가 경계 검사bounds check를 제거할 수 있기 때문이다. 인덱스로 접근하면 매 접근마다 범위를 확인해야 하지만, 반복자는 next()가 None을 반환하면 끝이므로 별도의 경계 검사가 필요 없다.
// 인덱스 기반 — 매 접근마다 경계 검사
for i in 0..v.len() {
total += v[i]; // 범위 검사 발생
}
// 반복자 기반 — 경계 검사 불필요
for x in v.iter() {
total += x;
}
또한 반복자 어댑터 체이닝은 중간 컬렉션을 생성하지 않는다. filter().map().sum()은 원소 하나가 filter → map → sum을 순서대로 통과한 뒤 다음 원소로 넘어가는 방식으로 실행된다. 이를 루프 융합loop fusion3이라 하며, 메모리 할당 없이 파이프라인이 실행되는 핵심 메커니즘이다.
출처
- The Rust Programming Language (n.d.) Processing a Series of Items with Iterators. Available at: https://doc.rust-lang.org/book/ch13-02-iterators.html (Accessed: 14 April 2026).
- The Rust Programming Language (n.d.) Comparing Performance: Loops vs. Iterators. Available at: https://doc.rust-lang.org/book/ch13-04-performance.html (Accessed: 14 April 2026).
- The Rust Standard Library (n.d.) std::iter::Iterator. Available at: https://doc.rust-lang.org/std/iter/trait.Iterator.html (Accessed: 14 April 2026).
- The Rust Standard Library (n.d.) std::iter::IntoIterator. Available at: https://doc.rust-lang.org/std/iter/trait.IntoIterator.html (Accessed: 14 April 2026).
Footnotes
-
기본 메서드(default method)란 트레이트 정의에서 본문이 제공되는 메서드다.
Iterator를 구현할 때next()만 작성하면map,filter,fold등 나머지 메서드는next()를 호출하는 기본 구현이 자동으로 적용된다. 필요하면 개별적으로 오버라이드할 수 있다. ↩ -
단축 평가(short-circuit evaluation)란 논리 연산에서 결과가 확정되면 나머지 피연산자를 평가하지 않는 전략이다.
any()는true를 발견하면,all()은false를 발견하면 즉시 반환한다. ↩ -
루프 융합(loop fusion)이란 여러 개의 루프를 하나로 합쳐 중간 데이터 구조 없이 실행하는 컴파일러 최적화 기법이다. Rust의 반복자 어댑터는 지연 평가 덕분에 이 최적화가 자연스럽게 달성된다. ↩