콘텐츠로 이동

Rust 난수 (rand 크레이트)

게임은 주사위를 굴려요. 퀴즈는 문제 순서를 섞어요. 테스트에는 샘플 데이터가 필요해요. 이 모든 일에 난수(random number)가 필요한데, Rust 표준 라이브러리에는 바로 쓸 수 있는 난수 생성기가 없어요. 대신 거의 모든 사람이 크레이트 하나를 써요. 바로 rand (영어)예요.

이 페이지에서는 최신 rand 0.10 API를 보여 줘요. 이게 중요한 이유는 rand가 0.9와 0.10 버전에서 일부 이름을 바꿨기 때문이에요. 오래된 튜토리얼에 thread_rng()나 gen_range()가 나온다면, 예전 버전을 기준으로 쓴 글이에요. 새 이름과, 예전 이름을 쓰면 나오는 에러도 함께 살펴볼게요.

프로젝트 폴더에서 다음을 실행하세요.

Terminal window
cargo add rand

그러면 Cargo.toml에 이런 줄이 추가돼요.

[dependencies]
rand = "0.10"

크레이트와 Cargo.toml이 처음이라면 그 페이지부터 읽어 보세요. 이 페이지의 “Try it” 버튼은 Rust Playground를 여는데, Playground에는 이미 rand가 들어 있어요.

난수 하나만 필요하다면 자유 함수(free function)인 rand::random_range와 rand::random을 쓰세요.

fn main() {
let die: u32 = rand::random_range(1..=6);
let coin: bool = rand::random();
let chance: f64 = rand::random();
println!("주사위 값이 1~6 사이인가요? {}", (1..=6).contains(&die));
println!("chance가 0.0..1.0 범위인가요? {}", (0.0..1.0).contains(&chance));
println!("동전이 앞면인가요? {coin}");
}
▶ 직접 실행해 보기

여기에 실행 결과가 없는 건 일부러예요. 마지막 줄이 무작위로 true나 false가 되거든요. “Try it”을 몇 번 눌러서 바뀌는 걸 확인해 보세요.

  • random_range(1..=6)은 1부터 6까지, 6을 포함한 숫자를 줘요. 1..6이라고 쓰면 1부터 5까지만 나와요. 범위는 for 반복문에서와 똑같이 동작해요.
  • random()은 요청한 타입의 값을 아무거나 골라요. bool이라면 공정한 동전 던지기예요. f64라면 0.0 이상 1.0 미만(1.0은 포함하지 않아요)의 숫자예요.
  • 타입 표기(: u32, : bool)가 rand에게 무엇을 만들지 알려 줘요. 이게 없으면 Rust가 어떤 타입을 원하는지 모를 수도 있어요.

난수가 많이 필요하다면, rand::rng()로 생성기(generator)를 한 번 얻어 두고 그 메서드를 호출하세요. 조금 더 빠르기도 하고, 대부분의 코드에서 볼 수 있는 스타일이에요.

use rand::RngExt;
fn main() {
let mut rng = rand::rng();
let rolls: Vec<u32> = (0..1000).map(|_| rng.random_range(1..=6)).collect();
println!("주사위를 {}번 굴렸어요", rolls.len());
println!("모두 1~6 사이인가요? {}", rolls.iter().all(|r| (1..=6).contains(r)));
let x: f64 = rng.random();
println!("x가 0.0..1.0 범위인가요? {}", (0.0..1.0).contains(&x));
}

실행 결과:

주사위를 1000번 굴렸어요
모두 1~6 사이인가요? true
x가 0.0..1.0 범위인가요? true
▶ 직접 실행해 보기

숫자 자체 대신 확인 결과를 출력했어요. 그래서 값은 매번 달라도 출력은 항상 같아요.

자주 쓰게 될 메서드가 두 개 더 있어요.

let lucky = rng.random_bool(0.1); // 약 10%의 확률로 true
let rare = rng.random_ratio(1, 100); // 약 100번에 1번꼴로 true

use rand::RngExt; 줄은 왜 필요할까요? random_range나 random_bool 같은 메서드는 트레이트에서 오는데, 트레이트의 메서드는 그 트레이트가 스코프 안에 있을 때만 호출할 수 있어요. rand 0.10에는 트레이트가 두 개 있어요.

  • Rng: “나는 무작위 비트를 만들 수 있어”라는 기본 트레이트예요. 모든 생성기가 구현해요.
  • RngExt: 쓰기 편한 메서드들을 더해 주는 트레이트예요. random, random_range, random_bool 등이요. 모든 Rng에서 자동으로 동작해요.

정리하면, 메서드를 호출하려면 RngExt를 가져오고, “아무 생성기나” 받는 함수를 쓸 때는 Rng를 쓰세요. 아래의 주사위 굴리기 예제에서 둘 다 보게 될 거예요.

rand::rng()는 실행할 때마다 다른 숫자를 줘요. 실제 게임에서는 그게 맞아요. 하지만 테스트나 디버깅, 게임의 “오늘의 도전” 스테이지 같은 경우에는 매번 같은 “무작위” 숫자가 필요해요.

이럴 때 시드(seed)를 써요. 시드를 준 생성기는 같은 시드에 대해 항상 같은 수열을 만들어요.

use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
fn main() {
let mut a = StdRng::seed_from_u64(2024);
let mut b = StdRng::seed_from_u64(2024);
let first: Vec<u8> = (0..4).map(|_| a.random_range(0..100)).collect();
let second: Vec<u8> = (0..4).map(|_| b.random_range(0..100)).collect();
println!("{:?}", first);
println!("{:?}", second);
println!("같은가요? {}", first == second);
}

실행 결과:

[53, 82, 74, 51]
[53, 82, 74, 51]
같은가요? true
▶ 직접 실행해 보기

seed_from_u64는 SeedableRng 트레이트에서 오기 때문에, 이것도 가져와야 해요. 2024를 다른 숫자로 바꾸면 다른 수열이 나오지만, 그 수열 역시 실행할 때마다 같아요.

솔직하게 한 가지 주의할 점이 있어요. StdRng의 알고리즘은 앞으로 나올 rand 버전에서 바뀔 수 있어요. 위의 숫자는 rand 0.10에서 나온 거예요. 테스트가 영원히 정확히 같은 숫자를 내야 한다면, rand 책에서는 ChaCha8Rng 같은, 이름이 정해진 생성기를 쓰라고 권해요(아래 “더 알아보기” 참고).

리스트를 다루려면 rand::seq에서 트레이트 두 개를 더 가져와요.

  • SliceRandom은 슬라이스를 그 자리에서 섞는 shuffle을 제공해요.
  • IndexedRandom은 choose(무작위 항목 하나)와 sample(서로 다른 항목 여러 개)을 제공해요.
use rand::rngs::StdRng;
use rand::seq::{IndexedRandom, SliceRandom};
use rand::SeedableRng;
fn main() {
let mut rng = StdRng::seed_from_u64(7);
let mut cards = vec!["A", "K", "Q", "J", "10"];
cards.shuffle(&mut rng);
println!("섞은 결과: {:?}", cards);
let colors = ["빨강", "초록", "파랑"];
println!("고른 색: {:?}", colors.choose(&mut rng));
let team: Vec<&str> = cards.sample(&mut rng, 2).copied().collect();
println!("팀: {:?}", team);
let empty: [i32; 0] = [];
println!("빈 배열에서: {:?}", empty.choose(&mut rng));
}

실행 결과:

섞은 결과: ["K", "J", "10", "Q", "A"]
고른 색: Some("빨강")
팀: ["K", "J"]
빈 배열에서: None
▶ 직접 실행해 보기

choose는 Option을 반환해요. 빈 슬라이스에서는 고를 게 없으니까요. 이 메서드들은 배열, 벡터, 슬라이스에서 똑같이 동작해요. 시드를 준 rng를 rand::rng()로 바꾸면 실행할 때마다 새롭게 섞여요.

주사위를 굴리는 함수를 만들어 볼게요. 핵심은 매개변수 타입이에요. &mut impl Rng는 “아무 난수 생성기나”라는 뜻이에요. 실제 게임에서는 rand::rng()를 넘기고, 테스트에서는 시드를 준 StdRng를 넘겨서 어떤 값이 나올지 정확히 알 수 있어요.

use rand::rngs::StdRng;
use rand::{Rng, RngExt, SeedableRng};
/// 면이 `sides`개인 주사위를 `count`개 굴려요.
fn roll(rng: &mut impl Rng, count: u32, sides: u32) -> Vec<u32> {
(0..count).map(|_| rng.random_range(1..=sides)).collect()
}
fn main() {
let mut rng = StdRng::seed_from_u64(1);
for (count, sides) in [(2, 6), (1, 20), (3, 4)] {
let rolls = roll(&mut rng, count, sides);
let total: u32 = rolls.iter().sum();
println!("{count}d{sides}: {:?} = {total}", rolls);
}
}

실행 결과:

2d6: [5, 6] = 11
1d20: [16] = 16
3d4: [3, 1, 2] = 6
▶ 직접 실행해 보기

2d6은 보드게임에서 “6면체 주사위 두 개”를 줄여 쓰는 표기예요. roll 안에서 rand::rng()를 호출하지 않고 생성기를 밖에서 넘겨받는 건 좋은 습관이에요. 함수를 테스트하기 쉬워지거든요.

허용된 문자 집합에서 16글자를 골라 볼게요. 이 집합에서는 0/O, 1/l처럼 헷갈리기 쉬운 문자를 뺐어요. 그래서 비밀번호를 입력하기가 더 쉬워요.

use rand::seq::IndexedRandom;
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%&*";
fn password(rng: &mut impl Rng, length: usize) -> String {
(0..length)
.map(|_| *CHARSET.choose(rng).unwrap() as char)
.collect()
}
fn main() {
let mut rng = rand::rng();
let pw = password(&mut rng, 16);
println!("길이: {}", pw.len());
println!("허용된 문자만 있나요? {}", pw.bytes().all(|b| CHARSET.contains(&b)));
// println!("{pw}"); // 주석을 풀면 비밀번호가 보여요. 실행할 때마다 달라요
}

실행 결과:

길이: 16
허용된 문자만 있나요? true
▶ 직접 실행해 보기

b"..."는 바이트 문자열이에요. ASCII 문자로 된 &[u8]이죠. choose가 바이트 하나를 고르고, as char가 그것을 문자로 바꿔요. 여기서는 시드를 준 생성기가 아니라 rand::rng()를 썼다는 점에 주의하세요. 시드를 준 생성기는 절대 쓰면 안 돼요. 고정된 시드로 만든 비밀번호는 매번 같은 비밀번호가 되니, 비밀번호라고 할 수 없어요.

숫자 맞히기 게임 프로젝트는 표준 라이브러리만 쓰려고 시계를 이용한 꼼수로 비밀 숫자를 골라요. rand를 쓰면 secret_number 함수 전체가 한 줄이 돼요.

let secret: u32 = rand::random_range(1..=100);

게임의 나머지 부분은 그대로예요. 다음은 Playground에서 실행해 볼 수 있는 버전이에요. 비밀 숫자는 시드를 준 생성기에서 나오고, 컴퓨터는 남은 범위의 한가운데를 고르는 방식으로 추측해요.

use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use std::cmp::Ordering;
fn main() {
// 실제 게임에서는: let secret: u32 = rand::random_range(1..=100);
let mut rng = StdRng::seed_from_u64(99);
let secret: u32 = rng.random_range(1..=100);
let (mut low, mut high) = (1, 100);
let mut attempts = 0;
loop {
let guess = (low + high) / 2;
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => { println!("{guess}: 너무 작아요"); low = guess + 1; }
Ordering::Greater => { println!("{guess}: 너무 커요"); high = guess - 1; }
Ordering::Equal => { println!("{guess}: 정답이에요! {attempts}번 만에 찾았어요."); break; }
}
}
}

실행 결과:

50: 너무 작아요
75: 너무 작아요
88: 너무 작아요
94: 너무 작아요
97: 너무 작아요
99: 정답이에요! 6번 만에 찾았어요.
▶ 직접 실행해 보기

재미있는 사실: 이렇게 “범위를 반으로 줄이는” 전략을 쓰면 1부터 100까지에서는 7번 넘게 추측할 일이 절대 없어요.

rand는 비밀번호나 키에 써도 안전할까?

섹션 제목: “rand는 비밀번호나 키에 써도 안전할까?”

짧게 답하면, rand::rng()는 예측하기 어렵도록 설계됐지만, 정말 중요한 비밀에는 그 일을 위해 만든 도구를 쓰세요.

  • rand::rng()는 암호학적으로 안전한 알고리즘(현재는 ChaCha12)을 써요. getrandom (영어) 크레이트를 통해 운영체제의 난수 소스에서 시드를 받고, 주기적으로 시드를 다시 받아요. rand 문서에서는 이것을 “빠르고, 꽤 안전한 생성기”라고 부르면서, 모든 보안 요구 사항에 맞는다고 약속할 수는 없다고 말해요.
  • 암호화 키, API 토큰 같은 중요한 비밀에는 getrandom(또는 rand::rngs::SysRng)으로 운영체제에서 바이트를 바로 받거나, 더 좋게는 전용 암호 라이브러리가 키를 만들게 하세요.
  • 시드를 준 생성기(seed_from_u64)나 빠른 SmallRng는 비밀에 절대 쓰지 마세요. 시드를 알거나 추측한 사람은 누구든 똑같은 “무작위” 값을 만들어 낼 수 있어요.

게임, 시뮬레이션, 섞기, 테스트 데이터에는 rand::rng()가 딱 맞아요.

RngExt 대신 Rng를 가져오기. rand 0.9에서는 메서드가 Rng에 있었기 때문에, 예전 코드는 use rand::Rng;라고 써요. rand 0.10에서는 이렇게 돼요.

use rand::Rng;
fn main() {
let mut rng = rand::rng();
let n = rng.random_range(1..=6);
println!("{n}");
}
error[E0599]: no method named `random_range` found for struct `ThreadRng` in the current scope
--> src/main.rs:5:17
|
5 | let n = rng.random_range(1..=6);
| ^^^^^^^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `RngExt` which provides `random_range` is implemented but not in scope; perhaps you want to import it
|
1 + use rand::RngExt;
|

ThreadRng에서 random_range 메서드를 찾을 수 없고, 그 메서드를 제공하는 RngExt 트레이트가 스코프에 없다는 뜻이에요. 컴파일러가 해결책까지 알려 줘요. use rand::RngExt;를 추가하면 돼요.

rand 0.8용 코드 복사하기. 아주 오래된 예제는 thread_rng()와 gen_range()를 써요.

fn main() {
let n = rand::thread_rng().gen_range(1..=6);
println!("{n}");
}
error[E0425]: cannot find function `thread_rng` in crate `rand`
--> src/main.rs:2:19
|
2 | let n = rand::thread_rng().gen_range(1..=6);
| ^^^^^^^^^^ not found in `rand`

rand 크레이트에서 thread_rng 함수를 찾을 수 없다는 에러예요. 새 이름은 rand::rng()와 random_range()예요. 마찬가지로 gen()은 random()이 됐고, gen_bool()은 random_bool()이 됐어요.

범위의 끝을 하나 틀리기. random_range(1..6)은 절대 6을 돌려주지 않아요. 주사위라면 1..=6이라고 쓰세요.

  • cargo add rand로 크레이트를 추가하세요. Rust 표준 라이브러리에는 자체 난수 생성기가 없어요.
  • 간단한 값에는 rand::random_range(1..=6)과 rand::random::<T>()를 쓰세요.
  • 값이 많이 필요하면 let mut rng = rand::rng();를 쓰고, random_range나 random_bool 같은 메서드를 위해 use rand::RngExt;를 추가하세요.
  • SliceRandom::shuffle은 슬라이스를 섞고, IndexedRandom::choose는 항목 하나를 골라 Option으로 돌려줘요.
  • StdRng::seed_from_u64(n)은 실행할 때마다 같은 수열을 줘요. 테스트나 반복 가능한 게임 스테이지에 좋지만, 비밀에는 절대 쓰지 마세요.
  • 키와 토큰에는 운영체제의 난수 소스(getrandom)나 전용 암호 크레이트를 쓰세요.

이해도 점검

1. 6을 돌려줄 수 있는 호출은 무엇일까요?

2. rand 0.10에서 rng.random_range(..)가 "no method named random_range" 에러로 실패해요. 무엇이 빠졌을까요?

3. StdRng::seed_from_u64(42)는 왜 쓸까요?

4. 빈 슬라이스에 choose를 호출하면 무엇을 돌려줄까요?

점수: 0 / 4