만드는 원리

Math.random 대신 crypto를 쓰는 이유, 그리고 나머지 연산의 함정

0부터 n-1까지 정수 하나 뽑는 일에도 생각보다 함정이 있습니다. 뽑기 도구 전체가 쓰는 함수 하나를 풀어 봅니다.

2026.10.09 · 한그릇 공작소

뽑기 도구 일곱 개가 모두 같은 함수 하나로 난수를 얻습니다. 열 줄도 안 되는데, 한 줄 한 줄 이유가 있어요.

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, 90 (4번)
1, 4, 71 (3번)
2, 5, 82 (3번)

10이 3으로 나누어떨어지지 않아서 0만 한 번 더 나옵니다. 232도 대부분의 n으로 나누어떨어지지 않으니 똑같은 일이 생겨요. 차이는 아주 작지만, 공정하다고 써 붙인 도구라면 없애는 게 맞다고 생각했습니다.

해결: 넘치는 구간은 버리고 다시 뽑기

lim은 n으로 딱 나누어떨어지는 가장 큰 경계예요. 그 경계를 넘는 값이 나오면 버리고 다시 뽑습니다. 위 예시로 치면 9가 나왔을 때 다시 굴리는 거죠. 그러면 0~8만 남아서 0, 1, 2가 정확히 세 번씩입니다.

다시 뽑아야 하는 경우는 n이 아무리 커도 절반 미만이고, 뽑기에서 쓰는 n(많아야 수십)이라면 사실상 거의 일어나지 않아요. 속도 걱정은 안 해도 됩니다.

그러면 화면 효과도 crypto로?

아니요. 색종이가 흩날리는 방향, 효과음 높낮이처럼 결과와 상관없는 곳은 그냥 Math.random을 씁니다. 결과를 정하는 자리에만 rnd를 쓰고, 나머지는 가볍게 두는 게 코드 읽기도 편했어요.

이 홈페이지 첫 화면의 주황색 룰렛도 같은 함수로 멈출 자리를 정합니다.