숫자 맞히기 게임 프로그래밍하기

숫자 맞히기 게임 프로그래밍하기

함께 직접 손으로 작업해보며 Rust로 뛰어들어볼게요! 이번 챕터는 실제 프로그램에서 사용하는 방법을 보여줌으로써 몇 가지 흔한 Rust 개념을 소개합니다. let, match, 메서드, 연관 함수(associated function), 외부 crate 등을 배우게 돼요. 다음 챕터들에서 이런 개념들을 더 자세히 탐구할 거지만, 이번 챕터에서는 기본기를 연습하기만 할게요.

고전적인 초보자 프로그래밍 문제를 구현해볼 텐데요, 바로 숫자 맞히기 게임입니다. 동작 방식은 이래요. 프로그램이 1과 100 사이의 랜덤 정수를 생성합니다. 그다음 플레이어에게 추측값을 입력하라고 요청하죠. 추측값이 입력되면 프로그램은 그 추측값이 너무 낮은지 너무 높은지 알려줍니다. 추측이 맞으면 게임은 축하 메시지를 출력하고 종료해요.

출처: The Rust Book

본문

새 프로젝트 세팅하기

새 프로젝트를 세팅하려면 Chapter 1에서 만든 projects 디렉터리로 가서 Cargo로 새 프로젝트를 만들면 됩니다. 이렇게요.

$ cargo new guessing_game
$ cd guessing_game

첫 번째 명령인 cargo new는 프로젝트 이름(guessing_game)을 첫 번째 인자로 받습니다. 두 번째 명령은 새 프로젝트의 디렉터리로 이동해요.

생성된 Cargo.toml 파일을 살펴볼게요.

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"

[dependencies]

Chapter 1에서 봤듯이 cargo new는 "Hello, world!" 프로그램을 만들어줍니다. src/main.rs 파일을 확인해보세요.

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

이제 이 "Hello, world!" 프로그램을 cargo run 명령으로 컴파일하고 실행하는 것을 한 단계로 해볼게요.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/guessing_game`
Hello, world!

run 명령은 이 게임에서 우리가 하게 될 것처럼 프로젝트를 빠르게 반복하며, 각 반복을 다음 단계로 넘어가기 전에 빠르게 테스트할 필요가 있을 때 유용합니다.

src/main.rs 파일을 다시 엽니다. 모든 코드를 이 파일에 작성하게 될 거예요.

추측값 처리하기 (Processing a Guess)

숫자 맞히기 게임 프로그램의 첫 부분은 사용자 입력을 요청하고, 그 입력을 처리하며, 입력이 예상된 형태인지 확인합니다. 우선 플레이어가 추측값을 입력할 수 있게 해볼게요. Listing 2-1의 코드를 src/main.rs에 입력하세요.

Filename: src/main.rs

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Listing 2-1: 사용자로부터 추측값을 받아 출력하는 코드

이 코드에는 많은 정보가 담겨 있으니, 한 줄씩 살펴볼게요. 사용자 입력을 얻고 결과를 출력으로 찍으려면 io 입출력 라이브러리를 스코프로 가져와야 해요. io 라이브러리는 std라고 알려진 표준 라이브러리에서 옵니다.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

기본적으로 Rust는 모든 프로그램의 스코프로 가져오는 표준 라이브러리의 항목 집합을 가지고 있습니다. 이 집합을 prelude라고 부르며, 표준 라이브러리 문서에서 그 모든 것을 볼 수 있어요.

사용하고 싶은 타입이 prelude에 없다면 use 문으로 그 타입을 명시적으로 스코프로 가져와야 합니다. std::io 라이브러리를 사용하면 사용자 입력을 받는 기능을 포함한 여러 유용한 기능을 제공받아요.

Chapter 1에서 봤듯이 main 함수는 프로그램의 진입점입니다.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

fn 문법은 새 함수를 선언하고, 괄호 ()는 매개변수가 없음을 나타내며, 중괄호 {는 함수 본문의 시작입니다.

Chapter 1에서 배웠듯이 println!은 문자열을 화면에 출력하는 매크로입니다.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이 코드는 게임이 무엇인지 알려주는 프롬프트를 출력하고 사용자에게 입력을 요청하고 있습니다.

변수로 값 저장하기

다음으로 사용자 입력을 저장할 *변수(variable)*를 만들게요. 이렇게요.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이제 프로그램이 재미있어지기 시작했어요! 이 작은 줄에는 많은 일이 담겨 있습니다. let 문으로 변수를 만들죠. 다른 예시를 하나 보여드릴게요.

let apples = 5;

이 줄은 apples라는 새 변수를 만들고 값 5에 바인딩합니다. Rust에서 변수는 기본적으로 불변(immutable)이라서, 변수에 값을 한 번 주면 그 값은 바뀌지 않습니다. 이 개념은 Chapter 3의 "Variables and Mutability" 섹션에서 자세히 다룰게요. 변수를 가변(mutable)으로 만들려면 변수 이름 앞에 mut를 추가하면 됩니다.

let apples = 5; // immutable
let mut bananas = 5; // mutable

참고: // 문법은 줄 끝까지 계속되는 주석을 시작합니다. Rust는 주석의 모든 것을 무시해요. 주석에 대해서는 Chapter 3에서 더 자세히 다룰게요.

숫자 맞히기 게임 프로그램으로 돌아가면, 이제 let mut guessguess라는 가변 변수를 도입한다는 걸 알 수 있어요. 등호(=)는 Rust에게 지금 뭔가를 변수에 바인딩하고 싶다고 알려줍니다. 등호 오른쪽은 guess가 바인딩되는 값으로, 새 String 인스턴스를 반환하는 함수인 String::new 호출의 결과입니다. String은 표준 라이브러리가 제공하는, 성장 가능하고 UTF-8로 인코딩된 텍스트 조각인 문자열 타입이에요.

::new 줄의 :: 문법은 newString 타입의 *연관 함수(associated function)*임을 나타냅니다. 연관 함수는 타입에 구현된 함수인데, 이 경우에는 String이죠. 이 new 함수는 새롭고 빈 문자열을 만듭니다. 많은 타입에서 new 함수를 찾을 수 있는데, 그것이 어떤 종류의 새 값을 만드는 함수의 흔한 이름이기 때문이에요.

정리하면 let mut guess = String::new(); 줄은 현재 새롭고 빈 String 인스턴스에 바인딩된 가변 변수를 만들었습니다. 휴, 힘드네요!

사용자 입력 받기

프로그램 첫 줄에서 use std::io;로 표준 라이브러리의 입출력 기능을 포함했다는 걸 기억하세요. 이제 io 모듈의 stdin 함수를 호출할 건데, 이 함수가 사용자 입력을 처리하게 해줍니다.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

프로그램 시작 부분에서 use std::io;io 모듈을 임포트하지 않았다면, 이 함수 호출을 std::io::stdin이라고 작성해 여전히 함수를 사용할 수 있었어요. stdin 함수는 std::io::Stdin의 인스턴스를 반환하는데, 이는 터미널의 표준 입력에 대한 핸들(handle)을 나타내는 타입입니다.

다음으로 .read_line(&mut guess) 줄은 표준 입력 핸들에서 read_line 메서드를 호출해 사용자에게서 입력을 얻습니다. 또한 &mut guessread_line의 인자로 전달해 사용자 입력을 어느 문자열에 저장할지 알려줍니다. read_line의 전체 임무는 사용자가 표준 입력에 입력한 것을 (그 내용을 덮어쓰지 않고) 문자열에 추가하는 것이므로, 그래서 그 문자열을 인자로 전달하는 거예요. 문자열 인자는 메서드가 문자열 내용을 바꿀 수 있도록 가변이어야 합니다.

&는 이 인자가 *참조(reference)*임을 나타내며, 이는 코드의 여러 부분이 한 조각의 데이터를 메모리에 여러 번 복사하지 않고 접근하게 해줍니다. 참조는 복잡한 기능이고, 참조를 얼마나 안전하고 쉽게 사용할 수 있게 하는지가 Rust의 주요 장점 중 하나예요. 이 프로그램을 끝내려면 그 세부 사항을 많이 알 필요는 없습니다. 지금은 변수처럼 참조도 기본적으로 불변이라는 점만 알면 돼요. 그래서 가변으로 만들려면 &guess가 아니라 &mut guess라고 써야 합니다. (Chapter 4에서 참조를 더 철저히 설명할게요.)

Result로 잠재적 실패 처리하기

아직도 이 코드 줄을 작업하고 있어요. 지금은 세 번째 텍스트 줄을 다루고 있지만, 여전히 하나의 논리적 코드 줄의 일부라는 점을 기억하세요. 다음 부분은 이 메서드입니다.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이 코드를 이렇게도 쓸 수 있었어요.

io::stdin().read_line(&mut guess).expect("Failed to read line");

하지만 한 줄이 너무 길면 읽기 어려우므로 나누는 게 최선이에요. .method_name() 문법으로 메서드를 호출할 때 줄바꿈과 다른 공백을 도입해 긴 줄을 끊는 것은 흔히 현명한 일입니다. 이제 이 줄이 뭘 하는지 살펴볼게요.

앞서 언급했듯이 read_line은 사용자가 입력한 것을 우리가 전달한 문자열에 넣지만, Result 값도 반환합니다. Result열거형(enumeration)으로, 흔히 enum이라고 불리며, 여러 가능한 상태 중 하나일 수 있는 타입이에요. 각 가능한 상태를 variant라고 부릅니다.

Chapter 6에서 enum을 더 자세히 다룰 거예요. 이러한 Result 타입의 목적은 오류 처리 정보를 인코딩하는 것에 있어요.

Result의 variant는 OkErr입니다. Ok variant는 연산이 성공했음을 나타내며 성공적으로 생성된 값을 담고 있어요. Err variant는 연산이 실패했음을 뜻하며, 연산이 어떻게 또는 왜 실패했는지에 대한 정보를 담고 있습니다.

Result 타입의 값은 다른 타입의 값처럼 그에 정의된 메서드를 가집니다. Result의 인스턴스에는 호출할 수 있는 expect 메서드가 있어요. 이 Result 인스턴스가 Err 값이라면 expect는 프로그램을 crash 시키고 expect에 인자로 전달한 메시지를 표시합니다. read_line 메서드가 Err를 반환한다면 그것은 아마 밑바탕의 운영체제에서 온 오류의 결과일 거예요. 이 Result 인스턴스가 Ok 값이라면 expectOk가 담고 있는 반환 값을 가져와 그 값만 반환해서 사용할 수 있게 해줍니다. 이 경우 그 값은 사용자 입력의 바이트 수입니다.

expect를 호출하지 않으면 프로그램은 컴파일되지만 경고가 나옵니다.

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin().read_line(&mut guess);
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s

Rust는 read_line에서 반환된 Result 값을 사용하지 않았다고 경고하며, 이는 프로그램이 가능한 오류를 처리하지 않았다는 뜻입니다.

경고를 억제하는 올바른 방법은 실제로 오류 처리 코드를 작성하는 것이지만, 여기서는 문제가 발생하면 프로그램을 그냥 crash 시키고 싶으므로 expect를 사용할 수 있어요. 오류로부터 복구하는 것에 대해서는 Chapter 9에서 배우게 될 거예요.

println! 플레이스홀더로 값 출력하기

닫는 중괄호를 제외하면 지금까지 코드에서 다룰 줄이 하나 더 있어요.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이 줄은 이제 사용자 입력을 담고 있는 문자열을 출력합니다. {} 중괄호 쌍은 플레이스홀더입니다. {}를 값을 제자리에 잡아두는 작은 게 집게발이라고 생각해보세요. 변수의 값을 출력할 때는 변수 이름이 중괄호 안에 들어갈 수 있어요. 표현식 평가 결과를 출력할 때는 형식 문자열에 빈 중괄호를 두고, 그런 다음 형식 문자열 뒤에 각 빈 중괄호 플레이스홀더에 출력할 표현식들을 같은 순서로 쉼표로 구분해 나열하면 됩니다. 한 번의 println! 호출에서 변수와 표현식 결과를 함께 출력하는 모습은 이렇게 생겼어요.

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);
}

이 코드는 x = 5 and y + 2 = 12를 출력할 거예요.

첫 부분 테스트하기

숫자 맞히기 게임의 첫 부분을 테스트해볼게요. cargo run으로 실행해보세요.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

이 시점에 게임의 첫 부분이 끝났습니다. 키보드에서 입력을 받아서 출력하고 있어요.

다음으로, 사용자가 맞히려고 할 비밀 숫자를 생성해야 해요. 게임이 한 번 이상 플레이하기에 재미있으려면 비밀 숫자는 매번 달라야 합니다. 게임이 너무 어렵지 않도록 1과 100 사이의 랜덤 숫자를 쓸 거예요. Rust는 아직 표준 라이브러리에 랜덤 숫자 기능을 포함하지 않습니다. 하지만 Rust 팀은 그 기능을 가진 rand crate를 제공합니다.

crate로 기능 늘리기

crate는 Rust 소스 코드 파일들의 모음이라는 걸 기억하세요. 우리가 만든 프로젝트는 실행 파일인 바이너리 crate예요. rand crate는 라이브러리 crate인데, 이는 다른 프로그램에서 사용되도록 의도된 코드를 담고 있으며 단독으로는 실행될 수 없습니다.

외부 crate를 조율하는 것이 Cargo가 정말 빛을 발하는 부분입니다. rand를 사용하는 코드를 작성하기 전에 Cargo.toml 파일을 수정해 rand crate를 의존성으로 포함해야 해요. 그 파일을 열고 Cargo가 만들어준 [dependencies] 섹션 헤더 아래, 파일 맨 아래에 다음 줄을 추가하세요. rand를 우리가 여기 적은 그대로, 이 버전 번호로 정확히 지정해야 해요. 그렇지 않으면 이 튜토리얼의 코드 예시가 동작하지 않을 수 있습니다.

Filename: Cargo.toml

[dependencies]
rand = "0.8.5"

Cargo.toml 파일에서 헤더 다음에 오는 모든 것은 그 섹션의 일부이며, 다음 섹션이 시작될 때까지 계속됩니다. [dependencies]에서는 프로젝트가 의존하는 외부 crate와 그 crate의 어떤 버전이 필요한지를 Cargo에게 알려줍니다. 이 경우 의미적 버전 지정자(semantic version specifier) 0.8.5rand crate를 지정합니다. Cargo는 Semantic Versioning(때로 SemVer라고 함)을 이해하는데, 이는 버전 번호를 쓰는 표준입니다. 0.8.5 지정자는 사실 ^0.8.5의 줄임말이며, 이는 0.8.5 이상이지만 0.9.0 미만인 어떤 버전이든 뜻합니다.

Cargo는 이 버전들이 0.8.5와 호환되는 공개 API를 가진다고 간주하며, 이 지정은 이 챕터의 코드로 여전히 컴파일될 최신 패치 릴리스를 얻을 수 있게 보장합니다. 0.9.0 이상의 어떤 버전도 다음 예시들이 사용하는 것과 같은 API를 가질 거라는 보장은 없어요.

이제 코드를 전혀 바꾸지 않고 Listing 2-2처럼 프로젝트를 빌드해볼게요.

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s

다른 버전 번호(SemVer 덕분에 전부 코드와 호환될 거예요!)와 다른 줄(운영체제에 따라)이 보일 수 있고, 줄의 순서도 다를 수 있습니다.

외부 의존성을 포함하면 Cargo는 registry에서 그 의존성이 필요한 모든 것의 최신 버전을 가져옵니다. registry는 Crates.io에서 온 데이터의 사본이에요. Crates.io는 Rust 생태계의 사람들이 오픈 소스 Rust 프로젝트를 다른 사람들이 쓰도록 게시하는 곳입니다.

registry를 업데이트한 뒤 Cargo는 [dependencies] 섹션을 확인하고 아직 다운로드되지 않은 나열된 crate들을 내려받습니다. 이 경우 우리는 rand만 의존성으로 나열했지만, rand가 동작하기 위해 의존하는 다른 crate들도 가져왔어요. crate들을 내려받은 뒤 Rust는 그것들을 컴파일하고, 의존성이 준비된 상태로 프로젝트를 컴파일합니다.

변경 없이 cargo build를 즉시 다시 실행하면 Finished 줄 외에는 아무 출력도 없을 거예요. Cargo는 이미 의존성을 다운로드하고 컴파일했다는 것을 알고 있으며, Cargo.toml 파일에서 의존성에 대해 아무것도 바꾸지 않았다는 것도 압니다. Cargo는 코드에 대해서도 아무것도 바꾸지 않았다는 것을 알기 때문에 그것도 다시 컴파일하지 않습니다. 할 일이 없으니 그냥 종료해요.

src/main.rs 파일을 열어 사소한 변경을 하고 저장한 뒤 다시 빌드하면 출력 두 줄만 보게 될 거예요.

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

이 줄들은 Cargo가 src/main.rs 파일에 대한 사소한 변경 하나만으로 빌드를 갱신한다는 것을 보여줍니다. 의존성은 바뀌지 않았으므로, Cargo는 이미 다운로드하고 컴파일한 것을 재사용할 수 있다는 걸 압니다.

재현 가능한 빌드 보장하기

Cargo에는 당신이나 다른 누군가가 코드를 빌드할 때마다 매번 같은 산출물을 다시 빌드할 수 있게 보장하는 메커니즘이 있습니다. Cargo는 달리 명시하기 전까지는 지정한 의존성 버전만 사용할 거예요. 예를 들어 다음 주에 rand crate의 버전 0.8.6이 나오고, 그 버전이 중요한 버그 수정을 담고 있지만 코드를 깨뜨릴 회귀(regression)도 담고 있다고 해볼게요. 이를 처리하기 위해 Rust는 cargo build를 처음 실행할 때 Cargo.lock 파일을 만드므로, 이제 guessing_game 디렉터리에 이것이 있게 됩니다.

프로젝트를 처음 빌드할 때 Cargo는 기준에 맞는 의존성의 모든 버전을 알아내 Cargo.lock 파일에 기록합니다. 앞으로 프로젝트를 빌드하면 Cargo는 Cargo.lock 파일이 존재함을 보고, 버전을 다시 알아내는 모든 작업을 하는 대신 거기에 지정된 버전을 사용합니다. 이렇게 하면 자동으로 재현 가능한 빌드가 가능해져요. 즉 Cargo.lock 덕분에 명시적으로 업그레이드하기 전까지 프로젝트는 0.8.5에 머무르게 됩니다. Cargo.lock 파일이 재현 가능한 빌드에 중요하기 때문에, 프로젝트의 나머지 코드와 함께 소스 제어에 종종 커밋됩니다.

새 버전 얻기 위해 crate 업데이트하기

정말 crate를 업데이트하고 싶을 때 Cargo는 update 명령을 제공하며, 이 명령은 Cargo.lock 파일을 무시하고 Cargo.toml의 지정에 맞는 모든 최신 버전을 알아냅니다. 그런 다음 Cargo는 그 버전들을 Cargo.lock 파일에 기록해요. 그렇지 않으면 기본적으로 Cargo는 0.8.5보다 크고 0.9.0보다 작은 버전만 찾습니다. rand crate가 0.8.6과 0.999.0이라는 두 개의 새 버전을 릴리스했다면, cargo update를 실행했을 때 다음을 보게 될 거예요.

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.999.0)

Cargo는 0.999.0 릴리스를 무시합니다. 이 시점에 Cargo.lock 파일에서 지금 사용 중인 rand crate 버전이 0.8.6이라는 변화도 눈치챌 수 있어요. rand 0.999.0 버전이나 0.999.x 시리즈의 어떤 버전을 사용하려면 Cargo.toml 파일을 이렇게 바꿔야 합니다. (실제로는 이 변경을 하지 마세요. 다음 예시들이 rand 0.8을 사용한다고 가정하기 때문이에요.)

[dependencies]
rand = "0.999.0"

다음 cargo build를 실행하면 Cargo는 사용 가능한 crate들의 registry를 업데이트하고, 지정한 새 버전에 따라 rand 요구 사항을 재평가합니다.

Cargo그 생태계에 대해서는 말할 것이 훨씬 많은데, Chapter 14에서 다룰게요. 하지만 지금은 그게 전부면 충분합니다. Cargo는 라이브러리 재사용을 매우 쉽게 만들어 주기 때문에, Rustaceans은 여러 패키지로 조립된 더 작은 프로젝트를 작성할 수 있어요.

이제 rand를 사용해 맞힐 숫자를 생성해볼게요. 다음 단계는 Listing 2-3처럼 src/main.rs를 업데이트하는 것입니다.

Filename: src/main.rs

use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

먼저 use rand::Rng; 줄을 추가합니다. Rng 트레이트(trait)는 랜덤 숫자 생성기가 구현하는 메서드들을 정의하며, 그 메서드들을 사용하려면 이 트레이트가 스코프에 있어야 합니다. Chapter 10에서 트레이트를 자세히 다룰게요.

다음으로 가운데에 두 줄을 추가하고 있어요. 첫 번째 줄에서는 사용할 특정 랜덤 숫자 생성기를 제공하는 rand::thread_rng 함수를 호출합니다. 이 생성기는 실행의 현재 스레드에 국한되고 운영체제가 seed를 제공하는 것이에요. 그런 다음 랜덤 숫자 생성기에서 gen_range 메서드를 호출합니다. 이 메서드는 use rand::Rng; 문으로 스코프에 가져온 Rng 트레이트가 정의합니다. gen_range 메서드는 범위 표현식을 인자로 받아 그 범위 안의 랜덤 숫자를 생성합니다. 여기서 사용하는 범위 표현식의 종류는 start..=end 형태이며 하한과 상한을 모두 포함(inclusive)하므로, 1과 100 사이의 숫자를 요청하려면 1..=100을 지정해야 해요.

참고: crate에서 어떤 트레이트를 쓰고 어떤 메서드와 함수를 호출해야 하는지 처음부터 아는 법은 없습니다. 그래서 각 crate에는 사용 지침이 담긴 문서가 있어요. Cargo의 또 다른 멋진 기능은 cargo doc --open 명령을 실행하면 모든 의존성이 제공하는 문서를 로컬에 빌드해 브라우저에서 열어준다는 점이에요. 예를 들어 rand crate의 다른 기능이 궁금하다면 cargo doc --open을 실행하고 왼쪽 사이드바에서 rand를 클릭하세요.

두 번째 새 줄은 비밀 숫자를 출력합니다. 개발 중에 프로그램을 테스트할 수 있게 해주므로 유용하지만, 최종 버전에서는 삭제할 거예요. 프로그램이 시작하자마자 답을 출력한다면 게임으로서는 부족하잖아요!

프로그램을 몇 번 실행해보세요.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

매번 다른 랜덤 숫자를 얻어야 하고, 모두 1과 100 사이의 숫자여야 해요. 잘했어요!

추측값을 비밀 숫자와 비교하기

이제 사용자 입력과 랜덤 숫자가 있으니 그것들을 비교할 수 있어요. 그 단계가 Listing 2-4에 나옵니다. 이 코드는 아직 컴파일되지 않는다는 점을 기억하세요. 곧 설명할 거예요.

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

먼저 use 문 하나를 더 추가해 표준 라이브러리에서 std::cmp::Ordering이라는 타입을 스코프로 가져옵니다. Ordering 타입은 또 다른 enum이며 Less, Greater, Equal이라는 variant를 가집니다. 이것들은 두 값을 비교했을 때 가능한 세 가지 결과입니다.

그런 다음 Ordering 타입을 사용하는 새 줄을 맨 아래에 다섯 개 추가합니다. cmp 메서드는 두 값을 비교하며, 비교할 수 있는 어떤 것에든 호출할 수 있어요. 비교하려는 대상에 대한 참조를 받습니다. 여기서는 guesssecret_number와 비교하고 있죠. 그런 다음 use 문으로 스코프에 가져온 Ordering enum의 variant 하나를 반환합니다. guesssecret_number의 값으로 cmp 호출에서 반환된 Ordering의 variant에 따라 무엇을 할지 결정하기 위해 match 표현식을 사용합니다.

match 표현식은 arm(팔)들로 구성됩니다. arm은 대조할 *패턴(pattern)*과, match에 주어진 값이 그 arm의 패턴에 맞으면 실행될 코드로 이뤄져 있어요. Rust는 match에 주어진 값을 가져와 각 arm의 패턴을 차례로 훑습니다. 패턴과 match 구조는 강력한 Rust 기능입니다. 코드가 마주칠 수 있는 다양한 상황을 표현하게 해주고, 그것들을 모두 처리하도록 보장하죠. 이 기능들은 각각 Chapter 6과 Chapter 19에서 자세히 다룰 거예요.

여기서 사용하는 match 표현식의 예시를 하나 따라가 볼게요. 사용자가 50을 맞혔고, 이번에 랜덤으로 생성된 비밀 숫자가 38이라고 해볼게요.

코드가 50을 38과 비교하면 cmp 메서드는 Ordering::Greater를 반환합니다. 50이 38보다 크기 때문이죠. match 표현식은 Ordering::Greater 값을 받아 각 arm의 패턴을 확인하기 시작합니다. 첫 번째 arm의 패턴인 Ordering::Less를 보고 Ordering::Greater 값이 Ordering::Less와 일치하지 않는다는 걸 확인하므로, 그 arm의 코드는 무시하고 다음 arm으로 이동합니다. 다음 arm의 패턴은 Ordering::Greater인데, Ordering::Greater일치하네요! 그 arm에 연결된 코드가 실행되어 화면에 Too big!를 출력합니다. match 표현식은 첫 번째 성공적인 일치 후에 끝나므로, 이 시나리오에서는 마지막 arm은 보지 않아요.

하지만 Listing 2-4의 코드는 아직 컴파일되지 않습니다. 실행해볼게요.

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/cmp.rs:979:8

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error

오류의 핵심은 *타입 불일치(mismatched types)*가 있다는 것입니다. Rust는 강력하고 정적인 타입 시스템을 가집니다. 하지만 타입 추론(type inference)도 있어요. let mut guess = String::new()를 썼을 때 Rust는 guessString이어야 한다는 것을 추론할 수 있었고, 우리가 타입을 쓰게 만들지 않았어요. 반면 secret_number는 숫자 타입입니다. 몇몇 Rust 숫자 타입이 1과 100 사이의 값을 가질 수 있는데, 예를 들어 32비트 숫자인 i32, 부호 없는 32비트 숫자인 u32, 64비트 숫자인 i64 등등이 있어요. 달리 지정하지 않으면 Rust는 기본적으로 i32를 사용하며, 다른 곳에 Rust가 다른 숫자 타입을 추론하게 하는 타입 정보를 추가하지 않는 한 secret_number의 타입이 i32가 됩니다. 오류의 이유는 Rust가 문자열과 숫자 타입을 비교할 수 없기 때문이에요.

궁극적으로 우리가 원하는 것은, 프로그램이 입력으로 읽은 String을 숫자 타입으로 변환해 비밀 숫자와 수치적으로 비교할 수 있게 하는 것입니다. 이를 위해 main 함수 본문에 이 줄을 추가합니다.

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

그 줄은 이렇게요.

let guess: u32 = guess.trim().parse().expect("Please type a number!");

guess라는 변수를 만듭니다. 잠깐, 프로그램에 이미 guess라는 변수가 있지 않나요? 맞아요, 있지만 도움 되게도 Rust는 guess의 이전 값을 새 값으로 가리는(shadow) 것을 허용합니다. *섀도잉(shadowing)*을 사용하면 guess_strguess처럼 고유한 변수 두 개를 만들도록 강요하는 대신 guess라는 변수 이름을 재사용할 수 있어요. 이에 대해 더 자세히는 Chapter 3에서 다룰 거지만, 지금은 값을 한 타입에서 다른 타입으로 변환하고 싶을 때 이 기능을 자주 쓴다는 것만 알아두세요.

이 새 변수를 guess.trim().parse()라는 표현식에 바인딩합니다. 표현식의 guess는 입력을 문자열로 담고 있던 원래 guess 변수를 가리켜요. String 인스턴스의 trim 메서드는 시작과 끝의 모든 공백을 제거하는데, 이것은 문자열을 숫자 데이터만 담을 수 있는 u32로 변환하기 전에 반드시 해야 해요. 사용자가 추측값을 입력하려면 read_line을 만족시키기 위해 엔터를 눌러야 하므로, 문자열에 줄바꿈 문자가 추가됩니다. 예를 들어 사용자가 5를 입력하고 엔터를 누르면 guess5\n처럼 보여요. \n은 "줄바꿈(newline)"을 나타냅니다. (Windows에서는 엔터를 누르면 캐리지 리턴과 줄바꿈, \r\n이 생깁니다.) trim 메서드는 \n이나 \r\n을 제거해 그냥 5만 남게 합니다.

문자열의 parse 메서드는 문자열을 다른 타입으로 변환합니다. 여기서는 문자열에서 숫자로 변환하는 데 사용하죠. let guess: u32로 원하는 정확한 숫자 타입을 Rust에게 알려줘야 합니다. guess 뒤의 콜론(:)은 Rust에게 변수의 타입을 주석으로 달 것임을 알려줍니다. Rust에는 몇 가지 내장 숫자 타입이 있는데, 여기 보이는 u32는 부호 없는 32비트 정수입니다. 작은 양수에 좋은 기본 선택이에요. 다른 숫자 타입들은 Chapter 3에서 배우게 될 거예요.

추가로, 이 예시 프로그램의 u32 주석과 secret_number와의 비교 덕분에 Rust는 secret_numberu32여야 한다고 추론할 것입니다. 이제 비교가 같은 타입의 두 값 사이에서 이뤄지게 됐어요!

parse 메서드는 논리적으로 숫자로 변환될 수 있는 문자에서만 동작하므로 쉽게 오류를 일으킬 수 있어요. 예를 들어 문자열이 A👍%를 담고 있다면 그것을 숫자로 변환할 방법이 없죠. 실패할 수 있기 때문에 parse 메서드는 앞서 "Handling Potential Failure with Result"에서 논의한 read_line 메서드처럼 Result 타입을 반환합니다. 이 Result를 같은 방식으로 expect 메서드를 사용해 다룰게요. parse가 문자열에서 숫자를 만들 수 없어 Err Result variant를 반환하면 expect 호출이 게임을 crash 시키고 우리가 준 메시지를 출력합니다. parse가 문자열을 숫자로 성공적으로 변환할 수 있으면 ResultOk variant를 반환하고, expectOk 값에서 우리가 원하는 숫자를 반환합니다.

이제 프로그램을 실행해볼게요.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

좋아요! 추측값 앞에 공백이 추가됐는데도 프로그램은 여전히 사용자가 76을 맞혔다는 걸 알아냈어요. 서로 다른 종류의 입력으로 다른 동작을 확인하기 위해 프로그램을 몇 번 실행해보세요. 숫자를 정확히 맞히거나, 너무 높은 숫자를 맞히거나, 너무 낮은 숫자를 맞히는 식으로요.

이제 게임의 대부분이 동작하지만, 사용자는 추측을 한 번만 할 수 있어요. 루프를 추가해 그것을 바꿔볼게요!

루프로 여러 번 추측 허용하기

loop 키워드는 무한 루프를 만듭니다. 사용자가 숫자를 맞힐 더 많은 기회를 주기 위해 루프를 추가할게요.

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

보시다시피, 추측 입력 프롬프트부터의 모든 것을 루프 안으로 옮겼습니다. 루프 안의 줄들을 각각 네 칸씩 더 들여쓰기하고 프로그램을 다시 실행하도록 하세요. 이제 프로그램이 영원히 추측값을 물어볼 텐데, 사실 이것은 새로운 문제를 도입해요. 사용자가 그만둘 수 없는 것처럼 보이잖아요!

사용자는 키보드 단축키 ctrl-C로 프로그램을 언제든 중단할 수 있어요. 하지만 "Comparing the Guess to the Secret Number"에서 parse를 논의할 때 언급한 대로 이 끝없는 괴물에서 벗어날 다른 방법이 있어요. 사용자가 숫자가 아닌 답을 입력하면 프로그램이 crash 하죠. 여기 보이는 것처럼 그걸 이용해 사용자가 종료할 수 있게 할 수 있어요.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit

thread 'main' panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

quit을 입력하면 게임이 종료되지만, 다른 어떤 숫자가 아닌 입력도 마찬가지로 종료시킨다는 걸 눈치챌 거예요. 이는 최소한 말해도 최적이 아닙니다. 우리는 올바른 숫자를 맞혔을 때도 게임이 멈추기를 원해요.

올바른 추측 후 종료하기

break 문을 추가해 사용자가 이겼을 때 종료하도록 게임을 프로그래밍해볼게요.

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

You win! 다음에 break 줄을 추가하면 사용자가 비밀 숫자를 정확히 맞혔을 때 프로그램이 루프를 빠져나옵니다. 루프가 main의 마지막 부분이므로 루프를 빠져나가는 것은 프로그램을 종료하는 것이기도 해요.

잘못된 입력 처리하기

게임 동작을 더 다듬기 위해, 사용자가 숫자가 아닌 값을 입력할 때 프로그램을 crash 시키는 대신 게임이 숫자가 아닌 값을 무시하고 사용자가 계속 추측할 수 있게 만들어볼게요. guessString에서 u32로 변환되는 줄을 Listing 2-5처럼 바꾸면 됩니다.

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

expect 호출에서 match 표현식으로 바꿔서 오류 시 crash 하는 것에서 오류를 처리하는 것으로 옮겨갔습니다. parseResult 타입을 반환하고 ResultOkErr variant를 가진 enum이라는 걸 기억하세요. cmp 메서드의 Ordering 결과로 했던 것처럼 여기서 match 표현식을 사용하고 있습니다.

parse가 문자열을 숫자로 성공적으로 바꿀 수 있다면 그 결과 숫자를 담은 Ok 값을 반환합니다. 그 Ok 값은 첫 번째 arm의 패턴과 일치하고, match 표현식은 parse가 만들어 Ok 값 안에 넣은 num 값을 그냥 반환합니다. 그 숫자는 우리가 만들고 있는 새 guess 변수에서 우리가 원하는 바로 그 자리에 오게 됩니다.

parse가 문자열을 숫자로 바꾸지 하면 오류에 대한 더 많은 정보를 담은 Err 값을 반환합니다. Err 값은 첫 번째 match arm의 Ok(num) 패턴과 일치하지 않지만, 두 번째 arm의 Err(_) 패턴과는 일치해요. 밑줄 _은 catch-all 값입니다. 이 예시에서는 그 안에 어떤 정보가 있든 상관없이 모든 Err 값을 일치시키고 싶다고 말하는 거예요. 그래서 프로그램은 두 번째 arm의 코드인 continue를 실행하며, 이는 프로그램에게 loop의 다음 반복으로 가서 또 다른 추측값을 물어보라고 지시합니다. 즉 효과적으로, 프로그램은 parse가 마주할 수 있는 모든 오류를 무시합니다!

이제 프로그램의 모든 것이 예상대로 동작해야 해요. 실행해볼게요.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

멋져요! 사소한 마지막 수정 하나로 숫자 맞히기 게임을 끝낼 거예요. 프로그램이 여전히 비밀 숫자를 출력하고 있다는 걸 기억하세요. 테스트에는 잘 동작했지만 게임을 망치죠. 비밀 숫자를 출력하는 println!을 삭제해볼게요. Listing 2-6이 최종 코드입니다.

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

이 시점에서 숫자 맞히기 게임을 성공적으로 만들었어요. 축하합니다!

요약

이 프로젝트는 let, match, 함수, 외부 crate 사용 같은 많은 새 Rust 개념을 소개하는 실습 방식이었어요. 다음 몇 챕터에서 이 개념들을 더 자세히 배우게 될 거예요. Chapter 3은 대부분의 프로그래밍 언어가 가진 변수, 데이터 타입, 함수 같은 개념을 다루고 그것들을 Rust에서 어떻게 쓰는지 보여줍니다. Chapter 4는 Rust를 다른 언어와 다르게 만드는 기능인 소유권(ownership)을 탐구하고, Chapter 5는 구조체(struct)와 메서드 문법을, Chapter 6은 enum이 어떻게 동작하는지 설명해요.

더 알아보기 (Learn more)