Rust의 Iterator와 Closure

1. 개요

Closure와 iterator는 Rust에서 함수형 스타일로 데이터를 처리하면서도 손으로 작성한 루프와 동일한 성능을 유지할 수 있도록 도와주는 두 가지 도구입니다. 이 글에서는 closure가 주변 환경에서 변수를 캡처하는 방식과 Fn, FnMut, FnOnce 세 특성을 설명합니다. 이후 Iterator 특성, map, filter 같은 일반적인 adapter와 직접 iterator를 작성하는 방법을 다룹니다. 이 글을 읽고 나면 filter → map → collect 같은 파이프라인을 자신 있게 작성할 수 있을 것입니다. 또한 컴파일러가 때때로 당신의 코드를 거부하는 이유도 알게 될 것입니다.

모든 예제는 Rust 1.80+ (edition 2021)을 사용하며 표준 라이브러리만 필요합니다.

2. Closure의 기초

2.1. 문법

Closure는 주변 스코프의 변수를 “볼 수 있는” 익명 함수입니다:

fn main() {
    let discount = 10; // 퍼센트
    let apply_discount = |price: u32| price - price * discount / 100;

    println!("{}", apply_discount(250));
    println!("{}", apply_discount(1000));
}
225
900

매개변수는 두 개의 |...| 사이에 위치하고, closure 본문은 바로 뒤에 옵니다. Closure는 전달되지 않은 discount 변수를 사용합니다. 일반 fn 함수는 이런 일을 할 수 없습니다.

매개변수 타입과 반환 타입은 보통 자동으로 추론됩니다. 컴파일러에 충분한 정보가 있으면 |price| ...로 작성할 수 있습니다. 하지만 타입은 한 번만 추론됩니다. 처음에 closure를 u32로 호출하고 나중에 i64로 호출하면 컴파일 오류가 발생합니다.

2.2. 세 가지 변수 캡처 방식

Closure는 본문이 필요로 하는 “가장 가벼운” 방식으로 변수를 캡처합니다. 우선순위는 불변 빌림, 다음으로 가변 빌림, 마지막으로 소유권 이전입니다.

fn main() {
    let mut cart = vec!["펜"];

    let print_cart = || println!("카트: {:?}", cart); // &cart를 빌리기
    print_cart();

    let mut add_item = |item| cart.push(item);        // &mut cart를 빌리기
    add_item("노트북");
    add_item("자");

    println!("최종: {:?}", cart);
}
카트: ["펜"]
최종: ["펜", "노트북", "자"]

각 closure를 분석해봅시다:

  • print_cart는 cart만 읽으므로 &cart를 보유합니다.
  • add_item은 push를 호출하므로 &mut cart가 필요합니다. Closure 변수도 mut로 선언해야 합니다. 호출할 때 내부 상태가 변하기 때문입니다.
  • Non-lexical lifetimes 덕분에 코드가 컴파일됩니다. print_cart는 첫 번째 호출 후에 더 이상 사용되지 않으므로 불변 빌림은 가변 빌림이 시작하기 전에 종료됩니다.

Closure가 데이터의 소유권을 가져야 할 때는 move 키워드를 추가합니다. 전형적인 경우는 closure를 다른 스레드로 보낼 때입니다:

use std::thread;

fn main() {
    let customer = String::from("Lan");

    let handle = thread::spawn(move || {
        println!("{}의 주문을 처리 중입니다", customer);
    });

    handle.join().unwrap();
}
Lan의 주문을 처리 중입니다

move가 없으면 컴파일러가 오류를 보고합니다. 새로운 스레드는 main보다 오래 살 수 있으므로 customer에 대한 참조는 안전하지 않습니다. move를 사용하면 String이 closure로 완전히 이전됩니다. 그 후 main에서 customer를 사용할 수 없습니다.

3. Fn, FnMut, FnOnce 세 가지 특성

각 closure는 자동으로 다음 세 특성 중 하나 이상을 구현합니다. 어떤 특성이 구현되는지는 closure가 캡처한 변수로 무엇을 하는지에 따라 달라집니다:

특성 Closure가 캡처한 변수에 하는 일 호출 가능 횟수
Fn 읽기만 함 여러 번
FnMut 변경함 여러 번
FnOnce 소비(밖으로 이전) 한 번

세 특성 간에는 상속 관계가 있습니다: 모든 Fn은 FnMut이고, 모든 FnMut은 FnOnce입니다. Closure를 받는 함수를 작성할 때는 함수가 필요로 하는 가장 느슨한 특성을 선택하세요. 이렇게 하면 호출자가 더 많은 선택지를 가집니다.

fn run_twice<F: Fn()>(f: F) {
    f();
    f();
}

fn run_twice_mut<F: FnMut()>(mut f: F) {
    f();
    f();
}

fn run_once<F: FnOnce() -> String>(f: F) -> String {
    f()
}

fn main() {
    let name = String::from("Minh");
    run_twice(|| println!("안녕 {name}"));

    let mut count = 0;
    run_twice_mut(|| count += 1);
    println!("count = {count}");

    let receipt = run_once(move || name + " - 결제 완료");
    println!("{receipt}");
}
안녕 Minh
안녕 Minh
count = 2
Minh - 결제 완료

세 호출은 세 가지 특성에 해당합니다:

  • 첫 번째 closure는 name만 읽으므로 Fn입니다.
  • 두 번째 closure는 count를 증가시키므로 FnMut입니다. 이를 run_twice에 전달하면 오류가 발생합니다.
  • 마지막 closure는 name + "..."를 사용합니다. String의 + 연산자는 name의 소유권을 가지므로 closure는 한 번만 호출할 수 있으며 FnOnce만 구현합니다.

3.1. 함수에서 closure 반환하기

각 closure는 고유의 익명 타입을 가집니다. 따라서 그 타입의 이름을 작성할 수 없습니다. Closure를 반환하는 두 가지 방법이 있습니다:

fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
    move |price| price - price * percent / 100
}

fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
    if is_vip {
        Box::new(|price| price * 80 / 100)
    } else {
        Box::new(|price| price)
    }
}

fn main() {
    let summer_sale = make_discount(15);
    println!("{}", summer_sale(200));

    let rule = pick_rule(true);
    println!("{}", rule(500));
}
170
400
  • impl Fn(...)는 함수가 항상 하나의 고정된 closure 타입을 반환할 때 사용합니다. 이 방법은 힙 할당이 없고 동적 호출 오버헤드가 없습니다.
  • Box<dyn Fn(...)>는 각 분기에서 다른 closure를 반환할 때 사용합니다. pick_rule의 두 closure는 서로 다른 타입이므로 특성 객체로 통일해야 합니다.
  • make_discount의 move는 필수입니다. 없으면 closure는 percent를 빌리는데, percent는 함수가 끝날 때 사라집니다.

4. Iterator 특성

4.1. 핵심: next 함수

Rust의 모든 iterator는 매우 작은 특성을 중심으로 돌아갑니다:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ... map, filter, sum 같은 수십 개의 내장 메서드 ...
}

next는 데이터가 남아있을 때 Some(원소)를 반환하고, 데이터가 끝나면 None을 반환합니다. for 루프는 사실상 계속해서 next를 호출하는 것을 단축한 문법일 뿐입니다.

4.2. iter, iter_mut, into_iter

컬렉션은 세 가지 종류의 iterator를 생성할 수 있습니다:

fn main() {
    let mut prices = vec![100, 200, 300];

    for p in prices.iter() {          // p: &i32
        print!("{p} ");
    }
    println!();

    for p in prices.iter_mut() {      // p: &mut i32
        *p += 10;
    }

    for p in prices.into_iter() {     // p: i32, vector 이전됨
        print!("{p} ");
    }
    println!();
    // println!("{:?}", prices); // 오류: move 후 사용
}
100 200 300 
110 210 310 
  • iter()는 불변 참조를 제공합니다. 컬렉션은 이후에도 사용할 수 있습니다.
  • iter_mut()은 가변 참조를 제공하여 제자리에서 원소를 수정합니다.
  • into_iter()은 소유권을 가집니다. for p in prices로 작성하면 암묵적으로 into_iter()을 호출합니다.

5. Adapter와 consumer

Iterator의 메서드는 두 그룹으로 나뉩니다:

  • Adapter (map, filter, take, zip, enumerate…)는 이 iterator를 다른 iterator로 변환합니다. 이들은 게으른 평가를 하므로 consumer가 있을 때까지 아무것도 실행하지 않습니다.
  • Consumer (collect, sum, count, fold, for_each, find…)는 실제로 next를 호출하고 결과를 반환합니다.

예제 데이터로 Order 구조체를 사용합니다:

#[derive(Debug)]
pub struct Order {
    pub id: u32,
    pub customer: String,
    pub total: u32,
    pub paid: bool,
}

pub fn unpaid_total(orders: &[Order]) -> u32 {
    orders.iter()
        .filter(|o| !o.paid)
        .map(|o| o.total)
        .sum()
}

pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
    orders.iter()
        .filter(|o| o.total >= threshold)
        .map(|o| o.customer.as_str())
        .collect()
}

fn main() {
    let orders = vec![
        Order { id: 1, customer: "An".into(),   total: 150_000, paid: true },
        Order { id: 2, customer: "Bình".into(), total: 80_000,  paid: false },
        Order { id: 3, customer: "Chi".into(),  total: 320_000, paid: false },
    ];

    println!("미수금: {}", unpaid_total(&orders));
    println!("큰 고객: {:?}", big_customers(&orders, 100_000));

    let summary = orders.iter()
        .fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
    println!("주문: {summary}");
}
미수금: 400000
큰 고객: ["An", "Chi"]
주문: #1 #2 #3 

예제의 각 단계:

  • filter(|o| !o.paid)는 bool을 반환하는 closure를 받습니다. 매개변수 o의 타입은 &&Order입니다. filter가 원소에 대한 참조를 전달하는데 iter()의 원소가 이미 &Order이기 때문입니다. 자동 역참조로 o.paid를 평상 방법으로 작성할 수 있습니다.
  • map(|o| o.total)은 각 주문을 금액으로 변환합니다. sum()이 모든 값을 더합니다.
  • collect()은 결과를 컬렉션으로 모읍니다. 대상 타입은 함수의 반환 타입 Vec<&str>에서 추론됩니다.
  • fold는 초기값과 closure (acc, 원소) -> acc_새로움을 받습니다. 이는 가장 일반적인 consumer입니다.

5.1. 게으른 평가

다음 예제는 adapter가 consumer에 의해서만 실행됨을 보여줍니다:

fn main() {
    let ids = vec![1, 2, 3];
    let doubled = ids.iter().map(|x| {
        println!("map {x}");
        x * 2
    });

    println!("아직 아무것도 실행되지 않음");
    let result: Vec<i32> = doubled.collect();
    println!("{result:?}");
}
아직 아무것도 실행되지 않음
map 1
map 2
map 3
[2, 4, 6]

게으른 평가 덕분에 filter → map → sum 체인은 데이터를 한 번만 순회하며 중간 컬렉션을 생성하지 않습니다. 컴파일러는 보통 이 파이프라인을 손으로 작성한 루프와 동등한 기계 코드로 최적화합니다. 이것이 iterator가 제로 오버헤드 추상화라고 불리는 이유입니다.

6. 직접 iterator 작성하기

next만 구현하면 됩니다. 다른 모든 adapter는 무료로 제공됩니다. 다음 예제는 HD-0001 형식의 송장 번호를 생성합니다:

pub struct InvoiceNumbers {
    current: u32,
    end: u32,
}

impl InvoiceNumbers {
    pub fn new(start: u32, end: u32) -> Self {
        Self { current: start, end }
    }
}

impl Iterator for InvoiceNumbers {
    type Item = String;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current > self.end {
            return None;
        }
        let number = format!("HD-{:04}", self.current);
        self.current += 1;
        Some(number)
    }
}

fn main() {
    let customers = ["An", "Bình", "Chi"];

    for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
        println!("{invoice} -> {customer}");
    }
}
HD-0007 -> An
HD-0008 -> Bình
HD-0009 -> Chi

설명:

  • type Item = String은 iterator가 반환하는 원소의 타입을 선언합니다.
  • next는 &mut self를 통해 상태를 업데이트하고 end를 초과하면 None을 반환합니다.
  • zip은 두 iterator를 쌍으로 연결하고 더 짧은 iterator가 끝나면 중지합니다. 따라서 숫자 범위가 100까지 이르더라도 세 줄만 출력됩니다.

7. 테스트 작성하기

Rust는 기본 제공 테스트 프레임워크를 가지고 있습니다. unpaid_total과 InvoiceNumbers를 포함하는 파일에 바로 테스트 모듈을 배치합니다:

#[cfg(test)]
mod tests {
    use super::*;

    fn order(id: u32, total: u32, paid: bool) -> Order {
        Order { id, customer: format!("고객{id}"), total, paid }
    }

    #[test]
    fn unpaid_total_only_counts_unpaid_orders() {
        let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
        assert_eq!(unpaid_total(&orders), 120);
    }

    #[test]
    fn unpaid_total_of_empty_slice_is_zero() {
        assert_eq!(unpaid_total(&[]), 0);
    }

    #[test]
    fn invoice_numbers_stop_after_end() {
        let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
        assert_eq!(numbers, vec!["HD-0009", "HD-0010", "HD-0011"]);
    }
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

두 번째 테스트는 경계 사례를 확인합니다. 빈 iterator에 대해 sum()은 0을 반환하며 panic이 발생하지 않습니다.

8. 흔한 오류와 iterator를 사용하지 말아야 할 때

주의: Consumer를 빠뜨리는 것이 가장 흔한 오류입니다. orders.iter().map(|o| println!("{}", o.id)); 줄은 아무것도 출력하지 않습니다. 컴파일러는 “iterators are lazy and do nothing unless consumed” 경고를 보냅니다. 부작용만 필요할 때는 for 루프나 for_each를 사용하세요.

기타 흔한 오류들:

  • collect가 대상 타입을 모를 때. let v = it.collect();라고 작성하면 “type annotations needed” 오류가 발생합니다. let v: Vec<_> = ...로 명시하거나 turbofish collect::<Vec<_>>()를 사용해야 합니다.
  • into_iter()를 사용한 후 컬렉션을 다시 사용하고 싶을 때. 읽기만 필요하면 iter()를 사용하세요.
  • Closure가 빌림을 너무 오래 유지할 때. FnMut인 closure가 &mut cart를 캡처하면 마지막으로 사용할 때까지 cart를 읽을 수 없습니다. Closure의 범위를 좁히거나 호출을 마친 후에 읽으세요.
  • Closure 내에서 ?가 필요할 때. Closure 내의 ? 연산자는 closure에서만 반환되고 외부 함수에서 반환되지 않습니다. collect::<Result<Vec<_>, _>>()를 사용하세요. 이 방식은 첫 오류에서 멈추고 Err을 반환합니다.

iterator 체인을 사용하지 말아야 할 때:

  • 로직이 많은 분기, 많은 break/continue, 또는 동시에 많은 변수를 업데이트해야 할 때. 명확한 for 루프가 10줄짜리 fold보다 읽기 쉽습니다.
  • 많은 분기에서 closure를 반환해야 하지만 성능을 걱정할 때. Box<dyn Fn>은 힙 할당 및 동적 호출 오버헤드가 있습니다. 대신 enum이나 generic을 고려하세요.

9. 결론

Rust의 closure는 변수를 가장 가벼운 방식으로 캡처합니다: 빌림, 가변 빌림, 또는 move. 이로부터 closure는 자동으로 Fn, FnMut, FnOnce를 구현합니다. Closure를 받는 함수를 작성할 때는 함수가 필요로 하는 가장 느슨한 특성을 선택하세요.

Iterator는 단 하나의 메서드 next에만 의존합니다. map, filter, zip 같은 adapter는 게으르고, collect, sum, fold 같은 consumer는 파이프라인을 실제로 실행합니다. 이 두 도구를 조합하면 데이터 처리 코드를 간결하고 메모리 안전하며 손으로 작성한 루프만큼 빠르게 작성할 수 있습니다. 또한 간단한 for 루프가 더 나은 선택일 때를 알아야 합니다.

rustiteratorclosure함수형 프로그래밍