Skip to content

2. Programming a Guessing Game


실습 프로젝트를 함께 진행하면서 Rust에 대해 알아봅시다. 이 장에서는 실제 프로그램에서 사용하는 방법을 보여줌으로써 몇 가지 일반적인 Rust 개념을 소개합니다. let, match, 메서드, 관련 함수, 크레이트 등에 대해 배우게 됩니다. 다음 장에서는 이러한 개념을 좀 더 자세히 살펴볼 것입니다. 이 장에서는 기본 기능만 연습해 보겠습니다.

고전적인 초급 프로그래밍 문제인 추측 게임을 구현해 보겠습니다. 작동 방식은 다음과 같습니다. 프로그램은 1에서 100 사이의 임의의 정수를 생성합니다. 그런 다음 플레이어에게 추측(Guess)을 입력하라는 메시지를 표시합니다. 추측을 입력하면 프로그램은 추측이 너무 낮거나 너무 높은지 여부를 표시합니다. 추측이 맞으면 게임이 축하 메시지를 출력하고 종료됩니다.

새 프로젝트 설정하기

새 프로젝트를 설정하려면 1장에서 생성한 project 디렉터리로 이동하여 다음과 같이 Cargo를 사용하여 새 프로젝트를 만듭니다:

cargo new guessing_game
cd guessing_game

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

생성된 Cargo.toml 파일을 확인합니다:

Cargo.toml
[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

1장에서 살펴본 것처럼 cargo new는 "Hello, world!" 프로그램을 생성합니다. src/main.rs 파일을 확인하세요:

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 [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Hello, world!

run 명령은 이 게임에서처럼 프로젝트를 빠르게 반복해야 할 때 유용하며, 다음 반복으로 넘어가기 전에 각 반복을 빠르게 테스트할 수 있습니다.

src/main.rs 파일을 다시 엽니다. 이 파일에 모든 코드를 작성할 것입니다.

추측 처리하기

추측 게임 프로그램의 첫 번째 부분은 사용자 입력을 요청하고, 그 입력을 처리하고, 입력이 예상된 형태인지 확인하는 것입니다. 먼저 플레이어가 추측을 입력하도록 허용하겠습니다. 목록 2-1의 코드를 src/main.rs에 입력합니다.

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}");
}

목록 2-1: Code that gets a guess from the user and prints it

이 코드에는 많은 정보가 포함되어 있으므로 한 줄씩 살펴봅시다. 사용자 입력을 받은 다음 결과를 출력으로 출력하려면 io 입력/출력 라이브러리를 범위로 가져와야 합니다. io 라이브러리는 표준 라이브러리인 std에서 제공됩니다:

use std::io;

기본적으로 Rust에는 모든 프로그램의 범위로 가져오는 표준 라이브러리에 정의된 항목 집합이 있습니다. 이 세트를 프렐루드(prelude)라고 하며, 표준 라이브러리 문서에서 이 세트의 모든 항목을 확인할 수 있습니다.

사용하려는 타입이 프렐루드에 없는 경우 use 문을 사용하여 해당 타입을 명시적으로 범위로 가져와야 합니다. std::io 라이브러리를 사용하면 사용자 입력을 수락하는 기능을 비롯한 여러 가지 유용한 기능을 사용할 수 있습니다.

1장에서 살펴본 것처럼 main 함수는 프로그램의 진입점입니다:

fn main() {

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

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

    println!("Guess the number!");

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

이 코드는 게임이 무엇인지에 대한 프롬프트를 출력하고 사용자에게 입력을 요청합니다.

변수로 값 저장하기

다음으로 사용자 입력을 저장할 변수를 다음과 같이 생성하겠습니다:

    let mut guess = String::new();

이 작은 줄에는 많은 일이 일어나고 있습니다. let 문을 사용하여 변수를 생성합니다. 다음은 또 다른 예제입니다:

let apples = 5;

이 줄은 apples라는 새 변수를 생성하고 5라는 값에 바인딩합니다. Rust에서 변수는 기본적으로 불변이므로 변수에 값을 지정하면 그 값은 변경되지 않습니다. 이 개념에 대해서는 3장의 "변수와 가변성" 섹션에서 자세히 설명하겠습니다. 변수를 변경 가능하게 만들려면 변수 이름 앞에 mut을 추가합니다:

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

Note

// 구문은 줄이 끝날 때까지 계속되는 주석을 시작합니다. Rust는 주석의 모든 내용을 무시합니다. 주석에 대해서는 3장에서 더 자세히 설명하겠습니다.

추측 게임 프로그램으로 돌아가서, 이제 let mut guessguess라는 가변 변수를 생성한다는 것을 알았습니다. 등호(=)는 이제 변수에 무언가를 바인딩하고 싶다는 것을 Rust에게 알려줍니다. 등호 오른쪽에는 guess가 바인딩되는 값이 있는데, 이는 String의 새 인스턴스를 반환하는 함수인 String::new를 호출한 결과입니다. String은 표준 라이브러리에서 제공하는 문자열 타입으로, 확장 가능한 UTF-8 인코딩된 텍스트 비트입니다.

::new 줄의 :: 구문은 newString 타입의 연관 함수임을 나타냅니다. 연관 함수(associated function)는 특정 타입(이 경우 String)에 구현된 함수입니다. 이 new 함수는 빈 문자열을 새로 만듭니다. new 함수는 어떤 종류의 새 값을 만드는 함수의 일반적인 이름이기 때문에 많은 타입에서 new 함수를 찾을 수 있습니다.

전체적으로 보면, let mut guess = String::new(); 줄은 현재 빈 String의 새 인스턴스에 바인딩된 변경 가능한 변수를 생성했습니다.

사용자 입력 받기

프로그램의 첫 줄에 use std::io;를 사용하여 표준 라이브러리의 입력/출력 기능을 포함했음을 기억하세요. 이제 사용자 입력을 처리할 수 있도록 io 모듈에서 stdin 함수를 호출하겠습니다:

    io::stdin()
        .read_line(&mut guess)

프로그램 시작 시 use std::io;를 사용하여 io 라이브러리를 임포트하지 않았다면 이 함수 호출을 std::io::stdin으로 작성하여 함수를 계속 사용할 수 있습니다. stdin 함수는 터미널의 표준 입력에 대한 핸들을 나타내는 타입인 std::io::Stdin의 인스턴스를 반환합니다.

다음으로 .read_line(&mut guess) 줄은 표준 입력 핸들에서 read_line 메서드를 호출하여 사용자로부터 입력을 받습니다. 또한 사용자 입력을 어떤 문자열에 저장할지 알려주기 위해 read_line에 인자로 &mut guess를 전달합니다. read_line의 전체 작업은 사용자가 표준 입력에 입력하는 모든 것을 가져와서 내용을 덮어쓰지 않고 문자열에 추가하는 것이므로 해당 문자열을 인수로 전달합니다. 메서드가 문자열의 내용을 변경할 수 있도록 문자열 인수는 변경 가능해야 합니다.

&는 이 인수가 참조(references)임을 나타내며, 이를 통해 코드의 여러 부분에서 해당 데이터를 메모리에 여러 번 복사할 필요 없이 하나의 데이터에 액세스할 수 있는 방법을 제공합니다. 참조는 복잡한 기능이지만, Rust의 주요 장점 중 하나는 참조를 사용하는 것이 얼마나 안전하고 쉬운가 하는 점입니다. 이 프로그램을 완성하기 위해 이러한 세부 사항을 많이 알 필요는 없습니다. 지금은 변수와 마찬가지로 참조도 기본적으로 불변이라는 것만 알아두면 됩니다. 따라서 변경 가능하게 만들려면 &guess가 아닌 &mut guess를 작성해야 합니다. (4장에서 참조에 대해 더 자세히 설명하겠습니다.)

Result로 잠재적 실패 처리하기

이 코드 라인은 여전히 작업 중입니다. 이제 세 번째 텍스트 줄에 대해 논의하고 있지만, 여전히 하나의 논리적 코드 줄의 일부라는 점에 유의하세요. 다음 부분은 이 메서드입니다:

        .expect("Failed to read line");

이 코드를 다음과 같이 작성할 수도 있습니다:

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

그러나 한 줄이 길면 읽기 어렵기 때문에 나누는 것이 가장 좋습니다. .method_name() 구문으로 메서드를 호출할 때 긴 줄을 나누기 위해 개행과 다른 공백을 도입하는 것이 좋습니다. 이제 이 줄이 무엇을 하는지 살펴보겠습니다.

앞서 언급했듯이 read_line은 사용자가 입력하는 모든 내용을 전달한 문자열에 넣지만, Result 값도 반환합니다. Result열거형(enum)이라고도 하며, 여러 가지 가능한 상태 중 하나에 속할 수 있는 타입입니다. 가능한 각 상태를 변형(variant)이라고 부릅니다.

6장에서는 열거형에 대해 더 자세히 다룹니다. 이러한 Result 타입의 목적은 오류 처리 정보를 인코딩하는 것입니다.

Result의 변형은 OkErr입니다. Ok 변형은 작업이 성공했음을 나타내며, Ok 내부에는 성공적으로 생성된 값이 있습니다. Err 변형은 작업이 실패했음을 의미하며 Err에는 작업이 실패한 방법 또는 이유에 대한 정보가 포함됩니다.

다른 타입의 값과 마찬가지로 Result 타입의 값에는 메서드가 정의되어 있습니다. Result의 인스턴스에는 호출할 수 있는 expect 메서드가 있습니다. 이 Result 인스턴스가 Err 값인 경우 expect를 호출하면 프로그램이 충돌하고 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: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

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

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

경고를 억제하는 올바른 방법은 실제로 오류 처리 코드를 작성하는 것이지만, 여기서는 문제가 발생했을 때 이 프로그램을 크래시하고 싶기 때문에 expect를 사용할 수 있습니다. 오류에서 복구하는 방법은 9장에서 배우게 됩니다.

println! 자리 표시자로 값 출력하기

닫는 중괄호를 제외하고 지금까지 코드에서 논의할 내용은 한 줄만 더 있습니다:

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

이 줄은 이제 사용자의 입력이 포함된 문자열을 출력합니다. 중괄호 {} 집합은 자리 표시자입니다. {}는 값을 제자리에 고정하는 작은 게 집게로 생각하면 됩니다. 변수 값을 출력할 때 변수 이름은 중괄호 안에 들어갈 수 있습니다. 표현식 평가 결과를 출력할 때는 형식 문자열에 빈 중괄호를 넣은 다음 쉼표로 구분된 표현식 목록과 함께 형식 문자열을 따라 각 빈 중괄호 자리 표시자에 동일한 순서로 출력합니다. 변수와 표현식의 결과를 println! 호출 한 번으로 출력하면 다음과 같이 표시됩니다:

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 [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 크레이트를 제공합니다.

크레이트를 사용하여 더 많은 기능 얻기

크레이트는 Rust 소스 코드 파일의 모음이라는 것을 기억하세요. 우리가 빌드하고 있는 프로젝트는 실행 파일인 바이너리 크레이트입니다. rand 크레이트는 라이브러리 크레이트로, 다른 프로그램에서 사용하기 위한 코드가 포함되어 있으며 자체적으로 실행할 수 없습니다.

외부 크레이트를 조정하는 것은 Cargo의 진가가 발휘되는 부분입니다. rand를 사용하는 코드를 작성하려면 먼저 rand를 종속성으로 포함하도록 Cargo.toml 파일을 수정해야 합니다. 지금 해당 파일을 열고 Cargo에서 생성한 [dependencies] 섹션 헤더 아래 하단에 다음 줄을 추가합니다. 이 버전 번호와 함께 여기에 있는 것처럼 정확히 rand를 지정해야 하며, 그렇지 않으면 이 튜토리얼의 코드 예제가 작동하지 않을 수 있습니다:

Cargo.toml
[dependencies]
rand = "0.8.5"

Cargo.toml 파일에서 헤더 뒤에 오는 모든 것은 다른 섹션이 시작될 때까지 계속되는 해당 섹션의 일부입니다. [dependencies]에서는 프로젝트가 의존하는 외부 크레이트와 필요한 해당 크레이트의 버전을 Cargo에 알려줍니다. 이 경우 시맨틱 버전 지정자 0.8.5rand 크레이트를 지정합니다. Cargo는 버전 번호를 작성하는 표준인 시맨틱 버전 관리(SemVer라고도 함)를 이해합니다. 0.8.5 지정자는 실제로 ^0.8.5의 줄임말로, 0.8.5 이상 0.9.0 미만의 모든 버전을 의미합니다.

Cargo는 이러한 버전을 0.8.5 버전과 호환되는 공용 API로 간주하며, 이 사양을 사용하면 이 장의 코드와 컴파일되는 최신 패치 릴리스를 받을 수 있습니다. 0.9.0 이상 버전은 다음 예제에서 사용하는 것과 동일한 API를 보장하지 않습니다.

이제 코드를 변경하지 않고 목록 2-2에 표시된 대로 프로젝트를 빌드해 보겠습니다.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
  Downloaded libc v0.2.127
  Downloaded getrandom v0.2.7
  Downloaded cfg-if v1.0.0
  Downloaded ppv-lite86 v0.2.16
  Downloaded rand_chacha v0.3.1
  Downloaded rand_core v0.6.3
   Compiling libc v0.2.127
   Compiling getrandom v0.2.7
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.16
   Compiling rand_core v0.6.3
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

목록 2-2: The output from running cargo build after adding the rand crate as a dependency

운영 체제에 따라 버전 번호가 다를 수 있으며(SemVer 덕분에 모두 코드와 호환됩니다!), 줄의 순서가 다를 수 있습니다.

외부 종속성을 포함할 때 Cargo는 해당 종속성에 필요한 모든 최신 버전을 레지스트리(registry)에서 가져오는데, 이 레지스트리는 Crates.io의 데이터 복사본입니다. Crates.io는 Rust 생태계의 사람들이 다른 사람들이 사용할 수 있도록 오픈 소스 Rust 프로젝트를 게시하는 곳입니다.

레지스트리를 업데이트한 후 Cargo는 [dependencies] 섹션을 확인하여 아직 다운로드되지 않은 모든 크레이트를 다운로드합니다. 이 경우에는 rand만 종속성으로 나열했지만, rand가 작동하기 위해 필요한 다른 크레이트도 가져옵니다. 크레이트를 다운로드한 후 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 [unoptimized + debuginfo] target(s) in 2.53 secs

이 줄은 Cargo가 src/main.rs 파일에 대한 작은 변경 사항으로만 빌드를 업데이트한다는 것을 보여줍니다. 종속성은 변경되지 않았으므로 Cargo는 이미 다운로드하고 컴파일한 종속성을 재사용할 수 있다는 것을 알고 있습니다.

Cargo.lock 파일로 재현 가능한 빌드 보장하기

Cargo에는 사용자 또는 다른 사람이 코드를 빌드할 때마다 동일한 아티팩트를 다시 빌드할 수 있도록 보장하는 메커니즘이 있습니다: Cargo는 사용자가 별도로 지정하지 않는 한 지정한 종속성 버전만 사용합니다. 예를 들어 다음 주에 rand 크레이트의 0.8.6 버전이 출시되는데 이 버전에는 중요한 버그 수정이 포함되어 있지만 코드를 망가뜨리는 회귀도 포함되어 있다고 가정해 보겠습니다. 이를 처리하기 위해 Rust는 cargo build를 처음 실행할 때 Cargo.lock 파일을 생성하므로 이제 guessing_game 디렉터리에 이 파일이 있습니다.

프로젝트를 처음 빌드할 때 Cargo는 기준에 맞는 모든 종속성 버전을 파악한 다음 Cargo.lock 파일에 씁니다. 나중에 프로젝트를 빌드할 때 Cargo는 Cargo.lock 파일이 존재하는지 확인하고 버전을 다시 파악하는 모든 작업을 수행하지 않고 이 파일에 지정된 버전을 사용합니다. 이렇게 하면 자동으로 재현 가능한 빌드를 생성할 수 있습니다. 즉, Cargo.lock 파일 덕분에 명시적으로 업그레이드하기 전까지는 프로젝트가 0.8.5 버전으로 유지됩니다. Cargo.lock 파일은 재현 가능한 빌드에 중요하기 때문에 프로젝트의 나머지 코드와 함께 소스 제어에 체크하는 경우가 많습니다.

크레이트를 업데이트하여 새 버전 받기

크레이트를 업데이트하고 싶을 때 Cargo는 update 명령을 제공하면 Cargo.lock 파일을 무시하고 Cargo.toml에서 사양에 맞는 최신 버전을 모두 찾아냅니다. 그러면 Cargo가 해당 버전을 Cargo.lock 파일에 기록합니다. 그렇지 않으면 기본적으로 Cargo는 0.8.5 이상 0.9.0 미만의 버전만 찾습니다. rand 크레이트에서 0.8.6 및 0.9.0의 두 가지 새 버전을 릴리스한 경우, cargo update를 실행하면 다음과 같은 내용이 표시됩니다:

$ cargo update
    Updating crates.io index
    Updating rand v0.8.5 -> v0.8.6

Cargo는 0.9.0 릴리스를 무시합니다. 이 시점에서 현재 사용 중인 rand 크레이트의 버전이 0.8.6임을 알리는 Cargo.lock 파일에도 변경 사항이 표시됩니다. rand 버전 0.9.0 또는 0.9.x 시리즈의 모든 버전을 사용하려면 Cargo.toml 파일을 다음과 같이 업데이트해야 합니다:

[dependencies]
rand = "0.9.0"

다음에 cargo build를 실행하면 Cargo에서 사용 가능한 크레이트 레지스트리를 업데이트하고 지정한 새 버전에 따라 rand 요구 사항을 재평가합니다.

Cargo그 생태계에 대해서는 14장에서 더 자세히 설명할 것이 많지만 지금은 여기까지만 알아두면 됩니다. Cargo를 사용하면 라이브러리를 매우 쉽게 재사용할 수 있으므로, 러스타세안은 여러 패키지로 조립된 소규모 프로젝트를 작성할 수 있습니다.

난수 생성하기

난수를 사용해 추측할 숫자를 생성해 보겠습니다. 다음 단계는 목록 2-3에 표시된 것처럼 src/main.rs를 업데이트하는 것입니다.

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}");
}

목록 2-3: Adding code to generate a random number

먼저 use rand::Rng; 줄을 추가합니다. Rng 트레잇(trait)은 난수 생성기가 구현하는 메서드를 정의하며, 해당 메서드를 사용하려면 이 트레잇이 범위 내에 있어야 합니다. 10장에서는 트레잇에 대해 자세히 다룰 것입니다.

다음으로 중간에 두 줄을 추가합니다. 첫 번째 줄에서는 현재 실행 중인 스레드에 로컬이고 운영 체제에 의해 시드된 특정 난수 생성기를 제공하는 rand::thread_rng 함수를 호출합니다. 그런 다음 난수 생성기에서 gen_range 메서드를 호출합니다. 이 메서드는 rand::Rng; 문을 사용하여 범위로 가져온 Rng 트레잇에 의해 정의됩니다. gen_range 메서드는 범위 표현식을 인수로 받아 해당 범위의 난수를 생성합니다. 여기서 사용하는 범위 표현식은 start..=end 형식을 취하고 하한과 상한을 포함하므로 1에서 100 사이의 숫자를 요청하려면 1..=100을 지정해야 합니다.

Note

어떤 트레잇을 사용할지, 크레이트에서 어떤 메서드와 함수를 호출할지 알 수 없으므로 각 크레이트에는 사용 지침이 포함된 문서가 있습니다. Cargo의 또 다른 멋진 기능은 cargo doc --open 명령을 실행하면 모든 종속성에서 제공하는 문서를 로컬로 빌드하여 브라우저에서 열어볼 수 있다는 것입니다. 예를 들어 랜드 크레이트의 다른 기능에 관심이 있다면 cargo doc --open을 실행하고 왼쪽 사이드바에서 rand를 클릭하세요.

두 번째 새 줄에 비밀 번호가 출력됩니다. 이 기능은 프로그램을 개발하는 동안 테스트하는 데 유용하지만 최종 버전에서는 삭제할 예정입니다. 프로그램이 시작하자마자 답을 출력하지 않도록 하기 위함입니다.

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

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s
     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 [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

추측과 비밀 번호 비교하기

이제 사용자 입력과 난수를 얻었으므로 이를 비교할 수 있습니다. 이 단계는 목록 2-4에 나와 있습니다. 이 코드는 아직 컴파일되지 않으므로 나중에 설명하겠습니다.

src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    // --snip--

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

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

목록 2-4: Handling the possible return values of comparing two numbers

먼저 다른 use 문을 추가하여 표준 라이브러리에서 std::cmp::Ordering이라는 타입을 범위로 가져옵니다. Ordering 타입은 또 다른 열거형이며 Less, Greater, Equal 변형이 있습니다. 두 값을 비교할 때 가능한 세 가지 결과입니다.

그런 다음 하단에 Ordering 타입을 사용하는 5개의 새 줄을 추가합니다. cmp 메서드는 두 값을 비교하며 비교할 수 있는 모든 값에 대해 호출할 수 있습니다. 비교하려는 값에 대한 참조가 필요합니다. 여기서는 guesssecret_number를 비교하고 있습니다. 그런 다음 use 문을 통해 범위로 가져온 Ordering 열거형의 변형을 반환합니다. match 표현식을 사용하여 guesssecret_number의 값과 함께 cmp 호출에서 반환된 Ordering의 변형을 기반으로 다음에 수행할 작업을 결정합니다.

match 표현식은 암(arms)으로 구성됩니다. 암은 일치시킬 패턴match에 주어진 값이 해당 암의 패턴에 맞을 때 실행해야 하는 코드로 구성됩니다. Rust는 match에 주어진 값을 받아 각 암의 패턴을 차례로 살펴봅니다. 패턴과 match 구조체는 코드에서 발생할 수 있는 다양한 상황을 표현하고 모든 상황을 처리할 수 있도록 해주는 강력한 Rust 기능입니다. 이 기능들은 각각 6장과 18장에서 자세히 다룰 것입니다.

여기서 사용하는 match 표현식을 예로 들어보겠습니다. 사용자가 50을 맞혔고 이번에 무작위로 생성된 비밀 번호가 38이라고 가정해 보겠습니다.

코드에서 50과 38을 비교하면 50이 38보다 크므로 cmp 메서드는 Ordering::Greater를 반환합니다. match 표현식은 Ordering::Greater 값을 가져와서 각 암의 패턴을 확인하기 시작합니다. 첫 번째 암의 패턴인 Ordering::Less를 살펴본 결과 Ordering::Greater 값이 Ordering::Less와 일치하지 않으므로 해당 암의 코드를 무시하고 다음 암으로 이동합니다. 다음 암의 패턴은 Ordering::Greater로, Ordering::Greater일치합니다. 해당 암의 관련 코드가 실행되어 화면에 Too big!을 출력합니다. match 표현식은 첫 번째 일치에 성공한 후에 종료되므로 이 시나리오에서는 마지막 암을 보지 않습니다.

그러나 목록 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:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected struct `String`, found integer
   |                 |
   |                 arguments to this function are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: associated function defined here

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

오류의 핵심은 일치하지 않는 타입이 있다는 것입니다. Rust는 강력하고 정적인 타입 시스템을 가지고 있습니다. 하지만 타입 추론 기능도 있습니다. let mut guess = String::new()를 작성했을 때 Rust는 guessString이어야 한다는 것을 추론하고 해당 타입을 작성하지 않았습니다. 반면 secret_number는 숫자 타입입니다. 32비트 숫자 i32, 부호 없는 32비트 숫자 u32, 64비트 숫자 i64 등 Rust의 숫자 타입 중 일부는 1에서 100 사이의 값을 가질 수 있습니다. 달리 지정하지 않는 한, Rust가 다른 숫자 타입을 유추할 수 있도록 다른 곳에 타입 정보를 추가하지 않는 한 Rust는 기본적으로 secret_number의 타입인 i32를 사용합니다. 이 오류의 원인은 Rust가 문자열과 숫자 타입을 비교할 수 없기 때문입니다.

궁극적으로 프로그램이 입력으로 읽은 String을 실수 타입으로 변환하여 비밀 번호와 숫자로 비교할 수 있도록 하고 싶습니다. 이를 위해 main 함수 본문에 이 줄을 추가합니다:

src/main.rs
    // --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의 이전 값을 새 값으로 섀도잉할 수 있습니다. 섀도잉(Shadowing)을 사용하면 guess 변수 이름을 재사용할 수 있으므로 예를 들어 guess_strguess와 같은 두 개의 고유 변수를 만들지 않아도 됩니다. 이 기능은 3장에서 더 자세히 다루겠지만, 지금은 한 타입에서 다른 타입으로 값을 변환할 때 자주 사용된다는 점을 알아두세요.

이 새 변수를 guess.trim().parse() 표현식에 바인딩합니다. 표현식의 guess는 입력을 문자열로 포함하는 원래 guess 변수를 참조합니다. String 인스턴스의 trim 메서드는 시작과 끝의 공백을 제거하는데, 이는 문자열을 숫자 데이터만 포함할 수 있는 u32와 비교하기 위해 반드시 수행해야 하는 작업입니다. 사용자는 엔터 키를 눌러 read_line을 충족하고 추측을 입력해야 하며, 그러면 문자열에 개행 문자가 추가됩니다. 예를 들어, 사용자가 5를 입력하고 엔터 키를 누르면 guess 값은 다음과 같이 표시됩니다: 5\n. \n은 "개행"을 나타냅니다. (Windows에서 엔터 키를 누르면 캐리지 리턴과 개행인 \r\n이 생성됩니다.) trim 메서드는 \n 또는 \r\n을 제거하여 5만 남습니다.

문자열의 parse 메서드는 문자열을 다른 타입으로 변환합니다. 여기서는 문자열을 숫자로 변환하는 데 사용합니다. let guess: u32를 사용하여 원하는 정확한 숫자 타입을 Rust에 알려야 합니다. guess 뒤에 오는 콜론(:)은 변수의 타입에 주석(annotation)을 달 것임을 Rust에 알려줍니다. Rust에는 몇 가지 기본 제공 숫자 타입이 있으며, 여기에 표시된 u32는 부호 없는 32비트 정수입니다. 작은 양의 숫자에 대한 좋은 기본 선택입니다. 다른 숫자 타입에 대해서는 3장에서 배우게 될 것입니다.

또한, 이 예제 프로그램의 u32 어노테이션과 secret_number와의 비교는 Rust가 secret_numberu32여야 한다고 추론한다는 것을 의미합니다. 이제 비교는 같은 타입의 두 값 사이에서 이루어집니다!

parse 메서드는 논리적으로 숫자로 변환할 수 있는 문자에 대해서만 작동하므로 오류가 쉽게 발생할 수 있습니다. 예를 들어 문자열에 A👍%가 포함되어 있다면 이를 숫자로 변환할 방법이 없습니다. 실패할 수 있기 때문에 parse 메서드는 read_line 메서드와 마찬가지로 Result 타입을 반환합니다("Result의 잠재적 실패 처리하기"에서 설명). expect 메서드를 다시 사용하여 이 Result를 같은 방식으로 처리하겠습니다. 문자열에서 숫자를 생성할 수 없어 parseErr Result 변형을 반환하는 경우, expect 호출은 게임을 크래시하고 우리가 제공한 메시지를 출력합니다. parse가 문자열을 숫자로 성공적으로 변환할 수 있으면 ResultOk 변형을 반환하고 expectOk 값에서 원하는 숫자를 반환합니다.

이제 프로그램을 실행해 봅시다:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     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 키워드는 무한 루프를 생성합니다. 루프를 추가하여 사용자가 숫자를 맞출 수 있는 기회를 더 많이 제공하겠습니다:

src/main.rs
    // --snip--

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

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

        // --snip--

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

보시다시피, 추측 입력 프롬프트부터 모든 것을 루프로 옮겼습니다. 루프 내부의 줄을 각각 네 칸씩 들여쓰고 프로그램을 다시 실행하세요. 이제 프로그램이 계속해서 다른 추측을 요청하므로 실제로 새로운 문제가 발생합니다. 사용자가 종료할 수 없는 것 같습니다.

사용자는 언제든지 키보드 단축키 ctrl-c를 사용하여 프로그램을 중단할 수 있습니다. 하지만 "추측과 비밀 번호 비교"parse 논의에서 언급했듯이 사용자가 숫자가 아닌 답을 입력하면 프로그램이 충돌하는 이 끝없는 괴물에서 벗어날 수 있는 또 다른 방법이 있습니다. 이 점을 이용해 아래와 같이 사용자가 종료할 수 있도록 할 수 있습니다:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     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 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

quit 키를 입력하면 게임이 종료되지만, 숫자가 아닌 다른 입력을 입력해도 게임이 종료됩니다. 정확한 숫자를 맞혔을 때 게임이 종료되기를 원하기 때문에 이는 차선책이라고 할 수 있습니다.

정답을 맞힌 후 종료하기

break 문을 추가하여 사용자가 이기면 게임이 종료되도록 프로그래밍해 봅시다:

src/main.rs
        // --snip--

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

You win! 뒤에 줄 바꿈을 추가하면 사용자가 비밀 번호를 정확하게 맞히면 프로그램이 루프를 종료합니다. 루프는 main 코드의 마지막 부분이기 때문에 루프를 종료한다는 것은 프로그램을 종료한다는 의미이기도 합니다.

유효하지 않은 입력 처리하기

게임의 동작을 더 세분화하기 위해 사용자가 숫자가 아닌 것을 입력할 때 프로그램을 충돌시키는 대신 숫자가 아닌 것을 무시하여 사용자가 계속 추측할 수 있도록 만들어 봅시다. 목록 2-5에 표시된 것처럼 guessString에서 u32로 변환되는 줄을 변경하여 이를 수행할 수 있습니다.

src/main.rs
        // --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--

목록 2-5: Ignoring a non-number guess and asking for another guess instead of crashing the program

오류 발생 시 크래시에서 오류 처리로 전환하기 위해 expect 호출에서 match 표현식으로 전환합니다. parseResult 타입을 반환하고 ResultOkErr 변형을 포함하는 열거형이라는 점을 기억하세요. 여기서는 cmp 메서드의 Ordering 결과에서와 마찬가지로 match 표현식을 사용하고 있습니다.

parse가 문자열을 숫자로 성공적으로 변환할 수 있으면 결과 숫자가 포함된 Ok 값을 반환합니다. 이 Ok 값은 첫 번째 암의 패턴과 일치하며, match 표현식은 parse가 생성한 num 값을 반환하여 Ok 값 안에 넣습니다. 이 숫자는 우리가 생성하는 새 guess 변수의 원하는 위치에 바로 배치됩니다.

parse가 문자열을 숫자로 변환할 수 없는 경우 오류에 대한 자세한 정보가 포함된 Err 값을 반환합니다. Err 값은 첫 번째 일치 항목의 Ok(num) 패턴과 일치하지 않지만, 두 번째 항목의 Err(_) 패턴과 일치합니다. 밑줄인 _는 포괄적인 값으로, 이 예제에서는 어떤 정보가 들어 있든 상관없이 모든 Err 값을 일치시키겠다는 의미입니다. 따라서 프로그램은 두 번째 암의 코드를 실행하고 계속하여 loop의 다음 반복으로 이동하여 다른 추측을 요청합니다. 따라서 사실상 프로그램은 parse에서 발생할 수 있는 모든 오류를 무시합니다.

이제 프로그램의 모든 것이 예상대로 작동합니다. 한번 해봅시다:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 4.45s
     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! 함수를 삭제해 봅시다. 목록 2-6은 최종 코드를 보여줍니다.

src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

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;
            }
        }
    }
}

목록 2-6: Complete guessing game code

이 시점에서 추측 게임을 성공적으로 구축했습니다.

요약

이 프로젝트는 let, match, 함수, 외부 크레이트 사용 등 여러 가지 새로운 Rust 개념을 소개하는 실습 방식이었습니다. 다음 몇 장에서는 이러한 개념에 대해 더 자세히 배우게 될 것입니다. 3장에서는 변수, 데이터 타입, 함수 등 대부분의 프로그래밍 언어가 가지고 있는 개념을 다루고 Rust에서 이러한 개념을 사용하는 방법을 보여줍니다. 4장에서는 다른 언어와 다른 Rust의 특징인 소유권에 대해 살펴봅니다. 5장에서는 구조체와 메서드 구문에 대해 설명하고, 6장에서는 열거형의 작동 원리를 설명합니다.


References