콘텐츠로 이동

Rust로 만드는 숫자 맞히기 게임

여러분의 첫 번째 진짜 프로젝트예요. 컴퓨터가 1부터 100 사이의 비밀 숫자를 하나 골라요. 여러분은 그 숫자를 맞혀요. 정답을 맞힐 때까지 컴퓨터가 “너무 작아요”나 “너무 커요”라고 알려 주고, 맞히면 몇 번 만에 맞혔는지 말해 줘요.

작은 프로그램이지만 지금까지 배운 것을 많이 써요. 변수, match, 반복문, Result요. 한 단계씩 만들어 갈 거고, 단계마다 코드가 따로 실행돼요.

다 만들고 나면, 여러분 컴퓨터에서 게임이 이렇게 돌아가요.

숫자를 맞혀 보세요! (1부터 100까지)
추측한 숫자를 입력하세요:
50
너무 커요!
추측한 숫자를 입력하세요:
hello
정수가 아니에요. 다시 해 보세요!
추측한 숫자를 입력하세요:
25
너무 작아요!
추측한 숫자를 입력하세요:
37
3번 만에 맞혔어요!

친절한 부분이 두 군데 있어요. 플레이어가 hello를 입력해도 게임이 멈추지 않고 다시 물어봐요. 그리고 그 잘못된 입력은 시도 횟수에 들어가지 않아요.

터미널을 열고 새 Cargo 프로젝트를 만드세요.

Terminal window
cargo new guessing_game
cd guessing_game

이 페이지의 코드는 모두 src/main.rs에 들어가요. 언제든 이렇게 실행할 수 있어요.

Terminal window
cargo run

아직 Rust를 설치하지 않았다면 시작하기를 먼저 보세요.

플레이어가 입력한 내용을 읽으려면 std::io::stdin()과 그 read_line 메서드를 써요.

use std::io;
fn main() {
println!("추측한 숫자를 입력하세요:");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("입력을 읽지 못했어요");
println!("추측한 값: {guess}");
}

이 프로그램은 여러분이 뭔가 입력하고 Enter를 누를 때까지 기다려요. 그러니 내 컴퓨터에서 cargo run으로 실행해 보세요. (Rust Playground에서는 키보드 입력을 받을 수 없어서, 여기에는 “직접 실행해 보기” 버튼이 없어요.)

여기서 몇 가지 일이 일어나요.

  • String::new()는 텍스트를 담을, 비어 있고 늘어날 수 있는 String을 만들어요.
  • read_line(&mut guess)는 입력된 줄을 guess의 끝에 덧붙여요. 문자열을 바꾸니까 &mut이 필요해요. &mut이 처음이라면 대여를 보세요.
  • 입력을 읽다가 실패할 수도 있으니 read_line은 Result를 돌려줘요. .expect(...)는 “실패하면 이 메시지와 함께 프로그램을 멈춰”라는 뜻이에요. 지금은 이걸로 충분해요. 나중에 더 부드럽게 처리할 거예요.

한 가지 놀라운 점이 있어요. 텍스트에 Enter 키까지 들어가요. 42를 입력하면 guess에는 "42\n"이 담겨요. 다음 단계에서 이걸 기억해 두세요.

플레이어가 입력한 건 텍스트지만, 우리는 비교할 수 있는 숫자가 필요해요. .trim()은 양 끝의 공백과 줄바꿈을 없애요. 그다음 .parse()가 텍스트를 숫자로 바꿔 보려고 해요.

파싱은 실패할 수 있어요. "seven"은 숫자가 아니니까요. 그래서 parse()는 Result를 돌려주고, 우리는 match로 두 경우를 모두 처리해요.

fn main() {
// 플레이어가 이 줄들을 입력했다고 쳐요(줄마다 Enter로 끝나요).
let inputs = ["42\n", " 7 \n", "seven\n", "-3\n"];
for input in inputs {
match input.trim().parse::<u32>() {
Ok(num) => println!("{:?} -> 숫자 {num}", input),
Err(e) => println!("{:?} -> 올바른 추측이 아니에요 ({e})", input),
}
}
}

실행 결과:

"42\n" -> 숫자 42
" 7 \n" -> 숫자 7
"seven\n" -> 올바른 추측이 아니에요 (invalid digit found in string)
"-3\n" -> 올바른 추측이 아니에요 (invalid digit found in string)
▶ 직접 실행해 보기

괄호 안의 invalid digit found in string은 Rust가 내는 에러 메시지로, “문자열에 숫자가 아닌 문자가 있어요”라는 뜻이에요. parse::<u32>()는 어떤 타입으로 파싱할지 Rust에게 알려 줘요. u32는 부호 없는(절대 음수가 아닌) 정수라서 -3은 거절돼요. 우리가 원하던 대로예요. 비밀 숫자는 언제나 1부터 100 사이니까요.

게임에서는 같은 생각을 이렇게 써요.

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("정수가 아니에요. 다시 해 보세요!");
continue;
}
};

여기서는 let guess: u32에서 타입을 알 수 있으니 ::<u32>가 필요 없어요. 파싱이 성공하면 match가 숫자를 돌려줘요. 실패하면 친절한 메시지를 출력하고 continue로 다시 물어봐요. (continue는 반복문 안에서만 쓸 수 있어요. 반복문은 5단계에서 추가할 거예요.)

그냥 .expect()를 쓰면 안 될까요? 그러면 오타 하나에 게임 전체가 멈춰 버려요. Err 경우를 처리해 주는 것, 그게 쓰기 편한 프로그램을 만드는 방법이에요.

4단계: 추측과 비밀 숫자 비교하기

섹션 제목: “4단계: 추측과 비밀 숫자 비교하기”

모든 숫자에는 .cmp() 메서드가 있어요. 두 값을 비교해서 std::cmp::Ordering을 돌려주는데, 이건 Less(작다), Greater(크다), Equal(같다) 세 배리언트를 가진 열거형이에요. 여기에 match를 쓰면 거의 말하듯이 읽혀요.

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
for guess in [10, 42, 99] {
match guess.cmp(&secret) {
Ordering::Less => println!("{guess}: 너무 작아요!"),
Ordering::Greater => println!("{guess}: 너무 커요!"),
Ordering::Equal => println!("{guess}: 맞혔어요!"),
}
}
}

실행 결과:

10: 너무 작아요!
42: 맞혔어요!
99: 너무 커요!
▶ 직접 실행해 보기

같은 걸 if guess < secret ... else if ...로 쓸 수도 있어요. match 버전이 좋은 건, 컴파일러가 세 가지 경우를 모두 처리했는지 확인해 주기 때문이에요. 하나라도 빠뜨리면 컴파일되지 않아요.

이제 전체를 loop로 감싸요. loop는 break로 빠져나올 때까지 끝없이 돌아요. 시도 횟수를 세는 attempts도 두고, 추측이 진짜 숫자였을 때만 1을 더해요.

진짜 게임에서는 키보드로 입력을 받아요. Playground에서 돌아가는 버전을 만들기 위해, 여기서는 정해진 비밀 숫자와 “입력된” 줄의 고정 목록을 써요. inputs.next()는 다음 줄을 주고, 목록이 바닥나면 None을 줘요.

use std::cmp::Ordering;
fn main() {
let secret: u32 = 37;
// 플레이어가 한 줄에 하나씩 이렇게 입력했다고 쳐요.
let mut inputs = ["50", "hello", "25", "37"].into_iter();
let mut attempts = 0;
println!("숫자를 맞혀 보세요! (1부터 100까지)");
loop {
let input = match inputs.next() {
Some(line) => line,
None => {
println!("추측이 더 없어요. 정답은 {secret}!");
break;
}
};
println!("> {input}");
let guess: u32 = match input.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("정수가 아니에요. 다시 해 보세요!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("너무 작아요!"),
Ordering::Greater => println!("너무 커요!"),
Ordering::Equal => {
println!("{attempts}번 만에 맞혔어요!");
break;
}
}
}
}

실행 결과:

숫자를 맞혀 보세요! (1부터 100까지)
> 50
너무 커요!
> hello
정수가 아니에요. 다시 해 보세요!
> 25
너무 작아요!
> 37
3번 만에 맞혔어요!
▶ 직접 실행해 보기

비밀 숫자나 추측 목록을 바꿔서 다시 실행해 보세요. 정답을 맨 앞에 두거나 아예 빼 보고, 어떻게 되는지 지켜보세요.

None 경우가 중요해요. “플레이어가 더 입력할 게 없다”를 Playground식으로 나타낸 거고, 진짜 게임에도 같은 생각이 필요해요(7단계를 보세요).

6단계: 비밀 숫자를 무작위로 고르기

섹션 제목: “6단계: 비밀 숫자를 무작위로 고르기”

매번 비밀 숫자가 같으면 별로 재미없겠죠. 실제 프로젝트라면 rand 크레이트(영어)를 쓸 거예요. Rust에서 난수가 필요할 때 표준처럼 쓰는 크레이트예요. 사용법은 난수 강좌에서 볼 수 있어요. 이렇게 추가해요.

Terminal window
cargo add rand

이 프로젝트에서는 아무 준비가 필요 없도록 표준 라이브러리만 쓸게요. 요령은 이래요. 시계에 현재 시각을 묻고, 그중 나노초 부분(0부터 999,999,999까지 쉬지 않고 바뀌는 숫자)을 가져와서, %(나머지)로 1부터 100 범위에 밀어 넣어요.

use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("시계가 1970년 이전으로 맞춰져 있어요")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
let secret = secret_number();
let in_range = (1..=100).contains(&secret);
println!("비밀 숫자를 골랐어요. 1부터 100 사이인가요? {in_range}");
}

실행 결과:

비밀 숫자를 골랐어요. 1부터 100 사이인가요? true
▶ 직접 실행해 보기

nanos % 100은 언제나 0부터 99까지라서, 1을 더하면 1부터 100이 돼요. 범위 안에 있는지만 출력하니까 실행 결과는 매번 같아요. 몰래 보고 싶다면 secret 자체를 출력해 보세요.

이게 뭔지는 솔직하게 알아 두세요. “게임에 쓰기에는 충분히 예측하기 어려운” 값일 뿐, 진짜 무작위가 아니에요. 비밀번호나 보안 토큰처럼 누군가 값을 알아내려고 할 수 있는 곳에는 절대 이런 요령을 쓰지 마세요. 그런 곳에는 제대로 된 크레이트를 쓰세요.

모든 걸 합친 코드예요. 이 버전을 src/main.rs에 넣고 cargo run으로 플레이하세요.

새로운 게 하나 있어요. read_line의 결과에 .expect()를 부르는 대신 match를 써요. 더 읽을 입력이 없으면 read_line은 Ok(0)을 돌려줘요. 예를 들어 플레이어가 Ctrl+D(Windows에서는 Ctrl+Z 다음 Enter)를 누르거나, 파일에서 입력을 파이프로 넘겼을 때요. 이 확인이 없으면 빈 입력으로 반복문이 끝없이 돌아요.

use std::cmp::Ordering;
use std::io;
use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("시계가 1970년 이전으로 맞춰져 있어요")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
println!("숫자를 맞혀 보세요! (1부터 100까지)");
let secret = secret_number();
let mut attempts = 0;
loop {
println!("추측한 숫자를 입력하세요:");
let mut guess = String::new();
match io::stdin().read_line(&mut guess) {
Ok(0) => {
// 입력 끝: 더 읽을 게 없어요.
println!("입력이 더 없어요. 정답은 {secret}! 다음에 또 만나요!");
break;
}
Ok(_) => {}
Err(e) => {
println!("입력을 읽을 수 없어요: {e}");
break;
}
}
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("정수가 아니에요. 다시 해 보세요!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("너무 작아요!"),
Ordering::Greater => println!("너무 커요!"),
Ordering::Equal => {
println!("{attempts}번 만에 맞혔어요!");
break;
}
}
}
}

브라우저에서 해 보고 싶다면, 정해진 추측 목록으로 플레이하는 5단계 버전을 쓰세요. 🦀

문자열에 mut 빠뜨리기. read_line은 문자열을 바꿔야 하니까, 문자열이 가변이어야 해요.

use std::io;
fn main() {
let guess = String::new();
io::stdin().read_line(&mut guess).expect("입력을 읽지 못했어요");
}
error[E0596]: cannot borrow `guess` as mutable, as it is not declared as mutable
--> src/main.rs:5:27
|
5 | io::stdin().read_line(&mut guess).expect("입력을 읽지 못했어요");
| ^^^^^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
4 | let mut guess = String::new();
| +++

“guess가 가변으로 선언되지 않아서 가변으로 빌릴 수 없어요”라는 뜻이에요. 고치는 방법은 컴파일러가 알려 준 그대로예요. let mut guess = String::new();라고 쓰면 돼요.

텍스트와 숫자 비교하기. 파싱을 깜빡하면 guess는 여전히 String이고, String과 u32는 비교할 수 없어요.

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
let guess = String::from("50");
match guess.cmp(&secret) {
Ordering::Less => println!("너무 작아요!"),
Ordering::Greater => println!("너무 커요!"),
Ordering::Equal => println!("맞혔어요!"),
}
}
error[E0308]: mismatched types
--> src/main.rs:7:21
|
7 | match guess.cmp(&secret) {
| --- ^^^^^^^ expected `&String`, found `&u32`
| |
| arguments to this method are incorrect

“타입이 맞지 않아요. &String을 기대했는데 &u32가 왔어요”라는 뜻이에요. Rust는 텍스트와 숫자를 절대 알아서 바꿔 주지 않아요. 먼저 파싱하고, 그다음에 비교하세요.

.trim() 빠뜨리기. 이건 컴파일은 되지만, 모든 추측이 거절돼요. 줄바꿈은 숫자가 아니니까요.

fn main() {
let typed = "42\n";
println!("{:?}", typed.parse::<u32>());
println!("{:?}", typed.trim().parse::<u32>());
}

실행 결과:

Err(ParseIntError { kind: InvalidDigit })
Ok(42)
▶ 직접 실행해 보기

게임이 돌아가면 혼자서 이런 것들을 해 보세요. 하나하나는 작아요.

  • 범위 확인하기. 추측이 0이거나 100보다 크면 “1부터 100 사이의 숫자를 골라 주세요”라고 말하고, 횟수에 넣지 마세요.
  • 시도 횟수 제한하기. 플레이어에게 7번의 기회를 주세요. 기회를 다 쓰면 비밀 숫자를 보여 주고 게임을 끝내요.
  • 다시 하기. 플레이어가 이기면 “다시 할까요? (y/n)“라고 묻고, 새 비밀 숫자로 새 판을 시작해요.
  • 최고 기록 남기기. 여러 판 중 가장 적은 시도 횟수를 기억해 뒀다가 마지막에 출력해요.
  • 더 따뜻한 힌트 주기. 추측이 비밀 숫자와 3 이내로 가까우면 “아주 가까워요!“라고 말해 주세요. guess.abs_diff(secret)은 두 숫자 사이의 거리를 알려 줘요.
  • 진짜 rand 크레이트 쓰기. cargo add rand를 실행하고, docs.rs(영어)의 문서를 따라 secret_number()를 바꿔 보세요.
  • 로직을 함수로 옮기기. fn check_guess(guess: u32, secret: u32) -> Ordering을 만들고 테스트를 붙여 보세요.
  • io::stdin().read_line(&mut s)는 줄바꿈을 포함해서 한 줄을 읽고, Result를 돌려줘요. Ok(0)은 더 이상 입력이 없다는 뜻이에요.
  • .trim().parse()는 텍스트를 숫자로 바꿔요. Result를 돌려주니까, match로 잘못된 입력을 친절하게 처리하세요.
  • a.cmp(&b)는 Ordering::Less, Greater, Equal 중 하나를 돌려주고, match가 셋을 모두 처리했는지 확인해 줘요.
  • continue와 break를 쓴 loop가 플레이어가 이길 때까지 게임을 이어 가요.
  • 시계를 쓰는 요령은 게임에는 괜찮지만, 실제 프로젝트에서는 rand 크레이트를 써요.

이해도 점검

1. 플레이어가 42를 입력하고 Enter를 눌렀어요. read_line으로 받은 문자열에는 무엇이 들어 있을까요?

2. 게임은 왜 parse()에 .expect() 대신 match를 쓸까요?

3. read_line이 Ok(0)을 돌려주면 무슨 뜻일까요?

4. guess.cmp(&secret)은 어떤 값을 돌려줄 수 있을까요?

점수: 0 / 4