뽑기 도구 일곱 개가 모두 같은 함수 하나로 난수를 얻습니다. 열 줄도 안 되는데, 한 줄 한 줄 이유가 있어요.
function rnd(n) {
if (n <= 1) return 0;
var lim = Math.floor(4294967296 / n) * n, a = new Uint32Array(1), x;
do { crypto.getRandomValues(a); x = a[0]; } while (x >= lim);
return x % n;
}
Math.random도 괜찮지 않나요
솔직히 말하면 회식 자리 정하는 정도라면 Math.random으로도 충분합니다. 그래도 바꾼 이유는 두 가지예요.
첫째, Math.random은 빠르게 그럴듯한 숫자를 내는 데 목적이 있어서, 이전 값 몇 개를 알면 다음 값을 추측할 수 있는 방식으로 만들어져 있습니다. 경품 추첨처럼 누군가 결과를 신경 쓰는 자리라면 찜찜하죠.
둘째, crypto.getRandomValues는 운영체제가 모은 예측 불가능한 값을 씁니다. 모든 최신 브라우저에 있고, 속도 차이도 뽑기 수준에서는 느낄 수 없어요. 쓰지 않을 이유가 없었습니다.
나머지 연산의 함정
crypto.getRandomValues는 0부터 4,294,967,295(232-1)까지의 정수를 줍니다. 여기서 0~4 중 하나를 얻으려고 x % 5를 하면 될 것 같지만, 아주 조금 치우칩니다.
작은 숫자로 보면 바로 보여요. 0~9까지 나오는 주사위로 0~2 중 하나를 고른다고 해 봅시다.
| 원래 값 | % 3 결과 |
|---|---|
| 0, 3, 6, 9 | 0 (4번) |
| 1, 4, 7 | 1 (3번) |
| 2, 5, 8 | 2 (3번) |
10이 3으로 나누어떨어지지 않아서 0만 한 번 더 나옵니다. 232도 대부분의 n으로 나누어떨어지지 않으니 똑같은 일이 생겨요. 차이는 아주 작지만, 공정하다고 써 붙인 도구라면 없애는 게 맞다고 생각했습니다.
해결: 넘치는 구간은 버리고 다시 뽑기
lim은 n으로 딱 나누어떨어지는 가장 큰 경계예요. 그 경계를 넘는 값이 나오면 버리고 다시 뽑습니다. 위 예시로 치면 9가 나왔을 때 다시 굴리는 거죠. 그러면 0~8만 남아서 0, 1, 2가 정확히 세 번씩입니다.
다시 뽑아야 하는 경우는 n이 아무리 커도 절반 미만이고, 뽑기에서 쓰는 n(많아야 수십)이라면 사실상 거의 일어나지 않아요. 속도 걱정은 안 해도 됩니다.
그러면 화면 효과도 crypto로?
아니요. 색종이가 흩날리는 방향, 효과음 높낮이처럼 결과와 상관없는 곳은 그냥 Math.random을 씁니다. 결과를 정하는 자리에만 rnd를 쓰고, 나머지는 가볍게 두는 게 코드 읽기도 편했어요.
이 홈페이지 첫 화면의 주황색 룰렛도 같은 함수로 멈출 자리를 정합니다.