콘텐츠로 이동

Rust로 만드는 단어 세기 프로그램

이 프로젝트에서는 작지만 진짜로 쓸 수 있는 도구, 단어 세기 프로그램(word counter)을 만들어요. 텍스트를 주면 어떤 단어가 가장 자주 나오는지 알려 줘요. 글을 쓰는 사람들은 이런 도구로 너무 자주 반복하는 단어를 찾아내요. 검색 엔진도 같은 생각으로 페이지가 무엇에 관한 내용인지 파악해요.

작은 단계로 나눠서 만들 거예요. 단계마다 그대로 실행되는 완전한 프로그램이니까, 언제든 멈춰서 이것저것 바꿔 보고 다시 이어 가면 돼요. 그 과정에서 문자열, HashMap, 정렬, 형식을 갖춘 출력, 명령줄 인자를 써요.

다 만들면 프로그램이 이런 내용을 출력해요.

WORD COUNT
----------------
the 6
cat 3
happy 2

이 페이지에서는 세는 대상인 텍스트도, 표 머리글(WORD, COUNT)도 영어 그대로 둬요. {:<10} 같은 정렬은 글자 수로 폭을 정하는데, 한글은 화면에서 두 칸을 차지해서 표의 열이 어긋나 버리거든요. 게다가 한국어는 “고양이가”, “고양이를”처럼 조사가 단어에 붙어서, 공백으로만 나누면 같은 단어가 서로 다른 단어로 세어져요.

첫 번째 일은 텍스트를 단어로 자르는 거예요. split_whitespace()는 문자열을 공백, 탭, 줄바꿈에서 나누고, 그 사이의 빈 조각은 건너뛰어요.

fn main() {
let text = "The cat sat on the mat. The dog sat on the log!";
let words: Vec<&str> = text.split_whitespace().collect();
println!("{:?}", words);
println!("단어 {}개", words.len());
}

실행 결과:

["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]
단어 12개
▶ 직접 실행해 보기

동작은 하지만, 자세히 보세요. "The"와 "the"가 서로 다른 단어로 취급되고, "mat."에는 마침표가 그대로 붙어 있어요. 지금 이대로 세면 결과가 틀려요. 이것부터 고쳐 볼게요.

단어를 정규화(normalize)한다는 건 하나의 표준 형태로 맞춘다는 뜻이에요. 두 가지를 할 거예요.

  1. trim_matches로 앞뒤의 문장 부호를 없애요.
  2. to_lowercase로 소문자로 바꿔요.
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. Don't stop -- said the DOG!";
for word in text.split_whitespace() {
let clean = normalize(word);
if clean.is_empty() {
println!("{:>6} -> (건너뜀)", word);
} else {
println!("{:>6} -> {}", word, clean);
}
}
}

실행 결과:

The -> the
cat -> cat
sat -> sat
on -> on
the -> the
mat. -> mat
Don't -> don't
stop -> stop
-- -> (건너뜀)
said -> said
the -> the
DOG! -> dog
▶ 직접 실행해 보기

trim_matches는 어떤 문자를 없앨지 정하는 클로저를 받아요. 여기서는 글자나 숫자가 아닌 것을 모두 없애는데, 양 끝에서만 없애요. 그래서 don't의 아포스트로피는 살아남아요. --처럼 문장 부호로만 된 “단어”는 빈 문자열이 되니까 건너뛰어요.

이제 핵심 아이디어예요. HashMap으로 단어마다 몇 번 나왔는지를 짝지어 둬요. entry API를 쓰면 세는 코드가 짧아져요.

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
println!("the: {}", counts["the"]);
println!("cat: {}", counts["cat"]);
println!("happy: {}", counts["happy"]);
println!("서로 다른 단어 {}개", counts.len());
}

실행 결과:

the: 6
cat: 3
happy: 2
서로 다른 단어 11개
▶ 직접 실행해 보기

*counts.entry(clean).or_insert(0) += 1;을 천천히 읽어 볼게요.

  • counts.entry(clean)은 맵에서 이 단어의 자리를 찾아요.
  • .or_insert(0)은 처음 보는 단어라면 그 자리에 0을 넣어요. 어느 쪽이든 그 개수를 가리키는 가변 참조자를 돌려줘요.
  • *는 그 참조자를 따라가서 숫자 자체에 닿고, += 1이 1을 더해요.

첫 번째 텍스트 줄 끝의 \는 문자열을 다음 줄로 이어 줄 뿐이에요. 그리고 counts["the"]는 키를 바로 찾아봐요. 키가 없으면 패닉이 일어나지만, 이 단어들이 텍스트에 있다는 걸 알고 있으니 여기서는 괜찮아요. 없을 수도 있는 키에는 .get()을 쓰세요.

왜 반복문으로 맵 전체를 출력하지 않았을까요? HashMap에는 정해진 순서가 없어요. 반복문으로 돌면 프로그램을 실행할 때마다 순서가 바뀔 수 있어요. 안정적이고 쓸모 있는 순서를 얻으려면 정렬해야 해요.

4단계: 개수로, 그다음 단어로 정렬하기

섹션 제목: “4단계: 개수로, 그다음 단어로 정렬하기”

맵의 쌍들을 Vec으로 옮긴 다음 정렬해요. 개수가 많은 것부터 보고 싶어요. 개수가 같은 단어들은 알파벳 순서로 놓아서, 출력이 언제나 똑같게 만들어요.

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (word, count) in &pairs {
println!("{word}: {count}");
}
}

실행 결과:

the: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ 직접 실행해 보기

정렬하는 줄이 이 프로젝트에서 가장 까다로운 부분이니까, 하나씩 뜯어볼게요.

  • sort_by는 두 항목 a와 b를 주면서 “어느 쪽이 먼저야?“라고 물어요.
  • b.1.cmp(&a.1)은 개수를 비교해요(.1은 튜플의 두 번째 부분이에요). 일부러 b를 a보다 먼저 썼어요. 그러면 순서가 뒤집혀서 큰 개수가 먼저 와요.
  • .then(a.0.cmp(&b.0))은 개수가 같을 때만 쓰여요. 단어를 평소대로 a에서 b 순서로 비교하니까, 개수가 같으면 알파벳순이 돼요.

sort_by와 비슷한 메서드들의 예제는 정렬 페이지에 더 있어요.

5단계: 상위 N개를 표로 출력하기

섹션 제목: “5단계: 상위 N개를 표로 출력하기”

긴 목록은 읽기 힘들어요. 상위 몇 개 단어만 가지런한 열로 보여 줄게요. 포맷 지정자가 정렬을 대신 해 줘요.

  • {:<10}은 “10글자 폭에 왼쪽 정렬”이라는 뜻이에요.
  • {:>5}는 “5글자 폭에 오른쪽 정렬”이라는 뜻이에요. 숫자는 보통 오른쪽 정렬이 보기 좋아요.

코드도 함수로 옮겨서 main이 짧은 이야기처럼 읽히게 할게요.

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "WORD", "COUNT");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let pairs = sorted_counts(count_words(text));
print_table(&pairs, 5);
}

실행 결과:

WORD COUNT
----------------
the 6
cat 3
happy 2
is 2
on 2
▶ 직접 실행해 보기

.take(top_n)은 top_n개를 꺼낸 뒤 멈춰요. 단어가 그보다 적으면 그냥 일찍 멈추니까, 끝을 넘어갈까 걱정할 필요가 없어요.

print_table이 &[(String, usize)], 즉 슬라이스를 받는다는 점도 눈여겨보세요. 목록을 빌리기만 한다는 뜻이고, 쌍을 담은 어떤 Vec과도 함께 쓸 수 있어요.

진짜 단어 세기 프로그램이라면 파일을 읽어야겠죠. 파일 경로는 명령줄에서 받고, 몇 줄을 보여 줄지는 생략할 수 있는 두 번째 인자로 받을게요. 뭔가 잘못되면 eprintln!(에러 출력에 써요)으로 친절한 메시지를 출력하고, 종료 코드 1로 끝내요.

프로젝트와 테스트용 텍스트 파일을 만드세요.

Terminal window
cargo new word_counter
cd word_counter
echo "The cat sat on the mat. The dog sat on the log!" > story.txt

이 코드를 src/main.rs에 넣으세요. Playground는 여러분의 파일을 읽을 수 없으니, 이 버전은 내 컴퓨터에서 실행하는 용도예요.

use std::collections::HashMap;
use std::env;
use std::fs;
use std::process;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "WORD", "COUNT");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
let args: Vec<String> = env::args().collect();
// args[0]은 프로그램 이름, args[1]은 파일 경로예요
let Some(path) = args.get(1) else {
eprintln!("사용법: word_counter <file> [top_n]");
process::exit(1);
};
// 두 번째 인자는 생략할 수 있어요. 없거나 숫자가 아니면 10을 써요
let top_n: usize = match args.get(2) {
Some(n) => n.parse().unwrap_or(10),
None => 10,
};
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) => {
eprintln!("{path} 파일을 읽을 수 없어요: {e}");
process::exit(1);
}
};
let pairs = sorted_counts(count_words(&text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("단어 {}개, 서로 다른 단어 {}개\n", total, pairs.len());
print_table(&pairs, top_n);
}

실행해 보세요. 내 프로그램에 줄 인자는 -- 뒤에 쓴다는 걸 기억하세요.

Terminal window
cargo run -- story.txt 3
단어 12개, 서로 다른 단어 7개
WORD COUNT
----------------
the 4
on 2
sat 2

파일 이름을 깜빡하면, 프로그램이 멈추는 대신 사용법 메시지가 나와요.

Terminal window
cargo run
사용법: word_counter <file> [top_n]

let Some(path) = args.get(1) else { ... };는 let-else라고 해요. “이 패턴이 맞으면 계속 진행하고, 아니면 else 블록을 실행해”라는 뜻인데, else 블록은 반드시 흐름을 멈춰야 해요(여기서는 프로그램을 종료해서요). read_to_string과 그 에러는 파일 입출력 페이지에서 더 자세히 설명해요.

다음은 텍스트를 코드 안에 넣어서 Playground에서 실행할 수 있게 만든 같은 프로그램이에요. main만 달라요.

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "WORD", "COUNT");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
// 파일에서 읽어 왔다고 쳐요
let text = "Rust is fast. Rust is safe. Rust is fun!
Is it hard? It takes practice, and practice is fun too.";
let top_n = 4;
let pairs = sorted_counts(count_words(text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("단어 {}개, 서로 다른 단어 {}개\n", total, pairs.len());
print_table(&pairs, top_n);
}

실행 결과:

단어 20개, 서로 다른 단어 11개
WORD COUNT
----------------
is 5
rust 3
fun 2
it 2
▶ 직접 실행해 보기

셀 때 * 빠뜨리기. or_insert가 주는 건 숫자 자체가 아니라 숫자를 가리키는 참조자예요. *가 없으면 Rust는 거기에 더하기를 거부해요.

*counts.entry(word).or_insert(0) += 1; // 맞음
counts.entry(word).or_insert(0) += 1; // 에러
error[E0368]: binary assignment operation `+=` cannot be applied to type `&mut {integer}`
--> src/main.rs:6:9
|
6 | counts.entry(word).or_insert(0) += 1;
| -------------------------------^^^^^
| |
| cannot use `+=` on type `&mut {integer}`
|
help: `+=` can be used on `{integer}` if you dereference the left-hand side
|
6 | *counts.entry(word).or_insert(0) += 1;
| +

“&mut {integer} 타입에는 +=를 쓸 수 없어요. 왼쪽을 역참조하면 쓸 수 있어요”라는 뜻이에요. 컴파일러가 해결책까지 보여 주죠. *를 붙이면 돼요.

HashMap을 그대로 출력하기. println!("{:?}", counts)도 동작은 하지만, 순서가 제멋대로이고 실행할 때마다 바뀔 수 있어요. 순서가 중요하다면 항상 먼저 Vec에 담아 정렬하세요.

개수로만 정렬하기. b.1.cmp(&a.1)만으로 정렬하면, 개수가 같은 단어들은 아무 순서로나 나올 수 있어요. .then(...)으로 동점일 때의 기준을 더해서 출력이 매번 같게 하세요.

이제 동작하는 도구가 생겼어요. 나만의 도구로 만들어 볼 방법을 쉬운 것부터 어려운 순서로 모아 봤어요.

  • 불용어 무시하기. “the”, “a”, “is”, “and” 같은 단어는 어디에나 있어서 별로 흥미롭지 않아요. 이런 단어를 HashSet에 넣어 두고, 거기 있는 단어는 건너뛰세요.
  • 비율 보여 주기. 개수 옆에 전체 단어 중 몇 퍼센트인지 출력해 보세요. the 6 28.6%처럼요. 소수점 한 자리를 보여 주려면 {:.1}을 쓰세요.
  • 파일이 없으면 stdin에서 읽기. std::io::read_to_string(std::io::stdin())을 쓰면 cat story.txt | cargo run처럼 텍스트를 파이프로 넘길 수 있어요.
  • 여러 파일 세기. args[1..]의 모든 경로를 돌면서, 모든 단어를 맵 하나에 더하세요.
  • 인자 처리에 clap 쓰기. cargo add clap --features derive를 실행하고, --top 5나 --ignore-case 같은 진짜 플래그를 추가해 보세요. --help 메시지는 덤으로 생겨요. 예제는 명령줄 인자 페이지에 있어요.
  • 테스트 쓰기. 함수들을 src/lib.rs로 옮기고, normalize와 count_words에 대한 테스트를 몇 개 추가해 보세요.
  • split_whitespace()는 텍스트를 단어로 자르고, trim_matches와 to_lowercase가 단어를 정규화해요.
  • *map.entry(key).or_insert(0) += 1은 HashMap으로 무언가를 셀 때 쓰는 표준적인 방법이에요.
  • HashMap에는 순서가 없어요. Vec에 모은 뒤, 동점 기준을 넣은 sort_by로 정렬하면 출력이 안정적이에요.
  • {:<10}과 {:>5}는 텍스트를 열에 맞춰 정렬하고, .take(n)은 출력할 줄 수를 제한해요.
  • 파일 경로는 env::args()에서 읽고, 에러는 친절한 메시지로 처리한 뒤 process::exit(1)로 종료하세요.

이해도 점검

1. "Hello!".trim_matches(|c: char| !c.is_alphanumeric())는 무엇을 돌려줄까요?

2. 출력하기 전에 왜 HashMap을 Vec에 모을까요?

3. pairs.sort_by(|a, b| b.1.cmp(&a.1))에서 왜 b를 a보다 먼저 썼을까요?

4. println!에서 {:>5}는 무슨 일을 할까요?

점수: 0 / 4