HOWTO · JavaScript

시드를 사용하여 JavaScript에서 난수 생성

이 기사에서는 시드를 사용하여 JavaScript에서 난수를 생성하는 방법을 다룹니다. 시드를 가져오고 해당 시드를 기반으로 난수를 반환하는 PRNG 덕분에 이를 달성했습니다.

이 페이지의 내용

JavaScript에서 같은 의사난수 수열을 다시 얻으려면 시드 기반 생성기를 초기화하고, 매번 시드와 생성기 구현, 호출 순서를 동일하게 유지해야 합니다. Math.random()은 시드를 받지 않으므로, 아래 예제와 같은 시드 기반 PRNG 또는 seedrandom.js를 사용하세요.

시드 기반 난수 생성의 작동 방식

JavaScript의 Math.random()은 시드를 받지 않습니다. 수열을 재현하려면 초기 상태를 받는 의사난수 생성기(PRNG)를 만들어야 합니다. 같은 시드와 같은 구현을 사용하고 같은 순서로 호출하면 해당 생성기의 수열을 재현할 수 있습니다. 이 입력 중 하나라도 바꾸면 이후 값이 달라질 수 있습니다.

시드는 생성기를 초기화하는 값이지, 그 자체가 난수는 아닙니다. 문자열 같은 사용자 친화적인 입력을 변환하는 과정과 생성기 상태를 갱신하는 알고리즘을 분리하세요. 아래 예제는 반복 가능한 시뮬레이션, 데모, 테스트에 쓸 수 있는 범용 PRNG를 보여 줍니다.

문자열을 생성기 시드로 변환하기

SFC32와 Xoshiro128**는 32비트 상태 워드 네 개를 사용합니다. 따라서 이 생성기들을 초기화하려면 문자열을 숫자 네 개로 변환해야 합니다. 간단한 해시가 그 방법 중 하나입니다. 이 해시는 암호학적 해시가 아니므로 비밀을 보호하는 데 사용하면 안 됩니다. 고정 폭 해시라면 어떤 것이든 서로 다른 문자열이 같은 값으로 충돌할 수 있습니다.

이 함수는 각 UTF-16 코드 단위를 네 개의 부호 없는 32비트 워드에 섞습니다. 결과는 결정적인 예제 상태로 쓸 수 있지만, 엔트로피를 더하거나 생성기를 안전하게 만들지는 않습니다. 시작 상태를 재현하려면 매번 같은 문자열과 함수를 사용하세요.

이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.

function hashStringToWords(string) {
  let a = 0x9e3779b9;
  let b = 0x243f6a88;
  let c = 0xb7e15162;
  let d = 0xdeadbeef;

  for (let i = 0; i < string.length; i++) {
    const k = string.charCodeAt(i);
    a = Math.imul(a ^ k, 0x85ebca6b);
    b = Math.imul(b ^ k, 0xc2b2ae35);
    c = Math.imul(c ^ k, 0x27d4eb2f);
    d = Math.imul(d ^ k, 0x165667b1);
    a ^= b >>> 13;
    b ^= c >>> 11;
    c ^= d >>> 15;
    d ^= a >>> 9;
  }

  return [a, b, c, d].map((word) => word >>> 0);
}

const words = hashStringToWords("demo seed");
console.log(words.join(", "));

생성기마다 요구하는 상태 형식도 맞춰야 합니다. SFC32와 Xoshiro128**는 워드 네 개를 받고, Mulberry32는 32비트 숫자 하나를 받습니다. 아래의 JSF 예제는 워드 하나를 초기 값 네 개로 확장하고, 값을 반환하기 전에 상태를 예열합니다. 모든 생성기가 올바르게 초기화될 것이라고 가정해 같은 값 하나를 네 번 전달하지 마세요.

SFC32, Mulberry32, Xoshiro128**, JSF로 난수 생성하기

이 함수들은 0 이상 1 미만의 값을 반환합니다. SFC32와 Xoshiro128**에는 hashStringToWords가 반환한 워드 네 개를 전달하고, Mulberry32와 JSF에는 첫 번째 워드를 사용합니다. 각 함수는 변경 가능한 상태를 클로저에 저장하므로 수열을 처음부터 다시 시작하려면 새 생성기를 만드세요.

SFC32는 상태가 모두 0인 경우에도 호출할 때마다 32비트 워드 네 개를 갱신합니다. Xoshiro128**에는 0이 아닌 상태가 필요합니다. 해시 도우미는 여기서 사용한 demo seed 입력으로 0이 아닌 워드 네 개를 제공합니다.

이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.

function hashStringToWords(string) {
  let a = 0x9e3779b9;
  let b = 0x243f6a88;
  let c = 0xb7e15162;
  let d = 0xdeadbeef;
  for (let i = 0; i < string.length; i++) {
    const k = string.charCodeAt(i);
    a = Math.imul(a ^ k, 0x85ebca6b);
    b = Math.imul(b ^ k, 0xc2b2ae35);
    c = Math.imul(c ^ k, 0x27d4eb2f);
    d = Math.imul(d ^ k, 0x165667b1);
    a ^= b >>> 13; b ^= c >>> 11; c ^= d >>> 15; d ^= a >>> 9;
  }
  return [a, b, c, d].map((word) => word >>> 0);
}

function sfc32(a, b, c, d) {
  return function () {
    a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
    let t = (a + b) | 0;
    a = b ^ (b >>> 9);
    b = (c + (c << 3)) | 0;
    c = (c << 21) | (c >>> 11);
    d = (d + 1) | 0;
    t = (t + d) | 0;
    c = (c + t) | 0;
    return (t >>> 0) / 4294967296;
  };
}

const random = sfc32(...hashStringToWords("demo seed"));
console.log(random());
console.log(random());

Mulberry32는 상태 워드 하나만 사용하는 간결한 생성기입니다. 고정 상수를 더해 상태를 갱신한 뒤, 각 결과를 만들 때 비트를 섞습니다.

function mulberry32(seed) {
  let state = seed >>> 0;
  return function () {
    let t = (state += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function hashStringToWords(string) {
  let a = 0x9e3779b9;
  for (let i = 0; i < string.length; i++) {
    a = Math.imul(a ^ string.charCodeAt(i), 0x85ebca6b);
    a ^= a >>> 13;
  }
  return a >>> 0;
}

const random = mulberry32(hashStringToWords("demo seed"));
for (let i = 0; i < 5; i++) console.log(random());

Xoshiro128**는 워드 네 개와 star-star 출력 변환을 사용합니다. 상태 전이는 출력 계산과 별개이므로 호출 사이에 갱신된 워드 네 개를 모두 유지해야 합니다.

function xoshiro128ss(s) {
  s = s.map((word) => word >>> 0);
  return function () {
    const result = Math.imul(((Math.imul(s[1], 5) << 7) |
      (Math.imul(s[1], 5) >>> 25)), 9) >>> 0;
    const t = s[1] << 9;
    s[2] ^= s[0];
    s[3] ^= s[1];
    s[1] ^= s[2];
    s[0] ^= s[3];
    s[2] ^= t;
    s[3] = (s[3] << 11) | (s[3] >>> 21);
    return result / 4294967296;
  };
}

function hashStringToWords(string) {
  let a = 0x9e3779b9, b = 0x243f6a88, c = 0xb7e15162, d = 0xdeadbeef;
  for (let i = 0; i < string.length; i++) {
    const k = string.charCodeAt(i);
    a = Math.imul(a ^ k, 0x85ebca6b); b = Math.imul(b ^ k, 0xc2b2ae35);
    c = Math.imul(c ^ k, 0x27d4eb2f); d = Math.imul(d ^ k, 0x165667b1);
    a ^= b >>> 13; b ^= c >>> 11; c ^= d >>> 15; d ^= a >>> 9;
  }
  return [a, b, c, d].map((word) => word >>> 0);
}

const random = xoshiro128ss(hashStringToWords("demo seed"));
console.log(random());

JSF(Jenkins Small Fast)도 워드 네 개를 유지합니다. 이 구현은 첫 번째 해시 워드에서 초기 상태를 만들고, 값을 생성하기 전에 상태 전이를 20회 수행한 다음, 32비트 결과를 같은 구간의 값으로 변환해 반환합니다.

function jsf32(seed) {
  let a = 0xf1ea5eed;
  let b = seed >>> 0;
  let c = seed >>> 0;
  let d = seed >>> 0;

  function nextUint32() {
    const e = (a - ((b << 27) | (b >>> 5))) | 0;
    a = b ^ ((c << 17) | (c >>> 15));
    b = (c + d) | 0;
    c = (d + e) | 0;
    d = (a + e) | 0;
    return d >>> 0;
  }

  for (let i = 0; i < 20; i++) nextUint32();
  return () => nextUint32() / 4294967296;
}

function hashStringToWord(string) {
  let a = 0x9e3779b9;
  for (let i = 0; i < string.length; i++) {
    a = Math.imul(a ^ string.charCodeAt(i), 0x85ebca6b);
    a ^= a >>> 13;
  }
  return a >>> 0;
}

const random = jsf32(hashStringToWord("demo seed"));
for (let i = 0; i < 3; i++) console.log(random());

상태 전이가 서로 다르므로 같은 문자열로 초기화해도 알고리즘마다 다른 수열이 생성됩니다. 재현성을 유지하려면 시드뿐 아니라 알고리즘과 호출 순서도 고정해야 합니다.

seedrandom.js 사용하기

seedrandom.js는 시드 기반 의사난수 생성을 위한 JavaScript 라이브러리입니다. 다음 브라우저 페이지 소스는 CDNJS에서 버전 3.0.5를 불러와 Johnson 문자열로 생성기를 만들고 첫 번째 값을 기록합니다. 스크립트를 불러오려면 브라우저의 네트워크 접속이 필요합니다. 이 코드는 응용해 사용할 소스이며, 여기서 실행 결과를 검증한 것은 아닙니다.

이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.

<body>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
  <script>
    const random = new Math.seedrandom("Johnson");
    console.log(random());
  </script>
</body>

생성기 인스턴스에는 quick() 및 int32() 메서드도 있습니다. quick()은 소수 값을 반환하고, int32()는 부호 있는 32비트 정수를 반환합니다. 호출할 때마다 생성기가 진행되므로 두 번째 호출은 수열에서 첫 번째 호출과 같은 지점을 나타내지 않습니다.

<body>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
  <script>
    const random = new Math.seedrandom("DavidBau");
    console.log("With quick():", random.quick());
    console.log("With int32():", random.int32());
  </script>
</body>

시드 고정, 엔트로피 혼합, 자동 동작 중 선택하기

같은 입력으로 재현할 수 있는 수열이 필요하면 명시적인 시드를 사용하세요. 제공된 seedrandom.js 예제는 시드와 함께 { entropy: true } 옵션을 전달하는 방법도 보여 줍니다. 이 예제가 입증하는 것은 해당 옵션을 전달한다는 점뿐입니다. 특정 엔트로피 출처나 보장, 재현성 특성을 보여 주지는 않으므로 그런 주장의 근거로 삼지 마세요.

이 섹션의 코드와 표시되는 레이블 또는 출력은 영어로 유지됩니다.

<body>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
  <script>
    const random = new Math.seedrandom("Antananarivo", { entropy: true });
    for (let i = 0; i < 5; i++) console.log(random());
  </script>
</body>

반복 가능한 데모 대신 매번 새로운 값이 필요하다면 그 요구에 맞는 적절한 출처를 선택하고 문서화된 동작을 확인하세요. 이 문서는 위에 나온 호출 이외의 시드 인수 생략이나 옵션의 동작을 가정하지 않습니다.

시드 기반 의사난수의 한계

시드 기반 PRNG는 시뮬레이션이나 결정적인 테스트 픽스처처럼 반복 가능한 수열이 필요한 프로그램에 유용합니다. 하지만 이는 예측할 수 없는 비밀 값을 제공하는 출처가 아니라 알고리즘입니다. 시드를 예측할 수 있으면 결과 수열도 예측할 수 있으며, 사용자 정의 문자열 해시를 사용해도 달라지지 않습니다.

비밀번호, 인증 토큰, 암호화 키 또는 그 밖의 보안에 민감한 값을 이 예제로 생성하지 마세요. 그런 작업에는 런타임과 플랫폼에 맞는 암호학적으로 안전한 난수 API를 사용하세요. 또한 시드가 서로 다른 PRNG 구현의 결과까지 일치시킨다고 가정하지 마세요. 알고리즘, 시드 변환, 초기 상태, 호출 횟수와 순서를 모두 동일하게 유지해야 합니다.