엔드포인트 검사 결과에서 장애와 느린 응답만 골라 보고서를 만든다고 해 보겠습니다. 중간 단계마다 Vec을 만들 필요는 없습니다. iter()로 빌리고 filter와 map을 이어 붙인 뒤, 결과를 실제로 저장해야 할 때만 collect를 쓰면 됩니다. 한 번만 읽을 보고서는 fold로 바로 만들 수 있습니다.
여기서 먼저 결정할 것은 메서드 이름이 아니라 소유권이죠. 원본을 계속 쓸 것인지, 제자리에서 고칠 것인지, 파이프라인이 가져가도 되는지에 따라 iter, iter_mut, into_iter가 갈립니다. 클로저의 Fn, FnMut, FnOnce도 외울 분류표라기보다 캡처한 값을 어떻게 쓰는지 설명하는 계약으로 보면 이해하기 쉽습니다.
1. 반복자 예제
독립 실행 가능한 Rust 2024 크레이트는 examples/article-15-closures-iterators에 있습니다. 외부 크레이트와 공유 endpoint-monitor에는 의존하지 않습니다.
[package]
name = "article-15-closures-iterators"
version = "0.1.0"
edition = "2024"
publish = false
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
all = "warn"
pedantic = "warn"
다음 명령으로 전체 예제를 확인할 수 있습니다.
cd examples/article-15-closures-iterators
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
예제의 EndpointResult는 성공 결과의 HTTP 상태와 지연 시간, 실패 결과의 이유를 소유합니다. 이하 파이프라인은 이 타입의 슬라이스나 벡터를 입력으로 받습니다.
2. Fn 계열과 캡처 동작
클로저는 주변 값을 빌리거나 가변으로 빌리거나 소유할 수 있습니다. 어떤 호출 trait을 구현하는지는 그 캡처를 호출 본문에서 어떻게 다루는지에 따라 정해집니다.
pub fn call_twice<F>(mut operation: F, value: u64) -> (u64, u64)
where
F: FnMut(u64) -> u64,
{
(operation(value), operation(value))
}
pub fn visit_alerts<F>(results: &[EndpointResult], slow_ms: u64, mut visitor: F)
where
F: FnMut(&EndpointResult),
{
for result in results
.iter()
.filter(|result| result.needs_attention(slow_ms))
{
visitor(result);
}
}
pub fn finish_report<F>(finish: F) -> String
where
F: FnOnce() -> String,
{
finish()
}
call_twice는 같은 클로저를 두 번 호출하므로 반복 호출을 허용하는 가장 약한 충분 조건인 FnMut을 요구합니다. 예제는 캡처한 호출 카운터를 증가시키고 두 호출에서 서로 다른 값을 반환하므로 이 API가 실제로 상태를 바꾸는 클로저를 받는다는 점도 확인합니다. Fn을 구현한 클로저 역시 FnMut 경계를 만족합니다.
visit_alerts의 방문자는 호출될 때마다 외부 카운터를 바꿀 수 있습니다. 그래서 매개변수를 mut로 받고 FnMut을 요구합니다. Fn을 구현한 클로저도 FnMut 자리에 쓸 수 있지만 반대는 항상 가능하지 않습니다.
finish_report는 클로저를 한 번만 쓰므로 가장 넓은 FnOnce 경계를 제공합니다. 예제의 move || prefix + &count.to_string()은 String 덧셈 과정에서 캡처한 prefix를 소비합니다. 이 클로저는 두 번째 호출에 쓸 값을 남기지 않으므로 FnOnce에 맞습니다. move를 붙였다는 사실만으로 무조건 FnOnce가 되는 것은 아닙니다. 소유권을 옮겨 캡처했더라도 본문이 그 값을 읽기만 하면 더 강한 호출 trait도 구현할 수 있습니다.
API를 설계할 때는 필요한 것보다 강한 경계를 요구하지 않는 편이 낫습니다. 한 번만 호출할 콜백에 Fn을 요구하면, 캡처를 소비해야 하는 유효한 클로저를 괜히 배제합니다.
3. 이터레이터 어댑터의 지연 실행
map, filter, filter_map 같은 어댑터는 새 이터레이터를 만듭니다. 어댑터를 연결했다는 이유만으로 입력 전체를 즉시 순회하지 않습니다. next, collect, fold, reduce 같은 소비자가 항목을 요청할 때 파이프라인이 진행됩니다.
#[test]
fn adapters_are_lazy_until_a_consumer_asks_for_items() {
let inspected = Cell::new(0);
let values = [10, 20, 30];
let mut doubled = values.iter().map(|value| {
inspected.set(inspected.get() + 1);
value * 2
});
assert_eq!(inspected.get(), 0);
assert_eq!(doubled.next(), Some(20));
assert_eq!(inspected.get(), 1);
}
doubled을 만든 직후 inspected는 0입니다. 첫 next()가 호출된 뒤에야 첫 항목을 읽고 1이 됩니다. 이 지연성 덕분에 어댑터 사이에 결과 컬렉션을 자동으로 만들지 않고 필요한 항목까지만 처리할 수 있습니다. 다만 “이터레이터니까 언제나 빠르다”는 결론은 나오지 않습니다. 실제 비용은 클로저 본문, 입력, 최적화 프로필에 따라 측정해야 합니다.
어댑터와 소비자를 구분하면 코드도 읽기 쉬워집니다. filter는 항목을 통과시킬지 정하고 map은 항목의 형태를 바꿉니다. collect는 새 컬렉션을 만들며 fold는 명시한 초기값에 항목을 누적합니다. reduce는 첫 항목을 초기 누적값으로 삼기 때문에 빈 입력에서는 None을 반환합니다.
4. iter 계열과 소유권
세 메서드는 비슷하게 보이지만 만들어 내는 항목의 소유권이 다릅니다.
| 선택 | 대표 항목 | 원본을 이후에 사용 | 적합한 작업 |
|---|---|---|---|
iter() |
&T |
가능 | 조회, 필터링, 빌린 뷰 반환 |
iter_mut() |
&mut T |
이터레이터가 끝난 뒤 가능 | 원소를 제자리에서 수정 |
into_iter() on Vec<T> |
T |
벡터는 소비됨 | 필드 이동, 소유 결과로 변환 |
경고 대상 이름만 잠시 읽는 함수는 iter()가 맞습니다. 반환 항목도 &str이므로 이름을 복제하지 않습니다.
pub fn alert_names(
results: &[EndpointResult],
slow_ms: u64,
) -> impl Iterator<Item = &str> {
results
.iter()
.filter(move |result| result.needs_attention(slow_ms))
.map(EndpointResult::name)
}
pub fn collect_alert_names(results: &[EndpointResult], slow_ms: u64) -> Vec<&str> {
alert_names(results, slow_ms).collect()
}
alert_names는 이터레이터를 그대로 반환합니다. 호출자가 한 번 순회할 뿐이라면 별도 Vec이 없습니다. collect_alert_names는 경고 이름을 인덱싱하거나 여러 번 순회해야 하는 호출자를 위한 선택입니다. 이 경우 Vec의 버퍼 할당은 반환 계약의 일부지만 문자열 자체는 여전히 빌립니다.
재시도 오버헤드를 기존 성공 결과에 더하는 함수는 iter_mut()로 각 항목의 가변 참조를 받습니다. 반대로 결과 벡터가 더 이상 필요 없고 그 안의 String 이름만 보관할 때는 into_iter()가 맞습니다. 벡터를 소비하면 각 이름을 새로 복제하지 않고 밖으로 이동할 수 있습니다.
pub fn add_retry_overhead(results: &mut [EndpointResult], overhead_ms: u64) {
for result in results.iter_mut() {
if let Outcome::Success { latency_ms, .. } = &mut result.outcome {
*latency_ms += overhead_ms;
}
}
}
pub fn into_endpoint_names(results: Vec<EndpointResult>) -> Vec<String> {
results
.into_iter()
.map(|result| result.name)
.collect()
}
여기서 마지막 collect는 필요합니다. 함수가 소유한 이름 컬렉션을 반환하기 때문입니다. 줄여야 할 것은 모든 할당이 아니라 의미 없는 중간 할당입니다.
5. 엔드포인트 결과를 보고서로 접기
한 번 출력할 경고 보고서라면 먼저 Vec<String>을 만들고 다시 합칠 이유가 없습니다. 다음 코드는 결과를 빌려 경고만 통과시킨 뒤 하나의 String 누적값에 각 줄을 씁니다.
pub fn alert_report(results: &[EndpointResult], slow_ms: u64) -> String {
results
.iter()
.filter(|result| result.needs_attention(slow_ms))
.fold(String::new(), |mut report, result| {
result.write_alert_line(&mut report);
report
})
}
pub fn average_success_latency(results: &[EndpointResult]) -> Option<u64> {
let (total, count) = results
.iter()
.filter_map(EndpointResult::latency_ms)
.fold((0_u64, 0_u64), |(total, count), latency| {
(total + latency, count + 1)
});
(count > 0).then_some(total / count)
}
pub fn slowest_success(results: &[EndpointResult]) -> Option<u64> {
results
.iter()
.filter_map(EndpointResult::latency_ms)
.reduce(u64::max)
}
alert_report는 출력 문자열 하나를 만듭니다. 문자열이 커지면서 내부 버퍼가 재할당될 가능성은 있지만 이 예제는 할당 횟수나 실행 시간을 측정하지 않았습니다. 확인할 수 있는 범위는 중간 Vec<String>을 만들지 않는다는 점입니다. 보고서 길이를 미리 신뢰성 있게 계산할 수 있다면 String::with_capacity를 검토할 수 있지만 근거 없는 용량 추정은 메모리를 남길 뿐입니다.
평균은 (합계, 개수)를 초기값으로 주어야 하므로 fold가 자연스럽습니다. 가장 느린 성공 응답은 첫 지연 시간을 기준으로 비교할 수 있어 reduce가 간결합니다. 성공 결과가 하나도 없을 때 reduce는 None이며 평균 함수도 0으로 나누는 대신 None을 반환합니다.
파이프라인이 길어졌다고 무조건 한 줄로 밀어 넣을 필요는 없습니다. 각 단계가 명확한 변환이면 체인이 잘 읽힙니다. 조건이 상태를 많이 공유하거나 오류 처리가 복잡해진다면 이름 있는 함수나 평범한 for 루프가 더 낫습니다.
6. iter 검증
Rust 2024 에디션, stable rustc 1.98.1, Cargo 1.98.1에서 포맷, 전체 타깃 검사, Clippy 경고 거부, 테스트, 실행이 모두 통과해야 합니다. 외부 의존성은 없습니다.
첫 번째 테스트 실행에는 7개 테스트가 있습니다. 바이너리와 문서 테스트에는 각각 0개 테스트가 있습니다.
running 7 tests
test tests::adapters_are_lazy_until_a_consumer_asks_for_items ... ok
test tests::closure_bounds_match_capture_behavior ... ok
test tests::filters_maps_and_collects_alert_names ... ok
test tests::fold_and_reduce_handle_successful_latencies ... ok
test tests::fold_builds_one_report_buffer ... ok
test tests::into_iter_moves_owned_names_without_cloning ... ok
test tests::iter_mut_updates_values_in_place ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
cargo run --quiet의 표준 출력은 다음과 같습니다.
alerts:
auth: timeout
search: HTTP 200 in 720 ms
average successful latency: 340 ms
summary: 2 alerts
adjusted samples: (125, 125)
클로저 trait은 캡처를 어떻게 쓰는지 드러내고 이터레이터 메서드는 원본의 소유권을 어떻게 다룰지 드러냅니다. 이 두 계약을 먼저 정하면 filter, map, fold, collect는 장식이 아니라 데이터가 이동하는 경로가 됩니다. 이 예제에서 확인한 구조적 결과는 컬렉션이 실제 결과일 때만 수집해 불필요한 중간 컬렉션을 만들지 않는다는 점입니다. 성능이나 할당 개선을 측정한 최적화 결과는 아닙니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 15 프로젝트에서 확인할 수 있습니다.
답글 남기기