Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

직접 손을 움직여 보는 프로젝트를 함께 진행하면서 러스트를 시작해 봅시다! 이 장에서는 실제 프로그램 안에서 몇 가지 흔한 러스트 개념을 사용하는 방법을 보여 주며, 여러분을 러스트에 입문시킵니다. let, match, 메서드, 연관 함수, 외부 크레이트 등 여러 가지를 배우게 됩니다! 이어지는 장들에서는 이 아이디어들을 더 자세히 살펴볼 것입니다. 이 장에서는 우선 기초를 직접 연습해 보겠습니다.

우리는 초보자용 고전 프로그래밍 문제인 숫자 맞히기 게임을 구현할 것입니다. 동작 방식은 이렇습니다. 프로그램은 1부터 100 사이의 임의의 정수를 하나 생성합니다. 그런 다음 플레이어에게 추측값을 입력하라고 요청합니다. 추측값이 입력되면, 프로그램은 그 값이 너무 작은지 너무 큰지 알려 줍니다. 정답을 맞히면 게임은 축하 메시지를 출력하고 종료합니다.

새 프로젝트 준비하기

새 프로젝트를 만들려면 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"

[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 0.08s
     Running `target/debug/guessing_game`
Hello, world!

run 명령은 이 게임에서처럼 프로젝트를 빠르게 반복 개발할 때 유용합니다. 매 반복마다 다음 단계로 넘어가기 전에 바로 테스트할 수 있기 때문입니다.

src/main.rs 파일을 다시 여세요. 앞으로 작성할 코드는 전부 이 파일 안에 넣게 됩니다.

추측값 처리하기

숫자 맞히기 게임 프로그램의 첫 번째 부분은 사용자 입력을 요청하고, 그 입력을 처리하며, 입력이 우리가 기대한 형식인지 확인하는 것입니다. 우선은 플레이어가 추측값을 입력할 수 있게만 해 봅시다. 목록 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}");
}

기본적으로 러스트는 표준 라이브러리 안의 몇몇 항목을 모든 프로그램의 스코프로 가져옵니다. 이 집합을 prelude 라고 하며, 그 전체 내용은 표준 라이브러리 문서에서 볼 수 있습니다.

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

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 문법은 새 함수를 선언하고, 괄호 () 는 매개변수가 없음을 뜻하며, 중괄호 { 는 함수 본문의 시작을 나타냅니다.

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

이 코드는 게임 이름을 출력하고 사용자에게 입력을 요청하는 프롬프트를 보여 줍니다.

변수로 값 저장하기

다음으로 사용자 입력을 저장할 변수 를 하나 만듭니다.

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 에 바인딩합니다. 러스트에서 변수는 기본적으로 불변입니다. 즉 한 번 값을 넣으면 그 값은 바뀌지 않습니다. 이 개념은 3장의 “변수와 가변성” 절에서 자세히 다룹니다. 변수를 가변으로 만들려면 변수 이름 앞에 mut 를 붙입니다.

let apples = 5; // 불변
let mut bananas = 5; // 가변

Note: // 문법은 해당 줄 끝까지 이어지는 주석을 시작합니다. 러스트는 주석 안의 모든 내용을 무시합니다. 주석은 3장에서 더 자세히 설명합니다.

숫자 맞히기 게임 프로그램으로 돌아오면, 이제 let mut guessguess 라는 이름의 가변 변수를 도입한다는 것을 알 수 있습니다. 등호(=)는 지금 어떤 값을 변수에 바인딩하겠다는 뜻입니다. 등호 오른쪽에 있는 값은 String::new 호출 결과이며, 이 함수는 새로운 String 인스턴스를 반환합니다. 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 인스턴스를 반환하는데, 이 타입은 터미널의 표준 입력을 가리키는 핸들을 나타냅니다.

다음 줄의 .read_line(&mut guess) 는 표준 입력 핸들에서 read_line 메서드를 호출해 사용자 입력을 받습니다. 그리고 read_line&mut guess 를 인수로 넘겨, 사용자 입력을 어떤 문자열에 저장할지 알려 줍니다. read_line 의 전체 역할은 사용자가 표준 입력에 입력한 내용을 우리가 넘긴 문자열의 끝에 덧붙이는 것(기존 내용을 덮어쓰지 않음)이므로, 그 문자열을 인수로 전달합니다. 또한 메서드가 문자열 내용을 바꿔야 하므로 문자열 인수는 가변이어야 합니다.

& 는 이 인수가 참조(reference) 임을 나타냅니다. 참조는 데이터를 메모리에 여러 번 복사하지 않고도 코드의 여러 부분이 하나의 데이터를 접근하게 해 주는 방법입니다. 참조는 복잡한 기능이지만, 러스트의 큰 장점 중 하나는 참조를 안전하고 쉽게 사용할 수 있다는 점입니다. 이 프로그램을 완성하는 데 지금 당장 그 세부를 많이 알 필요는 없습니다. 지금은 변수와 마찬가지로 참조도 기본적으로 불변이라는 점만 알면 충분합니다. 그래서 가변으로 만들기 위해 &guess 가 아니라 &mut guess 라고 써야 합니다. (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 이라고 부르는 타입입니다. enum은 여러 가능한 상태 중 하나가 될 수 있는 타입입니다. 우리는 각 가능한 상태를 variant 라고 부릅니다.

6장에서 enum을 더 자세히 다루겠습니다. 이런 Result 타입의 목적은 에러 처리 정보를 표현하는 것입니다.

Result 의 variant는 OkErr 입니다. Ok variant는 작업이 성공했음을 뜻하고 성공적으로 만들어진 값을 담고 있습니다. Err variant는 작업이 실패했음을 뜻하고, 어떻게 혹은 왜 실패했는지에 대한 정보를 담습니다.

다른 모든 타입의 값처럼 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: 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

러스트는 read_line 이 반환한 Result 값을 사용하지 않았다고 경고하며, 그 말은 프로그램이 발생 가능한 오류를 처리하지 않았다는 뜻입니다.

경고를 없애는 올바른 방법은 실제로 에러 처리 코드를 작성하는 것이지만, 여기서는 문제가 생기면 그냥 프로그램을 중단시키고 싶을 뿐이므로 expect 를 사용할 수 있습니다. 에러로부터 복구하는 방법은 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 사이의 임의의 수를 사용하겠습니다. 러스트는 아직 표준 라이브러리에 난수 생성 기능을 포함하고 있지 않습니다. 하지만 러스트 팀은 그 기능을 제공하는 rand crate를 제공합니다.

크레이트로 기능 늘리기

crate는 러스트 소스 코드 파일들의 모음이라는 점을 기억하세요. 지금까지 우리가 만들고 있던 프로젝트는 실행 가능한 바이너리 크레이트(binary crate) 입니다. 반면 rand crate는 라이브러리 크레이트(library crate) 로, 다른 프로그램에서 사용하도록 만든 코드를 담고 있으며 그 자체로 실행되지는 않습니다.

외부 크레이트를 다룰 때야말로 Cargo의 진가가 드러납니다. rand 를 사용하는 코드를 쓰기 전에, Cargo.toml 파일을 수정해 rand crate를 의존성으로 추가해야 합니다. 지금 파일을 열고, Cargo가 만들어 둔 [dependencies] 섹션 헤더 아래 맨 아래에 다음 줄을 추가하세요. 여기 적힌 것처럼 rand 와 이 버전 번호를 정확히 사용해야 합니다. 그렇지 않으면 이 튜토리얼의 코드 예제가 동작하지 않을 수 있습니다.

파일명: Cargo.toml

[dependencies]
rand = "0.8.5"

Cargo.toml 파일에서 어떤 헤더 뒤에 나오는 내용은 다음 섹션이 시작될 때까지 모두 그 섹션에 속합니다. [dependencies] 에서는 프로젝트가 어떤 외부 크레이트에 의존하는지, 그리고 그 크레이트의 어떤 버전을 요구하는지를 Cargo에게 알려 줍니다. 여기서는 rand crate를 시맨틱 버전 지정자 0.8.5 로 지정합니다. 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
   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
Listing 2-2: rand crate를 의존성으로 추가한 뒤 cargo build 를 실행했을 때의 출력

여러분의 화면에는 다른 버전 번호가 보일 수도 있습니다(하지만 SemVer 덕분에 모두 이 코드와 호환됩니다). 또 운영체제에 따라 줄 구성이 조금 다를 수 있고, 줄 순서도 달라질 수 있습니다.

외부 의존성을 포함하면 Cargo는 그 의존성이 필요로 하는 것들의 최신 버전을 registry 에서 가져옵니다. registry는 Crates.io의 데이터 사본입니다. Crates.io는 러스트 생태계 사람들이 자신의 오픈 소스 러스트 프로젝트를 올려 다른 사람이 사용할 수 있게 하는 곳입니다.

registry를 갱신한 뒤 Cargo는 [dependencies] 섹션을 검사하고 아직 다운로드되지 않은 크레이트를 내려받습니다. 여기서는 우리가 직접 명시한 의존성은 rand 하나뿐이지만, Cargo는 rand 가 동작하는 데 필요한 다른 크레이트도 함께 가져옵니다. 크레이트를 다운로드한 뒤 러스트는 그것들을 컴파일하고, 그런 다음 의존성을 사용할 수 있는 상태로 프로젝트 자체를 컴파일합니다.

아무 것도 바꾸지 않은 상태에서 곧바로 cargo build 를 다시 실행하면, Finished 줄 외에는 아무 출력도 보이지 않을 것입니다. Cargo는 의존성을 이미 다운로드하고 컴파일했으며, Cargo.toml 에서 그 의존성과 관련한 내용이 바뀌지 않았다는 사실을 알고 있습니다. 또한 여러분의 코드도 바뀌지 않았다는 것을 알기 때문에 그것도 다시 컴파일하지 않습니다. 할 일이 없으니 그냥 종료하는 것입니다.

반대로 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 버전이 나왔다고 해 봅시다. 그 버전에는 중요한 버그 수정이 들어 있지만, 동시에 여러분의 코드를 깨뜨리는 회귀도 포함되어 있다고 합시다. 이런 상황을 다루기 위해 러스트는 여러분이 처음 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.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는 사용 가능한 크레이트 registry를 갱신하고, 새로 지정한 버전에 따라 rand 요구 사항을 다시 계산합니다.

Cargo그 생태계에 관해서는 할 말이 훨씬 더 많고, 14장에서 다시 다룰 것입니다. 하지만 지금은 이 정도면 충분합니다. Cargo 덕분에 라이브러리 재사용이 매우 쉬워지므로, Rustacean은 여러 패키지를 조합해 더 작은 프로젝트들을 만들어 낼 수 있습니다.

임의의 숫자 생성하기

이제 rand 를 사용해 맞혀야 할 숫자를 생성해 봅시다. 다음 단계는 목록 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}");
}
Listing 2-3: 임의의 숫자를 생성하는 코드 추가하기

먼저 use rand::Rng; 줄을 추가합니다. Rng 트레이트는 난수 생성기가 구현하는 메서드를 정의하며, 우리가 그 메서드를 사용하려면 이 트레이트가 스코프에 있어야 합니다. 트레이트는 10장에서 자세히 다룹니다.

다음으로 중간에 두 줄을 더 추가합니다. 첫 번째 줄에서는 rand::thread_rng 함수를 호출해 우리가 사용할 특정 난수 생성기를 가져옵니다. 현재 실행 중인 스레드에 지역적인 난수 생성기이며, 운영체제가 시드를 제공합니다. 그런 다음 그 난수 생성기에 대해 gen_range 메서드를 호출합니다. 이 메서드는 앞에서 use rand::Rng; 문으로 스코프에 가져온 Rng 트레이트에 정의되어 있습니다. gen_range 메서드는 범위 식을 인수로 받아, 그 범위 안의 임의의 숫자를 생성합니다. 여기서 사용하는 범위 식은 start..=end 형태이며, 하한과 상한을 모두 포함합니다. 따라서 1부터 100 사이 숫자를 요청하려면 1..=100 을 써야 합니다.

Note: 어떤 트레이트를 사용해야 하고, 어떤 메서드와 함수를 호출해야 하는지를 그냥 저절로 알게 되는 것은 아닙니다. 그래서 각 크레이트는 사용 방법을 설명하는 문서를 제공합니다. 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 사이 숫자여야 합니다. 아주 좋습니다!

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

이제 사용자 입력과 임의의 숫자가 있으니 둘을 비교할 수 있습니다. 그 단계는 목록 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!"),
    }
}
Listing 2-4: 두 숫자를 비교한 결과로 가능한 반환값 처리하기

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

그다음 아래쪽에 Ordering 타입을 사용하는 다섯 줄을 추가합니다. cmp 메서드는 두 값을 비교하며, 비교 가능한 어떤 것에도 호출할 수 있습니다. 비교 대상에 대한 참조를 인수로 받는데, 여기서는 guesssecret_number 를 비교합니다. 그리고 use 문으로 스코프에 가져온 Ordering enum의 variant 중 하나를 반환합니다. 우리는 match 식을 사용해, guesssecret_numbercmp 로 비교한 결과 어떤 Ordering variant가 반환되었는지에 따라 다음에 무엇을 할지 결정합니다.

match 식은 여러 arm 으로 이루어집니다. 각 arm은 맞춰 볼 패턴 과, 그 값이 그 패턴에 맞을 때 실행할 코드로 구성됩니다. 러스트는 match 에 주어진 값을 가져와 각 arm의 패턴과 차례대로 비교합니다. 패턴과 match 구문은 아주 강력한 러스트 기능입니다. 코드가 마주칠 수 있는 다양한 상황을 표현할 수 있고, 그 모든 상황을 처리하도록 보장해 주기 때문입니다. 이 기능은 각각 6장과 19장에서 자세히 다룹니다.

여기서 사용한 match 식을 예로 들어봅시다. 사용자가 50을 추측했고, 이번에 무작위로 생성된 비밀 숫자가 38이라고 해 봅시다.

코드가 50과 38을 비교하면, 50이 38보다 크기 때문에 cmp 메서드는 Ordering::Greater 를 반환합니다. match 식은 Ordering::Greater 값을 받고, 각 arm의 패턴을 검사하기 시작합니다. 첫 번째 arm의 패턴인 Ordering::Less 를 보면 Ordering::Greater 와 맞지 않기 때문에 그 arm의 코드는 무시하고 다음 arm으로 이동합니다. 다음 arm의 패턴은 Ordering::Greater 이고, 이것은 Ordering::Greater 와 정확히 일치합니다! 따라서 이 arm에 연결된 코드가 실행되어 화면에 Too big! 를 출력합니다. match 식은 첫 번째로 성공한 매치 이후에 끝나므로, 이 경우 마지막 arm은 검사하지 않습니다.

하지만 목록 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) 는 것입니다. 러스트는 강력한 정적 타입 시스템을 가집니다. 하지만 타입 추론도 지원합니다. 예를 들어 우리가 let mut guess = String::new() 라고 썼을 때, 러스트는 guessString 이어야 한다는 것을 추론했고 타입을 직접 적으라고 요구하지 않았습니다. 반면 secret_number 는 숫자 타입입니다. 1부터 100 사이 값을 가질 수 있는 러스트의 숫자 타입은 여럿 있습니다. 32비트 정수 i32, 부호 없는 32비트 정수 u32, 64비트 정수 i64 등입니다. 별도의 정보가 없으면 러스트는 기본적으로 i32 를 사용하며, 다른 곳의 타입 정보가 영향을 주지 않는 한 secret_number 의 타입도 i32 입니다. 오류가 나는 이유는 러스트가 문자열과 숫자 타입을 서로 비교할 수 없기 때문입니다.

결국 우리가 원하는 것은 프로그램이 입력으로 읽어들인 String 을 숫자 타입으로 바꾸어, 그 값을 비밀 숫자와 수치적으로 비교하는 것입니다. 이를 위해 main 함수 본문에 다음 줄을 추가합니다.

파일명: 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 라는 변수가 있지 않았나요? 그렇습니다. 하지만 다행히 러스트는 이전 guess 값을 새로운 값으로 가릴 수 있게 해 줍니다. 이것을 shadowing 이라고 하며, 예를 들어 guess_strguess 처럼 서로 다른 이름의 두 변수를 억지로 만들지 않고도 guess 라는 이름을 재사용할 수 있게 합니다. 이것은 3장에서 더 자세히 다루겠지만, 지금은 한 타입의 값을 다른 타입으로 바꾸고 싶을 때 자주 쓰는 기능이라는 점만 기억하면 됩니다.

이 새 변수는 guess.trim().parse() 식의 결과에 바인딩됩니다. 여기서 식 안의 guess 는 문자열 입력을 담고 있던 원래의 guess 변수를 가리킵니다. String 인스턴스의 trim 메서드는 앞뒤 공백을 제거합니다. 문자열을 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비트 정수입니다. 작은 양의 정수를 다룰 때 좋은 기본 선택입니다. 다른 숫자 타입은 3장에서 배웁니다.

또한 이 예제 프로그램에서는 u32 타입 주석과 secret_number 와의 비교가 함께 있기 때문에, 러스트는 secret_number 역시 u32 여야 한다고 추론합니다. 이렇게 하면 비교가 같은 타입 두 값 사이에서 이루어집니다.

parse 메서드는 논리적으로 숫자로 바꿀 수 있는 문자에 대해서만 동작하므로 쉽게 오류가 날 수 있습니다. 예를 들어 문자열이 A👍% 라면 이를 숫자로 바꿀 방법이 없습니다. 실패할 가능성이 있으므로 parse 역시 read_line 과 마찬가지로 Result 타입을 반환합니다([앞의 “Result 타입으로 발생 가능한 실패 처리하기”] (#handling-potential-failure-with-result) 참조). 따라서 여기서도 같은 방식으로 다시 expect 메서드를 사용합니다. parse 가 문자열에서 숫자를 만들지 못해 Err variant를 반환하면, expect 호출은 게임을 중단시키고 우리가 준 메시지를 출력합니다. 반대로 parse 가 문자열을 숫자로 성공적으로 변환하면 ResultOk variant를 반환하고, expect 는 우리가 원하는 숫자를 그 Ok 값에서 꺼내 반환합니다.

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

$ 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 키워드는 무한 루프를 만듭니다. 사용자에게 더 많은 기회를 주기 위해 루프를 추가해 봅시다.

파일명: 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 로 프로그램을 강제 종료할 수 있습니다. 하지만 “추측값을 비밀 숫자와 비교하기” 에서 parse 를 설명할 때 언급했듯이, 다른 탈출 방법도 있습니다. 사용자가 숫자가 아닌 값을 입력하면 프로그램이 크래시 나는 것이지요. 이를 활용하면 사용자가 게임을 종료할 수 있습니다. 아래를 보세요.

$ 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 문을 추가해 봅시다.

파일명: 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 의 마지막 부분이기 때문입니다.

잘못된 입력 처리하기

이제 게임 동작을 한 단계 더 다듬어 봅시다. 사용자가 숫자가 아닌 값을 입력했을 때 프로그램을 크래시시키는 대신, 그런 입력은 무시하고 계속 추측하게 만들겠습니다. 이를 위해 guessString 에서 u32 로 변환하는 줄을 목록 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;
            }
        }
    }
}
Listing 2-5: 프로그램을 크래시시키는 대신, 숫자가 아닌 추측값은 무시하고 다음 추측을 받기

여기서는 expect 호출을 match 식으로 바꾸었습니다. 즉, 오류가 나면 크래시하는 방식에서 오류를 직접 처리하는 방식으로 이동한 것입니다. parseResult 타입을 반환하고, ResultOkErr variant를 가진 enum이라는 점을 기억하세요. 여기서는 cmp 메서드의 Ordering 결과를 다룰 때와 마찬가지로 match 식을 사용하고 있습니다.

parse 가 문자열을 숫자로 성공적으로 변환하면, 결과 숫자를 담은 Ok 값을 반환합니다. 그 Ok 값은 첫 번째 arm의 패턴과 일치하고, match 식은 parse 가 만든 값을 Ok 안에서 꺼내 그 num 값 자체를 돌려줍니다. 이 숫자는 우리가 새로 만드는 guess 변수에 바로 들어갑니다.

반대로 parse 가 문자열을 숫자로 바꾸지 못하면, 에러에 대한 자세한 정보를 담은 Err 값을 반환합니다. 이 Err 값은 첫 번째 arm의 Ok(num) 패턴과는 맞지 않지만, 두 번째 arm의 Err(_) 패턴과는 맞습니다. 밑줄 _ 은 모든 값을 받아들이는 패턴입니다. 여기서는 안에 어떤 정보가 들어 있든 모든 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! 을 삭제합시다. 최종 코드는 목록 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;
            }
        }
    }
}
Listing 2-6: 완성된 숫자 맞히기 게임 코드

이제 여러분은 숫자 맞히기 게임을 성공적으로 만들었습니다. 축하합니다!

정리

이 프로젝트는 많은 새로운 러스트 개념을 직접 손으로 익히는 방식으로 소개해 주었습니다. let, match, 함수, 외부 크레이트 사용법 등 여러 가지를 다뤘습니다. 다음 몇 장에서 이 개념들을 더 자세히 배우게 됩니다. 3장에서는 대부분의 프로그래밍 언어에 공통으로 있는 변수, 데이터 타입, 함수 같은 개념을 다루고, 그것들을 러스트에서 어떻게 사용하는지 보여 줍니다. 4장에서는 러스트를 다른 언어와 구별해 주는 소유권을 탐구합니다. 5장에서는 구조체와 메서드 문법을 다루고, 6장에서는 enum이 어떻게 동작하는지 설명합니다.