본문으로 건너뛰기

안동민 개발노트

본문 시작

추리 게임

숫자 추리 게임을 만들며 변수·입력·Result·match를 사용하고 rand 크레이트 의존성을 연결합니다.

실습 프로젝트를 통해 러스트를 사용해봅시다.

이번 장은 실제 프로젝트에서 몇몇 일반적인 러스트의 개념이 어떻게 활용되는지를 소개하려 합니다.

이 과정에서 let, match, 메서드, 연관 함수(associated functions), 외부 크레이트(external crates) 등의 활용 방법을 배울 수 있습니다.

이런 개념들은 다음 장들에서 더 자세히 다뤄질 것입니다.

이번 장에서는 여러분이 직접 기초적인 내용을 실습합니다.

여기서는 고전적인 입문자용 프로그래밍 문제인 추리 게임을 구현해 보려 합니다.

먼저 프로그램은 1~100 사이에 있는 임의의 정수를 생성합니다.

다음으로 플레이어가 프로그램에 추리한 정수를 입력합니다.

프로그램은 입력받은 추릿값이 정답보다 높거나 낮음을 알려줍니다.

추릿값이 정답이라면 축하 메시지를 보여주고 종료됩니다.

사용자가 한 번 입력한 문자열이 read_line의 Result 처리, trim과 숫자 parse를 거쳐 cmp와 match의 Less, Equal, Greater 결과로 나뉘는 흐름. 이 다이어그램은 반복, continue, break를 포함하지 않습니다.

Rust · input · Result · flowchart

한 번의 입력은 두 실패 경계를 지나 비교 결과가 된다

입력은 곧바로 숫자가 되지 않습니다. read_line의 I/O Result를 처리하고, 개행을 제거한 뒤 u32로 parse해야 비밀 숫자와 비교할 수 있습니다. 이 문맥의 실패 정책은 expect로 종료하는 것입니다.

단일 추릿값 입력과 비교 흐름 빈 String에 한 번 입력을 받고 read_line이 반환한 Result가 Err이면 expect로 종료합니다. Ok이면 trim과 parse를 거쳐 u32로 바꾸고, parse 실패 역시 expect로 종료합니다. 성공한 숫자는 cmp와 match를 거쳐 Less, Equal, Greater 출력 중 하나로 이어집니다. ERR OK LESS EQUAL GREATER 추릿값 한 번 입력 let mut guess = String::new() 표준 입력을 String에 추가 read_line(&mut guess) read_line의 Result Ok(bytes) | Err(io::Error) 입력 I/O 실패 expect → panic · stop 개행 제거 · 숫자 변환 trim().parse::<u32>() · Err → expect 비밀 숫자와 비교 guess.cmp(...) → match Ordering 작은 값 Ordering::Less · Too small! 정답 Ordering::Equal · You win! 큰 값 Ordering::Greater · Too big!
input

1. 빈 String에 한 번 입력

String::new()으로 버퍼를 만들고 read_line(&mut guess)가 입력을 추가합니다.

I/O Result

2. 성공과 실패를 분리

Ok(bytes)면 계속하고, Err(io::Error)면 이 문맥의 expect 정책에 따라 종료합니다.

convert

3. trim().parse::<u32>()

개행을 지우고 숫자로 변환합니다. 여기서 반환되는 Result도 현재 예제에서는 expect로 처리하므로 잘못된 입력은 종료됩니다.

compare

4. cmpmatch

LessToo small!, EqualYou win!, GreaterToo big! 한 갈래만 실행합니다.

Result의 오류가 다르다

read_lineErr는 I/O 실패이고, parseErr는 문자열을 u32로 바꾸지 못한 실패입니다.

이 그림의 종료점

한 번의 입력과 비교까지만 다룹니다. 다음 문서가 설명하는 loop, continue, break 의미는 이 흐름에 포함하지 않습니다.

타입 경계는 String → Result → u32 → Ordering 순서입니다. 각 단계의 반환 타입을 확인하면 compiler 진단이 어느 경계에서 발생했는지 좁힐 수 있습니다.


새로운 프로젝트를 준비하기

새로운 프로젝트를 준비하기 위해 1장에서 생성했던 디렉터리인 projects로 이동하고 아래와 같이 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 = "2024"

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

이제 cargo run 명령문을 이용하여 이 ‘Hello, world!’ 프로그램을 컴파일하고 실행해봅시다.

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

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

src/main.rs를 다시 열어 두세요.

이 파일에 모든 코드를 작성할 것입니다.


추릿값을 처리하기

프로그램의 첫 부분에서는 사용자 입력 요청, 입력값의 처리 후 입력값이 기대하던 형식인지 검증합니다.

첫 시작으로 플레이어가 추리한 값을 입력받을 수 있게 할 것입니다.

예제 1-1의 코드를 src/main.rs 에 작성하세요.

예제 1-1: 사용자가 추리한 값을 입력받아 그대로 출력하는 코드
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}");
}

이 코드에 담긴 다양한 정보를 한 줄씩 살펴보겠습니다.

사용자 입력을 받고 결괏값을 표시하기 위해서는 io 입출력 라이브러리를 스코프로 가져와야 합니다.

io 라이브러리는 std라고 불리는 표준 라이브러리에 있습니다.

use std::io;

기본적으로 러스트는 모든 프로그램의 스코프로 가져오는 표준 라이브러리에 정의된 아이템 집합을 가지고 있습니다.

이 집합을 프렐루드(prelude) 라고 부르며, 이와 관련한 것은 표준 라이브러리 문서에서 찾아볼 수 있습니다.

만약 여러분이 원하는 타입이 프렐루드에 없다면 use문을 활용하여 명시적으로 그 타입을 가져와야 합니다.

std::io는 사용자의 입력을 받는 것을 포함하여 io와 관련된 기능들을 제공합니다.

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

fn main() {
    // 함수 본문
}

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

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

    println!("Guess the number!");

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

이 코드는 게임에 대해 설명하여 사용자의 입력을 요청하는 프롬프트를 출력하고 있습니다.

변수에 값 저장하기

다음으로, 아래와 같이 사용자의 입력값을 저장할 변수(variable) 를 생성합니다.

    let mut guess = String::new();

이제 프로그램이 점점 흥미로워지고 있습니다!

이 짧은 라인에서 여러 일들이 벌어집니다.

변수를 만드는 데에는 let 구문을 사용합니다.

다음 코드도 변수를 선언하는 예시입니다.

let apples = 5;

이 라인은 apples라는 변수를 만들고 5라는 값을 묶어 넣습니다.

러스트에서 변수는 기본적으로 불변(immutable) 인데, 이는 변수에 어떤 값을 집어넣으면 그 값이 안 바뀔 것이란 뜻입니다.

이 개념에 대한 자세한 내용은 2장의 ‘변수와 가변성’ 절에서 논의할 예정입니다.

변수의 값을 가변(mutable), 즉 변경 가능하도록 하려면 변수명 앞에 mut를 추가합니다.

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

Note: // 문법은 현재 위치부터 라인의 끝까지 주석임을 나타냅니다. 러스트는 주석의 모든 내용을 무시합니다. 더 자세한 내용은 2장에서 설명할 예정입니다.

추리 게임 프로그램으로 다시 돌아와 보면, 이제는 let mut guessguess라는 이름의 가변 변수임을 알 수 있습니다.

등호(=)는 지금 해당 변수에 어떤 값을 묶어 넣고자 함을 뜻합니다.

등호의 오른쪽에는 guess에 묶일 값이 있는데, 이번 예시에서는 함수 String::new의 결괏값인 새로운 String 인스턴스가 묶일 대상이 됩니다.

String은 표준 라이브러리에서 제공하는 확장 가능한(growable) 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 함수는 터미널의 표준 입력의 핸들(handle)을 나타내는 타입인 std::io::Stdin의 인스턴스를 돌려줍니다.

코드의 다음 부분인 .read_line(&mut guess)는 사용자로부터 입력받기 위해 표준 입력 핸들에서 read_line 메서드를 호출합니다.

여기에 &mut guessread_line의 인수로 전달하여 사용자 입력이 어떤 문자열에 저장될 것인지 알려줍니다.

read_line의 전체 기능은 사용자가 표준 입력 장치에 입력할 때마다 입력된 문자들을 받아서 문자열에 추가하는 것이므로 문자열을 인수로 넘겨준 것입니다.

메서드가 문자열의 내용물을 바꿀 수 있기 때문에 이 문자열 인수는 가변이어야 합니다.

&는 코드의 여러 부분에서 데이터를 여러 번 메모리로 복사하지 않고 접근하기 위한 방법을 제공하는 참조자(reference) 임을 나타냅니다.

참조는 복잡한 기능이고, 러스트의 큰 이점 중 하나가 바로 참조자를 사용할 때의 안전성과 편의성입니다.

이 프로그램을 작성하기 위해 참조에 대한 자세한 내용을 알 필요는 없습니다.

지금 당장은 참조자가 변수처럼 기본적으로 불변임을 알기만 하면 됩니다.

따라서 &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 값을 돌려줍니다.

Resultenum이라고도 일컫는 열거형(enumeration)인데, 여러 개의 가능한 상태 중 하나의 값이 될 수 있는 타입입니다.

이러한 가능한 상태 값을 배리언트(variant) 라고 부릅니다.

5장에서 열거형에 대해 더 자세히 다루겠습니다.

Result 타입의 목적은 에러 처리용 정보를 담아내기 위한 것입니다.

Result의 배리언트는 OkErr입니다.

Ok는 처리가 성공했음을 나타내며 내부에 성공적으로 생성된 결과를 가지고 있습니다.

Err는 처리가 실패했음을 나타내고 그 이유에 대한 정보를 가지고 있습니다.

다른 타입들처럼 Result 타입의 값에도 메서드가 있습니다.

Result 인스턴스에는 expect 메서드가 있습니다.

만약 Result 인스턴스가 Err일 경우 expect 메서드는 프로그램의 작동을 멈추고 expect에 인수로 넘겼던 메시지를 출력하도록 합니다.

만약 read_line 메서드가 Err를 돌려줬다면 그 에러는 운영체제로부터 발생한 에러일 경우가 많습니다.

만약 ResultOk 값이라면 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

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

러스트는 read_line가 돌려주는 Result 값을 사용하지 않았음을 경고하며 일어날 수 있는 에러를 처리하지 않았음을 알려줍니다.

이 경고를 없애는 옳은 방법은 에러 처리용 코드를 작성하는 것이지만, 지금의 경우에는 문제가 발생했을 때 프로그램이 종료되는 것을 원하므로 expect를 사용할 수 있습니다.

8장에서 에러를 복구하는 방법에 대해 배우게 될 겁니다.

println! 자리표시자를 이용한 값 출력하기

지금까지 작성한 코드에서 닫는 중괄호 말고도 살펴봐야 하는 코드가 하나 더 있습니다.

내용은 아래와 같습니다.

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

이 라인은 사용자가 입력한 값을 담고 있는 문자열을 출력합니다.

{}는 자리표시자(placeholder) 입니다.

{}를 어떤 위치에 값을 자리하도록 하는 작은 집게발이라고 생각하면 됩니다.

어떤 변수의 값을 출력할 때라면 해당 변수 이름을 이 중괄호 안에 넣을 수 있습니다.

어떤 표현식의 결괏값을 출력할 때는 빈 중괄호를 형식 문자열에 위치시키고, 그 뒤에 쉼표로 구분된 표현식들을 나열하여 각 중괄호에 순차적으로 출력하도록 할 수 있습니다.

어떤 변수와 표현식 결괏값을 한 번의 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` 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 사이의 임의의 수를 사용하겠습니다.

러스트는 아직 표준 라이브러리에 임의의 값을 생성하는 기능을 포함시키지 않았습니다.

하지만 러스트 팀에서는 해당 기능을 가지고 있는 rand 크레이트(crate)를 제공합니다.

크레이트를 사용하여 더 많은 기능 가져오기

크레이트는 러스트 코드 파일들의 모음이라는 점을 기억하세요.

우리가 만들고 있는 프로젝트는 실행이 가능한 바이너리 크레이트(binary crate) 입니다.

rand 크레이트는 자체적으로 실행될 수는 없고 다른 프로그램에서 사용되기 위한 용도인 라이브러리 크레이트(library crate) 입니다.

Cargo의 외부 크레이트 조정 기능은 정말 멋진 부분입니다.

rand를 사용하는 코드를 작성하기 전에 Cargo.toml 을 수정하여 rand 크레이트를 의존성으로 추가해야 합니다.

이 파일을 열고, Cargo가 여러분을 위해 만들어둔 [dependencies] 섹션 헤더의 바로 아래에 다음의 내용을 추가하세요.

이 문서의 예제는 Rust 2024 에디션, rustc 1.85 이상, rand 0.10 계열을 하나의 계약으로 사용합니다.

Cargo.toml
[dependencies]
rand = "0.10.2"

Cargo.toml 파일에서 어떤 섹션 헤더 이후의 모든 내용은 그 섹션에 포함되며 이는 다음 섹션이 나타날 때까지 계속됩니다.

[dependencies]에서는 여러분의 프로젝트가 의존하고 있는 외부 크레이트와 각각의 요구 버전을 Cargo에게 알려주게 됩니다.

지금의 경우에는 rand 크레이트의 유의적 버전 요구사항인 0.10.2를 지정했습니다.

Cargo는 버전 명시의 표준인(종종 SemVer라고 불리는) 유의적 버전(Semantic Versioning)을 이해합니다.

지정자 0.10.2는 실제로는 ^0.10.2의 축약형인데, 이는 최소 0.10.2 이상이지만 0.11.0 아래인 버전을 허용합니다.

Cargo resolver는 manifest의 이 요구 범위와 다른 의존성의 제약을 함께 만족하는 package graph를 선택합니다.

0.11.0처럼 이 범위를 벗어난 버전은 자동으로 선택되지 않으며, 새 API로 이동하려면 manifest 요구사항과 코드를 함께 검토해야 합니다.

이제 예제 1-2처럼 코드 수정 없이 프로젝트를 빌드 해봅시다.

예제 1-2: rand 크레이트를 의존성으로 추가한 후 cargo build를 실행한 출력 결과
$ cargo build
    Updating crates.io index
     Locking packages to latest compatible versions
  Downloaded rand v0.10.2
   Compiling rand v0.10.2
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s)

여러분에게는 다른 버전명이 보이거나(하지만 SemVer 덕분에 현재 코드와 호환될 것입니다) 다른 라인들이 보이거나(운영 체제에 따라서 달라질 수 있습니다) 라인의 순서가 다르게 보일 수 있습니다.

외부 의존성을 포함시키면 Cargo는 Crates.io 레지스트리 인덱스와 manifest 요구사항을 이용해 호환 가능한 버전과 하위 의존성 graph를 해결합니다.

Crates.io는 러스트 생태계의 개발자들이 다른 사람들도 이용할 수 있도록 러스트 오픈 소스를 공개하는 곳입니다.

레지스트리를 업데이트한 후 Cargo는 [dependencies] 섹션을 확인하고 아직 다운로드하지 않은 크레이트들을 다운로드합니다.

지금의 경우에는 rand만 의존한다고 명시했지만 Cargo는 rand가 동작하기 위해 의존하고 있는 다른 크레이트들도 가져옵니다.

이것들을 다운로드한 후 러스트는 이들을 컴파일한 다음, 사용 가능한 의존성과 함께 프로젝트를 컴파일합니다.

만약 아무것도 변경하지 않고 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.lock으로 package 선택 고정하기

Cargo는 한 번 해결한 정확한 package 버전과 source 정보를 Cargo.lock에 기록합니다.

이 기록이 manifest 요구사항과 계속 맞는 동안 이후 명령은 잠긴 선택을 우선 사용합니다.

따라서 같은 lockfile을 공유하면 개발 환경과 CI가 같은 package graph를 선택하도록 만들 수 있습니다.

Cargo.lockcargo build처럼 dependency resolution이 필요한 명령을 처음 실행할 때 workspace 루트에 생성됩니다.

하지만 lockfile이 동일한 최종 바이너리 자체를 보장하지는 않습니다.

toolchain, target, feature, build profile, build script 입력, native dependency와 환경이 다르면 산출물도 달라질 수 있습니다.

CI에서 cargo build --locked를 사용하면 lockfile이 없거나 resolution 때문에 바뀌어야 할 때 Cargo가 실패하므로, package 선택 drift를 드러낼 수 있습니다.

Cargo.lock은 재현 가능한 빌드의 중요한 입력이지만, 재현성에 필요한 모든 입력을 담은 목록은 아닙니다.

이 바이너리 프로젝트에서는 Cargo.lock을 코드와 함께 version control에 포함합니다.

크레이트를 새로운 버전으로 업데이트하기

잠긴 package 선택을 새로 평가하려면 Cargo의 update 명령을 사용합니다.

인수 없이 cargo update를 실행하면 manifest 요구 범위 안에서 lockfile의 모든 package를 업데이트하려고 합니다.

-p rand처럼 package를 지정하면 해당 package를 보수적으로 업데이트하고, 필요한 경우에만 그 하위 의존성도 함께 바꿉니다.

두 경우 모두 기본 동작은 Cargo.toml 요구사항을 바꾸는 것이 아니라 새 resolution을 Cargo.lock에 기록하는 것입니다.

현재 요구 범위 안에서 rand만 다시 평가하려면 다음처럼 실행합니다.

$ cargo update -p rand

이미 최신 호환 버전이 잠겨 있다면 lockfile이 바뀌지 않을 수도 있습니다.

정확한 버전을 의도했다면 cargo update -p rand --precise 0.10.2처럼 지정할 수 있지만, 그 버전도 manifest 요구 범위를 만족해야 합니다.

앞서 쓴 caret 요구사항을 동등한 명시 범위로 적으면 다음과 같습니다.

[dependencies]
rand = ">=0.10.2, <0.11"

manifest 요구 범위 자체를 바꾸면 다음 resolution 명령에서 기존 lock entry가 새 요구를 만족하는지 확인하고, 만족하지 않으면 호환되는 선택으로 Cargo.lock을 갱신합니다.

Cargodependency resolution에 대한 더 많은 내용은 14장에서 다룰 예정이고, 지금 당장은 이 정도만 알면 됩니다.

Cargo는 라이브러리의 재사용을 쉽게 하여 러스트 사용자들이 많은 패키지들과 결합된 더 작은 프로젝트들을 작성할 수 있도록 도와줍니다.

임의의 숫자 생성하기

rand를 사용하여 추리할 임의의 숫자를 생성해봅시다.

다음 단계는 src/main.rs를 예제 1-3처럼 업데이트하면 됩니다.

예제 1-3: 임의의 숫자를 생성하기 위한 코드 추가하기
src/main.rs
use std::io;

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

    let secret_number = rand::random_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}");
}

rand::random_range 함수는 기본 thread-local 난수 생성기를 이용해 전달한 범위에서 값을 하나 생성합니다.

이 함수는 rand 0.10의 현재 top-level API이므로 별도의 extension trait import가 필요하지 않습니다. 난수 생성기를 직접 보관하며 여러 메서드를 호출할 때는 rand::rng()RngExt를 사용할 수 있지만, 이 예제는 값 하나만 필요하므로 더 작은 API를 선택합니다.

여기서 사용하고자 하는 범위 표현식은 start..=end이고 이는 상한선과 하한선을 포함하므로, 1부터 100 사이의 숫자를 생성하려면 1..=100이라고 지정해야 합니다.

Note: 어떤 크레이트에서 어떤 트레이트를 사용할지, 그리고 어떤 메서드와 함수를 호출해야 할지 모를 수도 있으므로, 각 크레이트는 사용법을 담고 있는 문서를 갖추고 있습니다. Cargo의 또 다른 멋진 기능에는 cargo doc --open 명령어를 사용하여 의존하는 크레이트의 문서를 로컬에서 모두 빌드한 다음, 브라우저에서 열어주는 기능이 있습니다. 예를 들어 rand 크레이트의 다른 기능이 궁금하다면, 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 사이의 숫자가 나타나야 합니다.

잘하셨습니다!

rand를 추가하는 순간부터는 코드 한 줄뿐 아니라 Cargo가 의존성 버전과 잠금 파일을 함께 관리한다는 점도 같이 봐야 합니다.

Cargo.toml의 rand 0.10.2 요구 범위를 resolver가 registry index와 기존 lockfile을 참고해 package graph로 해결하고, Cargo.lock에 정확한 선택을 기록한 뒤 cargo build와 rand random_range API 사용으로 이어지는 흐름

Rust · Cargo · dependency flow

manifest는 허용 범위를, lockfile은 선택 결과를 기록한다

rand = "0.10.2"는 정확한 버전 고정이 아니라 caret 요구 범위입니다. Cargo resolver가 registry와 다른 제약을 함께 계산하고, 실제로 선택한 package graph를 Cargo.lock에 남긴 뒤 그 API를 빌드합니다.

Cargo 의존성 해결과 잠금 흐름 Cargo.toml의 rand 0.10.2 요구사항은 0.11 미만의 호환 범위를 허용합니다. Cargo resolver는 registry index와 기존 lockfile을 참고해 하위 의존성을 포함한 graph를 선택하고 Cargo.lock에 정확한 package, source, checksum을 기록합니다. cargo build는 잠긴 선택으로 rand random_range API를 컴파일합니다. 요구 범위 선언 Cargo.toml rand = "0.10.2" >=0.10.2, <0.11 resolver · registry manifest constraints existing lock + index transitive package graph 선택 결과 잠금 Cargo.lock exact packages · sources checksums · revisions 빌드 · API 사용 cargo build --locked rand::random_range 1..=100
  1. 요구 범위 · Cargo.toml

    rand = "0.10.2">=0.10.2, <0.11인 caret 범위입니다. 정확한 선택은 아직 정해지지 않았습니다.

  2. resolver · registry

    Cargo가 manifest 제약, 기존 lock entry, registry index와 하위 의존성을 함께 계산해 호환 가능한 package graph를 고릅니다.

  3. 선택 결과 · Cargo.lock

    정확한 package 버전, source, checksum 또는 revision을 기록합니다. 이후 resolution은 이 선택을 우선 사용합니다.

  4. 빌드 · API 사용

    cargo build --locked로 package 선택 drift를 막고 rand::random_range(1..=100)를 컴파일합니다.

cargo update가 바꾸는 것

인수 없이 실행하면 lockfile의 모든 package를 요구 범위 안에서 업데이트하려고 합니다. cargo update -p rand는 지정 package를 보수적으로 갱신하며, 기본적으로 manifest 요구 범위를 넓히지 않습니다.

lockfile의 보장 경계

잠긴 package 선택은 재현성의 한 입력입니다. 동일 바이너리에는 toolchain, target, feature, profile, build script 입력, native dependency와 환경까지 통제해야 합니다.

Rust 2024와 rand 0.10.2는 rustc 1.85 이상을 요구합니다. 이 문서의 모든 rand 예제는 현재 top-level API인 rand::random_range를 사용합니다.


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

이제는 입력값과 임의의 정수를 가지고 있으므로 이 둘을 비교할 수 있습니다.

예제 1-4는 그 단계를 보여주고 있습니다.

이 코드는 아직 컴파일 되지 않는데, 그 이유는 곧 설명하겠습니다.

예제 1-4: 두 숫자의 비교에 대한 가능한 반환 값 처리하기
src/main.rs
use std::cmp::Ordering;
use std::io;

fn main() {
    // --생략--

    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은 열거형이고 Less, Greater, Equal이라는 배리언트들을 가지고 있습니다.

이들은 여러분이 어떤 두 값을 비교할 때 나올 수 있는 세 가지 결과입니다.

그런 다음에는 Ordering 타입을 이용하는 다섯 줄을 끝부분에 추가했습니다.

cmp 메서드는 두 값을 비교하며 비교 가능한 모든 것들에 대해 호출할 수 있습니다.

이 메서드는 비교하고 싶은 값들의 참조자를 받습니다.

여기서는 guesssecret_number를 비교하고 있습니다.

cmpOrdering 열거형을 돌려줍니다.

match 표현식을 이용하여 cmpguesssecret_number를 비교한 결과인 Ordering의 값에 따라 무엇을 할 것인지 결정합니다.

match 표현식은 갈래(arm) 들로 이루어져 있습니다.

하나의 갈래는 하나의 패턴match 표현식에서 주어진 값이 패턴과 맞는다면 실행할 코드로 이루어져 있습니다.

러스트는 match에 주어진 값을 갈래의 패턴에 맞는지 순서대로 확인합니다.

match 생성자와 패턴들은 여러분의 코드가 마주칠 다양한 상황을 표현할 수 있도록 하고 모든 경우의 수를 처리했음을 확신할 수 있도록 도와주는 강력한 특성들입니다.

이 기능들은 6장과 18장에서 각각 더 자세히 다뤄집니다.

여기서 사용된 match 표현식에서 무슨 일이 일어나는지 예를 들어 살펴봅시다.

사용자가 50을 예측했고 임의로 생성된 비밀 숫자는 38이라 칩시다.

50과 38을 비교하면 cmp 메서드의 결과는 Ordering::Greater입니다.

match 표현식은 Ordering::Greater 값을 받아서 각 갈래의 패턴을 확인합니다.

처음으로 마주하는 갈래의 패턴인 Ordering::LessOrdering::Greater와 매칭되지 않으므로 첫 번째 갈래는 무시하고 다음으로 넘어갑니다.

다음 갈래의 패턴인 Ordering::Greater확실히 Ordering::Greater와 매칭됩니다!

이 갈래와 연관된 코드가 실행될 것이고 Too big!가 출력될 것입니다.

첫 성공적인 매칭 이후 match 표현식은 끝나므로, 지금의 시나리오에서는 마지막 갈래를 확인하지 않습니다.

하지만 예제 1-4의 코드는 컴파일되지 않습니다.

한번 시도해봅시다.

$ cargo build
   Compiling rand v0.10.2
   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
  --> /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/core/src/cmp.rs:783:8

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

에러의 핵심은 일치하지 않는 타입(mismatched types) 이 있음을 알려주는 것입니다.

러스트는 강한 정적 타입 시스템을 가지고 있습니다.

하지만 타입 추론도 수행합니다.

만약 let guess = String::new()를 작성한다면 러스트는 guessString 타입이어야 함을 추론할 수 있으므로 타입을 작성하지 않아도 됩니다.

한편 secret_number는 정수형입니다.

러스트의 숫자 타입 몇 가지가 1과 100 사이의 값을 가질 수 있습니다.

i32는 32비트 정수, u32는 32비트의 부호 없는 정수, i64는 64비트의 정수이며 그 외에도 비슷합니다.

다른 정수형임을 유추할 수 있는 타입 정보를 다른 곳에 추가하지 않는다면 러스트는 기본적으로 숫자들을 i32로 생각합니다.

이 에러의 원인은 러스트가 문자열과 정수형을 비교할 수 없기 때문입니다.

최종적으로는 프로그램이 입력으로 읽은 String을 실제 숫자 타입으로 바꿔서 비밀 숫자와 비교할 수 있도록 하고 싶습니다.

이를 위해 main 함수의 본문에 아래와 같이 한 줄을 추가합니다.

src/main.rs
    // --생략--

    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라는 이름의 변수가 생성되지 않았나요?

그렇긴 하지만 러스트는 이전에 있던 guess의 값을 새로운 값으로 가리는 (shadow) 것을 허용합니다.

섀도잉(shadowing) 은, 이를테면 guess_strguess와 같은 두 개의 고유한 변수를 만들도록 강제하기 보다는 guess라는 변수 이름을 재사용하도록 해 줍니다.

2장에서 더 자세한 이야기를 다루겠지만, 지금은 어떤 한 타입의 값을 다른 타입으로 바꾸고 싶을 때 자주 사용되는 기능이라고만 알아두세요.

이 새로운 변수에 guess.trim().parse() 표현식을 묶습니다.

표현식 내에서 guess는 입력값을 문자열로 가지고 있었던 원래 guess를 참조합니다.

String 인스턴스에 호출한 trim 메서드는 앞뒤 Unicode 공백을 제거한 &str을 만듭니다. 이어서 parse::<u32>()가 그 문자열을 실제로 변환하며, 변환에 성공한 뒤에는 u32끼리 비교합니다.

사용자들은 추릿값을 입력한 뒤 read_line을 끝내기 위해 enter키를 반드시 눌러야 하고, 이것이 개행문자를 문자열에 추가시킵니다.

예를 들어 사용자가 5를 누르고 enter키를 누르면 guess5\n처럼 됩니다.

\n은 ‘새로운 라인’을 나타냅니다. (Windows에서 enter는 캐리지 리턴과 개행문자, 즉 \r\n을 발생시킵니다.)

trim 메서드는 \n 혹은 \r\n을 제거하고 5만 남도록 처리합니다.

문자열의 parse 메서드는 문자열을 다른 타입으로 바꿔줍니다.

여기서는 문자열을 숫자로 바꾸는 데 사용합니다.

let guess: u32를 사용하여 필요로 하는 정확한 숫자 타입을 러스트에 알려줄 필요가 있습니다.

guess 뒤의 콜론(:)은 변수의 타입을 명시했음을 의미합니다.

러스트는 내장 숫자 타입을 몇 개 가지고 있습니다; u32은 부호가 없는 32비트의 정수입니다.

이 타입은 작은 양수를 표현하기 좋은 선택입니다.

2장에서 다른 숫자 타입에 대해 배울 것입니다.

추가로 이 예제 프로그램의 u32 명시와 secret_number와의 비교를 통해 러스트는 secret_number의 타입도 u32이어야 한다고 추론할 수 있습니다.

이제 이 비교는 같은 타입의 두 값 사이에서 이루어집니다!

parse 메서드의 호출은 에러가 발생하기 쉽습니다.

예를 들어 A👍%과 같은 문자열이 포함되어 있다면 정수로 바꿀 방법이 없습니다.

parse 메서드는 실패할 수도 있으므로, Result 타입으로 잠재적 실패 다루기’에서 다루었던 read_line와 마찬가지로 Result 타입을 반환합니다.

Resultexpect 메서드를 사용하여 같은 방식으로 처리하겠습니다.

만약 parse 메서드가 문자열로부터 정수를 만들어 낼 수 없어 Err Result 배리언트를 반환한다면, expect 호출은 게임을 멈추고 제공한 메시지를 출력합니다.

만약 parse 메서드가 성공적으로 문자열을 정수로 바꾸었다면 ResultOk 배리언트를 돌려받으므로 expect에서 Ok에서 얻고 싶었던 값을 결과로 받게 됩니다.

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

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [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임을 파악했습니다.

추릿값이 맞을 때나 너무 클 경우, 혹은 너무 작은 경우 등 여러 종류의 입력값으로 여러 시나리오를 검증해봅시다.

이제 게임의 대부분이 동작하도록 처리했지만, 사용자는 한 번의 추리만 가능합니다.

반복문을 추가하여 이를 바꿔 봅시다!

이어서 보기

반복문과 게임 완성하기에서 여러 번 추리하도록 반복문을 추가하고 입력 오류를 처리한 뒤 게임을 완성합니다.