Rust 실전 강좌 #4 이터레이터 실전: 집계와 top-N, 그리고 release 빌드의 차이
3편에서 한 줄을 LogEntry로 바꿨으니, 이번에는 수백만 줄을 통계로 바꿉니다. 기초 7편에서 배운 이터레이터 체인과 HashMap이 실전에 투입되는 편이고, 마지막에는 이 시리즈에서 처음으로 스톱워치를 꺼냅니다.
stats: 실패도 세는 집계 #
stats 커맨드의 요구 사항을 먼저 정합니다. 총 라인 수, 파싱 성공 수, 파싱 실패 수, 그리고 상태 코드 분포. errors 커맨드(3편)와 달리 실패를 버리지 않고 세야 하므로, 집계 구조체를 하나 만들고 줄 단위로 흘려 넣습니다.
use std::collections::HashMap;
#[derive(Default)]
struct Stats {
total: u64,
parsed: u64,
failed: u64,
by_status: HashMap<u16, u64>,
bytes_sum: u64,
}
impl Stats {
fn feed(&mut self, line: &str) {
self.total += 1;
match parse_line(line) {
Ok(entry) => {
self.parsed += 1;
*self.by_status.entry(entry.status).or_insert(0) += 1;
self.bytes_sum += entry.bytes;
}
Err(ParseError::Empty) => {} // 빈 라인은 실패로 치지 않는다
Err(_) => self.failed += 1,
}
}
}2편에서 “빈 라인과 형식 오류는 다르게 취급하고 싶다"고 했던 요구가 여기서 실현됩니다. ParseError가 열거형이라서 match로 종류별 정책을 선언할 수 있고, 빈 라인은 total에만 잡히고 failed에서는 빠집니다. anyhow의 불투명한 에러였다면 이 분기는 문자열 비교라는 최악의 방법밖에 없었을 것입니다. #[derive(Default)]는 모든 필드를 0과 빈 맵으로 초기화하는 Stats::default()를 만들어 주는 파생입니다.
커맨드 본체는 짧습니다.
fn cmd_stats(path: &Path) -> anyhow::Result<()> {
let reader = BufReader::new(open_log(path)?);
let mut stats = Stats::default();
for line in reader.lines() {
stats.feed(&line?);
}
println!("총 라인: {}", stats.total);
println!("파싱 성공: {}", stats.parsed);
println!("파싱 실패: {}", stats.failed);
println!("전송 합계: {} bytes", stats.bytes_sum);
let mut statuses: Vec<_> = stats.by_status.iter().collect();
statuses.sort();
for (status, count) in statuses {
println!(" {status}: {count}");
}
Ok(())
}출력 직전의 두 줄이 실무 관용구입니다. HashMap은 순서가 없으므로, 사람에게 보여 줄 때는 Vec으로 모아 정렬합니다. 실행할 때마다 순서가 바뀌는 출력은 도구의 신뢰를 깎아 먹고, 6편에서 만들 테스트도 불안정하게 만듭니다.
top: 정렬로 뽑는 상위 N #
fn cmd_top(path: &Path, count: usize) -> anyhow::Result<()> {
let reader = BufReader::new(open_log(path)?);
let mut by_path: HashMap<String, u64> = HashMap::new();
for line in reader.lines() {
if let Ok(entry) = parse_line(&line?) {
*by_path.entry(entry.path).or_insert(0) += 1;
}
}
let mut ranked: Vec<_> = by_path.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1)); // 요청 수 내림차순
for (path, hits) in ranked.into_iter().take(count) {
println!("{hits:>8} {path}");
}
Ok(())
}집계는 stats와 같은 entry 관용구이고, 새로 등장한 것은 마무리 세 줄입니다. into_iter()로 맵의 소유권을 가져와(기초 7편의 구분) Vec으로 모으고, sort_by에 내림차순 비교를 넘기고, take(count)로 상위 N개만 자릅니다. clap이 파싱해 준 --count 값이 take에 그대로 꽂히는 것으로 1편의 선언이 4편의 로직과 만납니다. 수백만 경로쯤은 정렬로 충분하고, 그보다 커지면 힙(BinaryHeap)으로 바꾸는 선택지가 있다는 것 정도를 기억해 두면 됩니다.
스톱워치: debug와 release의 거리 #
도구가 동작하니 이제 속도를 재 봅니다. 계측 코드는 표준 라이브러리로 충분합니다.
let start = std::time::Instant::now();
// ... 집계 실행 ...
eprintln!("소요: {:?}", start.elapsed());println!이 아니라 eprintln!(표준 에러 출력)인 것에 주목해 주세요. 계측치는 분석 결과가 아니므로, loglens stats access.log > result.txt로 리다이렉트하는 사용자의 파일에 섞여 들어가면 안 됩니다. 표준 출력은 데이터, 표준 에러는 부가 정보라는 분리도 CLI의 오랜 관례입니다.
수백만 줄짜리 로그로 두 빌드를 비교하면 격차가 선명합니다.
cargo run -- stats big.log # debug 빌드
cargo run --release -- stats big.log # release 빌드환경에 따라 다르지만 release가 수 배에서 수십 배 빠른 것이 보통입니다. debug 빌드는 컴파일 속도와 디버깅 편의를 위해 최적화를 끄고, 기초 2편에서 본 오버플로 검사 같은 안전장치도 켭니다. 이터레이터 체인이 손으로 쓴 반복문과 대등해지는 것(기초 7편)도 최적화가 켜진 release 기준의 이야기입니다. 그래서 규칙은 단순합니다. 개발 반복은 debug로, 성능 측정과 배포는 반드시 release로. debug 빌드를 보고 “Rust가 느리다"는 결론을 내리는 것이 입문 단계에서 가장 흔한 측정 실수입니다.
정리 #
- 집계 구조체
Stats에 줄을 흘려 넣는 구조입니다.ParseError가 열거형이라서 빈 라인과 형식 오류의 정책을match로 선언할 수 있습니다. HashMap출력 전에는Vec으로 모아 정렬합니다. 실행마다 순서가 바뀌는 출력은 도구의 신뢰와 테스트 안정성을 해칩니다.- top-N은
into_iter→sort_by내림차순 →take(count)가 기본형입니다. clap의 인자가 로직에 그대로 연결됩니다. - 계측치는
eprintln!으로 냅니다. 표준 출력은 데이터, 표준 에러는 부가 정보라는 분리가 리다이렉트 사용자를 지킵니다. - 성능 측정과 배포는 반드시 release 빌드입니다. debug와의 격차는 수 배에서 수십 배로, debug로 잰 수치는 결론의 근거가 될 수 없습니다.
- 다음 편은 병렬화입니다. rayon으로 집계를 여러 코어에 나누고, 소유권이 데이터 레이스를 어떻게 컴파일 타임에 막는지 확인하겠습니다.