종합 실습 - 명령줄 가계부 도구
이 장에서 배우는 것
이 책의 마지막 장이다. 앞 장까지 다룬 소유권, 빌림, 구조체와 열거형, Option과 Result, 컬렉션, 모듈과 테스트를 한 프로그램에 모아 쓴다. 만들 것은 텍스트 파일에 적어 둔 수입·지출 기록을 읽어 합계와 분류별 집계를 출력하는 명령줄 가계부 도구다. 새 문법은 거의 없다. 이미 아는 도구를 어떤 순서로 이어 붙이는지가 이 장의 주제다.
std::env::args로 명령줄 인자를 받고, 인자가 잘못되었을 때 종료 코드로 알린다.std::fs::read_to_string으로 파일을 읽고 실패를Result로 처리한다.- 한 줄을 구조체로 바꾸는 파서를 쓰고, 틀린 줄은 줄 번호와 이유를 붙여 모아서 보고한다.
HashMap으로 분류별 합계를 내고, 출력 전에 정렬해 결과가 항상 같게 한다.map,filter,fold를 이어 붙여 반복문 없이 합계를 계산한다.
문제 상황
한 달 동안 쓴 돈을 메모장에 한 줄씩 적어 왔다고 하자. 월말에 "식비를 얼마나 썼는지", "남은 돈이 얼마인지"를 알고 싶다. 스프레드시트에 옮겨 붙이면 되지만, 매달 같은 작업을 반복하게 된다. 프로그램으로 만들면 파일 하나를 넘기는 것으로 끝난다.
손으로 적은 파일에는 실수가 섞이기 마련이다. 금액 자리에 "만오천"이라고 적거나, 필드 하나를 빼먹거나, 종류를 "저축"처럼 정해 두지 않은 말로 적는다. 이런 줄 하나 때문에 프로그램이 멈추면 쓸모가 없다. 그렇다고 조용히 무시하면 합계가 틀렸다는 것을 알 길이 없다. 그래서 이 도구는 다음 원칙을 따른다.
- 읽을 수 있는 줄은 모두 집계한다.
- 읽을 수 없는 줄은 몇 번째 줄인지와 이유를 끝에 모아서 보여 준다.
- 파일 자체를 열 수 없거나 인자가 없으면 이유를 알리고 종료 코드를 0이 아닌 값으로 끝낸다.
입력 형식은 쉼표로 나눈 네 칸이다. 빈 줄과 #로 시작하는 줄은 주석으로 보고 건너뛴다.
날짜,종류,분류,금액
2026-09-03,지출,식비,12500
전체 흐름은 그림 1과 같다. 파일을 문자열 하나로 읽고, 줄 단위로 나눠 한 줄씩 parse_line에 넘긴다. 결과는 Ok면 기록 목록으로, Err면 오류 목록으로 갈라진다.
명령줄 인자와 파일 읽기
프로그램이 실행될 때 받은 인자는 std::env::args()가 반복자로 돌려준다. 첫 번째 값은 프로그램 자신의 경로이고, 사용자가 넘긴 인자는 두 번째부터다. 그래서 skip(1)로 첫 값을 건너뛴다. cargo run에서는 -- 뒤에 쓴 것이 프로그램의 인자가 된다. -- 앞의 옵션은 cargo 가 가져간다.
cargo run -- ledger.txt
인자를 배열처럼 args[1]로 꺼내면 인자가 없을 때 프로그램이 패닉(panic, 복구하지 않고 즉시 중단되는 상태)으로 끝난다. 여기서는 next()를 두 번 불러 (Some(경로), None) 모양일 때만 통과시킨다. 인자가 없거나 두 개 이상이면 사용법을 알리고 종료한다. 사용법 메시지는 표준 오류(stderr)로 보내는 eprintln!을 쓴다. 정상 출력과 오류 메시지를 섞지 않으려는 것이다.
파일은 fs::read_to_string(경로)로 읽는다. 반환 타입은 Result<String, std::io::Error>다. 파일이 없거나 권한이 없으면 Err이고, io::Error는 {}로 출력할 수 있어 운영체제가 준 설명이 그대로 나온다. 성공하면 파일 전체가 소유권을 가진 String 하나로 돌아온다. 이 문자열이 main 안에서 끝까지 살아 있으므로, 이후 단계에서는 &str로 빌려 쓰기만 하면 된다.
줄 파싱과 오류 보고
기록을 나타내는 타입
한 줄은 Entry 구조체가 된다. 종류는 문자열 대신 열거형 Kind로 둔다. 그러면 "수입"이나 "지출" 외의 값은 파서 단계에서 걸러지고, 이후 코드는 두 경우만 생각하면 된다. 날짜와 분류는 파일에서 읽은 글자를 to_string()으로 복사해 Entry가 직접 소유하게 한다. 원본 문자열을 빌려 두는 방식도 가능하지만, 그러려면 Entry에 수명 표기가 필요하다. 가계부 규모에서는 복사 비용이 문제되지 않으니 단순한 쪽을 택한다.
한 줄을 읽는 함수
parse_line은 Result<Entry, String>을 돌려준다. 실패 이유가 사람이 읽을 문장이면 충분하므로 오류 타입은 String이다. 검사는 다음 순서로 한다. 앞의 검사에서 걸리면 뒤는 보지 않는다.
| 순서 | 검사 | 실패 예 | 메시지 요지 |
|---|---|---|---|
| 1 | 필드 개수가 4개인가 | 2026-09-30,지출,식비 | 필드는 4개여야 하는데 3개다 |
| 2 | 날짜가 10글자인가 | 9/3,지출,식비,100 | 날짜 형식 안내 |
| 3 | 종류가 수입 또는 지출인가 | 저축 | 알 수 없는 종류 |
| 4 | 분류가 비어 있지 않은가 | 2026-09-03,지출,,100 | 분류가 비어 있다 |
| 5 | 금액이 0보다 큰 정수인가 | 만오천, -9000 | 정수가 아니다 / 0보다 커야 한다 |
금액을 읽는 부분이 이 장에서 눈여겨볼 곳이다. parse()는 Result<i64, ParseIntError>를 돌려주는데, 함수의 오류 타입은 String이다. 둘이 다르면 ?가 그대로 넘겨 줄 수 없으므로 map_err로 오류를 문장으로 바꾼 뒤 ?를 붙인다. 성공이면 값을 꺼내 계속 진행하고, 실패면 함수가 그 자리에서 Err를 돌려주며 끝난다.
여러 줄을 모으는 함수
parse_ledger는 전체 텍스트를 받아 (Vec<Entry>, Vec<String>) 튜플을 돌려준다. lines().enumerate()로 줄 번호를 붙이고, match로 Ok는 기록 목록에, Err는 오류 목록에 넣는다. enumerate는 0부터 세므로 사람에게 보여 줄 때는 + 1을 한다. 파싱 실패에도 반복이 계속되므로 틀린 줄이 몇 개든 한 번의 실행에서 전부 볼 수 있다.
분류별 집계와 반복자 조합
HashMap 으로 합계 내기
분류별 합계는 HashMap<&str, i64>에 쌓는다. entry(키).or_insert(0)는 키가 없으면 0을 넣고, 어느 쪽이든 값에 대한 가변 참조를 돌려준다. 그 앞에 *를 붙여 참조를 따라가 더하면 "있으면 누적, 없으면 새로 시작"이 한 줄이 된다. 키를 &str로 둔 것은 entries가 가진 문자열을 빌려 쓰는 것이다. 복사가 없다. 대신 sums는 entries보다 오래 살 수 없다. 함수 안에서만 쓰고 결과는 String으로 바꿔 내보내니 이 조건이 자연스럽게 지켜진다.
HashMap은 순회 순서를 보장하지 않으며, 실행할 때마다 달라질 수 있다. 그래서 Vec<(String, i64)>로 옮긴 뒤 정렬한다. 금액이 큰 순으로, 금액이 같으면 분류 이름순으로 정하면 출력이 항상 같다. cmp 결과에 then_with를 이으면 "첫 기준이 같을 때만 둘째 기준을 본다"가 된다.
map · filter · fold
종류별 합계는 세 단계의 반복자 조합이다. 반복자는 값을 하나씩 흘려 보내는 관이라고 생각하면 된다. 각 단계는 이름대로 일한다.
| 메서드 | 하는 일 | 이 프로그램에서 | 단계 뒤의 개수 |
|---|---|---|---|
filter | 조건이 참인 값만 통과시킨다 | 종류가 같은 기록만 남긴다 | 줄어들 수 있다 |
map | 각 값을 다른 값으로 바꾼다 | 기록에서 금액만 꺼낸다 | 같다 |
fold | 초깃값에서 시작해 값을 하나로 합친다 | 0에서 시작해 금액을 더한다 | 하나가 된다 |
반복자는 게으르다(lazy). filter와 map은 호출한다고 바로 일하지 않고, fold처럼 값을 끝까지 소비하는 메서드가 요청할 때 비로소 값이 흐른다. 이 성질은 뒤의 "자주 틀리는 것"에서 경고로 나타난다.
한 가지 주의할 점이 있다. fold(0, |acc, a| acc + a)에서 acc의 타입은 초깃값 0이 아니라 a가 i64라는 사실에서 정해진다. 덧셈 연산자는 이 추론을 도와주지만, acc.max(a)처럼 타입이 아직 정해지지 않은 숫자에 메서드를 부르면 컴파일러가 "모호한 숫자 타입"이라고 오류를 낸다. 연습 문제 2에서 이 경우를 피하는 방법을 쓴다.
완성 코드
cargo new ledger로 만든 프로젝트의 src/main.rs 하나에 모두 들어간다. 맨 아래의 테스트 모듈은 cargo test에서만 컴파일되므로 cargo run에는 영향이 없다.
use std::collections::HashMap;
use std::env;
use std::fs;
use std::process;
#[derive(PartialEq)]
enum Kind {
Income,
Expense,
}
struct Entry {
date: String,
kind: Kind,
category: String,
amount: i64,
}
fn parse_line(line: &str) -> Result<Entry, String> {
let fields: Vec<&str> = line.split(',').map(|f| f.trim()).collect();
if fields.len() != 4 {
return Err(format!("필드는 4개여야 하는데 {}개다", fields.len()));
}
if fields[0].len() != 10 {
return Err(format!("날짜는 YYYY-MM-DD 형식이어야 한다: '{}'", fields[0]));
}
let kind = match fields[1] {
"수입" => Kind::Income,
"지출" => Kind::Expense,
other => return Err(format!("알 수 없는 종류 '{}'", other)),
};
if fields[2].is_empty() {
return Err(String::from("분류가 비어 있다"));
}
let amount: i64 = fields[3]
.parse()
.map_err(|_| format!("금액 '{}'을(를) 정수로 읽을 수 없다", fields[3]))?;
if amount <= 0 {
return Err(format!("금액은 0보다 커야 한다: {}", amount));
}
Ok(Entry {
date: fields[0].to_string(),
kind,
category: fields[2].to_string(),
amount,
})
}
fn parse_ledger(text: &str) -> (Vec<Entry>, Vec<String>) {
let mut entries = Vec::new();
let mut errors = Vec::new();
for (idx, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
match parse_line(line) {
Ok(entry) => entries.push(entry),
Err(msg) => errors.push(format!("{}번째 줄: {}", idx + 1, msg)),
}
}
(entries, errors)
}
fn total(entries: &[Entry], kind: Kind) -> i64 {
entries
.iter()
.filter(|e| e.kind == kind)
.map(|e| e.amount)
.fold(0, |acc, a| acc + a)
}
fn by_category(entries: &[Entry], kind: Kind) -> Vec<(String, i64)> {
let mut sums: HashMap<&str, i64> = HashMap::new();
for e in entries.iter().filter(|e| e.kind == kind) {
*sums.entry(e.category.as_str()).or_insert(0) += e.amount;
}
let mut rows: Vec<(String, i64)> = sums
.into_iter()
.map(|(name, sum)| (name.to_string(), sum))
.collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
rows
}
fn won(n: i64) -> String {
let digits = n.abs().to_string();
let mut out = String::new();
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
out.push(',');
}
out.push(ch);
}
if n < 0 {
format!("-{}원", out)
} else {
format!("{}원", out)
}
}
fn print_section(title: &str, rows: &[(String, i64)]) {
println!();
println!("[{}]", title);
for (name, sum) in rows {
println!("- {}: {}", name, won(*sum));
}
}
fn report_errors(errors: &[String]) {
if errors.is_empty() {
return;
}
println!();
println!("[건너뛴 줄]");
for msg in errors {
println!("- {}", msg);
}
}
fn main() {
let mut args = env::args().skip(1);
let path = match (args.next(), args.next()) {
(Some(p), None) => p,
_ => {
eprintln!("사용법: cargo run -- <가계부 파일>");
process::exit(2);
}
};
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
eprintln!("{} 을(를) 읽을 수 없다: {}", path, e);
process::exit(1);
}
};
let (entries, errors) = parse_ledger(&text);
println!("파일: {}", path);
println!("기록 {}건, 건너뛴 줄 {}개", entries.len(), errors.len());
let first = entries.iter().map(|e| e.date.as_str()).min();
let last = entries.iter().map(|e| e.date.as_str()).max();
if let (Some(f), Some(l)) = (first, last) {
println!("기간: {} ~ {}", f, l);
}
let income = total(&entries, Kind::Income);
let expense = total(&entries, Kind::Expense);
println!("수입 합계: {}", won(income));
println!("지출 합계: {}", won(expense));
println!("잔액: {}", won(income - expense));
print_section("지출 분류별", &by_category(&entries, Kind::Expense));
print_section("수입 분류별", &by_category(&entries, Kind::Income));
report_errors(&errors);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_line() {
let e = parse_line("2026-09-03,지출,식비,12500").unwrap();
assert!(e.kind == Kind::Expense);
assert_eq!(e.category, "식비");
assert_eq!(e.amount, 12500);
}
#[test]
fn rejects_bad_amount() {
assert!(parse_line("2026-09-03,지출,식비,abc").is_err());
}
}
줄별 해설
#[derive(PartialEq)]는Kind값끼리==로 비교할 수 있게 한다.total과by_category의filter가 이 비교를 쓴다.line.split(',').map(|f| f.trim()).collect()는 쉼표로 나눈 조각마다 앞뒤 공백을 지운다. 결과는line을 빌린&str조각의Vec이라 새 문자열을 만들지 않는다.match fields[1]의 마지막 갈래other => return Err(...)는 나머지 모든 값을 받는다.return은 값이 없는 식이라 다른 갈래의Kind타입과 충돌하지 않는다.let amount: i64 = ....parse().map_err(...)?;에서 타입 표기가parse의 목표 타입을 정한다. 성공하면i64값이amount에 들어간다.Ok(Entry { ..., kind, ... })의kind,는 같은 이름의 변수로 필드를 채우는 줄임 표기다.parse_ledger의let line = line.trim();은 같은 이름으로 새 변수를 만드는 가리기(shadowing)다. 이후에는 공백이 지워진 줄만 본다.total과by_category는&[Entry]를 받는다. 기록 목록의 소유권은main이 갖고, 집계 함수들은 빌려서 읽기만 하므로 같은 목록으로 여러 번 집계할 수 있다.kind: Kind는 값으로 받지만 비교만 하므로 함수 안에서 소비되지 않는다.rows.sort_by(...)에서b.1.cmp(&a.1)은 b 와 a 의 자리를 바꿔 비교하므로 내림차순이 된다. 튜플의.1은 금액,.0은 분류 이름이다.won은 뒤에서부터 세 자리마다 쉼표를 넣는다. 전체 자릿수에서 현재 위치를 뺀 값이 3의 배수이면 그 글자 앞에 쉼표를 붙인다. 앞자리(i > 0)에는 붙이지 않는다.print_section의for (name, sum) in rows는&(String, i64)를 순회한다. 패턴이 참조를 자동으로 풀어name은&String,sum은&i64가 되므로 금액은*sum으로 꺼낸다.main의process::exit는 값을 돌려주지 않는 함수라서match의 다른 갈래 타입과 맞는다. 종료 코드 2는 사용법 오류, 1은 파일 오류로 구분했다.entries.iter().map(|e| e.date.as_str()).min()은 날짜 문자열의 최솟값이다.YYYY-MM-DD형식은 글자순 비교가 곧 날짜순이라 따로 날짜 타입이 필요 없다. 기록이 하나도 없으면None이므로if let으로 둘 다 있을 때만 출력한다.#[cfg(test)]모듈의use super::*;는 바깥 함수와 타입을 가져온다. 테스트의unwrap은Err이면 패닉하므로 테스트 실패로 이어진다.
실행 결과
먼저 입력 파일을 만든다. 일부러 틀린 줄 네 개를 넣었다. 1번째 줄은 주석, 13번째 줄은 필드가 하나 모자라다.
$ cargo new ledger
$ cd ledger
$ cat > ledger.txt <<'EOF'
# 2026년 9월 가계부
2026-09-01,수입,급여,3200000
2026-09-03,지출,식비,12500
2026-09-05,지출,교통,48000
2026-09-09,지출,식비,33000
2026-09-12,수입,부수입,50000
2026-09-15,지출,월세,650000
2026-09-18,지출,식비,만오천
2026-09-20,지출,문화,-9000
2026-09-21,저축,적금,300000
2026-09-24,지출,교통,1450
2026-09-28,지출,문화,22000
2026-09-30,지출,식비
EOF
src/main.rs를 위의 완성 코드로 바꾼 뒤 실행한다. -q는 cargo 자신의 빌드 메시지를 숨기는 옵션이다.
$ cargo run -q -- ledger.txt
파일: ledger.txt
기록 8건, 건너뛴 줄 4개
기간: 2026-09-01 ~ 2026-09-28
수입 합계: 3,250,000원
지출 합계: 766,950원
잔액: 2,483,050원
[지출 분류별]
- 월세: 650,000원
- 교통: 49,450원
- 식비: 45,500원
- 문화: 22,000원
[수입 분류별]
- 급여: 3,200,000원
- 부수입: 50,000원
[건너뛴 줄]
- 8번째 줄: 금액 '만오천'을(를) 정수로 읽을 수 없다
- 9번째 줄: 금액은 0보다 커야 한다: -9000
- 10번째 줄: 알 수 없는 종류 '저축'
- 13번째 줄: 필드는 4개여야 하는데 3개다
파일 자체에 문제가 있을 때는 표준 오류로 이유가 나오고, 프로그램은 종료 코드 1 또는 2로 끝난다.
$ cargo run -q -- nofile.txt
nofile.txt 을(를) 읽을 수 없다: No such file or directory (os error 2)
$ cargo run -q
사용법: cargo run -- <가계부 파일>
cargo test를 실행하면 두 테스트가 통과한다.
실무에서 자주 틀리는 것
인자를 인덱스로 바로 꺼낸다
틀린 코드는 다음과 같다.
let args: Vec<String> = std::env::args().collect();
let path = &args[1];
인자 없이 실행하면 "index out of bounds: the len is 1 but the index is 1" 패닉이 난다. 사용자에게 필요한 것은 사용법 안내인데, 스택 추적 같은 내부 메시지가 나온다. 인자가 없을 수 있다는 사실을 Option으로 받아 처리한다.
let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
eprintln!("사용법: cargo run -- <가계부 파일>");
std::process::exit(2);
}
};
파싱 결과에 unwrap 을 쓴다
틀린 코드는 다음과 같다.
let amount: i64 = fields[3].parse().unwrap();
금액이 "만오천"인 줄을 만나면 "called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }"라는 패닉으로 전체가 멈춘다. 앞에서 읽은 기록도 모두 사라진다. 사람이 쓴 입력은 틀릴 수 있다고 전제하고 Result로 돌려준다.
let amount: i64 = fields[3]
.parse()
.map_err(|_| format!("금액 '{}'을(를) 정수로 읽을 수 없다", fields[3]))?;
HashMap 을 정렬 없이 출력한다
틀린 코드는 다음과 같다.
for (name, sum) in &sums {
println!("- {}: {}", name, won(*sum));
}
컴파일은 되지만 실행할 때마다 분류 순서가 달라질 수 있다. 출력을 파일에 저장해 비교하거나 테스트에 쓰려 할 때 원인 찾기 어려운 차이로 나타난다. Vec으로 옮겨 기준을 정해 정렬한 뒤 출력한다.
let mut rows: Vec<(&&str, &i64)> = sums.iter().collect();
rows.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
for (name, sum) in rows {
println!("- {}: {}", name, won(*sum));
}
반복자를 만들기만 하고 소비하지 않는다
틀린 코드는 다음과 같다.
entries.iter().map(|e| println!("{}", e.category));
컴파일러는 "unused `Map` that must be used"라는 경고를 내고, 반복자는 게으르므로 아무것도 출력되지 않는다. 부수 효과만 필요하면 for 반복문을 쓴다. 값을 만드는 조합이라면 fold, collect, count 같은 소비 메서드로 끝낸다.
for e in &entries {
println!("{}", e.category);
}
한눈에 보기
| 도구 | 반환 또는 성격 | 용도 | 이 프로그램의 위치 |
|---|---|---|---|
env::args() | String 반복자 | 명령줄 인자 읽기 | main |
fs::read_to_string | Result<String, io::Error> | 파일 전체 읽기 | main |
str::parse | Result<T, _> | 문자열을 숫자로 변환 | parse_line |
map_err 와 ? | 오류 변환과 조기 반환 | 오류 타입 맞추기 | parse_line |
HashMap::entry | 가변 참조 반환 | 분류별 누적 | by_category |
filter · map · fold | 게으른 반복자 조합 | 합계 계산 | total |
process::exit | 반환하지 않음 | 종료 코드 지정 | main |
| 상황 | 처리 | 출력 위치 | 종료 코드 |
|---|---|---|---|
| 인자 개수가 틀림 | 사용법 안내 후 종료 | 표준 오류 | 2 |
| 파일을 읽을 수 없음 | 원인 출력 후 종료 | 표준 오류 | 1 |
| 일부 줄이 틀림 | 건너뛰고 끝에 모아 보고 | 표준 출력 | 0 |
연습 문제
- 수입 기록 건수와 지출 기록 건수를 각각 세어 "수입 N건, 지출 M건"으로 출력하려 한다.
filter와count를 써서main에 들어갈 코드를 써라. - 지출 기록 중 가장 큰 금액 하나를
fold로 구하라. 초깃값은 0으로 한다. parse_line의 날짜 검사를 강화하라. 5번째와 8번째 글자는-이고 나머지 여덟 글자는 숫자여야 한다.- 건너뛴 줄이 하나라도 있으면 보고를 출력한 뒤 종료 코드 1로 끝나게 하라. 그렇게 하면 어떤 점이 쓸모 있는가.
정답과 해설
1. 수입 건수를 센 뒤, 전체에서 빼면 지출 건수가 된다. Kind는 두 가지뿐이고 파서가 그 외의 값을 이미 걸러냈기 때문에 성립하는 계산이다.
let income_count = entries.iter().filter(|e| e.kind == Kind::Income).count();
let expense_count = entries.len() - income_count;
println!("수입 {}건, 지출 {}건", income_count, expense_count);
2. 다음과 같이 쓴다.
let biggest = entries
.iter()
.filter(|e| e.kind == Kind::Expense)
.map(|e| e.amount)
.fold(0, i64::max);
클로저 안에서 m.max(a)라고 쓰면 m의 타입이 아직 정해지지 않은 숫자라서 컴파일러가 모호하다는 오류를 낸다. i64::max를 함수 그대로 넘기면 타입이 i64로 정해진다. 지출 기록이 없으면 0이 나온다는 점도 알아 둔다.
3. 글자의 위치와 종류를 한 번에 검사한다.
let d = fields[0];
let date_ok = d.len() == 10
&& d.chars().enumerate().all(|(i, c)| {
if i == 4 || i == 7 {
c == '-'
} else {
c.is_ascii_digit()
}
});
if date_ok == false {
return Err(format!("날짜는 YYYY-MM-DD 형식이어야 한다: '{}'", d));
}
all은 모든 값이 조건을 만족할 때만 참이다. 첫 거짓을 만나면 나머지를 보지 않고 끝난다. 이 검사는 월이 13인 날짜까지 걸러 내지는 못한다. 그것까지 하려면 숫자로 바꿔 범위를 검사해야 한다.
4. main의 마지막 줄 report_errors(&errors); 뒤에 다음을 더한다.
if errors.is_empty() == false {
process::exit(1);
}
종료 코드가 0이 아니면 셸 스크립트나 다른 도구가 "입력에 문제가 있었다"는 사실을 자동으로 알 수 있다. 예를 들어 월말 정리 스크립트가 틀린 줄이 있는 달의 보고서를 그대로 보관하지 않게 만들 수 있다. 다만 이 방침은 도구의 성격에 따라 정할 일이다. 이 장의 기본 동작은 집계를 보여 주는 것이 우선이라 0으로 끝냈다.